도구 호출/사용자 교정 신호를 PC별·세션별 events.jsonl로 수집 (내용 없이 포인터만)

에이전트의 경험 → 사실 → 판단 → 행동 규칙을 쌓는 메모리 시스템 + 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.ts 67 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { MemoryEvent } from '../types'
5import { correctionOf, eventFile, isoLocal, scopeOf, targetOf, toJsonl } from './core.ts'
6
7const buffer = atom({ plugin: 'memory-events', key: 'buffer' } as const, null)
8
9const opt = (options: PluginOptions, k: string): string => String(options[k] ?? '')
10
11// 이벤트 하나를 세션 버퍼에 추가 (모듈 변수 대신 $.state: 리로드에도 유지)
12async function push($: EngineInterface, ev: Omit<MemoryEvent, 'ts'>): Promise<void> {
13 // 엔진(amem)이 claude -p로 부른 세션은 기록하지 않는다
14 if ((await $.env.get('AMEM_INTERNAL')) === '1') return
15 const sessionId = await $.session.id()
16 await update($, buffer, b => {
17 const base = b && b.sessionId === sessionId ? b : { sessionId, startedAt: isoLocal(new Date()), events: [] }
18 return { ...base, events: [...base.events, { ts: isoLocal(new Date()), ...ev }] }
19 })
20}
21
22// 세션 파일 전체를 다시 쓴다 (fs.write는 append가 없음, 세션별 파일이라 다른 세션과 충돌 없음)
23async function flush($: EngineInterface, options: PluginOptions): Promise<void> {
24 const b = await read($, buffer)
25 if (!b || b.events.length === 0) return
26 const scope = scopeOf(await $.session.cwd(), opt(options, 'workPaths'), opt(options, 'defaultScope'))
27 const store = scope === 'work' ? opt(options, 'workStore') : opt(options, 'personalStore')
28 if (!store) {
29 $.ui.status(`memory-events: ${scope} store 경로 미설정`)
30 return
31 }
32 const path = eventFile(store, opt(options, 'machine') || 'unknown', b.startedAt, b.sessionId)
33 // 첫 flush 때 기존 파일 내용을 한 번 읽어 앞에 붙인다 (덮어쓰기로 이전 이벤트가 사라지지 않게)
34 let prior = b.prior ?? ''
35 if (!b.priorLoaded) {
36 prior = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
37 await update($, buffer, cur => (cur ? { ...cur, prior, priorLoaded: true } : cur))
38 }
39 await $.fs.write(path, prior + toJsonl(b.events))
40}
41
42export const register: Register = (on, options) => {
43 on('tool.call', async ($, e, next) => {
44 const ran = await next(e)
45 try {
46 const cwd = await $.session.cwd()
47 const ok = ran.deny === undefined && ran.isError !== true
48 await push($, { kind: 'tool', name: e.tool, target: targetOf(e.tool, e as unknown as Record<string, unknown>, cwd), ok })
49 } catch {}
50 return ran
51 }).catch(($, e, next) => next(e))
52
53 on('prompt.submit', async ($, e, next) => {
54 const hit = correctionOf(e.text)
55 if (hit) await push($, { kind: 'correction', name: hit }).catch(() => {})
56 return next(e)
57 }).catch(($, e, next) => next(e))
58
59 on('turn.complete', async ($, e, next) => {
60 try {
61 await push($, { kind: 'turn', name: e.reason })
62 await flush($, options)
63 } catch {}
64 return next(e)
65 })
66}
67hooks/core.ts 67 lines1// 순수 함수 모음 (맥/윈도우 경로 공통 처리)
2import type { MemoryEvent } from '../types'
3
4// 윈도우 역슬래시를 / 로 통일하고 끝 슬래시 제거
5export const norm = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '')
6
7// cwd가 업무 경로에 속하면 work, 아니면 기본 scope (윈도우는 대소문자 무시)
8export function scopeOf(cwd: string, workPaths: string, defaultScope: string): 'personal' | 'work' {
9 const c = norm(cwd).toLowerCase()
10 const hit = workPaths
11 .split(',')
12 .map(s => norm(s.trim()).toLowerCase())
13 .filter(Boolean)
14 .some(prefix => c === prefix || c.startsWith(prefix + '/'))
15 if (hit) return 'work'
16 return defaultScope === 'work' ? 'work' : 'personal'
17}
18
19// 사용자가 Claude 판단을 고친 신호 (암묵지 후보). 원문은 저장하지 않고 키워드만
20const CORRECTION = /^\s*(아니(?:야|요|라)?|그게 아니|그거 말고|다시 ?해\S*|틀렸\S*|잘못\S*|no|wrong|that's not)(?=[\s,.!?]|$)/i
21export function correctionOf(text: string): string | null {
22 return CORRECTION.exec(text)?.[1]?.trim().toLowerCase() ?? null
23}
24
25// 도구 대상: 파일 경로는 cwd 기준 상대경로, Bash는 첫 단어(명령 이름)만
26export function targetOf(tool: string, input: Record<string, unknown>, cwd: string): string | undefined {
27 const path = input.file_path ?? input.notebook_path ?? input.path
28 if (typeof path === 'string') {
29 const p = norm(path)
30 const base = norm(cwd)
31 return p.toLowerCase().startsWith(base.toLowerCase() + '/') ? p.slice(base.length + 1) : p.split('/').pop()
32 }
33 if (tool === 'Bash' && typeof input.command === 'string') {
34 // 앞쪽 환경변수 대입(TOKEN=xxx)은 건너뛰고, 명령 이름은 basename만, 안전한 글자만 허용
35 const words = input.command.trim().split(/\s+/).filter(w => !/^\w+=/.test(w))
36 const head = safeWord(words[0]?.split(/[\/\\]/).pop())
37 if (!head) return undefined
38 const sub = SUBCOMMAND_TOOLS.has(head) ? safeWord(words[1]) : undefined
39 return sub ? `${head} ${sub}` : head
40 }
41 return undefined
42}
43
44// git commit 처럼 하위명령까지 의미 있는 도구
45const SUBCOMMAND_TOOLS = new Set(['git', 'npm', 'uv', 'pnpm', 'docker', 'claude', 'gh'])
46
47// 명령 이름으로 보이는 짧은 토큰만 통과 (비밀값/경로/옵션 차단)
48function safeWord(w: string | undefined): string | undefined {
49 return w && /^[A-Za-z0-9._-]{1,32}$/.test(w) && !w.startsWith('-') ? w : undefined
50}
51
52// store/events/<machine>/<YYYY-MM-DD>_<세션앞8자>.jsonl
53export function eventFile(store: string, machine: string, startedAt: string, sessionId: string): string {
54 return `${norm(store)}/events/${machine}/${startedAt.slice(0, 10)}_${sessionId.slice(0, 8)}.jsonl`
55}
56
57export const toJsonl = (events: readonly MemoryEvent[]): string => events.map(e => JSON.stringify(e)).join('\n') + '\n'
58
59// 로컬 시간대 오프셋을 붙인 ISO 문자열 (두 PC 모두 KST지만 명시)
60export function isoLocal(d: Date): string {
61 const off = -d.getTimezoneOffset()
62 const sign = off >= 0 ? '+' : '-'
63 const pad = (n: number) => String(Math.floor(Math.abs(n))).padStart(2, '0')
64 const local = new Date(d.getTime() + off * 60000).toISOString().slice(0, 19)
65 return `${local}${sign}${pad(off / 60)}:${pad(off % 60)}`
66}
67types/index.d.ts 20 lines1// events.jsonl 한 줄: 내용 원문 없이 "무슨 일이 있었는지" 포인터만 남긴다
2export type MemoryEvent = {
3 ts: string
4 kind: 'tool' | 'correction' | 'turn'
5 // tool: 도구 이름 / correction: 매칭된 교정 키워드 / turn: 종료 사유
6 name: string
7 // 도구 대상 (cwd 기준 상대경로 또는 파일명), 없으면 생략
8 target?: string
9 ok?: boolean
10}
11
12// prior: 이 프로세스가 시작되기 전 같은 세션 파일에 있던 내용 (재시작/--resume 시 보존)
13export type EventBuffer = { sessionId: string; startedAt: string; events: MemoryEvent[]; prior?: string; priorLoaded?: boolean }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'memory-events': { buffer: EventBuffer | null }
18 }
19}
20