claude-memory-layer 1.0.18 → 1.0.20

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.
Files changed (42) hide show
  1. package/config/kpi-thresholds.json +7 -0
  2. package/dist/cli/index.js +532 -79
  3. package/dist/cli/index.js.map +3 -3
  4. package/dist/core/index.js +49 -4
  5. package/dist/core/index.js.map +2 -2
  6. package/dist/hooks/post-tool-use.js +140 -3
  7. package/dist/hooks/post-tool-use.js.map +2 -2
  8. package/dist/hooks/session-end.js +140 -3
  9. package/dist/hooks/session-end.js.map +2 -2
  10. package/dist/hooks/session-start.js +140 -3
  11. package/dist/hooks/session-start.js.map +2 -2
  12. package/dist/hooks/stop.js +140 -3
  13. package/dist/hooks/stop.js.map +2 -2
  14. package/dist/hooks/user-prompt-submit.js +379 -34
  15. package/dist/hooks/user-prompt-submit.js.map +3 -3
  16. package/dist/server/api/index.js +467 -34
  17. package/dist/server/api/index.js.map +3 -3
  18. package/dist/server/index.js +474 -41
  19. package/dist/server/index.js.map +3 -3
  20. package/dist/services/memory-service.js +140 -3
  21. package/dist/services/memory-service.js.map +2 -2
  22. package/dist/ui/app.js +362 -4
  23. package/dist/ui/index.html +90 -0
  24. package/dist/ui/style.css +41 -0
  25. package/memory/_index.md +3 -0
  26. package/memory/agent_response/uncategorized/2026-03-03.md +14 -0
  27. package/memory/session_summary/uncategorized/2026-03-03.md +5 -0
  28. package/memory/tool_observation/uncategorized/2026-03-03.md +21 -0
  29. package/package.json +3 -2
  30. package/scripts/delete-unknown-projects.js +154 -0
  31. package/src/cli/index.ts +23 -1
  32. package/src/core/embedder.ts +3 -2
  33. package/src/core/sqlite-event-store.ts +32 -0
  34. package/src/core/types.ts +2 -2
  35. package/src/core/vector-store.ts +20 -0
  36. package/src/hooks/user-prompt-submit.ts +225 -29
  37. package/src/server/api/events.ts +7 -0
  38. package/src/server/api/stats.ts +346 -0
  39. package/src/services/memory-service.ts +119 -2
  40. package/src/ui/app.js +362 -4
  41. package/src/ui/index.html +90 -0
  42. package/src/ui/style.css +41 -0
@@ -11,7 +11,10 @@
11
11
  */
12
12
 
13
13
  import { randomUUID } from 'crypto';
14
- import { getLightweightMemoryService } from '../services/memory-service.js';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import * as os from 'os';
17
+ import { getLightweightMemoryService, getMemoryServiceForSession } from '../services/memory-service.js';
15
18
  import { writeTurnState } from '../core/turn-state.js';
16
19
  import type { UserPromptSubmitInput, UserPromptSubmitOutput } from '../core/types.js';
17
20
 
@@ -21,6 +24,20 @@ const MAX_MEMORIES = parseInt(process.env.CLAUDE_MEMORY_MAX_COUNT || '5');
21
24
  const BASE_MIN_SCORE = parseFloat(process.env.CLAUDE_MEMORY_MIN_SCORE || '0.4');
22
25
  const FALLBACK_MIN_SCORE = parseFloat(process.env.CLAUDE_MEMORY_FALLBACK_MIN_SCORE || '0.3');
23
26
  const ENABLE_SEARCH = process.env.CLAUDE_MEMORY_SEARCH !== 'false';
27
+ const RETRIEVAL_MODE = (process.env.CLAUDE_MEMORY_RETRIEVAL_MODE || 'hybrid') as 'keyword' | 'semantic' | 'hybrid';
28
+ const SEMANTIC_TIMEOUT_MS = parseInt(process.env.CLAUDE_MEMORY_SEMANTIC_TIMEOUT_MS || '1200');
29
+ const ADHERENCE_INTERVAL_TURNS = parseInt(process.env.CLAUDE_MEMORY_ADHERENCE_INTERVAL_TURNS || '3');
30
+
31
+ const ADHERENCE_STATE_DIR = path.join(os.homedir(), '.claude-code', 'memory');
32
+
33
+ interface AdherenceState {
34
+ sessionId: string;
35
+ turnCount: number;
36
+ lastCheckedTurn: number;
37
+ lastPrompt: string;
38
+ lastReason?: string;
39
+ updatedAt: string;
40
+ }
24
41
 
25
42
  /**
26
43
  * Determine if a prompt is worth storing as a memory.
@@ -42,6 +59,120 @@ function getDynamicMinScore(prompt: string): number {
42
59
  return BASE_MIN_SCORE;
43
60
  }
44
61
 
62
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
63
+ return new Promise((resolve, reject) => {
64
+ const timer = setTimeout(() => reject(new Error(`semantic retrieval timeout (${timeoutMs}ms)`)), timeoutMs);
65
+ promise
66
+ .then((result) => {
67
+ clearTimeout(timer);
68
+ resolve(result);
69
+ })
70
+ .catch((error) => {
71
+ clearTimeout(timer);
72
+ reject(error);
73
+ });
74
+ });
75
+ }
76
+
77
+ function formatMemoryContext(items: Array<{ type: string; content: string }>): string {
78
+ if (items.length === 0) return '';
79
+ const lines = items.map((m) => {
80
+ const preview = m.content.length > 300 ? m.content.substring(0, 300) + '...' : m.content;
81
+ return `- [${m.type}] ${preview}`;
82
+ });
83
+ return `💡 **Related memories found:**\n\n${lines.join('\n\n')}`;
84
+ }
85
+
86
+ function getAdherenceStatePath(sessionId: string): string {
87
+ return path.join(ADHERENCE_STATE_DIR, `.adherence-state-${sessionId}.json`);
88
+ }
89
+
90
+ function readAdherenceState(sessionId: string): AdherenceState {
91
+ try {
92
+ const filePath = getAdherenceStatePath(sessionId);
93
+ if (!fs.existsSync(filePath)) {
94
+ return {
95
+ sessionId,
96
+ turnCount: 0,
97
+ lastCheckedTurn: 0,
98
+ lastPrompt: '',
99
+ lastReason: 'init',
100
+ updatedAt: new Date().toISOString()
101
+ };
102
+ }
103
+
104
+ const data = fs.readFileSync(filePath, 'utf8');
105
+ const parsed = JSON.parse(data) as AdherenceState;
106
+ if (parsed.sessionId !== sessionId) throw new Error('session mismatch');
107
+ return parsed;
108
+ } catch {
109
+ return {
110
+ sessionId,
111
+ turnCount: 0,
112
+ lastCheckedTurn: 0,
113
+ lastPrompt: '',
114
+ lastReason: 'init',
115
+ updatedAt: new Date().toISOString()
116
+ };
117
+ }
118
+ }
119
+
120
+ function writeAdherenceState(state: AdherenceState): void {
121
+ try {
122
+ if (!fs.existsSync(ADHERENCE_STATE_DIR)) {
123
+ fs.mkdirSync(ADHERENCE_STATE_DIR, { recursive: true });
124
+ }
125
+ const filePath = getAdherenceStatePath(state.sessionId);
126
+ const tempPath = filePath + '.tmp';
127
+ fs.writeFileSync(tempPath, JSON.stringify(state));
128
+ fs.renameSync(tempPath, filePath);
129
+ } catch {
130
+ // non-critical
131
+ }
132
+ }
133
+
134
+ function hasWriteIntent(prompt: string): boolean {
135
+ return /(fix|refactor|implement|change|modify|edit|update|rewrite|patch|create|add|remove|delete|버그|수정|리팩터|구현|추가|삭제|개선)/i.test(prompt);
136
+ }
137
+
138
+ function tokenize(text: string): string[] {
139
+ const stopwords = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'have', 'what', 'when', 'where', 'how', 'why', '그리고', '그리고요', '이거', '그거', '해주세요', '해줘', '좀', '에서', '으로', '하는', '해']);
140
+ return text
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9가-힣\s]/g, ' ')
143
+ .split(/\s+/)
144
+ .filter((w) => w.length >= 2 && !stopwords.has(w));
145
+ }
146
+
147
+ function isTopicShift(currentPrompt: string, lastPrompt: string): boolean {
148
+ if (!lastPrompt || lastPrompt.length < 10) return false;
149
+ const a = new Set(tokenize(currentPrompt));
150
+ const b = new Set(tokenize(lastPrompt));
151
+ if (a.size === 0 || b.size === 0) return false;
152
+
153
+ let intersection = 0;
154
+ for (const token of a) {
155
+ if (b.has(token)) intersection++;
156
+ }
157
+ const union = a.size + b.size - intersection;
158
+ const similarity = union > 0 ? intersection / union : 0;
159
+ return similarity < 0.2;
160
+ }
161
+
162
+ function shouldRunAdherenceCheck(turnCount: number, prompt: string, state: AdherenceState): { run: boolean; reason: string } {
163
+ if (turnCount === 1) return { run: true, reason: 'first-turn' };
164
+ if (hasWriteIntent(prompt)) return { run: true, reason: 'write-intent' };
165
+ if (isTopicShift(prompt, state.lastPrompt)) return { run: true, reason: 'topic-shift' };
166
+ if (turnCount - state.lastCheckedTurn >= ADHERENCE_INTERVAL_TURNS) return { run: true, reason: 'interval' };
167
+ return { run: false, reason: 'skip' };
168
+ }
169
+
170
+ function logAdherenceDecision(sessionId: string, turn: number, run: boolean, reason: string): void {
171
+ if (!process.env.CLAUDE_MEMORY_DEBUG) return;
172
+ const mode = run ? 'enforced' : 'skipped';
173
+ console.error(`[adherence] session=${sessionId} turn=${turn} mode=${mode} reason=${reason}`);
174
+ }
175
+
45
176
  async function main(): Promise<void> {
46
177
  // Read input from stdin
47
178
  const inputData = await readStdin();
@@ -58,62 +189,127 @@ async function main(): Promise<void> {
58
189
  const memoryService = getLightweightMemoryService(input.session_id);
59
190
 
60
191
  try {
192
+ let context = '';
193
+
194
+ const adherenceState = readAdherenceState(input.session_id);
195
+ const currentTurn = adherenceState.turnCount + 1;
196
+ const adherenceDecision = shouldRunAdherenceCheck(currentTurn, input.prompt, adherenceState);
197
+ logAdherenceDecision(input.session_id, currentTurn, adherenceDecision.run, adherenceDecision.reason);
198
+
61
199
  // Store only non-trivial prompts (skip /commands, short inputs)
62
200
  if (shouldStorePrompt(input.prompt)) {
63
201
  await memoryService.storeUserPrompt(
64
202
  input.session_id,
65
203
  input.prompt,
66
- { turnId }
204
+ {
205
+ turnId,
206
+ adherence: {
207
+ checked: adherenceDecision.run,
208
+ reason: adherenceDecision.reason,
209
+ turn: currentTurn
210
+ }
211
+ }
67
212
  );
68
213
  }
69
214
 
70
- let context = '';
71
-
72
- // Fast keyword search if enabled
73
- if (ENABLE_SEARCH && input.prompt.length > 10) {
215
+ // Search strategy: turn-1 always enforce adherence check,
216
+ // then adaptively enforce on write-intent/topic-shift/interval
217
+ if (ENABLE_SEARCH && input.prompt.length > 10 && adherenceDecision.run) {
74
218
  const minScore = getDynamicMinScore(input.prompt);
75
- let results = await memoryService.keywordSearch(input.prompt, {
76
- topK: MAX_MEMORIES,
77
- minScore
78
- });
219
+ let mergedMemories: Array<{ type: string; content: string; id?: string; score?: number }> = [];
220
+
221
+ const canUseSemantic = RETRIEVAL_MODE === 'semantic' || RETRIEVAL_MODE === 'hybrid';
222
+ if (canUseSemantic) {
223
+ try {
224
+ const semanticService = getMemoryServiceForSession(input.session_id);
225
+ const semantic = await withTimeout(
226
+ semanticService.retrieveMemories(input.prompt, {
227
+ topK: MAX_MEMORIES,
228
+ minScore,
229
+ sessionId: input.session_id,
230
+ intentRewrite: true,
231
+ adaptiveRerank: true,
232
+ projectScopeMode: 'strict'
233
+ }),
234
+ SEMANTIC_TIMEOUT_MS
235
+ );
79
236
 
80
- // recall rescue: if nothing found at tuned threshold, retry with fallback floor
81
- if (results.length === 0 && FALLBACK_MIN_SCORE < minScore) {
82
- results = await memoryService.keywordSearch(input.prompt, {
237
+ mergedMemories = semantic.memories.map((m) => ({
238
+ type: m.event.eventType,
239
+ content: m.event.content,
240
+ id: m.event.id,
241
+ score: m.score
242
+ }));
243
+ } catch {
244
+ // Semantic retrieval is best-effort; fallback below handles the rest
245
+ }
246
+ }
247
+
248
+ const shouldUseKeywordFallback =
249
+ RETRIEVAL_MODE === 'keyword' ||
250
+ RETRIEVAL_MODE === 'hybrid' ||
251
+ mergedMemories.length === 0;
252
+
253
+ if (shouldUseKeywordFallback && mergedMemories.length < MAX_MEMORIES) {
254
+ let results = await memoryService.keywordSearch(input.prompt, {
83
255
  topK: MAX_MEMORIES,
84
- minScore: FALLBACK_MIN_SCORE
256
+ minScore
85
257
  });
258
+
259
+ // recall rescue: if nothing found at tuned threshold, retry with fallback floor
260
+ if (results.length === 0 && FALLBACK_MIN_SCORE < minScore) {
261
+ results = await memoryService.keywordSearch(input.prompt, {
262
+ topK: MAX_MEMORIES,
263
+ minScore: FALLBACK_MIN_SCORE
264
+ });
265
+ }
266
+
267
+ const existingIds = new Set(mergedMemories.map((m) => m.id).filter(Boolean));
268
+ for (const r of results) {
269
+ if (existingIds.has(r.event.id)) continue;
270
+ mergedMemories.push({
271
+ type: r.event.eventType,
272
+ content: r.event.content,
273
+ id: r.event.id,
274
+ score: r.score
275
+ });
276
+ if (mergedMemories.length >= MAX_MEMORIES) break;
277
+ }
86
278
  }
87
279
 
88
- if (results.length > 0) {
280
+ if (mergedMemories.length > 0) {
89
281
  // Increment access count for found memories
90
- const eventIds = results.map(r => r.event.id);
91
- await memoryService.incrementMemoryAccess(eventIds);
282
+ const eventIds = mergedMemories.map((m) => m.id).filter((v): v is string => Boolean(v));
283
+ if (eventIds.length > 0) {
284
+ await memoryService.incrementMemoryAccess(eventIds);
285
+ }
92
286
 
93
287
  // Record each retrieval for helpfulness tracking
94
- for (const r of results) {
288
+ for (const m of mergedMemories) {
289
+ if (!m.id) continue;
95
290
  try {
96
291
  await memoryService.recordRetrieval(
97
- r.event.id,
292
+ m.id,
98
293
  input.session_id,
99
- r.score,
294
+ m.score ?? minScore,
100
295
  input.prompt
101
296
  );
102
297
  } catch { /* non-critical */ }
103
298
  }
104
299
 
105
- // Format context
106
- const memories = results.map(r => {
107
- const preview = r.event.content.length > 300
108
- ? r.event.content.substring(0, 300) + '...'
109
- : r.event.content;
110
- return `- [${r.event.eventType}] ${preview}`;
111
- });
112
-
113
- context = `💡 **Related memories found:**\n\n${memories.join('\n\n')}`;
300
+ context = formatMemoryContext(mergedMemories);
114
301
  }
115
302
  }
116
303
 
304
+ writeAdherenceState({
305
+ sessionId: input.session_id,
306
+ turnCount: currentTurn,
307
+ lastCheckedTurn: adherenceDecision.run ? currentTurn : adherenceState.lastCheckedTurn,
308
+ lastPrompt: input.prompt,
309
+ lastReason: adherenceDecision.reason,
310
+ updatedAt: new Date().toISOString()
311
+ });
312
+
117
313
  const output: UserPromptSubmitOutput = { context };
118
314
  console.log(JSON.stringify(output));
119
315
  } catch (error) {
@@ -14,6 +14,7 @@ eventsRouter.get('/', async (c) => {
14
14
  const eventType = c.req.query('type');
15
15
  const level = c.req.query('level');
16
16
  const sort = c.req.query('sort') || 'recent'; // recent | accessed | oldest
17
+ const q = (c.req.query('q') || '').trim().toLowerCase();
17
18
  const limit = parseInt(c.req.query('limit') || '100', 10);
18
19
  const offset = parseInt(c.req.query('offset') || '0', 10);
19
20
  const memoryService = getServiceFromQuery(c);
@@ -40,6 +41,11 @@ eventsRouter.get('/', async (c) => {
40
41
  events = events.filter(e => e.eventType === eventType);
41
42
  }
42
43
 
44
+ // Content query filter
45
+ if (q) {
46
+ events = events.filter(e => (e.content || '').toLowerCase().includes(q));
47
+ }
48
+
43
49
  // Sort
44
50
  if (sort === 'accessed') {
45
51
  events.sort((a: any, b: any) => {
@@ -66,6 +72,7 @@ eventsRouter.get('/', async (c) => {
66
72
  sessionId: e.sessionId,
67
73
  preview: e.content.slice(0, 200) + (e.content.length > 200 ? '...' : ''),
68
74
  contentLength: e.content.length,
75
+ metadata: e.metadata,
69
76
  accessCount: (e as any).access_count || 0,
70
77
  lastAccessedAt: (e as any).last_accessed_at || null
71
78
  })),