claude-memory-layer 1.0.18 → 1.0.19
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 +372 -74
- package/dist/cli/index.js.map +3 -3
- package/dist/hooks/post-tool-use.js +6 -0
- package/dist/hooks/post-tool-use.js.map +2 -2
- package/dist/hooks/session-end.js +6 -0
- package/dist/hooks/session-end.js.map +2 -2
- package/dist/hooks/session-start.js +6 -0
- package/dist/hooks/session-start.js.map +2 -2
- package/dist/hooks/stop.js +6 -0
- package/dist/hooks/stop.js.map +2 -2
- package/dist/hooks/user-prompt-submit.js +245 -31
- package/dist/hooks/user-prompt-submit.js.map +3 -3
- package/dist/server/api/index.js +329 -31
- package/dist/server/api/index.js.map +3 -3
- package/dist/server/index.js +336 -38
- package/dist/server/index.js.map +3 -3
- package/dist/services/memory-service.js +6 -0
- package/dist/services/memory-service.js.map +2 -2
- package/dist/ui/app.js +236 -4
- package/dist/ui/index.html +51 -0
- package/dist/ui/style.css +34 -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/hooks/user-prompt-submit.ts +225 -29
- package/src/server/api/events.ts +1 -0
- package/src/server/api/stats.ts +346 -0
- package/src/services/memory-service.ts +7 -0
- package/src/ui/app.js +236 -4
- package/src/ui/index.html +51 -0
- package/src/ui/style.css +34 -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);
|
|
@@ -1474,6 +1474,13 @@ export class MemoryService {
|
|
|
1474
1474
|
this.graduation.recordAccess(eventId, sessionId, confidence);
|
|
1475
1475
|
}
|
|
1476
1476
|
|
|
1477
|
+
/**
|
|
1478
|
+
* Backward-compatible alias used by some hooks
|
|
1479
|
+
*/
|
|
1480
|
+
async close(): Promise<void> {
|
|
1481
|
+
await this.shutdown();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1477
1484
|
/**
|
|
1478
1485
|
* Shutdown service
|
|
1479
1486
|
*/
|