SLOPSHOPPER

memory-lens

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

newpaneguardcommandtoaststatus
v0.6.0no licenseupdated 2026-10-12alstjrwjd99/agent-memory/mods/memory-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-lens
│ ┃ 🧭 작업 흐름 ✕ › fix the failing auth test and add an audit log call │ ┃ 흐름 없음: 대화가 쌓이면 자동으로 정리돼요 │ ┃ (/flow) ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────── ⎿ Read 6 lines │ ┃ 원칙 후보 0 · 활성 원칙 0 ⏺ Update(src/auth.ts) │ ┃ [ 새로고침 ] [ 접기 ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /memory-lens │ ⎿ memory-lens: 기억 현황 pane을 열었어요. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🧭 작업 흐름
흐름 없음: 대화가 쌓이면 자동으로 정리돼요 (/flow) ──────────────────────────────────── 원칙 후보 0 · 활성 원칙 0 [ 새로고침 ] [ 접기 ]
README

agent-memory

에이전트의 경험 → 사실 → 판단 → 행동 규칙을 쌓는 메모리 시스템 + Claude Code mods. 설계: docs/DESIGN.md

윈도우(회사 PC)는 docs/SETUP-WINDOWS.md를 따르세요.

설치 (Mac / Windows 공통)

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만 하면 반영됨.

기억 store (private)

git clone https://github.com/alstjrwjd99/memory-personal.git ~/workspace/memory-personal

작업 전후로 uv run amem sync (pull --rebase + push). events/는 PC 로컬에만 남음.

엔진 (amem)

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)

memory-events 설정 (/config 또는 settings.json pluginConfigs)

필드MacWindows(회사)
machinemacwin-work
defaultScopepersonalwork
personalStore~/workspace/memory-personal 절대경로(개인 store 경로)
workStore(비움)C:\...\memory-work
workPaths(비움)필요 시
Source 4 files
hooks/register.tsx 355 lines
1import { 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}
355
hooks/flow.ts 124 lines
1// 흐름 요약 파싱/표시 유틸 (순수 함수, 테스트 대상)
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}
124
hooks/parse.ts 88 lines
1// 경로/제안 해석 유틸 (순수 함수, 테스트 대상)
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}
88
types/index.d.ts 66 lines
1// 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