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.
- package/config/kpi-thresholds.json +7 -0
- package/dist/cli/index.js +532 -79
- package/dist/cli/index.js.map +3 -3
- package/dist/core/index.js +49 -4
- package/dist/core/index.js.map +2 -2
- package/dist/hooks/post-tool-use.js +140 -3
- package/dist/hooks/post-tool-use.js.map +2 -2
- package/dist/hooks/session-end.js +140 -3
- package/dist/hooks/session-end.js.map +2 -2
- package/dist/hooks/session-start.js +140 -3
- package/dist/hooks/session-start.js.map +2 -2
- package/dist/hooks/stop.js +140 -3
- package/dist/hooks/stop.js.map +2 -2
- package/dist/hooks/user-prompt-submit.js +379 -34
- package/dist/hooks/user-prompt-submit.js.map +3 -3
- package/dist/server/api/index.js +467 -34
- package/dist/server/api/index.js.map +3 -3
- package/dist/server/index.js +474 -41
- package/dist/server/index.js.map +3 -3
- package/dist/services/memory-service.js +140 -3
- package/dist/services/memory-service.js.map +2 -2
- package/dist/ui/app.js +362 -4
- package/dist/ui/index.html +90 -0
- package/dist/ui/style.css +41 -0
- package/memory/_index.md +3 -0
- package/memory/agent_response/uncategorized/2026-03-03.md +14 -0
- package/memory/session_summary/uncategorized/2026-03-03.md +5 -0
- package/memory/tool_observation/uncategorized/2026-03-03.md +21 -0
- package/package.json +3 -2
- package/scripts/delete-unknown-projects.js +154 -0
- package/src/cli/index.ts +23 -1
- package/src/core/embedder.ts +3 -2
- package/src/core/sqlite-event-store.ts +32 -0
- package/src/core/types.ts +2 -2
- package/src/core/vector-store.ts +20 -0
- package/src/hooks/user-prompt-submit.ts +225 -29
- package/src/server/api/events.ts +7 -0
- package/src/server/api/stats.ts +346 -0
- package/src/services/memory-service.ts +119 -2
- package/src/ui/app.js +362 -4
- package/src/ui/index.html +90 -0
- package/src/ui/style.css +41 -0
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { randomUUID } from 'crypto';
|
|
14
|
-
import
|
|
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
|
-
{
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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 (
|
|
280
|
+
if (mergedMemories.length > 0) {
|
|
89
281
|
// Increment access count for found memories
|
|
90
|
-
const eventIds =
|
|
91
|
-
|
|
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
|
|
288
|
+
for (const m of mergedMemories) {
|
|
289
|
+
if (!m.id) continue;
|
|
95
290
|
try {
|
|
96
291
|
await memoryService.recordRetrieval(
|
|
97
|
-
|
|
292
|
+
m.id,
|
|
98
293
|
input.session_id,
|
|
99
|
-
|
|
294
|
+
m.score ?? minScore,
|
|
100
295
|
input.prompt
|
|
101
296
|
);
|
|
102
297
|
} catch { /* non-critical */ }
|
|
103
298
|
}
|
|
104
299
|
|
|
105
|
-
|
|
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) {
|
package/src/server/api/events.ts
CHANGED
|
@@ -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
|
})),
|