agent-memory 패널: 작업 흐름이 주인공(+ 다른 작업들), 기억은 쓰일 때/새로 생길 때만 표시, recall 주입

에이전트의 경험 → 사실 → 판단 → 행동 규칙을 쌓는 메모리 시스템 + Claude Code mods. 설계: docs/DESIGN.md
윈도우(회사 PC)는 docs/SETUP-WINDOWS.md를 따르세요.
git clone <이 저장소> ~/workspace/agent-memory
claude plugin marketplace add ~/workspace/agent-memory
claude plugin install memory-events@agent-memory
claude plugin install memory-lens@agent-memory
claude plugin install env-guard@agent-memory
로컬 폴더 marketplace라 git pull 후 세션에서 /reload-plugins만 하면 반영됨.
git clone https://github.com/alstjrwjd99/memory-personal.git ~/workspace/memory-personal
작업 전후로 uv run amem sync (pull --rebase + push). events/는 PC 로컬에만 남음.
cd engine
uv sync
uv run amem init --machine mac --personal ~/workspace/memory-personal # 맥
uv run amem init --machine win-work --work C:\work\memory-work --personal ... # 윈도우
uv run amem health
uv run amem promote # events → episode (신호 있는 세션만 LLM 호출)
uv run amem reflect # 판단 변화 제안 생성
uv run amem review # 제안 확인
uv run amem apply <id> --only 1,3 / uv run amem reject <id>
uv run amem export # 확신도 높은 판단을 Claude Code 메모리로
uv run amem recall "질문" --cwd . # 관련 기억 꺼내기 (memory-lens가 프롬프트마다 자동 호출)
uv run amem history <id> # 한 기억의 변천 (versions + 확신도 + git diff)
uv run amem sync # store git pull --rebase + push
uv run amem --store work ... # 업무 store 대상
설정: ~/.config/agent-memory/config.yaml (윈도우: %USERPROFILE%\.config\agent-memory\config.yaml)
/config 또는 settings.json pluginConfigs)| 필드 | Mac | Windows(회사) |
|---|---|---|
| machine | mac | win-work |
| defaultScope | personal | work |
| personalStore | ~/workspace/memory-personal 절대경로 | (개인 store 경로) |
| workStore | (비움) | C:\...\memory-work |
| workPaths | (비움) | 필요 시 |
hooks/register.tsx 355 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Flow, Health, LastRecall, OtherFlow, PendingItem, RecallHit, Scope, StoreHealth } from '../types'
5import { ago, buildPrompt, otherFlows, parseFlow, projectOf, storeKey, transcriptDigest, verticalSteps } from './flow.ts'
6import { changeLine, classifyPath, clip, countsLine, resolvePaths, shouldRecall } from './parse.ts'
7
8const PANE = 'memory-lens'
9const TITLE = '🧭 작업 흐름'
10const stores = atom({ plugin: 'memory-lens', key: 'stores' } as const, [] as StoreHealth[])
11const pending = atom({ plugin: 'memory-lens', key: 'pending' } as const, [] as PendingItem[])
12const busy = atom({ plugin: 'memory-lens', key: 'busy' } as const, null as string | null)
13const message = atom({ plugin: 'memory-lens', key: 'message' } as const, null as string | null)
14const saved = atom({ plugin: 'memory-lens', key: 'saved' } as const, [] as { at: number; name: string }[])
15const turns = atom({ plugin: 'memory-lens', key: 'turns' } as const, 0)
16const lastRecall = atom({ plugin: 'memory-lens', key: 'recall' } as const, null as LastRecall | null)
17const loadedAt = atom({ plugin: 'memory-lens', key: 'loadedAt' } as const, 0)
18const KIND_LABEL: Record<string, string> = { opinion: '판단', procedure: '절차', observation: '관찰', experience: '경험', world: '사실' }
19const RELOAD_EVERY_MS = 30_000
20const flow = atom({ plugin: 'memory-lens', key: 'flow' } as const, null as Flow | null)
21const flowUpdating = atom({ plugin: 'memory-lens', key: 'flowUpdating' } as const, false)
22const toolsSince = atom({ plugin: 'memory-lens', key: 'toolsSince' } as const, 0)
23const turnsSinceFlow = atom({ plugin: 'memory-lens', key: 'turnsSinceFlow' } as const, 0)
24// 도구를 안 쓴 대화 턴도 이 횟수마다 한 번은 흐름 갱신 (내 답 필요 반영)
25const FLOW_EVERY_TURNS = 2
26const MARK = { done: '✓', now: '●', next: '○' } as const
27const others = atom({ plugin: 'memory-lens', key: 'others' } as const, [] as OtherFlow[])
28// 기억 상세(Health, 승인 버튼) 펼침 여부, 마지막으로 확인한 승인 대기 수 (새로 생겼을 때만 알림)
29const expanded = atom({ plugin: 'memory-lens', key: 'expanded' } as const, false)
30const seenPending = atom({ plugin: 'memory-lens', key: 'seenPending' } as const, 0)
31// 다른 작업 중 펼쳐 본 프로젝트 (클릭하면 그 흐름 그래프를 펼침)
32const openOther = atom({ plugin: 'memory-lens', key: 'openOther' } as const, null as string | null)
33
34// 다른 폴더(작업)들의 흐름: 같은 PC의 다른 세션이 저장한 것도 포함
35async function loadOthers($: EngineInterface): Promise<void> {
36 const cwd = await $.session.cwd()
37 const flows: Flow[] = []
38 for (const key of await $.store.keys()) {
39 if (!key.startsWith('flow:') || key === storeKey(cwd)) continue
40 const f = (await $.store.get(key)) as Flow | undefined
41 if (f) flows.push(f)
42 }
43 await update($, others, () => otherFlows(flows, projectOf(cwd)))
44}
45
46// 대화 요약 → 흐름 갱신 → 폴더별 저장 (세션이 바뀌어도 유지). haiku로 최근 40개 메시지만
47async function refreshFlow($: EngineInterface, options: PluginOptions): Promise<void> {
48 if (await read($, flowUpdating)) return
49 await update($, flowUpdating, () => true)
50 try {
51 const cwd = await $.session.cwd()
52 const messages = await $.session.messages()
53 if (!Array.isArray(messages) || messages.length === 0) return
54 const prev = await read($, flow)
55 const r = await $.model.complete({
56 model: String(options.flowModel || 'haiku'),
57 prompt: buildPrompt(transcriptDigest(messages), prev && prev.project === projectOf(cwd) ? prev : null),
58 maxTokens: 800,
59 timeoutMs: 30_000,
60 })
61 if (!r.isAnswered) return
62 const next = parseFlow(r.text, projectOf(cwd), Date.now())
63 if (!next) return
64 await update($, flow, () => next)
65 await $.store.set(storeKey(cwd), next)
66 } finally {
67 await update($, toolsSince, () => 0)
68 await update($, turnsSinceFlow, () => 0)
69 await update($, flowUpdating, () => false)
70 }
71}
72
73async function paths($: EngineInterface, options: PluginOptions) {
74 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
75 return resolvePaths(home, String(options.store ?? ''), String(options.engineDir ?? ''), String(options.workStore ?? ''))
76}
77
78// personal/work store의 health + 승인 대기 제안을 파일에서 다시 읽는다 (엔진 실행 없음)
79async function reload($: EngineInterface, options: PluginOptions): Promise<void> {
80 const p = await paths($, options)
81 const machine = String(options.machine || 'mac')
82 const found: StoreHealth[] = []
83 const items: PendingItem[] = []
84 for (const [scope, root] of [['personal', p.store], ['work', p.workStore]] as [Scope, string][]) {
85 if (!(await $.fs.exists(root))) continue
86 const hp = `${root}/.amem/health-${machine}.json`
87 const health = (await $.fs.exists(hp)) ? (JSON.parse(String(await $.fs.read(hp))) as Health) : null
88 found.push({ scope, health })
89 // 승인 대기 = 원칙 후보 (엔진이 health에 적어 둠)
90 for (const c of health?.heuristics?.candidates ?? []) items.push({ scope, id: c.id, text: c.statement, evidence: c.evidence })
91 }
92 await update($, stores, () => found)
93 await update($, pending, () => items)
94 await update($, loadedAt, () => Date.now())
95 await loadOthers($).catch(() => {})
96}
97
98// 엔진 실행 경로: venv의 amem이 있으면 직접, 없으면 uv run
99async function amemArgv($: EngineInterface, engineDir: string): Promise<string[]> {
100 for (const candidate of [`${engineDir}/.venv/bin/amem`, `${engineDir}/.venv/Scripts/amem.exe`]) {
101 if (await $.fs.exists(candidate)) return [candidate]
102 }
103 return ['uv', 'run', '--project', engineDir, 'amem']
104}
105
106// amem CLI 실행 후 새로 읽기. 실행 중엔 버튼을 잠근다
107async function amem($: EngineInterface, options: PluginOptions, label: string, scope: Scope, args: string[]): Promise<void> {
108 if (await read($, busy)) return
109 await update($, busy, () => label)
110 try {
111 const { engineDir } = await paths($, options)
112 const exe = await amemArgv($, engineDir)
113 const r = await $.process.run([...exe, '--store', scope, ...args], { timeoutMs: 600_000 })
114 const last = (r.exitCode === 0 ? r.stdout : r.stderr).trim().split('\n').pop() ?? ''
115 await update($, message, () => `${r.exitCode === 0 ? '✓' : '✗'} ${label}: ${clip(last, 60)}`)
116 } catch (err) {
117 await update($, message, () => `✗ ${label}: ${String(err).slice(0, 60)}`)
118 } finally {
119 await update($, busy, () => null)
120 await reload($, options)
121 }
122}
123
124// 엔진 recall 호출 (LLM 없음). 실패하면 null → 프롬프트는 그대로 진행
125async function runRecall($: EngineInterface, options: PluginOptions, query: string): Promise<{ context: string; scope: string; hits: RecallHit[] } | null> {
126 const { engineDir } = await paths($, options)
127 const cwd = await $.session.cwd()
128 const exe = await amemArgv($, engineDir)
129 const r = await $.process.run([...exe, 'recall', query, '--cwd', cwd, '--json'], { timeoutMs: 5_000 })
130 if (r.exitCode !== 0) return null
131 return JSON.parse(r.stdout) as { context: string; scope: string; hits: RecallHit[] }
132}
133
134export const register: Register = (on, options) => {
135 on('session.start', async ($, e, next) => {
136 await $.command.register({ name: 'memory-lens', description: '기억 현황 pane 열기' })
137 await $.command.register({ name: 'flow', description: '작업 흐름 지금 갱신' })
138 // 같은 폴더의 지난 흐름을 바로 보여줌
139 const saved = (await $.store.get(storeKey(await $.session.cwd()))) as Flow | undefined
140 if (saved && Array.isArray(saved.steps)) await update($, flow, () => ({ ...saved, detail: saved.detail ?? null }))
141 void reload($, options)
142 void $.ui.open({ id: PANE, title: TITLE })
143 // 터미널에서 직접 amem을 돌려도 pane이 따라오도록 주기적으로 파일을 다시 읽음
144 $.clock.every(RELOAD_EVERY_MS, () => void reload($, options))
145 return next(e)
146 })
147
148 on('command.run', { command: 'memory-lens' }, async $ => {
149 await reload($, options)
150 await update($, expanded, () => true)
151 const { isPlaced } = await $.ui.open({ id: PANE, title: TITLE })
152 return { text: isPlaced ? '기억 현황 pane을 열었어요.' : '터미널 폭이 좁아 pane을 못 띄웠어요. 창을 넓혀주세요.' }
153 })
154
155 on('command.run', { command: 'flow' }, async $ => {
156 await refreshFlow($, options)
157 const f = await read($, flow)
158 void $.ui.open({ id: PANE, title: TITLE })
159 return { text: f ? `흐름 갱신: ${f.goal}${f.detail ? ` (지금: ${f.detail})` : ''}` : '갱신할 대화가 아직 없어요.' }
160 })
161
162 // recall: 사용자가 보낸 프롬프트에 관련 기억을 보이지 않는 context로 붙인다
163 on('prompt.submit', async ($, e, next) => {
164 if (options.recall === false || !shouldRecall(e.text)) return next(e)
165 // 엔진 자신이 띄운 claude -p에는 붙이지 않음 (기억이 기억 추출을 오염시키지 않게)
166 if ((await $.env.get('AMEM_INTERNAL')) === '1') return next(e)
167 let found: Awaited<ReturnType<typeof runRecall>> = null
168 try {
169 found = await runRecall($, options, e.text)
170 } catch {}
171 if (!found || found.hits.length === 0 || !found.context) {
172 await update($, lastRecall, () => ({ at: Date.now(), query: e.text, scope: found?.scope ?? '-', hits: [] }))
173 return next(e)
174 }
175 await update($, lastRecall, () => ({ at: Date.now(), query: e.text, scope: found.scope, hits: found.hits }))
176 $.ui.status(`🧠 recall ${found.hits.length}개`)
177 return next({ ...e, context: [...(e.context ?? []), found.context] })
178 }).catch(($, e, next) => next(e))
179
180 on('turn.complete', async ($, e, next) => {
181 const done = await next(e)
182 // 메인 대화의 정상 종료만 (서브에이전트, 엔진 내부 claude -p 제외)
183 if (e.agentId || (await $.env.get('AMEM_INTERNAL')) === '1') return done
184 await update($, turns, n => n + 1)
185 void reload($, options)
186 if (e.reason === 'answer' && options.flow !== false) {
187 const t = await update($, turnsSinceFlow, n => n + 1)
188 if ((await read($, toolsSince)) > 0 || t >= FLOW_EVERY_TURNS || !(await read($, flow))) {
189 // 턴 종료를 늦추지 않게 백그라운드로
190 void refreshFlow($, options).catch(() => {})
191 }
192 }
193 return done
194 })
195
196 // Claude가 Claude Code 메모리/스킬을 쓰면 알림
197 on('tool.call', async ($, e, next) => {
198 const ran = await next(e)
199 if (!e.agentId) await update($, toolsSince, n => n + 1).catch(() => {})
200 if (e.tool !== 'Write' && e.tool !== 'Edit') return ran
201 const hit = classifyPath(e.file_path)
202 if (!hit || ran.deny !== undefined || ran.isError === true) return ran
203 try {
204 const label = hit.kind === 'skill' ? `skill: ${hit.name}` : hit.name
205 $.ui.toast(`🧠 기억 저장: ${label}`)
206 await update($, saved, list => [...list, { at: Date.now(), name: label }].slice(-20))
207 } catch {}
208 return ran
209 }).catch(($, e, next) => next(e))
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const { Box, Text, Button } = $.ui.resolve(e)
213 const all = await read($, stores)
214 const items = await read($, pending)
215 const working = await read($, busy)
216 const msg = await read($, message)
217 const rc = await read($, lastRecall)
218 const f = await read($, flow)
219 const fUpdating = await read($, flowUpdating)
220 const rest = await read($, others)
221 const isOpen = await read($, expanded)
222 const seen = await read($, seenPending)
223 const opened = await read($, openOther)
224 const width = Math.max(24, (e.viewport?.columns ?? 44) - 6)
225 const now = Date.now()
226 const recallHits = rc && rc.hits.length > 0 ? rc.hits : []
227 const activeRules = all.reduce((n, st) => n + (st.health?.heuristics?.active ?? 0), 0)
228 const newPending = Math.max(0, items.length - seen)
229 // 기억 영역은 쓰였거나(recall) 새 제안이 생겼거나 직접 펼쳤을 때만
230 const showMemory = isOpen || recallHits.length > 0 || newPending > 0
231 const changes = all
232 .flatMap(st => (st.health?.recent_changes ?? []).map(c => ({ ...c, scope: st.scope })))
233 .sort((x, y) => (x.at < y.at ? 1 : -1))
234 .slice(0, 3)
235
236 return (
237 <Box flexDirection="column">
238 {/* ── 주인공: 지금 작업 흐름 ── */}
239 {!f && <Text dimColor>흐름 없음: 대화가 쌓이면 자동으로 정리돼요 (/flow)</Text>}
240 {f && (
241 <Box key="flow" flexDirection="column">
242 <Text>
243 <Text bold>📍 {f.project}</Text>
244 <Text dimColor>{fUpdating ? ' · 갱신 중…' : ` · ${ago(f.at, now)} 전 갱신`}</Text>
245 </Text>
246 {f.goal && <Text dimColor>목표: {clip(f.goal, width)}</Text>}
247 {verticalSteps(f.steps).map((row, i, rows) =>
248 row.kind === 'folded' ? (
249 <Text key="fold" dimColor> ✓ … {row.count}개 완료</Text>
250 ) : (
251 <Box key={`step-${i}`} flexDirection="column">
252 <Text bold={row.status === 'now'} dimColor={row.status === 'done'} color={row.status === 'now' ? 'cyan' : undefined}>
253 {' '}{MARK[row.status]} {clip(row.label, width - 4)}
254 </Text>
255 {row.status === 'now' && f.detail && <Text color="cyan"> │ └ 지금: {clip(f.detail, width - 12)}</Text>}
256 {i < rows.length - 1 && <Text dimColor> │</Text>}
257 </Box>
258 ),
259 )}
260 {f.waiting && !working && <Text color="yellow">⏸ 내 답 필요: {clip(f.waiting, width - 10)}</Text>}
261 </Box>
262 )}
263
264 {/* ── 다른 작업: 여러 일을 오갈 때 돌아갈 곳 ── */}
265 {rest.length > 0 && <Text bold>─ 다른 작업 ─</Text>}
266 {rest.map(o => (
267 <Box key={`other-${o.project}`} flexDirection="column">
268 <Button key={`other-${o.project}`} onPress={() => update($, openOther, cur => (cur === o.project ? null : o.project))}>
269 <Text dimColor>{opened === o.project ? '▾ ' : '▸ '}</Text>
270 <Text bold>{clip(o.project, 16)}</Text>
271 {o.waiting ? <Text color="yellow"> ⏸ {clip(o.waiting, width - 24)}</Text> : <Text> ● {clip(o.now ?? '-', width - 24)}</Text>}
272 <Text dimColor> {ago(o.at, now)}</Text>
273 </Button>
274 {opened === o.project && (
275 <Box key={`other-flow-${o.project}`} flexDirection="column">
276 {o.goal && <Text dimColor> 목표: {clip(o.goal, width - 9)}</Text>}
277 {verticalSteps(o.steps).map((row, i, rows) =>
278 row.kind === 'folded' ? (
279 <Text key={`of-${i}`} dimColor> ✓ … {row.count}개 완료</Text>
280 ) : (
281 <Box key={`os-${i}`} flexDirection="column">
282 <Text bold={row.status === 'now'} dimColor={row.status === 'done'} color={row.status === 'now' ? 'cyan' : undefined}>
283 {' '}{MARK[row.status]} {clip(row.label, width - 7)}
284 </Text>
285 {row.status === 'now' && o.detail && <Text color="cyan"> │ └ 지금: {clip(o.detail, width - 15)}</Text>}
286 {i < rows.length - 1 && <Text dimColor> │</Text>}
287 </Box>
288 ),
289 )}
290 </Box>
291 )}
292 </Box>
293 ))}
294
295 {/* ── 조연: 기억 (쓰일 때/새로 생길 때만) ── */}
296 {showMemory && <Text dimColor>{'─'.repeat(Math.min(width, 36))}</Text>}
297 {recallHits.length > 0 && (
298 <Box key="recall" flexDirection="column">
299 <Text bold>🧠 방금 꺼낸 기억</Text>
300 {recallHits.slice(0, 3).map(h => (
301 <Text key={`rc-${h.id}`}>
302 <Text dimColor> {KIND_LABEL[h.kind] ?? h.kind} </Text>
303 {clip(h.label, width - 8)}
304 </Text>
305 ))}
306 </Box>
307 )}
308 {newPending > 0 && !isOpen && (
309 <Box key="new" flexDirection="row">
310 <Text color="magenta">✨ 새 원칙 후보 {newPending}개 </Text>
311 <Button key="open" label="보기" onPress={() => update($, expanded, () => true)} />
312 <Text> </Text>
313 <Button key="later" label="나중에" onPress={() => update($, seenPending, () => items.length)} />
314 </Box>
315 )}
316 {isOpen && (
317 <Box key="detail" flexDirection="column">
318 {all.map(st => (
319 <Text key={`st-${st.scope}`}>
320 <Text bold>{st.scope}</Text>
321 <Text dimColor> {st.health ? countsLine(st.health.counts) : '(health 없음)'}</Text>
322 </Text>
323 ))}
324 {changes.map(ch => (
325 <Text key={`ch-${ch.scope}-${ch.id}`} dimColor>{changeLine(ch, width - 12)}</Text>
326 ))}
327 <Text bold>원칙 후보 {items.length} <Text dimColor>· 활성 원칙 {activeRules}</Text></Text>
328 {items.slice(0, 5).map(it => (
329 <Box key={`it-${it.scope}-${it.id}`} flexDirection="column">
330 <Text>
331 <Text dimColor>{it.scope === 'work' ? 'W ' : 'P '}근거 {it.evidence} </Text>
332 {clip(it.text, width - 10)}
333 </Text>
334 <Box flexDirection="row">
335 <Button key={`ok-${it.scope}-${it.id}`} label="승인" onPress={() => amem($, options, '원칙 승인', it.scope, ['heuristics', '--accept', it.id])} />
336 <Text> </Text>
337 <Button key={`no-${it.scope}-${it.id}`} label="거절" onPress={() => amem($, options, '원칙 거절', it.scope, ['heuristics', '--reject', it.id])} />
338 </Box>
339 </Box>
340 ))}
341 {items.length > 5 && <Text dimColor>…외 {items.length - 5}개 (amem heuristics)</Text>}
342 <Box flexDirection="row">
343 <Button key="refresh" label="새로고침" onPress={() => reload($, options)} />
344 <Text> </Text>
345 <Button key="close" label="접기" onPress={async () => { await update($, seenPending, () => items.length); await update($, expanded, () => false) }} />
346 </Box>
347 {working && <Text color="yellow">⏳ {working} 실행 중…</Text>}
348 {!working && msg && <Text dimColor>{msg}</Text>}
349 </Box>
350 )}
351 </Box>
352 )
353 })
354}
355hooks/flow.ts 124 lines1// 흐름 요약 파싱/표시 유틸 (순수 함수, 테스트 대상)
2import type { Flow, OtherFlow, Step } from '../types'
3
4export const STATUSES = new Set(['done', 'now', 'next'])
5
6// 모델 응답에서 첫 JSON 객체를 꺼내 검증. 이상하면 null
7export function parseFlow(text: string, project: string, now: number): Flow | null {
8 const m = /\{[\s\S]*\}/.exec(text)
9 if (!m) return null
10 try {
11 const d = JSON.parse(m[0]) as { goal?: unknown; steps?: unknown; waiting?: unknown; detail?: unknown }
12 const steps = (Array.isArray(d.steps) ? d.steps : [])
13 .filter((s): s is Step => !!s && typeof s.label === 'string' && STATUSES.has(s.status))
14 .map(s => ({ label: s.label.trim().slice(0, 40), status: s.status }))
15 .slice(0, 12)
16 if (steps.length === 0) return null
17 const waiting = typeof d.waiting === 'string' && d.waiting.trim() && d.waiting.trim() !== 'null' ? d.waiting.trim().slice(0, 120) : null
18 const detail = typeof d.detail === 'string' && d.detail.trim() && d.detail.trim() !== 'null' ? d.detail.trim().slice(0, 60) : null
19 return { project, goal: typeof d.goal === 'string' ? d.goal.trim().slice(0, 60) : '', steps, detail, waiting, at: now }
20 } catch {
21 return null
22 }
23}
24
25const MARK: Record<Step['status'], string> = { done: '✓', now: '●', next: '○' }
26
27// 폭에 맞게 흐름 한 줄: 지금 단계 중심으로 앞뒤를 줄이고 잘린 쪽엔 …
28export function stepsLine(steps: readonly Step[], width: number): string {
29 const cells = steps.map(s => `${MARK[s.status]} ${s.label}`)
30 const join = (xs: string[]) => xs.join(' → ')
31 if (join(cells).length <= width) return join(cells)
32 let now = steps.findIndex(s => s.status === 'now')
33 if (now < 0) now = steps.findIndex(s => s.status === 'next')
34 if (now < 0) now = steps.length - 1
35 let lo = now
36 let hi = now
37 // 지금 단계부터 뒤(남은 단계) 하나, 앞(끝난 단계) 하나씩 번갈아 넓힌다
38 for (let grew = true; grew; ) {
39 grew = false
40 for (const side of ['hi', 'lo'] as const) {
41 const nlo = side === 'lo' ? lo - 1 : lo
42 const nhi = side === 'hi' ? hi + 1 : hi
43 if (nlo < 0 || nhi >= cells.length) continue
44 const text = (nlo > 0 ? '… ' : '') + join(cells.slice(nlo, nhi + 1)) + (nhi < cells.length - 1 ? ' …' : '')
45 if (text.length <= width) {
46 lo = nlo
47 hi = nhi
48 grew = true
49 }
50 }
51 }
52 const text = (lo > 0 ? '… ' : '') + join(cells.slice(lo, hi + 1)) + (hi < cells.length - 1 ? ' …' : '')
53 return text.length <= width ? text : `${cells[now]}`.slice(0, width)
54}
55
56// 요약할 대화: 최근 메시지 텍스트 + 쓴 도구 이름 (길이 제한)
57export function transcriptDigest(messages: readonly { role: string; text: string; toolUses?: readonly { tool?: string }[] }[], max = 40, per = 400): string {
58 return messages
59 .slice(-max)
60 .map(m => {
61 const tools = (m.toolUses ?? []).map(t => t.tool).filter(Boolean)
62 const text = m.text.replace(/\s+/g, ' ').trim().slice(0, per)
63 return `[${m.role === 'user' ? 'U' : 'A'}] ${text}${tools.length ? ` (도구: ${tools.join(',')})` : ''}`
64 })
65 .filter(l => l.length > 5)
66 .join('\n')
67}
68
69export function buildPrompt(digest: string, prev: Flow | null): string {
70 return `아래는 사용자와 AI 코딩 에이전트의 최근 대화다. 사용자가 다른 일을 하다 돌아왔을 때 한눈에 보도록 "작업 흐름"을 요약하라.
71
72규칙 (2단 흐름):
73- goal: 이 프로젝트의 큰 목표 한 구절 (20자 이내). 중간에 끼어든 작은 작업(버그 수정, 화면 조정, 질문 답변)으로 바꾸지 마라
74- steps: 큰 목표의 굵직한 단계 3~8개. status는 done(끝남) / now(지금, 1개) / next(남음). label은 12자 이내 명사구
75- detail: 지금 실제로 손대고 있는 작은 작업 (25자 이내). 큰 단계에 들어가지 않는 세부 작업은 여기에만 쓴다
76- 이전 흐름이 있으면 goal과 steps 이름을 그대로 유지하고 상태만 갱신하라. 사용자가 명시적으로 다른 큰 일로 넘어갔을 때만 새로 짜라
77- waiting: 에이전트의 마지막 답변이 사용자에게 결정/확인을 묻고 끝났으면 그 질문을 25자 이내로, 아니면 null
78- JSON만 출력: {"goal": "...", "steps": [{"label": "...", "status": "done"}], "detail": "..." 또는 null, "waiting": "..." 또는 null}
79
80## 이전 흐름
81${prev ? JSON.stringify({ goal: prev.goal, steps: prev.steps, detail: prev.detail, waiting: prev.waiting }) : '(없음)'}
82
83## 최근 대화
84${digest}`
85}
86
87// 폴더 경로 → 프로젝트 이름 (마지막 폴더)
88export const projectOf = (cwd: string): string => cwd.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || cwd
89
90export const storeKey = (cwd: string): string => `flow:${cwd.replace(/\\/g, '/').toLowerCase()}`
91
92// 세로 흐름: 끝난 단계가 많으면 앞쪽을 "✓ N개 완료"로 접는다
93export type FlowRow = { kind: 'folded'; count: number } | ({ kind: 'step' } & Step)
94
95export function verticalSteps(steps: readonly Step[], keepDone = 3): FlowRow[] {
96 const doneIdx = steps.map((s, i) => (s.status === 'done' ? i : -1)).filter(i => i >= 0)
97 const fold = Math.max(0, doneIdx.length - keepDone)
98 const out: FlowRow[] = []
99 if (fold > 0) out.push({ kind: 'folded', count: fold })
100 steps.forEach((s, i) => {
101 if (s.status === 'done' && doneIdx.indexOf(i) < fold) return
102 out.push({ kind: 'step', ...s })
103 })
104 return out
105}
106
107// 다른 폴더들의 흐름 → 최근 순 한 줄 요약 (지금 폴더 제외)
108export function otherFlows(flows: readonly Flow[], currentProject: string, limit = 4): OtherFlow[] {
109 return flows
110 .filter(f => f && f.project && f.project !== currentProject && Array.isArray(f.steps))
111 .sort((a, b) => b.at - a.at)
112 .slice(0, limit)
113 .map(f => ({ project: f.project, now: f.steps.find(s => s.status === 'now')?.label ?? null, waiting: f.waiting ?? null, at: f.at, goal: f.goal, detail: f.detail ?? null, steps: f.steps }))
114}
115
116// 경과 시간: 방금 / 12분 / 3시간 / 2일
117export function ago(at: number, now: number): string {
118 const m = Math.max(0, Math.round((now - at) / 60_000))
119 if (m < 1) return '방금'
120 if (m < 60) return `${m}분`
121 if (m < 60 * 24) return `${Math.round(m / 60)}시간`
122 return `${Math.round(m / 1440)}일`
123}
124hooks/parse.ts 88 lines1// 경로/제안 해석 유틸 (순수 함수, 테스트 대상)
2
3// 윈도우 역슬래시를 / 로 통일
4export const norm = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '')
5
6// Claude가 Claude Code 메모리나 스킬 파일을 쓰는지 판정 (저장 알림용)
7export function classifyPath(raw: string): { kind: 'memory' | 'skill'; name: string } | null {
8 const path = norm(raw)
9 if (!path.endsWith('.md')) return null
10 const memory = /\/\.claude\/projects\/[^/]+\/memory\/([^/]+)\.md$/.exec(path)
11 if (memory?.[1]) return memory[1] === 'MEMORY' ? null : { kind: 'memory', name: memory[1] }
12 const skill = /\/\.claude\/skills\/([^/]+)\/SKILL\.md$/.exec(path)
13 if (skill?.[1]) return { kind: 'skill', name: skill[1] }
14 return null
15}
16
17// 설정이 비어 있으면 홈 기준 기본 경로
18export function resolvePaths(home: string, store: string, engineDir: string, workStore = ''): { store: string; workStore: string; engineDir: string } {
19 const h = norm(home)
20 return {
21 store: store ? norm(store) : `${h}/workspace/memory-personal`,
22 workStore: workStore ? norm(workStore) : `${h}/workspace/memory-work`,
23 engineDir: engineDir ? norm(engineDir) : `${h}/workspace/agent-memory/engine`,
24 }
25}
26
27type ProposalItem = { n: number; op: string; status: string; [k: string]: unknown }
28
29const str = (v: unknown): string => (typeof v === 'string' ? v : '')
30const len = (v: unknown): number => (Array.isArray(v) ? v.length : 0)
31
32// 근거 개수로 계산하는 confidence (엔진과 같은 공식)
33export const confidence = (support: number, counter: number): number => (support + 1) / (support + counter + 2)
34
35// 제안 항목 한 줄 요약 (engine review.describe 와 같은 의미)
36export function describe(it: ProposalItem): string {
37 switch (it.op) {
38 case 'new_opinion':
39 return `[판단 ${confidence(len(it.supports), len(it.contradicts)).toFixed(2)}] ${str(it.statement)}`
40 case 'new_procedure':
41 return `[절차] ${str(it.trigger)}`
42 case 'observe':
43 return `[관찰] ${str(it.subject)}: ${str(it.summary)}`
44 case 'evidence':
45 return `[근거] ${str(it.id)} +${len(it.supports)} / 반례 +${len(it.contradicts)}`
46 case 'invalidate':
47 return `[무효화] ${str(it.id)}: ${str(it.reason)}`
48 default:
49 return `[${it.op}]`
50 }
51}
52
53// proposals/*.json 텍스트들 → 승인 대기 항목
54export function pendingItems<S extends string>(files: readonly string[], scope: S): { scope: S; pid: string; n: number; text: string }[] {
55 const out: { scope: S; pid: string; n: number; text: string }[] = []
56 for (const text of files) {
57 try {
58 const p = JSON.parse(text) as { id: string; status: string; items: ProposalItem[] }
59 if (p.status !== 'pending') continue
60 for (const it of p.items) if (it.status === 'pending') out.push({ scope, pid: p.id, n: it.n, text: describe(it) })
61 } catch {
62 // 동기화 중 깨진 파일은 건너뜀
63 }
64 }
65 return out
66}
67
68// recall을 건너뛸 프롬프트: 슬래시 명령, 너무 짧은 대답(ㄱㄱ, 응 등)
69export function shouldRecall(text: string): boolean {
70 const t = text.trim()
71 return t.length >= 6 && !t.startsWith('/')
72}
73
74export const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
75
76// 판단 변화 한 줄: ↑ / ↓ / NEW
77export function changeLine(c: { label: string; from: number | null; to: number }, width: number): string {
78 const label = clip(c.label, width)
79 if (c.from === null) return `NEW ${label} ${c.to.toFixed(2)}`
80 const arrow = c.to > c.from ? '↑' : c.to < c.from ? '↓' : '='
81 return `${arrow} ${label} ${c.from.toFixed(2)}→${c.to.toFixed(2)}`
82}
83
84// 한 줄 요약: E25 Op10 Pr2 Ob2 W0
85export function countsLine(c: Record<string, number>): string {
86 return `경험 ${c.experience ?? 0} · 판단 ${c.opinion ?? 0} · 절차 ${c.procedure ?? 0} · 관찰 ${c.observation ?? 0}`
87}
88types/index.d.ts 66 lines1// amem health JSON (engine/src/amem/health.py 와 같은 모양)
2export type Health = {
3 machine: string
4 counts: Record<'world' | 'experience' | 'observation' | 'opinion' | 'procedure', number>
5 new_today: number
6 updated_today: number
7 conflicts: string[]
8 pending_sessions: number
9 fresh_episodes: number
10 pending_proposals: number
11 recent_changes: { id: string; label: string; from: number | null; to: number; at: string }[]
12 heuristics?: { active: number; candidates: { id: string; statement: string; evidence: number; level: string; parent: string | null }[] }
13}
14
15export type Scope = 'personal' | 'work'
16
17// store별 health (없는 store는 목록에 안 들어감)
18export type StoreHealth = { scope: Scope; health: Health | null }
19
20// 승인 대기: 원칙 후보 하나
21export type PendingItem = { scope: Scope; id: string; text: string; evidence: number }
22
23export type StepStatus = 'done' | 'now' | 'next'
24export type Step = { label: string; status: StepStatus }
25
26// 프로젝트(폴더) 하나의 작업 흐름 (2단: 큰 단계 + 지금 세부 작업)
27export type Flow = {
28 project: string
29 goal: string
30 steps: Step[]
31 detail: string | null
32 // Claude가 사용자 답을 기다리는 질문 (없으면 null)
33 waiting: string | null
34 at: number
35}
36
37// 다른 폴더(작업)의 흐름 한 줄 요약
38export type OtherFlow = { project: string; now: string | null; waiting: string | null; at: number; goal: string; detail: string | null; steps: Step[] }
39
40// amem recall --json 의 hit (Memory Inspector용)
41export type RecallHit = { id: string; kind: string; score: number; why: string[]; label: string }
42export type LastRecall = { at: number; query: string; scope: string; hits: RecallHit[] }
43
44declare module 'claude-code' {
45 interface PluginState {
46 'memory-lens': {
47 stores: StoreHealth[]
48 pending: PendingItem[]
49 busy: string | null
50 message: string | null
51 saved: { at: number; name: string }[]
52 turns: number
53 recall: LastRecall | null
54 loadedAt: number
55 flow: Flow | null
56 flowUpdating: boolean
57 toolsSince: number
58 turnsSinceFlow: number
59 others: OtherFlow[]
60 expanded: boolean
61 seenPending: number
62 openOther: string | null
63 }
64 }
65}
66