claude-memory-layer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/commands/memory-forget.md +42 -0
- package/.claude-plugin/commands/memory-history.md +34 -0
- package/.claude-plugin/commands/memory-import.md +56 -0
- package/.claude-plugin/commands/memory-list.md +37 -0
- package/.claude-plugin/commands/memory-search.md +36 -0
- package/.claude-plugin/commands/memory-stats.md +34 -0
- package/.claude-plugin/hooks.json +59 -0
- package/.claude-plugin/plugin.json +24 -0
- package/.history/package_20260201112328.json +45 -0
- package/.history/package_20260201113602.json +45 -0
- package/.history/package_20260201113713.json +45 -0
- package/.history/package_20260201114110.json +45 -0
- package/Memo.txt +558 -0
- package/README.md +520 -0
- package/context.md +636 -0
- package/dist/.claude-plugin/commands/memory-forget.md +42 -0
- package/dist/.claude-plugin/commands/memory-history.md +34 -0
- package/dist/.claude-plugin/commands/memory-import.md +56 -0
- package/dist/.claude-plugin/commands/memory-list.md +37 -0
- package/dist/.claude-plugin/commands/memory-search.md +36 -0
- package/dist/.claude-plugin/commands/memory-stats.md +34 -0
- package/dist/.claude-plugin/hooks.json +59 -0
- package/dist/.claude-plugin/plugin.json +24 -0
- package/dist/cli/index.js +3539 -0
- package/dist/cli/index.js.map +7 -0
- package/dist/core/index.js +4408 -0
- package/dist/core/index.js.map +7 -0
- package/dist/hooks/session-end.js +2971 -0
- package/dist/hooks/session-end.js.map +7 -0
- package/dist/hooks/session-start.js +2969 -0
- package/dist/hooks/session-start.js.map +7 -0
- package/dist/hooks/stop.js +3123 -0
- package/dist/hooks/stop.js.map +7 -0
- package/dist/hooks/user-prompt-submit.js +2960 -0
- package/dist/hooks/user-prompt-submit.js.map +7 -0
- package/dist/services/memory-service.js +2931 -0
- package/dist/services/memory-service.js.map +7 -0
- package/package.json +45 -0
- package/plan.md +1642 -0
- package/scripts/build.ts +102 -0
- package/spec.md +624 -0
- package/specs/citations-system/context.md +243 -0
- package/specs/citations-system/plan.md +495 -0
- package/specs/citations-system/spec.md +371 -0
- package/specs/endless-mode/context.md +305 -0
- package/specs/endless-mode/plan.md +620 -0
- package/specs/endless-mode/spec.md +455 -0
- package/specs/entity-edge-model/context.md +401 -0
- package/specs/entity-edge-model/plan.md +459 -0
- package/specs/entity-edge-model/spec.md +391 -0
- package/specs/evidence-aligner-v2/context.md +401 -0
- package/specs/evidence-aligner-v2/plan.md +303 -0
- package/specs/evidence-aligner-v2/spec.md +312 -0
- package/specs/mcp-desktop-integration/context.md +278 -0
- package/specs/mcp-desktop-integration/plan.md +550 -0
- package/specs/mcp-desktop-integration/spec.md +494 -0
- package/specs/post-tool-use-hook/context.md +319 -0
- package/specs/post-tool-use-hook/plan.md +469 -0
- package/specs/post-tool-use-hook/spec.md +364 -0
- package/specs/private-tags/context.md +288 -0
- package/specs/private-tags/plan.md +412 -0
- package/specs/private-tags/spec.md +345 -0
- package/specs/progressive-disclosure/context.md +346 -0
- package/specs/progressive-disclosure/plan.md +663 -0
- package/specs/progressive-disclosure/spec.md +415 -0
- package/specs/task-entity-system/context.md +297 -0
- package/specs/task-entity-system/plan.md +301 -0
- package/specs/task-entity-system/spec.md +314 -0
- package/specs/vector-outbox-v2/context.md +470 -0
- package/specs/vector-outbox-v2/plan.md +562 -0
- package/specs/vector-outbox-v2/spec.md +466 -0
- package/specs/web-viewer-ui/context.md +384 -0
- package/specs/web-viewer-ui/plan.md +797 -0
- package/specs/web-viewer-ui/spec.md +516 -0
- package/src/cli/index.ts +570 -0
- package/src/core/canonical-key.ts +186 -0
- package/src/core/citation-generator.ts +63 -0
- package/src/core/consolidated-store.ts +279 -0
- package/src/core/consolidation-worker.ts +384 -0
- package/src/core/context-formatter.ts +276 -0
- package/src/core/continuity-manager.ts +336 -0
- package/src/core/edge-repo.ts +324 -0
- package/src/core/embedder.ts +124 -0
- package/src/core/entity-repo.ts +342 -0
- package/src/core/event-store.ts +672 -0
- package/src/core/evidence-aligner.ts +635 -0
- package/src/core/graduation.ts +365 -0
- package/src/core/index.ts +32 -0
- package/src/core/matcher.ts +210 -0
- package/src/core/metadata-extractor.ts +203 -0
- package/src/core/privacy/filter.ts +179 -0
- package/src/core/privacy/index.ts +20 -0
- package/src/core/privacy/tag-parser.ts +145 -0
- package/src/core/progressive-retriever.ts +415 -0
- package/src/core/retriever.ts +235 -0
- package/src/core/task/blocker-resolver.ts +325 -0
- package/src/core/task/index.ts +9 -0
- package/src/core/task/task-matcher.ts +238 -0
- package/src/core/task/task-projector.ts +345 -0
- package/src/core/task/task-resolver.ts +414 -0
- package/src/core/types.ts +841 -0
- package/src/core/vector-outbox.ts +295 -0
- package/src/core/vector-store.ts +182 -0
- package/src/core/vector-worker.ts +488 -0
- package/src/core/working-set-store.ts +244 -0
- package/src/hooks/post-tool-use.ts +127 -0
- package/src/hooks/session-end.ts +78 -0
- package/src/hooks/session-start.ts +57 -0
- package/src/hooks/stop.ts +78 -0
- package/src/hooks/user-prompt-submit.ts +54 -0
- package/src/mcp/handlers.ts +212 -0
- package/src/mcp/index.ts +47 -0
- package/src/mcp/tools.ts +78 -0
- package/src/server/api/citations.ts +101 -0
- package/src/server/api/events.ts +101 -0
- package/src/server/api/index.ts +18 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/sessions.ts +111 -0
- package/src/server/api/stats.ts +97 -0
- package/src/server/index.ts +91 -0
- package/src/services/memory-service.ts +626 -0
- package/src/services/session-history-importer.ts +367 -0
- package/tests/canonical-key.test.ts +101 -0
- package/tests/evidence-aligner.test.ts +152 -0
- package/tests/matcher.test.ts +112 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +15 -0
package/plan.md
ADDED
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
# Implementation Plan: Claude Code Memory Plugin
|
|
2
|
+
|
|
3
|
+
## 1. 개발 단계 개요
|
|
4
|
+
|
|
5
|
+
AXIOMMIND 파이프라인 구현 단계에 맞춰 구성:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ P0: 필수 품질 (Must Have) │
|
|
10
|
+
│ ├── EventStore (append-only, dedupe) │
|
|
11
|
+
│ ├── EvidenceAligner (증거 스팬 정렬) │
|
|
12
|
+
│ ├── Task Entity System (canonical_key) │
|
|
13
|
+
│ ├── Vector Outbox (single-writer) │
|
|
14
|
+
│ ├── Basic Hooks (SessionStart, Stop) │
|
|
15
|
+
│ └── CLI 기본 명령어 │
|
|
16
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
17
|
+
│ P1: 운영 (Operations) │
|
|
18
|
+
│ ├── build_runs 테이블 (파이프라인 실행 추적) │
|
|
19
|
+
│ ├── conflict_ledger (충돌 기록) │
|
|
20
|
+
│ ├── decision_ledger (의사결정 기록) │
|
|
21
|
+
│ ├── 메트릭 스켈레톤 │
|
|
22
|
+
│ └── 에러 복구 메커니즘 │
|
|
23
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
24
|
+
│ P2: 검색 (Retrieval) │
|
|
25
|
+
│ ├── 하이브리드 검색 (Vector + FTS) │
|
|
26
|
+
│ ├── 골드셋 평가 시스템 │
|
|
27
|
+
│ ├── 인사이트 추출 (LLM 기반) │
|
|
28
|
+
│ └── L2→L3→L4 승격 파이프라인 │
|
|
29
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 구현 Phase 매핑
|
|
33
|
+
|
|
34
|
+
| AXIOMMIND | 플러그인 Phase | 핵심 산출물 |
|
|
35
|
+
|-----------|---------------|-------------|
|
|
36
|
+
| **P0** | Phase 0-1 | EventStore, VectorWorker, 기본 Hook |
|
|
37
|
+
| **P0** | Phase 2-3 | Embedder, Retriever, Hook 통합 |
|
|
38
|
+
| **P1** | Phase 4 | CLI, 운영 도구, 메트릭 |
|
|
39
|
+
| **P2** | Phase 5 | 하이브리드 검색, 평가, 최적화 |
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Phase 0: 프로젝트 설정
|
|
43
|
+
↓
|
|
44
|
+
Phase 1: Core Storage Layer (P0 - EventStore, Outbox)
|
|
45
|
+
↓
|
|
46
|
+
Phase 2: Embedding & Retrieval (P0 - Vector, Matcher)
|
|
47
|
+
↓
|
|
48
|
+
Phase 3: Hook Integration (P0 - 기본 동작)
|
|
49
|
+
↓
|
|
50
|
+
Phase 4: Commands & CLI (P1 - 운영)
|
|
51
|
+
↓
|
|
52
|
+
Phase 5: Testing & Polish (P2 - 검색 최적화)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Phase 0: 프로젝트 설정
|
|
58
|
+
|
|
59
|
+
### 0.1 디렉토리 구조 생성
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
code-memory/
|
|
63
|
+
├── .claude-plugin/
|
|
64
|
+
│ └── plugin.json # 플러그인 메타데이터
|
|
65
|
+
├── commands/
|
|
66
|
+
│ ├── search.md # /code-memory:search
|
|
67
|
+
│ ├── history.md # /code-memory:history
|
|
68
|
+
│ ├── insights.md # /code-memory:insights
|
|
69
|
+
│ ├── forget.md # /code-memory:forget
|
|
70
|
+
│ └── stats.md # /code-memory:stats
|
|
71
|
+
├── hooks/
|
|
72
|
+
│ └── hooks.json # Hook 설정
|
|
73
|
+
├── src/
|
|
74
|
+
│ ├── cli/
|
|
75
|
+
│ │ ├── index.ts # CLI 엔트리포인트
|
|
76
|
+
│ │ ├── commands/
|
|
77
|
+
│ │ │ ├── session-start.ts
|
|
78
|
+
│ │ │ ├── session-end.ts
|
|
79
|
+
│ │ │ ├── search.ts
|
|
80
|
+
│ │ │ ├── save.ts
|
|
81
|
+
│ │ │ └── init.ts
|
|
82
|
+
│ │ └── utils.ts
|
|
83
|
+
│ ├── core/
|
|
84
|
+
│ │ ├── canonical-key.ts # 정규화된 키 생성 (AXIOMMIND)
|
|
85
|
+
│ │ ├── event-store.ts # DuckDB 이벤트 저장소
|
|
86
|
+
│ │ ├── vector-store.ts # LanceDB 벡터 저장소
|
|
87
|
+
│ │ ├── vector-worker.ts # Single-writer 패턴 워커 (AXIOMMIND)
|
|
88
|
+
│ │ ├── embedder.ts # 임베딩 생성
|
|
89
|
+
│ │ ├── matcher.ts # 가중치 기반 매칭 (AXIOMMIND)
|
|
90
|
+
│ │ ├── evidence-aligner.ts # 증거 스팬 정렬 (AXIOMMIND 원칙4)
|
|
91
|
+
│ │ ├── retriever.ts # 기억 검색
|
|
92
|
+
│ │ ├── projector.ts # 이벤트→엔티티 투영 (AXIOMMIND)
|
|
93
|
+
│ │ ├── graduation.ts # L0→L4 승격 파이프라인 (AXIOMMIND)
|
|
94
|
+
│ │ └── types.ts # 타입 정의
|
|
95
|
+
│ ├── hooks/
|
|
96
|
+
│ │ ├── session-start.ts
|
|
97
|
+
│ │ ├── user-prompt-submit.ts
|
|
98
|
+
│ │ ├── stop.ts
|
|
99
|
+
│ │ └── session-end.ts
|
|
100
|
+
│ └── index.ts
|
|
101
|
+
├── tests/
|
|
102
|
+
│ ├── event-store.test.ts
|
|
103
|
+
│ ├── vector-store.test.ts
|
|
104
|
+
│ ├── retriever.test.ts
|
|
105
|
+
│ └── integration.test.ts
|
|
106
|
+
├── scripts/
|
|
107
|
+
│ └── build.ts
|
|
108
|
+
├── package.json
|
|
109
|
+
├── tsconfig.json
|
|
110
|
+
├── vitest.config.ts
|
|
111
|
+
├── README.md
|
|
112
|
+
├── spec.md
|
|
113
|
+
├── plan.md
|
|
114
|
+
└── context.md
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 0.2 초기 설정 작업
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# 1. package.json 생성
|
|
121
|
+
npm init -y
|
|
122
|
+
|
|
123
|
+
# 2. TypeScript 및 핵심 의존성 설치
|
|
124
|
+
npm install -D typescript @types/node tsx esbuild vitest
|
|
125
|
+
|
|
126
|
+
# 3. 런타임 의존성
|
|
127
|
+
npm install zod commander duckdb lancedb
|
|
128
|
+
|
|
129
|
+
# 4. 임베딩 (선택)
|
|
130
|
+
npm install @xenova/transformers # 또는 Python sentence-transformers
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 0.3 TypeScript 설정
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
// tsconfig.json
|
|
137
|
+
{
|
|
138
|
+
"compilerOptions": {
|
|
139
|
+
"target": "ES2022",
|
|
140
|
+
"module": "ESNext",
|
|
141
|
+
"moduleResolution": "bundler",
|
|
142
|
+
"strict": true,
|
|
143
|
+
"esModuleInterop": true,
|
|
144
|
+
"skipLibCheck": true,
|
|
145
|
+
"outDir": "dist",
|
|
146
|
+
"rootDir": "src",
|
|
147
|
+
"declaration": true,
|
|
148
|
+
"resolveJsonModule": true
|
|
149
|
+
},
|
|
150
|
+
"include": ["src/**/*"],
|
|
151
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Phase 1: Core Storage Layer
|
|
158
|
+
|
|
159
|
+
### 1.1 타입 정의 (src/core/types.ts)
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// Idris2 영감: 완전하고 불변한 타입 정의
|
|
163
|
+
|
|
164
|
+
import { z } from 'zod';
|
|
165
|
+
|
|
166
|
+
// 이벤트 타입 스키마
|
|
167
|
+
export const EventTypeSchema = z.enum([
|
|
168
|
+
'user_prompt',
|
|
169
|
+
'agent_response',
|
|
170
|
+
'session_summary'
|
|
171
|
+
]);
|
|
172
|
+
export type EventType = z.infer<typeof EventTypeSchema>;
|
|
173
|
+
|
|
174
|
+
// 메모리 이벤트 스키마
|
|
175
|
+
export const MemoryEventSchema = z.object({
|
|
176
|
+
id: z.string().uuid(),
|
|
177
|
+
eventType: EventTypeSchema,
|
|
178
|
+
sessionId: z.string(),
|
|
179
|
+
timestamp: z.date(),
|
|
180
|
+
content: z.string(),
|
|
181
|
+
contentHash: z.string(),
|
|
182
|
+
metadata: z.record(z.unknown()).optional()
|
|
183
|
+
});
|
|
184
|
+
export type MemoryEvent = z.infer<typeof MemoryEventSchema>;
|
|
185
|
+
|
|
186
|
+
// 세션 스키마
|
|
187
|
+
export const SessionSchema = z.object({
|
|
188
|
+
id: z.string(),
|
|
189
|
+
startedAt: z.date(),
|
|
190
|
+
endedAt: z.date().optional(),
|
|
191
|
+
projectPath: z.string().optional(),
|
|
192
|
+
summary: z.string().optional(),
|
|
193
|
+
tags: z.array(z.string()).optional()
|
|
194
|
+
});
|
|
195
|
+
export type Session = z.infer<typeof SessionSchema>;
|
|
196
|
+
|
|
197
|
+
// 검색 결과
|
|
198
|
+
export const MemoryMatchSchema = z.object({
|
|
199
|
+
event: MemoryEventSchema,
|
|
200
|
+
score: z.number().min(0).max(1),
|
|
201
|
+
relevanceReason: z.string().optional()
|
|
202
|
+
});
|
|
203
|
+
export type MemoryMatch = z.infer<typeof MemoryMatchSchema>;
|
|
204
|
+
|
|
205
|
+
// 설정 스키마
|
|
206
|
+
export const ConfigSchema = z.object({
|
|
207
|
+
storage: z.object({
|
|
208
|
+
path: z.string().default('~/.claude-code/memory'),
|
|
209
|
+
maxSizeMB: z.number().default(500)
|
|
210
|
+
}),
|
|
211
|
+
embedding: z.object({
|
|
212
|
+
provider: z.enum(['local', 'openai']).default('local'),
|
|
213
|
+
model: z.string().default('all-MiniLM-L6-v2'),
|
|
214
|
+
batchSize: z.number().default(32)
|
|
215
|
+
}),
|
|
216
|
+
retrieval: z.object({
|
|
217
|
+
topK: z.number().default(5),
|
|
218
|
+
minScore: z.number().default(0.7),
|
|
219
|
+
maxTokens: z.number().default(2000)
|
|
220
|
+
})
|
|
221
|
+
});
|
|
222
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
223
|
+
|
|
224
|
+
// AXIOMMIND: Matching 결과 타입
|
|
225
|
+
export const MatchConfidenceSchema = z.enum(['high', 'suggested', 'none']);
|
|
226
|
+
export type MatchConfidence = z.infer<typeof MatchConfidenceSchema>;
|
|
227
|
+
|
|
228
|
+
export const MatchResultSchema = z.object({
|
|
229
|
+
match: MemoryMatchSchema.nullable(),
|
|
230
|
+
confidence: MatchConfidenceSchema,
|
|
231
|
+
gap: z.number().optional(),
|
|
232
|
+
alternatives: z.array(MemoryMatchSchema).optional()
|
|
233
|
+
});
|
|
234
|
+
export type MatchResult = z.infer<typeof MatchResultSchema>;
|
|
235
|
+
|
|
236
|
+
// AXIOMMIND: Matching Thresholds
|
|
237
|
+
export const MATCH_THRESHOLDS = {
|
|
238
|
+
minCombinedScore: 0.92,
|
|
239
|
+
minGap: 0.03,
|
|
240
|
+
suggestionThreshold: 0.75
|
|
241
|
+
} as const;
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 1.2 Canonical Key 구현 (src/core/canonical-key.ts) - AXIOMMIND
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
/**
|
|
248
|
+
* AXIOMMIND canonical_key.py 포팅
|
|
249
|
+
* 동일한 제목은 항상 동일한 키를 생성하는 결정론적 정규화
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
import { createHash } from 'crypto';
|
|
253
|
+
|
|
254
|
+
const MAX_KEY_LENGTH = 200;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 텍스트를 정규화된 canonical key로 변환
|
|
258
|
+
*
|
|
259
|
+
* 정규화 단계:
|
|
260
|
+
* 1. NFKC 유니코드 정규화
|
|
261
|
+
* 2. 소문자 변환
|
|
262
|
+
* 3. 구두점 제거
|
|
263
|
+
* 4. 연속 공백 정리
|
|
264
|
+
* 5. 컨텍스트 추가 (선택)
|
|
265
|
+
* 6. 긴 키 truncate + MD5
|
|
266
|
+
*/
|
|
267
|
+
export function makeCanonicalKey(
|
|
268
|
+
title: string,
|
|
269
|
+
context?: { project?: string; sessionId?: string }
|
|
270
|
+
): string {
|
|
271
|
+
// Step 1: NFKC 정규화
|
|
272
|
+
let normalized = title.normalize('NFKC');
|
|
273
|
+
|
|
274
|
+
// Step 2: 소문자 변환
|
|
275
|
+
normalized = normalized.toLowerCase();
|
|
276
|
+
|
|
277
|
+
// Step 3: 구두점 제거 (유니코드 호환)
|
|
278
|
+
normalized = normalized.replace(/[^\p{L}\p{N}\s]/gu, '');
|
|
279
|
+
|
|
280
|
+
// Step 4: 연속 공백 정리
|
|
281
|
+
normalized = normalized.replace(/\s+/g, ' ').trim();
|
|
282
|
+
|
|
283
|
+
// Step 5: 컨텍스트 추가
|
|
284
|
+
let key = normalized;
|
|
285
|
+
if (context?.project) {
|
|
286
|
+
key = `${context.project}::${key}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Step 6: 긴 키 처리
|
|
290
|
+
if (key.length > MAX_KEY_LENGTH) {
|
|
291
|
+
const hashSuffix = createHash('md5').update(key).digest('hex').slice(0, 8);
|
|
292
|
+
key = key.slice(0, MAX_KEY_LENGTH - 9) + '_' + hashSuffix;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return key;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 두 텍스트가 동일한 canonical key를 가지는지 확인
|
|
300
|
+
*/
|
|
301
|
+
export function isSameCanonicalKey(a: string, b: string): boolean {
|
|
302
|
+
return makeCanonicalKey(a) === makeCanonicalKey(b);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Dedupe key 생성 (content + session으로 유일성 보장)
|
|
307
|
+
*/
|
|
308
|
+
export function makeDedupeKey(content: string, sessionId: string): string {
|
|
309
|
+
const contentHash = createHash('sha256').update(content).digest('hex');
|
|
310
|
+
return `${sessionId}:${contentHash}`;
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### 1.3 Event Store 구현 (src/core/event-store.ts)
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// AXIOMMIND 스타일: append-only, 멱등성, 단일 진실 공급원
|
|
318
|
+
|
|
319
|
+
import { Database } from 'duckdb';
|
|
320
|
+
import { createHash } from 'crypto';
|
|
321
|
+
import { MemoryEvent, EventType } from './types';
|
|
322
|
+
|
|
323
|
+
export class EventStore {
|
|
324
|
+
private db: Database;
|
|
325
|
+
|
|
326
|
+
constructor(dbPath: string) {
|
|
327
|
+
this.db = new Database(dbPath);
|
|
328
|
+
this.initialize();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private initialize(): void {
|
|
332
|
+
// 이벤트 테이블 (불변, append-only)
|
|
333
|
+
this.db.run(`
|
|
334
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
335
|
+
id VARCHAR PRIMARY KEY,
|
|
336
|
+
event_type VARCHAR NOT NULL,
|
|
337
|
+
session_id VARCHAR NOT NULL,
|
|
338
|
+
timestamp TIMESTAMP NOT NULL,
|
|
339
|
+
content TEXT NOT NULL,
|
|
340
|
+
metadata JSON,
|
|
341
|
+
content_hash VARCHAR UNIQUE
|
|
342
|
+
)
|
|
343
|
+
`);
|
|
344
|
+
|
|
345
|
+
// 중복 방지 테이블
|
|
346
|
+
this.db.run(`
|
|
347
|
+
CREATE TABLE IF NOT EXISTS event_dedup (
|
|
348
|
+
content_hash VARCHAR PRIMARY KEY,
|
|
349
|
+
event_id VARCHAR NOT NULL
|
|
350
|
+
)
|
|
351
|
+
`);
|
|
352
|
+
|
|
353
|
+
// 세션 테이블
|
|
354
|
+
this.db.run(`
|
|
355
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
356
|
+
id VARCHAR PRIMARY KEY,
|
|
357
|
+
started_at TIMESTAMP NOT NULL,
|
|
358
|
+
ended_at TIMESTAMP,
|
|
359
|
+
project_path VARCHAR,
|
|
360
|
+
summary TEXT
|
|
361
|
+
)
|
|
362
|
+
`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 멱등성 보장 저장
|
|
366
|
+
async append(event: Omit<MemoryEvent, 'id' | 'contentHash'>): Promise<{
|
|
367
|
+
success: boolean;
|
|
368
|
+
eventId?: string;
|
|
369
|
+
isDuplicate?: boolean;
|
|
370
|
+
}> {
|
|
371
|
+
const contentHash = this.hashContent(event.content);
|
|
372
|
+
|
|
373
|
+
// 중복 확인
|
|
374
|
+
const existing = this.db.prepare(`
|
|
375
|
+
SELECT event_id FROM event_dedup WHERE content_hash = ?
|
|
376
|
+
`).get(contentHash);
|
|
377
|
+
|
|
378
|
+
if (existing) {
|
|
379
|
+
return { success: true, eventId: existing.event_id, isDuplicate: true };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const id = crypto.randomUUID();
|
|
383
|
+
|
|
384
|
+
this.db.run(`
|
|
385
|
+
INSERT INTO events (id, event_type, session_id, timestamp, content, metadata, content_hash)
|
|
386
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
387
|
+
`, [id, event.eventType, event.sessionId, event.timestamp, event.content,
|
|
388
|
+
JSON.stringify(event.metadata), contentHash]);
|
|
389
|
+
|
|
390
|
+
this.db.run(`
|
|
391
|
+
INSERT INTO event_dedup (content_hash, event_id) VALUES (?, ?)
|
|
392
|
+
`, [contentHash, id]);
|
|
393
|
+
|
|
394
|
+
return { success: true, eventId: id, isDuplicate: false };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private hashContent(content: string): string {
|
|
398
|
+
return createHash('sha256').update(content).digest('hex');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 세션별 이벤트 조회
|
|
402
|
+
async getSessionEvents(sessionId: string): Promise<MemoryEvent[]> {
|
|
403
|
+
return this.db.prepare(`
|
|
404
|
+
SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC
|
|
405
|
+
`).all(sessionId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 최근 이벤트 조회
|
|
409
|
+
async getRecentEvents(limit: number = 100): Promise<MemoryEvent[]> {
|
|
410
|
+
return this.db.prepare(`
|
|
411
|
+
SELECT * FROM events ORDER BY timestamp DESC LIMIT ?
|
|
412
|
+
`).all(limit);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### 1.3 Vector Store 구현 (src/core/vector-store.ts)
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
import * as lancedb from 'lancedb';
|
|
421
|
+
|
|
422
|
+
export class VectorStore {
|
|
423
|
+
private db: lancedb.Connection;
|
|
424
|
+
private table: lancedb.Table | null = null;
|
|
425
|
+
|
|
426
|
+
constructor(private dbPath: string) {}
|
|
427
|
+
|
|
428
|
+
async initialize(): Promise<void> {
|
|
429
|
+
this.db = await lancedb.connect(this.dbPath);
|
|
430
|
+
|
|
431
|
+
// 테이블이 없으면 생성 (첫 데이터 삽입 시)
|
|
432
|
+
try {
|
|
433
|
+
this.table = await this.db.openTable('conversations');
|
|
434
|
+
} catch {
|
|
435
|
+
// 테이블이 없으면 나중에 생성
|
|
436
|
+
this.table = null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async upsert(data: {
|
|
441
|
+
id: string;
|
|
442
|
+
eventId: string;
|
|
443
|
+
sessionId: string;
|
|
444
|
+
eventType: string;
|
|
445
|
+
content: string;
|
|
446
|
+
vector: number[];
|
|
447
|
+
timestamp: string;
|
|
448
|
+
metadata?: Record<string, unknown>;
|
|
449
|
+
}): Promise<void> {
|
|
450
|
+
if (!this.table) {
|
|
451
|
+
// 첫 데이터로 테이블 생성
|
|
452
|
+
this.table = await this.db.createTable('conversations', [data]);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await this.table.add([data]);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async search(queryVector: number[], options: {
|
|
460
|
+
limit?: number;
|
|
461
|
+
minScore?: number;
|
|
462
|
+
filter?: string;
|
|
463
|
+
} = {}): Promise<Array<{
|
|
464
|
+
id: string;
|
|
465
|
+
eventId: string;
|
|
466
|
+
content: string;
|
|
467
|
+
score: number;
|
|
468
|
+
}>> {
|
|
469
|
+
if (!this.table) {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const { limit = 5, minScore = 0.7 } = options;
|
|
474
|
+
|
|
475
|
+
const results = await this.table
|
|
476
|
+
.search(queryVector)
|
|
477
|
+
.limit(limit)
|
|
478
|
+
.execute();
|
|
479
|
+
|
|
480
|
+
return results
|
|
481
|
+
.filter(r => r._distance <= (1 - minScore)) // distance to score
|
|
482
|
+
.map(r => ({
|
|
483
|
+
id: r.id,
|
|
484
|
+
eventId: r.eventId,
|
|
485
|
+
content: r.content,
|
|
486
|
+
score: 1 - r._distance
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async delete(eventId: string): Promise<void> {
|
|
491
|
+
if (!this.table) return;
|
|
492
|
+
await this.table.delete(`eventId = "${eventId}"`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### 1.4 Vector Worker 구현 (src/core/vector-worker.ts) - AXIOMMIND Single-Writer
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
/**
|
|
501
|
+
* AXIOMMIND vector_worker.py 포팅
|
|
502
|
+
* Single-Writer 패턴으로 LanceDB 동시성 문제 해결
|
|
503
|
+
*
|
|
504
|
+
* 원리:
|
|
505
|
+
* 1. 이벤트 저장 시 embedding_outbox에 작업 추가 (빠름)
|
|
506
|
+
* 2. 별도 워커가 outbox를 순차적으로 처리 (단일 쓰기)
|
|
507
|
+
* 3. 처리 완료 시 outbox에서 삭제
|
|
508
|
+
*/
|
|
509
|
+
|
|
510
|
+
import { Database } from 'duckdb';
|
|
511
|
+
import { VectorStore } from './vector-store';
|
|
512
|
+
import { Embedder } from './embedder';
|
|
513
|
+
|
|
514
|
+
interface OutboxItem {
|
|
515
|
+
id: string;
|
|
516
|
+
eventId: string;
|
|
517
|
+
content: string;
|
|
518
|
+
status: 'pending' | 'processing' | 'done' | 'failed';
|
|
519
|
+
retryCount: number;
|
|
520
|
+
createdAt: Date;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export class VectorWorker {
|
|
524
|
+
private isRunning = false;
|
|
525
|
+
private pollInterval = 1000; // 1초
|
|
526
|
+
|
|
527
|
+
constructor(
|
|
528
|
+
private db: Database,
|
|
529
|
+
private vectorStore: VectorStore,
|
|
530
|
+
private embedder: Embedder
|
|
531
|
+
) {}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Outbox에 임베딩 작업 추가 (빠른 반환)
|
|
535
|
+
*/
|
|
536
|
+
async enqueue(eventId: string, content: string): Promise<string> {
|
|
537
|
+
const id = crypto.randomUUID();
|
|
538
|
+
|
|
539
|
+
this.db.run(`
|
|
540
|
+
INSERT INTO embedding_outbox (id, event_id, content, status, retry_count, created_at)
|
|
541
|
+
VALUES (?, ?, ?, 'pending', 0, NOW())
|
|
542
|
+
`, [id, eventId, content]);
|
|
543
|
+
|
|
544
|
+
return id;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 워커 시작 (백그라운드에서 실행)
|
|
549
|
+
*/
|
|
550
|
+
async start(): Promise<void> {
|
|
551
|
+
if (this.isRunning) return;
|
|
552
|
+
this.isRunning = true;
|
|
553
|
+
|
|
554
|
+
while (this.isRunning) {
|
|
555
|
+
await this.processOutbox();
|
|
556
|
+
await this.sleep(this.pollInterval);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 워커 중지
|
|
562
|
+
*/
|
|
563
|
+
stop(): void {
|
|
564
|
+
this.isRunning = false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Outbox 처리 (핵심 로직)
|
|
569
|
+
*/
|
|
570
|
+
private async processOutbox(batchSize: number = 32): Promise<number> {
|
|
571
|
+
// 1. Pending 항목 가져오기 + 락 획득 (atomic update)
|
|
572
|
+
const pending = this.db.prepare(`
|
|
573
|
+
UPDATE embedding_outbox
|
|
574
|
+
SET status = 'processing'
|
|
575
|
+
WHERE id IN (
|
|
576
|
+
SELECT id FROM embedding_outbox
|
|
577
|
+
WHERE status = 'pending'
|
|
578
|
+
ORDER BY created_at
|
|
579
|
+
LIMIT ?
|
|
580
|
+
)
|
|
581
|
+
RETURNING *
|
|
582
|
+
`).all(batchSize) as OutboxItem[];
|
|
583
|
+
|
|
584
|
+
if (pending.length === 0) {
|
|
585
|
+
return 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
// 2. 배치 임베딩 생성
|
|
590
|
+
const contents = pending.map(p => p.content);
|
|
591
|
+
const vectors = await this.embedder.embedBatch(contents);
|
|
592
|
+
|
|
593
|
+
// 3. LanceDB에 저장 (단일 쓰기 - 동시성 안전)
|
|
594
|
+
for (let i = 0; i < pending.length; i++) {
|
|
595
|
+
const item = pending[i];
|
|
596
|
+
await this.vectorStore.upsert({
|
|
597
|
+
id: crypto.randomUUID(),
|
|
598
|
+
eventId: item.eventId,
|
|
599
|
+
sessionId: '', // 필요시 조회
|
|
600
|
+
eventType: '',
|
|
601
|
+
content: item.content.slice(0, 1000), // 미리보기용
|
|
602
|
+
vector: vectors[i],
|
|
603
|
+
timestamp: new Date().toISOString()
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 4. 성공한 항목 삭제
|
|
608
|
+
const ids = pending.map(p => `'${p.id}'`).join(',');
|
|
609
|
+
this.db.run(`DELETE FROM embedding_outbox WHERE id IN (${ids})`);
|
|
610
|
+
|
|
611
|
+
return pending.length;
|
|
612
|
+
|
|
613
|
+
} catch (error) {
|
|
614
|
+
// 5. 실패 시 retry_count 증가
|
|
615
|
+
const ids = pending.map(p => `'${p.id}'`).join(',');
|
|
616
|
+
this.db.run(`
|
|
617
|
+
UPDATE embedding_outbox
|
|
618
|
+
SET status = CASE WHEN retry_count >= 3 THEN 'failed' ELSE 'pending' END,
|
|
619
|
+
retry_count = retry_count + 1,
|
|
620
|
+
error_message = ?
|
|
621
|
+
WHERE id IN (${ids})
|
|
622
|
+
`, [String(error)]);
|
|
623
|
+
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private sleep(ms: number): Promise<void> {
|
|
629
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### 1.5 Matcher 구현 (src/core/matcher.ts) - AXIOMMIND Weighted Scoring
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
/**
|
|
638
|
+
* AXIOMMIND task_matcher.py 포팅
|
|
639
|
+
* 가중치 기반 스코어링 + 엄격한 매칭 임계값
|
|
640
|
+
*/
|
|
641
|
+
|
|
642
|
+
import { MemoryMatch, MatchResult, MATCH_THRESHOLDS } from './types';
|
|
643
|
+
|
|
644
|
+
interface ScoringWeights {
|
|
645
|
+
semanticSimilarity: number;
|
|
646
|
+
ftsScore: number;
|
|
647
|
+
recencyBonus: number;
|
|
648
|
+
statusWeight: number;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const DEFAULT_WEIGHTS: ScoringWeights = {
|
|
652
|
+
semanticSimilarity: 0.4,
|
|
653
|
+
ftsScore: 0.25,
|
|
654
|
+
recencyBonus: 0.2,
|
|
655
|
+
statusWeight: 0.15
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
interface RawSearchResult {
|
|
659
|
+
id: string;
|
|
660
|
+
eventId: string;
|
|
661
|
+
content: string;
|
|
662
|
+
vectorScore: number; // 벡터 유사도 (0-1)
|
|
663
|
+
ftsScore?: number; // 전문 검색 점수 (0-1)
|
|
664
|
+
timestamp: Date;
|
|
665
|
+
eventType: string;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 가중치 결합 점수 계산
|
|
670
|
+
*/
|
|
671
|
+
export function calculateWeightedScore(
|
|
672
|
+
result: RawSearchResult,
|
|
673
|
+
weights: ScoringWeights = DEFAULT_WEIGHTS
|
|
674
|
+
): number {
|
|
675
|
+
// 최신성 가산점 (30일 이내 = 1.0, 이후 감소)
|
|
676
|
+
const daysSince = (Date.now() - result.timestamp.getTime()) / (1000 * 60 * 60 * 24);
|
|
677
|
+
const recencyScore = Math.max(0, 1 - daysSince / 30);
|
|
678
|
+
|
|
679
|
+
// 상태별 가중치 (agent_response > user_prompt)
|
|
680
|
+
const statusScore = result.eventType === 'agent_response' ? 1.0 : 0.8;
|
|
681
|
+
|
|
682
|
+
const score =
|
|
683
|
+
result.vectorScore * weights.semanticSimilarity +
|
|
684
|
+
(result.ftsScore ?? 0) * weights.ftsScore +
|
|
685
|
+
recencyScore * weights.recencyBonus +
|
|
686
|
+
statusScore * weights.statusWeight;
|
|
687
|
+
|
|
688
|
+
return Math.min(1, Math.max(0, score)); // 0-1 범위로 클램프
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 엄격한 매칭: top-1이 확실히 우세할 때만 확정
|
|
693
|
+
*
|
|
694
|
+
* AXIOMMIND 규칙:
|
|
695
|
+
* - combined score >= 0.92 AND gap >= 0.03 → 'high' (확정)
|
|
696
|
+
* - 0.75 <= score < 0.92 → 'suggested' (제안)
|
|
697
|
+
* - score < 0.75 → 'none' (매칭 없음)
|
|
698
|
+
*/
|
|
699
|
+
export function matchWithConfidence(
|
|
700
|
+
candidates: MemoryMatch[]
|
|
701
|
+
): MatchResult {
|
|
702
|
+
if (candidates.length === 0) {
|
|
703
|
+
return { match: null, confidence: 'none' };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 점수순 정렬
|
|
707
|
+
const sorted = [...candidates].sort((a, b) => b.score - a.score);
|
|
708
|
+
const top = sorted[0];
|
|
709
|
+
|
|
710
|
+
// 임계값 미달
|
|
711
|
+
if (top.score < MATCH_THRESHOLDS.suggestionThreshold) {
|
|
712
|
+
return { match: null, confidence: 'none' };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// 확정 매칭 체크
|
|
716
|
+
if (top.score >= MATCH_THRESHOLDS.minCombinedScore) {
|
|
717
|
+
// 단일 후보
|
|
718
|
+
if (sorted.length === 1) {
|
|
719
|
+
return { match: top, confidence: 'high' };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 2위와의 gap 체크
|
|
723
|
+
const gap = top.score - sorted[1].score;
|
|
724
|
+
if (gap >= MATCH_THRESHOLDS.minGap) {
|
|
725
|
+
return { match: top, confidence: 'high', gap };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// 제안 모드 (확실하지 않음)
|
|
730
|
+
return {
|
|
731
|
+
match: top,
|
|
732
|
+
confidence: 'suggested',
|
|
733
|
+
gap: sorted.length > 1 ? top.score - sorted[1].score : undefined,
|
|
734
|
+
alternatives: sorted.slice(1, 4) // 상위 3개 대안
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 검색 결과를 MemoryMatch로 변환
|
|
740
|
+
*/
|
|
741
|
+
export function toMemoryMatch(
|
|
742
|
+
result: RawSearchResult,
|
|
743
|
+
weights?: ScoringWeights
|
|
744
|
+
): MemoryMatch {
|
|
745
|
+
const score = calculateWeightedScore(result, weights);
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
event: {
|
|
749
|
+
id: result.eventId,
|
|
750
|
+
eventType: result.eventType as any,
|
|
751
|
+
sessionId: '',
|
|
752
|
+
timestamp: result.timestamp,
|
|
753
|
+
content: result.content,
|
|
754
|
+
contentHash: ''
|
|
755
|
+
},
|
|
756
|
+
score,
|
|
757
|
+
relevanceReason: `Weighted score: ${(score * 100).toFixed(1)}% ` +
|
|
758
|
+
`(vector: ${(result.vectorScore * 100).toFixed(0)}%)`
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### 1.6 Evidence Aligner 구현 (src/core/evidence-aligner.ts) - AXIOMMIND 원칙4
|
|
764
|
+
|
|
765
|
+
```typescript
|
|
766
|
+
/**
|
|
767
|
+
* AXIOMMIND 원칙 4: 증거 범위는 파이프라인이 확정
|
|
768
|
+
*
|
|
769
|
+
* LLM이 추출한 대략적인 인용문을 원본 텍스트에서
|
|
770
|
+
* 정확한 (start, end) 위치로 변환
|
|
771
|
+
*/
|
|
772
|
+
|
|
773
|
+
interface EvidenceSpan {
|
|
774
|
+
start: number;
|
|
775
|
+
end: number;
|
|
776
|
+
confidence: number;
|
|
777
|
+
matchType: 'exact' | 'fuzzy' | 'none';
|
|
778
|
+
originalQuote: string;
|
|
779
|
+
alignedText: string;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
interface FuzzyMatch {
|
|
783
|
+
start: number;
|
|
784
|
+
end: number;
|
|
785
|
+
score: number;
|
|
786
|
+
text: string;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export class EvidenceAligner {
|
|
790
|
+
private fuzzyThreshold: number;
|
|
791
|
+
|
|
792
|
+
constructor(fuzzyThreshold: number = 0.85) {
|
|
793
|
+
this.fuzzyThreshold = fuzzyThreshold;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* LLM 인용문을 원본 텍스트에서 정확한 스팬으로 변환
|
|
798
|
+
*/
|
|
799
|
+
align(sourceText: string, llmQuote: string): EvidenceSpan | null {
|
|
800
|
+
// 1. 정확한 매칭 시도
|
|
801
|
+
const exactPos = sourceText.indexOf(llmQuote);
|
|
802
|
+
if (exactPos >= 0) {
|
|
803
|
+
return {
|
|
804
|
+
start: exactPos,
|
|
805
|
+
end: exactPos + llmQuote.length,
|
|
806
|
+
confidence: 1.0,
|
|
807
|
+
matchType: 'exact',
|
|
808
|
+
originalQuote: llmQuote,
|
|
809
|
+
alignedText: llmQuote
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 2. 정규화 후 매칭 (공백, 대소문자 무시)
|
|
814
|
+
const normalizedMatch = this.normalizedSearch(sourceText, llmQuote);
|
|
815
|
+
if (normalizedMatch && normalizedMatch.score >= 0.95) {
|
|
816
|
+
return {
|
|
817
|
+
start: normalizedMatch.start,
|
|
818
|
+
end: normalizedMatch.end,
|
|
819
|
+
confidence: normalizedMatch.score,
|
|
820
|
+
matchType: 'exact',
|
|
821
|
+
originalQuote: llmQuote,
|
|
822
|
+
alignedText: normalizedMatch.text
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 3. Fuzzy 매칭 (Levenshtein 거리)
|
|
827
|
+
const fuzzyMatch = this.fuzzySearch(sourceText, llmQuote);
|
|
828
|
+
if (fuzzyMatch && fuzzyMatch.score >= this.fuzzyThreshold) {
|
|
829
|
+
return {
|
|
830
|
+
start: fuzzyMatch.start,
|
|
831
|
+
end: fuzzyMatch.end,
|
|
832
|
+
confidence: fuzzyMatch.score,
|
|
833
|
+
matchType: 'fuzzy',
|
|
834
|
+
originalQuote: llmQuote,
|
|
835
|
+
alignedText: fuzzyMatch.text
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// 4. 매칭 실패
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* 정규화된 검색 (공백/대소문자 무시)
|
|
845
|
+
*/
|
|
846
|
+
private normalizedSearch(text: string, query: string): FuzzyMatch | null {
|
|
847
|
+
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
848
|
+
const normalizedText = normalize(text);
|
|
849
|
+
const normalizedQuery = normalize(query);
|
|
850
|
+
|
|
851
|
+
const pos = normalizedText.indexOf(normalizedQuery);
|
|
852
|
+
if (pos < 0) return null;
|
|
853
|
+
|
|
854
|
+
// 원본 텍스트에서 실제 위치 찾기
|
|
855
|
+
let originalStart = 0;
|
|
856
|
+
let normalizedPos = 0;
|
|
857
|
+
for (let i = 0; i < text.length && normalizedPos < pos; i++) {
|
|
858
|
+
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) {
|
|
859
|
+
normalizedPos++;
|
|
860
|
+
}
|
|
861
|
+
originalStart = i + 1;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
start: originalStart,
|
|
866
|
+
end: originalStart + query.length,
|
|
867
|
+
score: 0.98,
|
|
868
|
+
text: text.slice(originalStart, originalStart + query.length)
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Fuzzy 검색 (슬라이딩 윈도우 + Levenshtein)
|
|
874
|
+
*/
|
|
875
|
+
private fuzzySearch(text: string, query: string): FuzzyMatch | null {
|
|
876
|
+
const windowSize = Math.min(query.length * 1.5, text.length);
|
|
877
|
+
let bestMatch: FuzzyMatch | null = null;
|
|
878
|
+
|
|
879
|
+
for (let i = 0; i <= text.length - query.length; i++) {
|
|
880
|
+
const window = text.slice(i, i + Math.ceil(windowSize));
|
|
881
|
+
const score = this.similarity(window.slice(0, query.length), query);
|
|
882
|
+
|
|
883
|
+
if (!bestMatch || score > bestMatch.score) {
|
|
884
|
+
bestMatch = {
|
|
885
|
+
start: i,
|
|
886
|
+
end: i + query.length,
|
|
887
|
+
score,
|
|
888
|
+
text: window.slice(0, query.length)
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return bestMatch;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Levenshtein 기반 유사도 (0-1)
|
|
898
|
+
*/
|
|
899
|
+
private similarity(a: string, b: string): number {
|
|
900
|
+
const distance = this.levenshteinDistance(a.toLowerCase(), b.toLowerCase());
|
|
901
|
+
const maxLen = Math.max(a.length, b.length);
|
|
902
|
+
return maxLen === 0 ? 1 : 1 - distance / maxLen;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private levenshteinDistance(a: string, b: string): number {
|
|
906
|
+
const matrix: number[][] = [];
|
|
907
|
+
for (let i = 0; i <= b.length; i++) {
|
|
908
|
+
matrix[i] = [i];
|
|
909
|
+
}
|
|
910
|
+
for (let j = 0; j <= a.length; j++) {
|
|
911
|
+
matrix[0][j] = j;
|
|
912
|
+
}
|
|
913
|
+
for (let i = 1; i <= b.length; i++) {
|
|
914
|
+
for (let j = 1; j <= a.length; j++) {
|
|
915
|
+
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
|
|
916
|
+
matrix[i][j] = Math.min(
|
|
917
|
+
matrix[i - 1][j] + 1,
|
|
918
|
+
matrix[i][j - 1] + 1,
|
|
919
|
+
matrix[i - 1][j - 1] + cost
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return matrix[b.length][a.length];
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
### 1.7 Graduation Pipeline 구현 (src/core/graduation.ts) - AXIOMMIND L0→L4
|
|
929
|
+
|
|
930
|
+
```typescript
|
|
931
|
+
/**
|
|
932
|
+
* AXIOMMIND Memory Graduation Pipeline
|
|
933
|
+
* L0(Raw) → L1(Structured) → L2(Candidates) → L3(Verified) → L4(Active)
|
|
934
|
+
*/
|
|
935
|
+
|
|
936
|
+
import { EventStore } from './event-store';
|
|
937
|
+
import { VectorStore } from './vector-store';
|
|
938
|
+
import { VectorWorker } from './vector-worker';
|
|
939
|
+
import { MemoryEvent } from './types';
|
|
940
|
+
|
|
941
|
+
type MemoryLevel = 'L0' | 'L1' | 'L2' | 'L3' | 'L4';
|
|
942
|
+
|
|
943
|
+
interface GraduationResult {
|
|
944
|
+
eventId: string;
|
|
945
|
+
fromLevel: MemoryLevel;
|
|
946
|
+
toLevel: MemoryLevel;
|
|
947
|
+
success: boolean;
|
|
948
|
+
reason?: string;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
interface LevelStatus {
|
|
952
|
+
level: MemoryLevel;
|
|
953
|
+
count: number;
|
|
954
|
+
lastUpdated: Date | null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export class GraduationPipeline {
|
|
958
|
+
constructor(
|
|
959
|
+
private eventStore: EventStore,
|
|
960
|
+
private vectorStore: VectorStore,
|
|
961
|
+
private vectorWorker: VectorWorker
|
|
962
|
+
) {}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* L0 → L1: 원본 이벤트에서 구조화된 데이터 추출
|
|
966
|
+
* (LLM 기반 추출은 별도 서비스에서 처리)
|
|
967
|
+
*/
|
|
968
|
+
async promoteToL1(eventId: string): Promise<GraduationResult> {
|
|
969
|
+
// L1 승격은 LLM 추출 완료 후 마킹
|
|
970
|
+
// 여기서는 메타데이터만 업데이트
|
|
971
|
+
return {
|
|
972
|
+
eventId,
|
|
973
|
+
fromLevel: 'L0',
|
|
974
|
+
toLevel: 'L1',
|
|
975
|
+
success: true
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* L1 → L2: 타입 검증 대상으로 승격
|
|
981
|
+
* TypeScript 타입 체크 통과 여부 확인
|
|
982
|
+
*/
|
|
983
|
+
async promoteToL2(eventId: string, structuredData: unknown): Promise<GraduationResult> {
|
|
984
|
+
try {
|
|
985
|
+
// Zod 스키마로 검증
|
|
986
|
+
// const validated = SomeSchema.parse(structuredData);
|
|
987
|
+
|
|
988
|
+
return {
|
|
989
|
+
eventId,
|
|
990
|
+
fromLevel: 'L1',
|
|
991
|
+
toLevel: 'L2',
|
|
992
|
+
success: true
|
|
993
|
+
};
|
|
994
|
+
} catch (error) {
|
|
995
|
+
return {
|
|
996
|
+
eventId,
|
|
997
|
+
fromLevel: 'L1',
|
|
998
|
+
toLevel: 'L2',
|
|
999
|
+
success: false,
|
|
1000
|
+
reason: `Validation failed: ${error}`
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* L2 → L3: 검증 완료 (모순 없음 확인)
|
|
1007
|
+
*/
|
|
1008
|
+
async promoteToL3(eventId: string): Promise<GraduationResult> {
|
|
1009
|
+
// 기존 지식과의 모순 체크
|
|
1010
|
+
// 중복 체크
|
|
1011
|
+
// 신뢰도 계산
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
eventId,
|
|
1015
|
+
fromLevel: 'L2',
|
|
1016
|
+
toLevel: 'L3',
|
|
1017
|
+
success: true
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* L3 → L4: 검색 가능 상태로 승격 (벡터 인덱싱)
|
|
1023
|
+
*/
|
|
1024
|
+
async promoteToL4(eventId: string, content: string): Promise<GraduationResult> {
|
|
1025
|
+
try {
|
|
1026
|
+
// VectorWorker의 outbox에 추가 (비동기 인덱싱)
|
|
1027
|
+
await this.vectorWorker.enqueue(eventId, content);
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
eventId,
|
|
1031
|
+
fromLevel: 'L3',
|
|
1032
|
+
toLevel: 'L4',
|
|
1033
|
+
success: true
|
|
1034
|
+
};
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
return {
|
|
1037
|
+
eventId,
|
|
1038
|
+
fromLevel: 'L3',
|
|
1039
|
+
toLevel: 'L4',
|
|
1040
|
+
success: false,
|
|
1041
|
+
reason: `Indexing failed: ${error}`
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* 전체 파이프라인 실행 (L0 → L4)
|
|
1048
|
+
*/
|
|
1049
|
+
async graduateFull(event: MemoryEvent): Promise<GraduationResult[]> {
|
|
1050
|
+
const results: GraduationResult[] = [];
|
|
1051
|
+
|
|
1052
|
+
// L0 → L1 (구조화)
|
|
1053
|
+
const l1Result = await this.promoteToL1(event.id);
|
|
1054
|
+
results.push(l1Result);
|
|
1055
|
+
if (!l1Result.success) return results;
|
|
1056
|
+
|
|
1057
|
+
// L1 → L2 (타입 검증)
|
|
1058
|
+
const l2Result = await this.promoteToL2(event.id, event);
|
|
1059
|
+
results.push(l2Result);
|
|
1060
|
+
if (!l2Result.success) return results;
|
|
1061
|
+
|
|
1062
|
+
// L2 → L3 (모순 체크)
|
|
1063
|
+
const l3Result = await this.promoteToL3(event.id);
|
|
1064
|
+
results.push(l3Result);
|
|
1065
|
+
if (!l3Result.success) return results;
|
|
1066
|
+
|
|
1067
|
+
// L3 → L4 (인덱싱)
|
|
1068
|
+
const l4Result = await this.promoteToL4(event.id, event.content);
|
|
1069
|
+
results.push(l4Result);
|
|
1070
|
+
|
|
1071
|
+
return results;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* 각 레벨별 통계 조회
|
|
1076
|
+
*/
|
|
1077
|
+
async getLevelStats(): Promise<LevelStatus[]> {
|
|
1078
|
+
// 실제 구현에서는 DB 쿼리
|
|
1079
|
+
return [
|
|
1080
|
+
{ level: 'L0', count: 0, lastUpdated: null },
|
|
1081
|
+
{ level: 'L1', count: 0, lastUpdated: null },
|
|
1082
|
+
{ level: 'L2', count: 0, lastUpdated: null },
|
|
1083
|
+
{ level: 'L3', count: 0, lastUpdated: null },
|
|
1084
|
+
{ level: 'L4', count: 0, lastUpdated: null }
|
|
1085
|
+
];
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Phase 2: Embedding & Retrieval
|
|
1093
|
+
|
|
1094
|
+
### 2.1 Embedder 구현 (src/core/embedder.ts)
|
|
1095
|
+
|
|
1096
|
+
```typescript
|
|
1097
|
+
import { pipeline, Pipeline } from '@xenova/transformers';
|
|
1098
|
+
|
|
1099
|
+
export class Embedder {
|
|
1100
|
+
private model: Pipeline | null = null;
|
|
1101
|
+
private modelName: string;
|
|
1102
|
+
|
|
1103
|
+
constructor(modelName: string = 'Xenova/all-MiniLM-L6-v2') {
|
|
1104
|
+
this.modelName = modelName;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async initialize(): Promise<void> {
|
|
1108
|
+
this.model = await pipeline('feature-extraction', this.modelName);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async embed(text: string): Promise<number[]> {
|
|
1112
|
+
if (!this.model) {
|
|
1113
|
+
await this.initialize();
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const result = await this.model!(text, {
|
|
1117
|
+
pooling: 'mean',
|
|
1118
|
+
normalize: true
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
return Array.from(result.data);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async embedBatch(texts: string[]): Promise<number[][]> {
|
|
1125
|
+
return Promise.all(texts.map(t => this.embed(t)));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
### 2.2 Retriever 구현 (src/core/retriever.ts)
|
|
1131
|
+
|
|
1132
|
+
```typescript
|
|
1133
|
+
import { Embedder } from './embedder';
|
|
1134
|
+
import { VectorStore } from './vector-store';
|
|
1135
|
+
import { EventStore } from './event-store';
|
|
1136
|
+
import { MemoryMatch, Config } from './types';
|
|
1137
|
+
|
|
1138
|
+
export class Retriever {
|
|
1139
|
+
constructor(
|
|
1140
|
+
private embedder: Embedder,
|
|
1141
|
+
private vectorStore: VectorStore,
|
|
1142
|
+
private eventStore: EventStore,
|
|
1143
|
+
private config: Config
|
|
1144
|
+
) {}
|
|
1145
|
+
|
|
1146
|
+
async search(query: string): Promise<MemoryMatch[]> {
|
|
1147
|
+
// 1. 쿼리 임베딩
|
|
1148
|
+
const queryVector = await this.embedder.embed(query);
|
|
1149
|
+
|
|
1150
|
+
// 2. 벡터 검색
|
|
1151
|
+
const vectorResults = await this.vectorStore.search(queryVector, {
|
|
1152
|
+
limit: this.config.retrieval.topK,
|
|
1153
|
+
minScore: this.config.retrieval.minScore
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// 3. 이벤트 정보 보강
|
|
1157
|
+
const matches: MemoryMatch[] = [];
|
|
1158
|
+
|
|
1159
|
+
for (const result of vectorResults) {
|
|
1160
|
+
// 원본 이벤트 조회
|
|
1161
|
+
const events = await this.eventStore.getSessionEvents(result.eventId);
|
|
1162
|
+
const event = events.find(e => e.id === result.eventId);
|
|
1163
|
+
|
|
1164
|
+
if (event) {
|
|
1165
|
+
matches.push({
|
|
1166
|
+
event,
|
|
1167
|
+
score: result.score,
|
|
1168
|
+
relevanceReason: `Semantic similarity: ${(result.score * 100).toFixed(1)}%`
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return matches;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// 컨텍스트 포맷팅
|
|
1177
|
+
formatContext(matches: MemoryMatch[]): string {
|
|
1178
|
+
if (matches.length === 0) {
|
|
1179
|
+
return '';
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const lines = ['## Relevant Memories\n'];
|
|
1183
|
+
|
|
1184
|
+
for (const match of matches) {
|
|
1185
|
+
const date = new Date(match.event.timestamp).toLocaleDateString();
|
|
1186
|
+
lines.push(`### ${match.event.eventType} (${date})`);
|
|
1187
|
+
lines.push(`> ${match.event.content.slice(0, 500)}...`);
|
|
1188
|
+
lines.push(`_Relevance: ${(match.score * 100).toFixed(0)}%_\n`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return lines.join('\n');
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
---
|
|
1197
|
+
|
|
1198
|
+
## Phase 3: Hook Integration
|
|
1199
|
+
|
|
1200
|
+
### 3.1 hooks.json 설정
|
|
1201
|
+
|
|
1202
|
+
```json
|
|
1203
|
+
{
|
|
1204
|
+
"hooks": {
|
|
1205
|
+
"SessionStart": [
|
|
1206
|
+
{
|
|
1207
|
+
"type": "command",
|
|
1208
|
+
"command": "npx code-memory session-start",
|
|
1209
|
+
"timeout": 5000
|
|
1210
|
+
}
|
|
1211
|
+
],
|
|
1212
|
+
"UserPromptSubmit": [
|
|
1213
|
+
{
|
|
1214
|
+
"type": "command",
|
|
1215
|
+
"command": "npx code-memory search --stdin",
|
|
1216
|
+
"timeout": 3000
|
|
1217
|
+
}
|
|
1218
|
+
],
|
|
1219
|
+
"Stop": [
|
|
1220
|
+
{
|
|
1221
|
+
"type": "command",
|
|
1222
|
+
"command": "npx code-memory save --stdin",
|
|
1223
|
+
"timeout": 5000
|
|
1224
|
+
}
|
|
1225
|
+
],
|
|
1226
|
+
"SessionEnd": [
|
|
1227
|
+
{
|
|
1228
|
+
"type": "command",
|
|
1229
|
+
"command": "npx code-memory session-end --stdin",
|
|
1230
|
+
"timeout": 10000
|
|
1231
|
+
}
|
|
1232
|
+
]
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
### 3.2 UserPromptSubmit Hook (src/hooks/user-prompt-submit.ts)
|
|
1238
|
+
|
|
1239
|
+
```typescript
|
|
1240
|
+
import { Retriever } from '../core/retriever';
|
|
1241
|
+
import { loadServices } from '../core/services';
|
|
1242
|
+
|
|
1243
|
+
export async function handleUserPromptSubmit(input: {
|
|
1244
|
+
session_id: string;
|
|
1245
|
+
prompt: string;
|
|
1246
|
+
}): Promise<{ context?: string }> {
|
|
1247
|
+
const { retriever } = await loadServices();
|
|
1248
|
+
|
|
1249
|
+
// 관련 기억 검색
|
|
1250
|
+
const matches = await retriever.search(input.prompt);
|
|
1251
|
+
|
|
1252
|
+
if (matches.length === 0) {
|
|
1253
|
+
return {};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// 컨텍스트 포맷팅
|
|
1257
|
+
const context = retriever.formatContext(matches);
|
|
1258
|
+
|
|
1259
|
+
return { context };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// CLI 엔트리포인트
|
|
1263
|
+
if (process.stdin.isTTY === false) {
|
|
1264
|
+
let data = '';
|
|
1265
|
+
process.stdin.on('data', chunk => data += chunk);
|
|
1266
|
+
process.stdin.on('end', async () => {
|
|
1267
|
+
const input = JSON.parse(data);
|
|
1268
|
+
const result = await handleUserPromptSubmit(input);
|
|
1269
|
+
console.log(JSON.stringify(result));
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
```
|
|
1273
|
+
|
|
1274
|
+
### 3.3 Stop Hook (src/hooks/stop.ts)
|
|
1275
|
+
|
|
1276
|
+
```typescript
|
|
1277
|
+
import { EventStore } from '../core/event-store';
|
|
1278
|
+
import { VectorStore } from '../core/vector-store';
|
|
1279
|
+
import { Embedder } from '../core/embedder';
|
|
1280
|
+
import { loadServices } from '../core/services';
|
|
1281
|
+
|
|
1282
|
+
export async function handleStop(input: {
|
|
1283
|
+
session_id: string;
|
|
1284
|
+
messages: Array<{ role: string; content: string }>;
|
|
1285
|
+
}): Promise<void> {
|
|
1286
|
+
const { eventStore, vectorStore, embedder } = await loadServices();
|
|
1287
|
+
|
|
1288
|
+
// 마지막 user-assistant 쌍 저장
|
|
1289
|
+
const messages = input.messages.slice(-2);
|
|
1290
|
+
|
|
1291
|
+
for (const msg of messages) {
|
|
1292
|
+
const eventType = msg.role === 'user' ? 'user_prompt' : 'agent_response';
|
|
1293
|
+
|
|
1294
|
+
// 1. 이벤트 저장
|
|
1295
|
+
const result = await eventStore.append({
|
|
1296
|
+
eventType,
|
|
1297
|
+
sessionId: input.session_id,
|
|
1298
|
+
timestamp: new Date(),
|
|
1299
|
+
content: msg.content,
|
|
1300
|
+
metadata: {}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// 2. 임베딩 생성 및 벡터 저장 (중복 아닌 경우)
|
|
1304
|
+
if (result.success && !result.isDuplicate) {
|
|
1305
|
+
const vector = await embedder.embed(msg.content);
|
|
1306
|
+
|
|
1307
|
+
await vectorStore.upsert({
|
|
1308
|
+
id: crypto.randomUUID(),
|
|
1309
|
+
eventId: result.eventId!,
|
|
1310
|
+
sessionId: input.session_id,
|
|
1311
|
+
eventType,
|
|
1312
|
+
content: msg.content.slice(0, 1000), // 미리보기용
|
|
1313
|
+
vector,
|
|
1314
|
+
timestamp: new Date().toISOString()
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
---
|
|
1322
|
+
|
|
1323
|
+
## Phase 4: Commands & CLI
|
|
1324
|
+
|
|
1325
|
+
### 4.1 Search Command (commands/search.md)
|
|
1326
|
+
|
|
1327
|
+
```markdown
|
|
1328
|
+
---
|
|
1329
|
+
description: Search through your conversation memory
|
|
1330
|
+
---
|
|
1331
|
+
|
|
1332
|
+
# Memory Search
|
|
1333
|
+
|
|
1334
|
+
Search for relevant memories based on your query.
|
|
1335
|
+
|
|
1336
|
+
## Usage
|
|
1337
|
+
|
|
1338
|
+
The user wants to search their conversation memory for: "$ARGUMENTS"
|
|
1339
|
+
|
|
1340
|
+
Search the memory database and return the most relevant past conversations, code snippets, and insights related to the query.
|
|
1341
|
+
|
|
1342
|
+
Display the results in a clear format showing:
|
|
1343
|
+
1. The date of the memory
|
|
1344
|
+
2. A brief excerpt of the content
|
|
1345
|
+
3. The relevance score
|
|
1346
|
+
|
|
1347
|
+
If no relevant memories are found, inform the user and suggest they can build up their memory by having more conversations.
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
### 4.2 CLI Entry Point (src/cli/index.ts)
|
|
1351
|
+
|
|
1352
|
+
```typescript
|
|
1353
|
+
import { Command } from 'commander';
|
|
1354
|
+
import { handleSessionStart } from './commands/session-start';
|
|
1355
|
+
import { handleSearch } from './commands/search';
|
|
1356
|
+
import { handleSave } from './commands/save';
|
|
1357
|
+
import { handleSessionEnd } from './commands/session-end';
|
|
1358
|
+
import { handleInit } from './commands/init';
|
|
1359
|
+
|
|
1360
|
+
const program = new Command();
|
|
1361
|
+
|
|
1362
|
+
program
|
|
1363
|
+
.name('code-memory')
|
|
1364
|
+
.description('Claude Code Memory Plugin CLI')
|
|
1365
|
+
.version('1.0.0');
|
|
1366
|
+
|
|
1367
|
+
program
|
|
1368
|
+
.command('init')
|
|
1369
|
+
.description('Initialize the memory database')
|
|
1370
|
+
.action(handleInit);
|
|
1371
|
+
|
|
1372
|
+
program
|
|
1373
|
+
.command('session-start')
|
|
1374
|
+
.description('Handle session start')
|
|
1375
|
+
.option('--session-id <id>', 'Session ID')
|
|
1376
|
+
.option('--cwd <path>', 'Current working directory')
|
|
1377
|
+
.action(handleSessionStart);
|
|
1378
|
+
|
|
1379
|
+
program
|
|
1380
|
+
.command('search')
|
|
1381
|
+
.description('Search memories')
|
|
1382
|
+
.option('--query <text>', 'Search query')
|
|
1383
|
+
.option('--stdin', 'Read from stdin')
|
|
1384
|
+
.option('--limit <n>', 'Max results', '5')
|
|
1385
|
+
.action(handleSearch);
|
|
1386
|
+
|
|
1387
|
+
program
|
|
1388
|
+
.command('save')
|
|
1389
|
+
.description('Save conversation')
|
|
1390
|
+
.option('--stdin', 'Read from stdin')
|
|
1391
|
+
.action(handleSave);
|
|
1392
|
+
|
|
1393
|
+
program
|
|
1394
|
+
.command('session-end')
|
|
1395
|
+
.description('Handle session end')
|
|
1396
|
+
.option('--stdin', 'Read from stdin')
|
|
1397
|
+
.action(handleSessionEnd);
|
|
1398
|
+
|
|
1399
|
+
program.parse();
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
---
|
|
1403
|
+
|
|
1404
|
+
## Phase 5: Testing & Polish
|
|
1405
|
+
|
|
1406
|
+
### 5.1 테스트 케이스 (tests/event-store.test.ts)
|
|
1407
|
+
|
|
1408
|
+
```typescript
|
|
1409
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1410
|
+
import { EventStore } from '../src/core/event-store';
|
|
1411
|
+
import { unlink } from 'fs/promises';
|
|
1412
|
+
|
|
1413
|
+
describe('EventStore', () => {
|
|
1414
|
+
let store: EventStore;
|
|
1415
|
+
const testDbPath = './test-events.db';
|
|
1416
|
+
|
|
1417
|
+
beforeEach(() => {
|
|
1418
|
+
store = new EventStore(testDbPath);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
afterEach(async () => {
|
|
1422
|
+
await unlink(testDbPath).catch(() => {});
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it('should append events with deduplication', async () => {
|
|
1426
|
+
const event = {
|
|
1427
|
+
eventType: 'user_prompt' as const,
|
|
1428
|
+
sessionId: 'test-session',
|
|
1429
|
+
timestamp: new Date(),
|
|
1430
|
+
content: 'Hello, how are you?',
|
|
1431
|
+
metadata: {}
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
// 첫 번째 저장
|
|
1435
|
+
const result1 = await store.append(event);
|
|
1436
|
+
expect(result1.success).toBe(true);
|
|
1437
|
+
expect(result1.isDuplicate).toBe(false);
|
|
1438
|
+
|
|
1439
|
+
// 중복 저장 시도
|
|
1440
|
+
const result2 = await store.append(event);
|
|
1441
|
+
expect(result2.success).toBe(true);
|
|
1442
|
+
expect(result2.isDuplicate).toBe(true);
|
|
1443
|
+
expect(result2.eventId).toBe(result1.eventId);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it('should retrieve events by session', async () => {
|
|
1447
|
+
const sessionId = 'test-session';
|
|
1448
|
+
|
|
1449
|
+
await store.append({
|
|
1450
|
+
eventType: 'user_prompt',
|
|
1451
|
+
sessionId,
|
|
1452
|
+
timestamp: new Date(),
|
|
1453
|
+
content: 'First message',
|
|
1454
|
+
metadata: {}
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
await store.append({
|
|
1458
|
+
eventType: 'agent_response',
|
|
1459
|
+
sessionId,
|
|
1460
|
+
timestamp: new Date(),
|
|
1461
|
+
content: 'First response',
|
|
1462
|
+
metadata: {}
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
const events = await store.getSessionEvents(sessionId);
|
|
1466
|
+
expect(events.length).toBe(2);
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
### 5.2 통합 테스트 (tests/integration.test.ts)
|
|
1472
|
+
|
|
1473
|
+
```typescript
|
|
1474
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
1475
|
+
import { EventStore } from '../src/core/event-store';
|
|
1476
|
+
import { VectorStore } from '../src/core/vector-store';
|
|
1477
|
+
import { Embedder } from '../src/core/embedder';
|
|
1478
|
+
import { Retriever } from '../src/core/retriever';
|
|
1479
|
+
|
|
1480
|
+
describe('Integration: Memory Retrieval', () => {
|
|
1481
|
+
let eventStore: EventStore;
|
|
1482
|
+
let vectorStore: VectorStore;
|
|
1483
|
+
let embedder: Embedder;
|
|
1484
|
+
let retriever: Retriever;
|
|
1485
|
+
|
|
1486
|
+
beforeAll(async () => {
|
|
1487
|
+
eventStore = new EventStore('./test-integration.db');
|
|
1488
|
+
vectorStore = new VectorStore('./test-integration-vectors');
|
|
1489
|
+
embedder = new Embedder();
|
|
1490
|
+
|
|
1491
|
+
await vectorStore.initialize();
|
|
1492
|
+
await embedder.initialize();
|
|
1493
|
+
|
|
1494
|
+
retriever = new Retriever(eventStore, vectorStore, embedder, {
|
|
1495
|
+
retrieval: { topK: 5, minScore: 0.5, maxTokens: 2000 }
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
// 테스트 데이터 삽입
|
|
1499
|
+
const testData = [
|
|
1500
|
+
{ content: 'How to implement rate limiting in Express?', type: 'user_prompt' },
|
|
1501
|
+
{ content: 'You can use express-rate-limit middleware...', type: 'agent_response' },
|
|
1502
|
+
{ content: 'How to add authentication to my API?', type: 'user_prompt' },
|
|
1503
|
+
{ content: 'Use Passport.js or JWT for authentication...', type: 'agent_response' }
|
|
1504
|
+
];
|
|
1505
|
+
|
|
1506
|
+
for (const data of testData) {
|
|
1507
|
+
const result = await eventStore.append({
|
|
1508
|
+
eventType: data.type as any,
|
|
1509
|
+
sessionId: 'test-session',
|
|
1510
|
+
timestamp: new Date(),
|
|
1511
|
+
content: data.content,
|
|
1512
|
+
metadata: {}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
const vector = await embedder.embed(data.content);
|
|
1516
|
+
await vectorStore.upsert({
|
|
1517
|
+
id: crypto.randomUUID(),
|
|
1518
|
+
eventId: result.eventId!,
|
|
1519
|
+
sessionId: 'test-session',
|
|
1520
|
+
eventType: data.type,
|
|
1521
|
+
content: data.content,
|
|
1522
|
+
vector,
|
|
1523
|
+
timestamp: new Date().toISOString()
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
it('should find relevant memories', async () => {
|
|
1529
|
+
const matches = await retriever.search('rate limiting');
|
|
1530
|
+
|
|
1531
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
1532
|
+
expect(matches[0].event.content).toContain('rate limiting');
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it('should format context correctly', () => {
|
|
1536
|
+
const matches = [
|
|
1537
|
+
{
|
|
1538
|
+
event: {
|
|
1539
|
+
id: '1',
|
|
1540
|
+
eventType: 'user_prompt',
|
|
1541
|
+
sessionId: 'test',
|
|
1542
|
+
timestamp: new Date(),
|
|
1543
|
+
content: 'Test content',
|
|
1544
|
+
contentHash: 'hash'
|
|
1545
|
+
},
|
|
1546
|
+
score: 0.95,
|
|
1547
|
+
relevanceReason: 'High similarity'
|
|
1548
|
+
}
|
|
1549
|
+
];
|
|
1550
|
+
|
|
1551
|
+
const context = retriever.formatContext(matches);
|
|
1552
|
+
expect(context).toContain('Relevant Memories');
|
|
1553
|
+
expect(context).toContain('Test content');
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
### 5.3 빌드 스크립트 (scripts/build.ts)
|
|
1559
|
+
|
|
1560
|
+
```typescript
|
|
1561
|
+
import * as esbuild from 'esbuild';
|
|
1562
|
+
|
|
1563
|
+
async function build() {
|
|
1564
|
+
// CLI 빌드
|
|
1565
|
+
await esbuild.build({
|
|
1566
|
+
entryPoints: ['src/cli/index.ts'],
|
|
1567
|
+
bundle: true,
|
|
1568
|
+
platform: 'node',
|
|
1569
|
+
target: 'node18',
|
|
1570
|
+
outfile: 'dist/cli.js',
|
|
1571
|
+
external: ['duckdb', 'lancedb', '@xenova/transformers']
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
console.log('Build complete!');
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
build().catch(console.error);
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
---
|
|
1581
|
+
|
|
1582
|
+
## 마일스톤 체크리스트
|
|
1583
|
+
|
|
1584
|
+
### Phase 0: 프로젝트 설정
|
|
1585
|
+
- [ ] 디렉토리 구조 생성
|
|
1586
|
+
- [ ] package.json 초기화
|
|
1587
|
+
- [ ] 의존성 설치
|
|
1588
|
+
- [ ] TypeScript 설정
|
|
1589
|
+
- [ ] plugin.json 생성
|
|
1590
|
+
|
|
1591
|
+
### Phase 1: Core Storage Layer
|
|
1592
|
+
- [ ] types.ts - Zod 스키마 정의
|
|
1593
|
+
- [ ] event-store.ts - DuckDB 연동
|
|
1594
|
+
- [ ] vector-store.ts - LanceDB 연동
|
|
1595
|
+
- [ ] 단위 테스트
|
|
1596
|
+
|
|
1597
|
+
### Phase 2: Embedding & Retrieval
|
|
1598
|
+
- [ ] embedder.ts - 로컬 임베딩
|
|
1599
|
+
- [ ] retriever.ts - 검색 로직
|
|
1600
|
+
- [ ] 컨텍스트 포맷터
|
|
1601
|
+
- [ ] 단위 테스트
|
|
1602
|
+
|
|
1603
|
+
### Phase 3: Hook Integration
|
|
1604
|
+
- [ ] hooks.json 설정
|
|
1605
|
+
- [ ] session-start hook
|
|
1606
|
+
- [ ] user-prompt-submit hook
|
|
1607
|
+
- [ ] stop hook
|
|
1608
|
+
- [ ] session-end hook
|
|
1609
|
+
|
|
1610
|
+
### Phase 4: Commands & CLI
|
|
1611
|
+
- [ ] CLI 엔트리포인트
|
|
1612
|
+
- [ ] search 명령어
|
|
1613
|
+
- [ ] history 명령어
|
|
1614
|
+
- [ ] forget 명령어
|
|
1615
|
+
- [ ] stats 명령어
|
|
1616
|
+
|
|
1617
|
+
### Phase 5: Testing & Polish
|
|
1618
|
+
- [ ] 통합 테스트
|
|
1619
|
+
- [ ] README.md
|
|
1620
|
+
- [ ] 에러 처리 개선
|
|
1621
|
+
- [ ] 성능 최적화
|
|
1622
|
+
- [ ] 첫 릴리스 준비
|
|
1623
|
+
|
|
1624
|
+
---
|
|
1625
|
+
|
|
1626
|
+
## 리스크 및 대응
|
|
1627
|
+
|
|
1628
|
+
| 리스크 | 영향 | 대응 |
|
|
1629
|
+
|--------|------|------|
|
|
1630
|
+
| 임베딩 모델 로드 시간 | 세션 시작 지연 | 모델 캐싱, 지연 로드 |
|
|
1631
|
+
| 대용량 대화 처리 | 메모리 부족 | 스트리밍, 배치 처리 |
|
|
1632
|
+
| DuckDB/LanceDB 호환성 | 설치 실패 | fallback 구현, 순수 JS 대안 |
|
|
1633
|
+
| Hook 타임아웃 | 기능 미작동 | 비동기 처리, 캐싱 |
|
|
1634
|
+
|
|
1635
|
+
---
|
|
1636
|
+
|
|
1637
|
+
## 다음 단계
|
|
1638
|
+
|
|
1639
|
+
1. **Phase 0 완료 후**: 기본 플러그인 구조 동작 확인
|
|
1640
|
+
2. **Phase 1-2 완료 후**: 기억 저장/검색 기능 데모
|
|
1641
|
+
3. **Phase 3-4 완료 후**: 실제 Claude Code에서 테스트
|
|
1642
|
+
4. **Phase 5 완료 후**: 커뮤니티 마켓플레이스 등록
|