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
package/src/server/api/stats.ts
CHANGED
|
@@ -4,11 +4,213 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
7
9
|
import { getMemoryServiceForProject } from '../../services/memory-service.js';
|
|
8
10
|
import { getServiceFromQuery } from './utils.js';
|
|
11
|
+
import type { MemoryEvent } from '../../core/types.js';
|
|
9
12
|
|
|
10
13
|
export const statsRouter = new Hono();
|
|
11
14
|
|
|
15
|
+
type KpiWindow = '24h' | '7d' | '30d';
|
|
16
|
+
|
|
17
|
+
type KpiThresholds = {
|
|
18
|
+
usefulRecallRateMin: number;
|
|
19
|
+
reworkRateMax: number;
|
|
20
|
+
postChangeFailureRateMax: number;
|
|
21
|
+
avgCompletionTurnsMax: number;
|
|
22
|
+
memoryHitRateMin: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_KPI_THRESHOLDS: KpiThresholds = {
|
|
26
|
+
usefulRecallRateMin: 0.45,
|
|
27
|
+
reworkRateMax: 0.25,
|
|
28
|
+
postChangeFailureRateMax: 0.2,
|
|
29
|
+
avgCompletionTurnsMax: 12,
|
|
30
|
+
memoryHitRateMin: 0.35
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function loadKpiThresholds(): KpiThresholds {
|
|
34
|
+
try {
|
|
35
|
+
const filePath = path.resolve(process.cwd(), 'config', 'kpi-thresholds.json');
|
|
36
|
+
if (!fs.existsSync(filePath)) return DEFAULT_KPI_THRESHOLDS;
|
|
37
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Partial<KpiThresholds>;
|
|
38
|
+
return {
|
|
39
|
+
usefulRecallRateMin: Number(parsed.usefulRecallRateMin ?? DEFAULT_KPI_THRESHOLDS.usefulRecallRateMin),
|
|
40
|
+
reworkRateMax: Number(parsed.reworkRateMax ?? DEFAULT_KPI_THRESHOLDS.reworkRateMax),
|
|
41
|
+
postChangeFailureRateMax: Number(parsed.postChangeFailureRateMax ?? DEFAULT_KPI_THRESHOLDS.postChangeFailureRateMax),
|
|
42
|
+
avgCompletionTurnsMax: Number(parsed.avgCompletionTurnsMax ?? DEFAULT_KPI_THRESHOLDS.avgCompletionTurnsMax),
|
|
43
|
+
memoryHitRateMin: Number(parsed.memoryHitRateMin ?? DEFAULT_KPI_THRESHOLDS.memoryHitRateMin)
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return DEFAULT_KPI_THRESHOLDS;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function windowToMs(window: KpiWindow): number {
|
|
51
|
+
if (window === '24h') return 24 * 60 * 60 * 1000;
|
|
52
|
+
if (window === '7d') return 7 * 24 * 60 * 60 * 1000;
|
|
53
|
+
return 30 * 24 * 60 * 60 * 1000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function inWindow(e: MemoryEvent, now: number, window: KpiWindow): boolean {
|
|
57
|
+
return now - e.timestamp.getTime() <= windowToMs(window);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isEditToolName(name: string): boolean {
|
|
61
|
+
return ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseToolPayload(e: MemoryEvent): { toolName?: string; success?: boolean; filePath?: string; command?: string } | null {
|
|
65
|
+
if (e.eventType !== 'tool_observation') return null;
|
|
66
|
+
try {
|
|
67
|
+
const payload = JSON.parse(e.content) as any;
|
|
68
|
+
return {
|
|
69
|
+
toolName: payload?.toolName,
|
|
70
|
+
success: payload?.success,
|
|
71
|
+
filePath: payload?.metadata?.filePath,
|
|
72
|
+
command: payload?.metadata?.command
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return {
|
|
76
|
+
toolName: (e.metadata as any)?.toolName,
|
|
77
|
+
success: (e.metadata as any)?.success,
|
|
78
|
+
filePath: (e.metadata as any)?.filePath,
|
|
79
|
+
command: (e.metadata as any)?.command
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isTestLikeCommand(command?: string): boolean {
|
|
85
|
+
if (!command) return false;
|
|
86
|
+
return /(test|jest|vitest|pytest|go test|cargo test|lint|eslint|build|tsc)/i.test(command);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function safeRatio(num: number, den: number): number {
|
|
90
|
+
if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) return 0;
|
|
91
|
+
return num / den;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function round(value: number, digits = 4): number {
|
|
95
|
+
const factor = 10 ** digits;
|
|
96
|
+
return Math.round(value * factor) / factor;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function computeSessionTurnCount(sessionEvents: MemoryEvent[]): number {
|
|
100
|
+
const turnIds = new Set<string>();
|
|
101
|
+
for (const e of sessionEvents) {
|
|
102
|
+
const turnId = (e.metadata as any)?.turnId;
|
|
103
|
+
if (typeof turnId === 'string' && turnId.length > 0) turnIds.add(turnId);
|
|
104
|
+
}
|
|
105
|
+
if (turnIds.size > 0) return turnIds.size;
|
|
106
|
+
return sessionEvents.filter((e) => e.eventType === 'user_prompt').length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type KpiMetrics = {
|
|
110
|
+
memoryHitRate: number;
|
|
111
|
+
usefulRecallRate: number;
|
|
112
|
+
avgCompletionTurns: number;
|
|
113
|
+
timeToFirstValidEditMinutes: number;
|
|
114
|
+
reworkRate: number;
|
|
115
|
+
postChangeFailureRate: number;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function computeKpiMetrics(events: MemoryEvent[], usefulRecallRate: number): KpiMetrics {
|
|
119
|
+
const prompts = events.filter((e) => e.eventType === 'user_prompt');
|
|
120
|
+
const promptCount = prompts.length;
|
|
121
|
+
const memoryHitPrompts = prompts.filter((p) => (p.metadata as any)?.adherence?.checked).length;
|
|
122
|
+
const memoryHitRate = round(safeRatio(memoryHitPrompts, promptCount));
|
|
123
|
+
|
|
124
|
+
const sessions = new Map<string, MemoryEvent[]>();
|
|
125
|
+
for (const e of events) {
|
|
126
|
+
const arr = sessions.get(e.sessionId) || [];
|
|
127
|
+
arr.push(e);
|
|
128
|
+
sessions.set(e.sessionId, arr);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let sessionTurnTotal = 0;
|
|
132
|
+
let sessionTurnSamples = 0;
|
|
133
|
+
let firstValidEditMinutesTotal = 0;
|
|
134
|
+
let firstValidEditSamples = 0;
|
|
135
|
+
|
|
136
|
+
for (const sessionEvents of sessions.values()) {
|
|
137
|
+
sessionEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
138
|
+
const turns = computeSessionTurnCount(sessionEvents);
|
|
139
|
+
if (turns > 0) {
|
|
140
|
+
sessionTurnTotal += turns;
|
|
141
|
+
sessionTurnSamples++;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const firstPrompt = sessionEvents.find((e) => e.eventType === 'user_prompt');
|
|
145
|
+
const firstEdit = sessionEvents.find((e) => {
|
|
146
|
+
const payload = parseToolPayload(e);
|
|
147
|
+
return payload?.toolName && isEditToolName(payload.toolName) && payload.success === true;
|
|
148
|
+
});
|
|
149
|
+
if (firstPrompt && firstEdit) {
|
|
150
|
+
const minutes = (firstEdit.timestamp.getTime() - firstPrompt.timestamp.getTime()) / 60000;
|
|
151
|
+
if (minutes >= 0) {
|
|
152
|
+
firstValidEditMinutesTotal += minutes;
|
|
153
|
+
firstValidEditSamples++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const avgCompletionTurns = round(safeRatio(sessionTurnTotal, sessionTurnSamples), 2);
|
|
159
|
+
const timeToFirstValidEditMinutes = round(safeRatio(firstValidEditMinutesTotal, firstValidEditSamples), 2);
|
|
160
|
+
|
|
161
|
+
const editActions: Array<{ sessionId: string; timestamp: number; filePath?: string }> = [];
|
|
162
|
+
let testRunsAfterEdit = 0;
|
|
163
|
+
let failedTestRunsAfterEdit = 0;
|
|
164
|
+
|
|
165
|
+
for (const [sessionId, sessionEvents] of sessions.entries()) {
|
|
166
|
+
const sorted = [...sessionEvents].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
167
|
+
let seenEdit = false;
|
|
168
|
+
|
|
169
|
+
for (const e of sorted) {
|
|
170
|
+
const payload = parseToolPayload(e);
|
|
171
|
+
if (!payload?.toolName) continue;
|
|
172
|
+
|
|
173
|
+
if (isEditToolName(payload.toolName) && payload.success === true) {
|
|
174
|
+
editActions.push({ sessionId, timestamp: e.timestamp.getTime(), filePath: payload.filePath });
|
|
175
|
+
seenEdit = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (seenEdit && isTestLikeCommand(payload.command)) {
|
|
180
|
+
testRunsAfterEdit++;
|
|
181
|
+
if (payload.success === false) failedTestRunsAfterEdit++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
|
187
|
+
let reworkCount = 0;
|
|
188
|
+
const bySessionFile = new Map<string, number>();
|
|
189
|
+
const sortedEdits = [...editActions].sort((a, b) => a.timestamp - b.timestamp);
|
|
190
|
+
for (const edit of sortedEdits) {
|
|
191
|
+
if (!edit.filePath) continue;
|
|
192
|
+
const key = `${edit.sessionId}::${edit.filePath}`;
|
|
193
|
+
const prev = bySessionFile.get(key);
|
|
194
|
+
if (typeof prev === 'number' && edit.timestamp - prev <= THIRTY_MIN_MS) {
|
|
195
|
+
reworkCount++;
|
|
196
|
+
}
|
|
197
|
+
bySessionFile.set(key, edit.timestamp);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const reworkRate = round(safeRatio(reworkCount, editActions.length));
|
|
201
|
+
const postChangeFailureRate = round(safeRatio(failedTestRunsAfterEdit, testRunsAfterEdit));
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
memoryHitRate,
|
|
205
|
+
usefulRecallRate,
|
|
206
|
+
avgCompletionTurns,
|
|
207
|
+
timeToFirstValidEditMinutes,
|
|
208
|
+
reworkRate,
|
|
209
|
+
postChangeFailureRate
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
12
214
|
// GET /api/stats/shared - Get shared store statistics
|
|
13
215
|
statsRouter.get('/shared', async (c) => {
|
|
14
216
|
const memoryService = getServiceFromQuery(c);
|
|
@@ -336,6 +538,150 @@ statsRouter.get('/retrieval-traces', async (c) => {
|
|
|
336
538
|
}
|
|
337
539
|
});
|
|
338
540
|
|
|
541
|
+
// GET /api/stats/kpi - Productivity KPI summary + trend
|
|
542
|
+
statsRouter.get('/kpi', async (c) => {
|
|
543
|
+
const rawWindow = (c.req.query('window') || '7d') as KpiWindow;
|
|
544
|
+
const window: KpiWindow = rawWindow === '24h' || rawWindow === '30d' ? rawWindow : '7d';
|
|
545
|
+
const memoryService = getServiceFromQuery(c);
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
await memoryService.initialize();
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
const thresholds = loadKpiThresholds();
|
|
551
|
+
const allEvents = await memoryService.getRecentEvents(20000);
|
|
552
|
+
const events = allEvents.filter((e) => inWindow(e, now, window));
|
|
553
|
+
|
|
554
|
+
const helpfulness = await memoryService.getHelpfulnessStats();
|
|
555
|
+
const usefulRecallRate = helpfulness.totalEvaluated > 0
|
|
556
|
+
? round(safeRatio(helpfulness.helpful, helpfulness.totalEvaluated))
|
|
557
|
+
: 0;
|
|
558
|
+
|
|
559
|
+
const metrics = computeKpiMetrics(events, usefulRecallRate);
|
|
560
|
+
|
|
561
|
+
const windowMs = windowToMs(window);
|
|
562
|
+
const prevEvents = allEvents.filter((e) => {
|
|
563
|
+
const age = now - e.timestamp.getTime();
|
|
564
|
+
return age > windowMs && age <= windowMs * 2;
|
|
565
|
+
});
|
|
566
|
+
const previousMetrics = computeKpiMetrics(prevEvents, usefulRecallRate);
|
|
567
|
+
const deltas = {
|
|
568
|
+
memoryHitRate: round(metrics.memoryHitRate - previousMetrics.memoryHitRate),
|
|
569
|
+
usefulRecallRate: round(metrics.usefulRecallRate - previousMetrics.usefulRecallRate),
|
|
570
|
+
avgCompletionTurns: round(metrics.avgCompletionTurns - previousMetrics.avgCompletionTurns, 2),
|
|
571
|
+
timeToFirstValidEditMinutes: round(metrics.timeToFirstValidEditMinutes - previousMetrics.timeToFirstValidEditMinutes, 2),
|
|
572
|
+
reworkRate: round(metrics.reworkRate - previousMetrics.reworkRate),
|
|
573
|
+
postChangeFailureRate: round(metrics.postChangeFailureRate - previousMetrics.postChangeFailureRate)
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
|
577
|
+
|
|
578
|
+
// Trend (daily buckets for last 30 days)
|
|
579
|
+
const trendWindowMs = 30 * 24 * 60 * 60 * 1000;
|
|
580
|
+
const trendEvents = allEvents.filter((e) => now - e.timestamp.getTime() <= trendWindowMs);
|
|
581
|
+
const buckets = new Map<string, MemoryEvent[]>();
|
|
582
|
+
for (const e of trendEvents) {
|
|
583
|
+
const day = e.timestamp.toISOString().split('T')[0];
|
|
584
|
+
const arr = buckets.get(day) || [];
|
|
585
|
+
arr.push(e);
|
|
586
|
+
buckets.set(day, arr);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const trendDaily = Array.from(buckets.entries())
|
|
590
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
591
|
+
.map(([date, dayEvents]) => {
|
|
592
|
+
const dayPrompts = dayEvents.filter((e) => e.eventType === 'user_prompt');
|
|
593
|
+
const dayPromptCount = dayPrompts.length;
|
|
594
|
+
const dayMemoryHit = dayPrompts.filter((p) => (p.metadata as any)?.adherence?.checked).length;
|
|
595
|
+
|
|
596
|
+
// lightweight day rework/failure approximation
|
|
597
|
+
const dayEdits = dayEvents.filter((e) => {
|
|
598
|
+
const p = parseToolPayload(e);
|
|
599
|
+
return Boolean(p?.toolName && isEditToolName(p.toolName) && p.success === true);
|
|
600
|
+
});
|
|
601
|
+
const dayEditActions = dayEdits
|
|
602
|
+
.map((e) => {
|
|
603
|
+
const p = parseToolPayload(e);
|
|
604
|
+
return { sessionId: e.sessionId, timestamp: e.timestamp.getTime(), filePath: p?.filePath };
|
|
605
|
+
})
|
|
606
|
+
.filter((x) => Boolean(x.filePath));
|
|
607
|
+
let dayReworkCount = 0;
|
|
608
|
+
const dayBySessionFile = new Map<string, number>();
|
|
609
|
+
for (const edit of dayEditActions) {
|
|
610
|
+
const key = `${edit.sessionId}::${edit.filePath}`;
|
|
611
|
+
const prev = dayBySessionFile.get(key);
|
|
612
|
+
if (typeof prev === 'number' && edit.timestamp - prev <= THIRTY_MIN_MS) dayReworkCount++;
|
|
613
|
+
dayBySessionFile.set(key, edit.timestamp);
|
|
614
|
+
}
|
|
615
|
+
const dayTests = dayEvents.filter((e) => {
|
|
616
|
+
const p = parseToolPayload(e);
|
|
617
|
+
return Boolean(p?.toolName && isTestLikeCommand(p.command));
|
|
618
|
+
});
|
|
619
|
+
const dayFailedTests = dayEvents.filter((e) => {
|
|
620
|
+
const p = parseToolPayload(e);
|
|
621
|
+
return Boolean(p?.toolName && isTestLikeCommand(p.command) && p.success === false);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const turnsBySession = new Map<string, MemoryEvent[]>();
|
|
625
|
+
for (const e of dayEvents) {
|
|
626
|
+
const arr = turnsBySession.get(e.sessionId) || [];
|
|
627
|
+
arr.push(e);
|
|
628
|
+
turnsBySession.set(e.sessionId, arr);
|
|
629
|
+
}
|
|
630
|
+
let dayTurnsTotal = 0;
|
|
631
|
+
let dayTurnsSamples = 0;
|
|
632
|
+
for (const sessionEvents of turnsBySession.values()) {
|
|
633
|
+
const turns = computeSessionTurnCount(sessionEvents);
|
|
634
|
+
if (turns > 0) {
|
|
635
|
+
dayTurnsTotal += turns;
|
|
636
|
+
dayTurnsSamples++;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
date,
|
|
642
|
+
memoryHitRate: round(safeRatio(dayMemoryHit, dayPromptCount)),
|
|
643
|
+
usefulRecallRate,
|
|
644
|
+
reworkRate: round(safeRatio(dayReworkCount, dayEditActions.length)),
|
|
645
|
+
postChangeFailureRate: round(safeRatio(dayFailedTests.length, dayTests.length)),
|
|
646
|
+
avgCompletionTurns: round(safeRatio(dayTurnsTotal, dayTurnsSamples), 2)
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const alerts: Array<{ metric: string; level: 'warn'; message: string; value: number; threshold: number }> = [];
|
|
651
|
+
if (metrics.usefulRecallRate < thresholds.usefulRecallRateMin) {
|
|
652
|
+
alerts.push({ metric: 'usefulRecallRate', level: 'warn', message: 'Useful recall rate is below threshold', value: metrics.usefulRecallRate, threshold: thresholds.usefulRecallRateMin });
|
|
653
|
+
}
|
|
654
|
+
if (metrics.reworkRate > thresholds.reworkRateMax) {
|
|
655
|
+
alerts.push({ metric: 'reworkRate', level: 'warn', message: 'Rework rate is above threshold', value: metrics.reworkRate, threshold: thresholds.reworkRateMax });
|
|
656
|
+
}
|
|
657
|
+
if (metrics.postChangeFailureRate > thresholds.postChangeFailureRateMax) {
|
|
658
|
+
alerts.push({ metric: 'postChangeFailureRate', level: 'warn', message: 'Post-change failure rate is above threshold', value: metrics.postChangeFailureRate, threshold: thresholds.postChangeFailureRateMax });
|
|
659
|
+
}
|
|
660
|
+
if (metrics.avgCompletionTurns > thresholds.avgCompletionTurnsMax) {
|
|
661
|
+
alerts.push({ metric: 'avgCompletionTurns', level: 'warn', message: 'Average completion turns is above threshold', value: metrics.avgCompletionTurns, threshold: thresholds.avgCompletionTurnsMax });
|
|
662
|
+
}
|
|
663
|
+
if (metrics.memoryHitRate < thresholds.memoryHitRateMin) {
|
|
664
|
+
alerts.push({ metric: 'memoryHitRate', level: 'warn', message: 'Memory hit rate is below threshold', value: metrics.memoryHitRate, threshold: thresholds.memoryHitRateMin });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return c.json({
|
|
668
|
+
window,
|
|
669
|
+
metrics,
|
|
670
|
+
previousMetrics,
|
|
671
|
+
deltas,
|
|
672
|
+
trend: {
|
|
673
|
+
daily: trendDaily
|
|
674
|
+
},
|
|
675
|
+
thresholds,
|
|
676
|
+
alerts
|
|
677
|
+
});
|
|
678
|
+
} catch (error) {
|
|
679
|
+
return c.json({ error: (error as Error).message }, 500);
|
|
680
|
+
} finally {
|
|
681
|
+
await memoryService.shutdown();
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
339
685
|
// POST /api/stats/graduation/run - Force graduation evaluation
|
|
340
686
|
statsRouter.post('/graduation/run', async (c) => {
|
|
341
687
|
const memoryService = getServiceFromQuery(c);
|
|
@@ -213,9 +213,11 @@ export class MemoryService {
|
|
|
213
213
|
private readonly readOnly: boolean;
|
|
214
214
|
private readonly lightweightMode: boolean;
|
|
215
215
|
private readonly mdMirror: MarkdownMirror;
|
|
216
|
+
private readonly storagePath: string;
|
|
216
217
|
|
|
217
218
|
constructor(config: MemoryServiceConfig & { projectHash?: string; projectPath?: string; sharedStoreConfig?: SharedStoreConfig }) {
|
|
218
219
|
const storagePath = this.expandPath(config.storagePath);
|
|
220
|
+
this.storagePath = storagePath;
|
|
219
221
|
this.readOnly = config.readOnly ?? false;
|
|
220
222
|
this.lightweightMode = config.lightweightMode ?? false;
|
|
221
223
|
this.mdMirror = new MarkdownMirror(process.cwd());
|
|
@@ -268,8 +270,9 @@ export class MemoryService {
|
|
|
268
270
|
}
|
|
269
271
|
|
|
270
272
|
this.vectorStore = new VectorStore(path.join(storagePath, 'vectors'));
|
|
271
|
-
|
|
272
|
-
|
|
273
|
+
const embeddingModel = config.embeddingModel || process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
|
|
274
|
+
this.embedder = embeddingModel
|
|
275
|
+
? new Embedder(embeddingModel)
|
|
273
276
|
: getDefaultEmbedder();
|
|
274
277
|
this.matcher = getDefaultMatcher();
|
|
275
278
|
// Retriever uses SQLite as primary (always available)
|
|
@@ -1474,6 +1477,120 @@ export class MemoryService {
|
|
|
1474
1477
|
this.graduation.recordAccess(eventId, sessionId, confidence);
|
|
1475
1478
|
}
|
|
1476
1479
|
|
|
1480
|
+
getEmbeddingModelName(): string {
|
|
1481
|
+
return this.embedder.getModelName();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Ensure embedding model metadata is in sync and optionally migrate vectors.
|
|
1486
|
+
* Migration strategy: clear vector index + clear embedding outbox + re-enqueue all events.
|
|
1487
|
+
*/
|
|
1488
|
+
async ensureEmbeddingModelForImport(options?: { autoMigrate?: boolean }): Promise<{
|
|
1489
|
+
changed: boolean;
|
|
1490
|
+
previousModel: string | null;
|
|
1491
|
+
currentModel: string;
|
|
1492
|
+
enqueued: number;
|
|
1493
|
+
reason?: string;
|
|
1494
|
+
}> {
|
|
1495
|
+
await this.initialize();
|
|
1496
|
+
|
|
1497
|
+
const currentModel = this.getEmbeddingModelName();
|
|
1498
|
+
const metaPath = path.join(this.storagePath, 'embedding-meta.json');
|
|
1499
|
+
|
|
1500
|
+
let previousModel: string | null = null;
|
|
1501
|
+
try {
|
|
1502
|
+
if (fs.existsSync(metaPath)) {
|
|
1503
|
+
const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as { model?: string };
|
|
1504
|
+
previousModel = parsed?.model || null;
|
|
1505
|
+
}
|
|
1506
|
+
} catch {
|
|
1507
|
+
previousModel = null;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const stats = await this.getStats();
|
|
1511
|
+
const hasExistingVectors = (stats.vectorCount || 0) > 0;
|
|
1512
|
+
|
|
1513
|
+
// First-time metadata write (no migration needed unless legacy vectors exist)
|
|
1514
|
+
if (!previousModel && !hasExistingVectors) {
|
|
1515
|
+
fs.writeFileSync(metaPath, JSON.stringify({ model: currentModel, updatedAt: new Date().toISOString() }, null, 2));
|
|
1516
|
+
return { changed: false, previousModel: null, currentModel, enqueued: 0, reason: 'initialized-meta' };
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const modelChanged = previousModel !== currentModel;
|
|
1520
|
+
const legacyUnknownButVectorsExist = !previousModel && hasExistingVectors;
|
|
1521
|
+
|
|
1522
|
+
if (!modelChanged && !legacyUnknownButVectorsExist) {
|
|
1523
|
+
return { changed: false, previousModel, currentModel, enqueued: 0 };
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
if (options?.autoMigrate === false) {
|
|
1527
|
+
return {
|
|
1528
|
+
changed: true,
|
|
1529
|
+
previousModel,
|
|
1530
|
+
currentModel,
|
|
1531
|
+
enqueued: 0,
|
|
1532
|
+
reason: legacyUnknownButVectorsExist ? 'legacy-vectors-without-meta' : 'model-mismatch'
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Pause background vector processing while preparing migration
|
|
1537
|
+
const wasRunning = this.vectorWorker?.isRunning() || false;
|
|
1538
|
+
if (wasRunning) this.vectorWorker?.stop();
|
|
1539
|
+
|
|
1540
|
+
// Reset vector and outbox state
|
|
1541
|
+
await this.vectorStore.clearAll();
|
|
1542
|
+
await this.sqliteStore.clearEmbeddingOutbox();
|
|
1543
|
+
|
|
1544
|
+
// Re-enqueue all events for new embeddings
|
|
1545
|
+
const pageSize = 1000;
|
|
1546
|
+
let offset = 0;
|
|
1547
|
+
let enqueued = 0;
|
|
1548
|
+
|
|
1549
|
+
while (true) {
|
|
1550
|
+
const page = await this.sqliteStore.getEventsPage(pageSize, offset);
|
|
1551
|
+
if (page.length === 0) break;
|
|
1552
|
+
|
|
1553
|
+
for (const event of page) {
|
|
1554
|
+
await this.sqliteStore.enqueueForEmbedding(event.id, event.content);
|
|
1555
|
+
enqueued += 1;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
offset += page.length;
|
|
1559
|
+
if (page.length < pageSize) break;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
fs.writeFileSync(
|
|
1563
|
+
metaPath,
|
|
1564
|
+
JSON.stringify(
|
|
1565
|
+
{
|
|
1566
|
+
model: currentModel,
|
|
1567
|
+
previousModel,
|
|
1568
|
+
migratedAt: new Date().toISOString(),
|
|
1569
|
+
enqueued
|
|
1570
|
+
},
|
|
1571
|
+
null,
|
|
1572
|
+
2
|
|
1573
|
+
)
|
|
1574
|
+
);
|
|
1575
|
+
|
|
1576
|
+
if (wasRunning) this.vectorWorker?.start();
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
changed: true,
|
|
1580
|
+
previousModel,
|
|
1581
|
+
currentModel,
|
|
1582
|
+
enqueued,
|
|
1583
|
+
reason: legacyUnknownButVectorsExist ? 'legacy-vectors-without-meta' : 'model-mismatch'
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Backward-compatible alias used by some hooks
|
|
1589
|
+
*/
|
|
1590
|
+
async close(): Promise<void> {
|
|
1591
|
+
await this.shutdown();
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1477
1594
|
/**
|
|
1478
1595
|
* Shutdown service
|
|
1479
1596
|
*/
|