claude-memory-layer 1.0.9 → 1.0.11
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/dist/cli/index.js +1373 -184
- package/dist/cli/index.js.map +4 -4
- package/dist/core/index.js +445 -7
- package/dist/core/index.js.map +2 -2
- package/dist/hooks/post-tool-use.js +705 -43
- package/dist/hooks/post-tool-use.js.map +4 -4
- package/dist/hooks/session-end.js +593 -52
- package/dist/hooks/session-end.js.map +3 -3
- package/dist/hooks/session-start.js +581 -25
- package/dist/hooks/session-start.js.map +3 -3
- package/dist/hooks/stop.js +693 -73
- package/dist/hooks/stop.js.map +4 -4
- package/dist/hooks/user-prompt-submit.js +674 -94
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +1045 -42
- package/dist/server/api/index.js.map +4 -4
- package/dist/server/index.js +1054 -51
- package/dist/server/index.js.map +4 -4
- package/dist/services/memory-service.js +599 -25
- package/dist/services/memory-service.js.map +3 -3
- package/dist/ui/app.js +1380 -55
- package/dist/ui/index.html +311 -148
- package/dist/ui/style.css +892 -0
- package/docs/OPERATIONS.md +18 -0
- package/package.json +8 -2
- package/scripts/fix-sync-gap.js +32 -0
- package/scripts/heartbeat-memory-orchestrator.sh +28 -0
- package/scripts/report-sync-gap.js +26 -0
- package/scripts/review-queue-auto-resolve.js +21 -0
- package/scripts/sync-gap-auto-heal.sh +17 -0
- package/specs/20260207-dashboard-upgrade/context.md +38 -0
- package/specs/20260207-dashboard-upgrade/spec.md +96 -0
- package/src/cli/index.ts +110 -58
- package/src/core/sqlite-event-store.ts +542 -6
- package/src/core/sqlite-wrapper.ts +8 -0
- package/src/core/turn-state.ts +159 -0
- package/src/core/types.ts +23 -8
- package/src/core/vector-store.ts +21 -3
- package/src/hooks/post-tool-use.ts +68 -23
- package/src/hooks/session-end.ts +8 -3
- package/src/hooks/stop.ts +96 -25
- package/src/hooks/user-prompt-submit.ts +78 -65
- package/src/server/api/chat.ts +244 -0
- package/src/server/api/citations.ts +3 -3
- package/src/server/api/events.ts +30 -5
- package/src/server/api/index.ts +7 -1
- package/src/server/api/projects.ts +74 -0
- package/src/server/api/search.ts +3 -3
- package/src/server/api/sessions.ts +3 -3
- package/src/server/api/stats.ts +43 -7
- package/src/server/api/turns.ts +143 -0
- package/src/server/api/utils.ts +46 -0
- package/src/services/memory-service.ts +208 -9
- package/src/services/session-history-importer.ts +215 -51
- package/src/ui/app.js +1380 -55
- package/src/ui/index.html +311 -148
- package/src/ui/style.css +892 -0
- package/.claude/settings.local.json +0 -27
- package/.claude-memory/test.sqlite +0 -0
- package/.history/package_20260201112328.json +0 -45
- package/.history/package_20260201113602.json +0 -45
- package/.history/package_20260201113713.json +0 -45
- package/.history/package_20260201114110.json +0 -45
- package/.history/package_20260201114632.json +0 -46
- package/.history/package_20260201133143.json +0 -45
- package/.history/package_20260201134319.json +0 -45
- package/.history/package_20260201134326.json +0 -45
- package/.history/package_20260201134334.json +0 -45
- package/.history/package_20260201134912.json +0 -45
- package/.history/package_20260201142928.json +0 -46
- package/.history/package_20260201192048.json +0 -47
- package/.history/package_20260202114053.json +0 -49
- package/test_access.js +0 -49
package/src/server/api/stats.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
-
import {
|
|
7
|
+
import { getMemoryServiceForProject } from '../../services/memory-service.js';
|
|
8
|
+
import { getServiceFromQuery } from './utils.js';
|
|
8
9
|
|
|
9
10
|
export const statsRouter = new Hono();
|
|
10
11
|
|
|
11
12
|
// GET /api/stats/shared - Get shared store statistics
|
|
12
13
|
statsRouter.get('/shared', async (c) => {
|
|
13
|
-
const memoryService =
|
|
14
|
+
const memoryService = getServiceFromQuery(c);
|
|
14
15
|
try {
|
|
15
16
|
await memoryService.initialize();
|
|
16
17
|
const sharedStats = await memoryService.getSharedStoreStats();
|
|
@@ -74,7 +75,7 @@ statsRouter.get('/levels/:level', async (c) => {
|
|
|
74
75
|
return c.json({ error: `Invalid level. Must be one of: ${validLevels.join(', ')}` }, 400);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
const memoryService =
|
|
78
|
+
const memoryService = getServiceFromQuery(c);
|
|
78
79
|
try {
|
|
79
80
|
await memoryService.initialize();
|
|
80
81
|
let events = await memoryService.getEventsByLevel(level, { limit: limit * 2, offset });
|
|
@@ -131,7 +132,7 @@ statsRouter.get('/levels/:level', async (c) => {
|
|
|
131
132
|
|
|
132
133
|
// GET /api/stats - Get overall statistics
|
|
133
134
|
statsRouter.get('/', async (c) => {
|
|
134
|
-
const memoryService =
|
|
135
|
+
const memoryService = getServiceFromQuery(c);
|
|
135
136
|
try {
|
|
136
137
|
await memoryService.initialize();
|
|
137
138
|
const stats = await memoryService.getStats();
|
|
@@ -187,7 +188,7 @@ statsRouter.get('/', async (c) => {
|
|
|
187
188
|
statsRouter.get('/most-accessed', async (c) => {
|
|
188
189
|
const limit = parseInt(c.req.query('limit') || '10', 10);
|
|
189
190
|
// Use the same read-only service that other stats endpoints use
|
|
190
|
-
const memoryService =
|
|
191
|
+
const memoryService = getServiceFromQuery(c);
|
|
191
192
|
|
|
192
193
|
try {
|
|
193
194
|
await memoryService.initialize();
|
|
@@ -222,7 +223,7 @@ statsRouter.get('/most-accessed', async (c) => {
|
|
|
222
223
|
// GET /api/stats/timeline - Get activity timeline
|
|
223
224
|
statsRouter.get('/timeline', async (c) => {
|
|
224
225
|
const days = parseInt(c.req.query('days') || '7', 10);
|
|
225
|
-
const memoryService =
|
|
226
|
+
const memoryService = getServiceFromQuery(c);
|
|
226
227
|
|
|
227
228
|
try {
|
|
228
229
|
await memoryService.initialize();
|
|
@@ -255,9 +256,44 @@ statsRouter.get('/timeline', async (c) => {
|
|
|
255
256
|
}
|
|
256
257
|
});
|
|
257
258
|
|
|
259
|
+
// GET /api/stats/helpfulness - Get helpfulness statistics and top helpful memories
|
|
260
|
+
statsRouter.get('/helpfulness', async (c) => {
|
|
261
|
+
const limit = parseInt(c.req.query('limit') || '10', 10);
|
|
262
|
+
const memoryService = getServiceFromQuery(c);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await memoryService.initialize();
|
|
266
|
+
const stats = await memoryService.getHelpfulnessStats();
|
|
267
|
+
const topMemories = await memoryService.getHelpfulMemories(limit);
|
|
268
|
+
|
|
269
|
+
return c.json({
|
|
270
|
+
...stats,
|
|
271
|
+
topMemories: topMemories.map(m => ({
|
|
272
|
+
eventId: m.eventId,
|
|
273
|
+
summary: m.summary,
|
|
274
|
+
helpfulnessScore: m.helpfulnessScore,
|
|
275
|
+
accessCount: m.accessCount,
|
|
276
|
+
evaluationCount: m.evaluationCount
|
|
277
|
+
}))
|
|
278
|
+
});
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return c.json({
|
|
281
|
+
avgScore: 0,
|
|
282
|
+
totalEvaluated: 0,
|
|
283
|
+
totalRetrievals: 0,
|
|
284
|
+
helpful: 0,
|
|
285
|
+
neutral: 0,
|
|
286
|
+
unhelpful: 0,
|
|
287
|
+
topMemories: []
|
|
288
|
+
});
|
|
289
|
+
} finally {
|
|
290
|
+
await memoryService.shutdown();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
258
294
|
// POST /api/stats/graduation/run - Force graduation evaluation
|
|
259
295
|
statsRouter.post('/graduation/run', async (c) => {
|
|
260
|
-
const memoryService =
|
|
296
|
+
const memoryService = getServiceFromQuery(c);
|
|
261
297
|
try {
|
|
262
298
|
await memoryService.initialize();
|
|
263
299
|
const result = await memoryService.forceGraduation();
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns API
|
|
3
|
+
* Endpoints for viewing events grouped by conversation turn
|
|
4
|
+
*
|
|
5
|
+
* A "turn" groups a user_prompt with its associated tool_observations
|
|
6
|
+
* and the final agent_response into a single logical unit.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from 'hono';
|
|
10
|
+
import { getServiceFromQuery } from './utils.js';
|
|
11
|
+
|
|
12
|
+
export const turnsRouter = new Hono();
|
|
13
|
+
|
|
14
|
+
// GET /api/turns?sessionId=xxx - List turns for a session
|
|
15
|
+
turnsRouter.get('/', async (c) => {
|
|
16
|
+
const sessionId = c.req.query('sessionId');
|
|
17
|
+
const limit = parseInt(c.req.query('limit') || '20', 10);
|
|
18
|
+
const offset = parseInt(c.req.query('offset') || '0', 10);
|
|
19
|
+
|
|
20
|
+
if (!sessionId) {
|
|
21
|
+
return c.json({ error: 'sessionId is required' }, 400);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const memoryService = getServiceFromQuery(c);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await memoryService.initialize();
|
|
28
|
+
|
|
29
|
+
const turns = await memoryService.getSessionTurns(sessionId, { limit, offset });
|
|
30
|
+
const totalTurns = await memoryService.countSessionTurns(sessionId);
|
|
31
|
+
|
|
32
|
+
return c.json({
|
|
33
|
+
turns: turns.map(t => ({
|
|
34
|
+
turnId: t.turnId,
|
|
35
|
+
startedAt: t.startedAt.toISOString(),
|
|
36
|
+
promptPreview: t.promptPreview,
|
|
37
|
+
eventCount: t.eventCount,
|
|
38
|
+
toolCount: t.toolCount,
|
|
39
|
+
hasResponse: t.hasResponse,
|
|
40
|
+
events: t.events.map(e => ({
|
|
41
|
+
id: e.id,
|
|
42
|
+
eventType: e.eventType,
|
|
43
|
+
timestamp: e.timestamp instanceof Date ? e.timestamp.toISOString() : e.timestamp,
|
|
44
|
+
preview: e.content.slice(0, 300) + (e.content.length > 300 ? '...' : ''),
|
|
45
|
+
contentLength: e.content.length
|
|
46
|
+
}))
|
|
47
|
+
})),
|
|
48
|
+
total: totalTurns,
|
|
49
|
+
limit,
|
|
50
|
+
offset,
|
|
51
|
+
hasMore: offset + limit < totalTurns
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return c.json({ error: (error as Error).message }, 500);
|
|
55
|
+
} finally {
|
|
56
|
+
await memoryService.shutdown();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// GET /api/turns/:turnId - Get full turn details
|
|
61
|
+
turnsRouter.get('/:turnId', async (c) => {
|
|
62
|
+
const { turnId } = c.req.param();
|
|
63
|
+
const memoryService = getServiceFromQuery(c);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await memoryService.initialize();
|
|
67
|
+
|
|
68
|
+
const events = await memoryService.getEventsByTurn(turnId);
|
|
69
|
+
|
|
70
|
+
if (events.length === 0) {
|
|
71
|
+
return c.json({ error: 'Turn not found' }, 404);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const promptEvent = events.find(e => e.eventType === 'user_prompt');
|
|
75
|
+
const toolEvents = events.filter(e => e.eventType === 'tool_observation');
|
|
76
|
+
const responseEvents = events.filter(e => e.eventType === 'agent_response');
|
|
77
|
+
|
|
78
|
+
return c.json({
|
|
79
|
+
turnId,
|
|
80
|
+
sessionId: events[0].sessionId,
|
|
81
|
+
startedAt: events[0].timestamp instanceof Date
|
|
82
|
+
? events[0].timestamp.toISOString()
|
|
83
|
+
: events[0].timestamp,
|
|
84
|
+
prompt: promptEvent ? {
|
|
85
|
+
id: promptEvent.id,
|
|
86
|
+
content: promptEvent.content,
|
|
87
|
+
timestamp: promptEvent.timestamp instanceof Date
|
|
88
|
+
? promptEvent.timestamp.toISOString()
|
|
89
|
+
: promptEvent.timestamp
|
|
90
|
+
} : null,
|
|
91
|
+
tools: toolEvents.map(e => {
|
|
92
|
+
let toolName = '';
|
|
93
|
+
let success = true;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(e.content);
|
|
96
|
+
toolName = parsed.toolName || '';
|
|
97
|
+
success = parsed.success !== false;
|
|
98
|
+
} catch { /* ignore */ }
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id: e.id,
|
|
102
|
+
toolName,
|
|
103
|
+
success,
|
|
104
|
+
timestamp: e.timestamp instanceof Date ? e.timestamp.toISOString() : e.timestamp,
|
|
105
|
+
preview: e.content.slice(0, 500) + (e.content.length > 500 ? '...' : '')
|
|
106
|
+
};
|
|
107
|
+
}),
|
|
108
|
+
responses: responseEvents.map(e => ({
|
|
109
|
+
id: e.id,
|
|
110
|
+
content: e.content,
|
|
111
|
+
timestamp: e.timestamp instanceof Date ? e.timestamp.toISOString() : e.timestamp
|
|
112
|
+
})),
|
|
113
|
+
totalEvents: events.length
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return c.json({ error: (error as Error).message }, 500);
|
|
117
|
+
} finally {
|
|
118
|
+
await memoryService.shutdown();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// POST /api/turns/backfill - Backfill turn_ids from metadata
|
|
123
|
+
turnsRouter.post('/backfill', async (c) => {
|
|
124
|
+
const memoryService = getServiceFromQuery(c);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await memoryService.initialize();
|
|
128
|
+
const updated = await memoryService.backfillTurnIds();
|
|
129
|
+
|
|
130
|
+
return c.json({
|
|
131
|
+
success: true,
|
|
132
|
+
updated,
|
|
133
|
+
message: `Backfilled turn_id for ${updated} events`
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return c.json({
|
|
137
|
+
success: false,
|
|
138
|
+
error: (error as Error).message
|
|
139
|
+
}, 500);
|
|
140
|
+
} finally {
|
|
141
|
+
await memoryService.shutdown();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Utilities
|
|
3
|
+
* Shared helpers for API endpoints
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Context } from 'hono';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import { getReadOnlyMemoryService } from '../../services/memory-service.js';
|
|
10
|
+
import { MemoryService } from '../../services/memory-service.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the appropriate MemoryService based on the ?project= query parameter.
|
|
14
|
+
* - If ?project=<hash> is set (8 hex chars), resolves directly to project storage
|
|
15
|
+
* - If ?project=<path> is set, computes hash from path
|
|
16
|
+
* - Otherwise, returns the global read-only service
|
|
17
|
+
*
|
|
18
|
+
* Always creates read-only services for the dashboard API to avoid
|
|
19
|
+
* VectorWorker lifecycle issues with per-request services.
|
|
20
|
+
*/
|
|
21
|
+
export function getServiceFromQuery(c: Context): MemoryService {
|
|
22
|
+
const project = c.req.query('project');
|
|
23
|
+
if (project) {
|
|
24
|
+
// Check if it's a hash (8 hex chars) or a path
|
|
25
|
+
const isHash = /^[a-f0-9]{8}$/.test(project);
|
|
26
|
+
let storagePath: string;
|
|
27
|
+
|
|
28
|
+
if (isHash) {
|
|
29
|
+
storagePath = path.join(os.homedir(), '.claude-code', 'memory', 'projects', project);
|
|
30
|
+
} else {
|
|
31
|
+
// Import hashProjectPath dynamically to compute the hash from path
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const normalized = project.replace(/\/+$/, '') || '/';
|
|
34
|
+
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
|
35
|
+
storagePath = path.join(os.homedir(), '.claude-code', 'memory', 'projects', hash);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return new MemoryService({
|
|
39
|
+
storagePath,
|
|
40
|
+
readOnly: true,
|
|
41
|
+
analyticsEnabled: false,
|
|
42
|
+
sharedStoreConfig: { enabled: false }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return getReadOnlyMemoryService();
|
|
46
|
+
}
|
|
@@ -52,6 +52,8 @@ export interface MemoryServiceConfig {
|
|
|
52
52
|
readOnly?: boolean;
|
|
53
53
|
/** Enable DuckDB analytics store (default: true for server, false for hooks) */
|
|
54
54
|
analyticsEnabled?: boolean;
|
|
55
|
+
/** Lightweight mode for hooks - skip heavy initialization (default: false) */
|
|
56
|
+
lightweightMode?: boolean;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
// ============================================================
|
|
@@ -101,18 +103,18 @@ export function getProjectStoragePath(projectPath: string): string {
|
|
|
101
103
|
const REGISTRY_PATH = path.join(os.homedir(), '.claude-code', 'memory', 'session-registry.json');
|
|
102
104
|
const SHARED_STORAGE_PATH = path.join(os.homedir(), '.claude-code', 'memory', 'shared');
|
|
103
105
|
|
|
104
|
-
interface SessionRegistryEntry {
|
|
106
|
+
export interface SessionRegistryEntry {
|
|
105
107
|
projectPath: string;
|
|
106
108
|
projectHash: string;
|
|
107
109
|
registeredAt: string;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
interface SessionRegistry {
|
|
112
|
+
export interface SessionRegistry {
|
|
111
113
|
version: number;
|
|
112
114
|
sessions: Record<string, SessionRegistryEntry>;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
function loadSessionRegistry(): SessionRegistry {
|
|
117
|
+
export function loadSessionRegistry(): SessionRegistry {
|
|
116
118
|
try {
|
|
117
119
|
if (fs.existsSync(REGISTRY_PATH)) {
|
|
118
120
|
const data = fs.readFileSync(REGISTRY_PATH, 'utf-8');
|
|
@@ -200,10 +202,12 @@ export class MemoryService {
|
|
|
200
202
|
private projectHash: string | null = null;
|
|
201
203
|
|
|
202
204
|
private readonly readOnly: boolean;
|
|
205
|
+
private readonly lightweightMode: boolean;
|
|
203
206
|
|
|
204
207
|
constructor(config: MemoryServiceConfig & { projectHash?: string; sharedStoreConfig?: SharedStoreConfig }) {
|
|
205
208
|
const storagePath = this.expandPath(config.storagePath);
|
|
206
209
|
this.readOnly = config.readOnly ?? false;
|
|
210
|
+
this.lightweightMode = config.lightweightMode ?? false;
|
|
207
211
|
|
|
208
212
|
// Ensure storage directory exists (only if not read-only)
|
|
209
213
|
if (!this.readOnly && !fs.existsSync(storagePath)) {
|
|
@@ -272,6 +276,13 @@ export class MemoryService {
|
|
|
272
276
|
// Initialize PRIMARY store: SQLite (always)
|
|
273
277
|
await this.sqliteStore.initialize();
|
|
274
278
|
|
|
279
|
+
// Lightweight mode: only SQLite, no embedder/vector/workers
|
|
280
|
+
// Used for hooks that just need to store data quickly
|
|
281
|
+
if (this.lightweightMode) {
|
|
282
|
+
this.initialized = true;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
275
286
|
// Initialize analytics store if available (DuckDB)
|
|
276
287
|
if (this.analyticsStore) {
|
|
277
288
|
try {
|
|
@@ -479,6 +490,9 @@ export class MemoryService {
|
|
|
479
490
|
// Create content for storage (JSON stringified payload)
|
|
480
491
|
const content = JSON.stringify(payload);
|
|
481
492
|
|
|
493
|
+
// Extract turnId from payload metadata if present (set by PostToolUse hook)
|
|
494
|
+
const turnId = payload.metadata?.turnId;
|
|
495
|
+
|
|
482
496
|
const result = await this.sqliteStore.append({
|
|
483
497
|
eventType: 'tool_observation',
|
|
484
498
|
sessionId,
|
|
@@ -486,7 +500,8 @@ export class MemoryService {
|
|
|
486
500
|
content,
|
|
487
501
|
metadata: {
|
|
488
502
|
toolName: payload.toolName,
|
|
489
|
-
success: payload.success
|
|
503
|
+
success: payload.success,
|
|
504
|
+
...(turnId ? { turnId } : {})
|
|
490
505
|
}
|
|
491
506
|
});
|
|
492
507
|
|
|
@@ -517,10 +532,8 @@ export class MemoryService {
|
|
|
517
532
|
): Promise<UnifiedRetrievalResult> {
|
|
518
533
|
await this.initialize();
|
|
519
534
|
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
await this.vectorWorker.processAll();
|
|
523
|
-
}
|
|
535
|
+
// Note: Pending embeddings are processed by the background worker
|
|
536
|
+
// Don't block retrieval - search with whatever vectors are available
|
|
524
537
|
|
|
525
538
|
// Use unified retrieval if shared search is requested
|
|
526
539
|
if (options?.includeShared && this.sharedStore) {
|
|
@@ -534,6 +547,38 @@ export class MemoryService {
|
|
|
534
547
|
return this.retriever.retrieve(query, options);
|
|
535
548
|
}
|
|
536
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Fast keyword search using SQLite FTS5
|
|
552
|
+
* Much faster than vector search - no embedding model needed
|
|
553
|
+
*/
|
|
554
|
+
async keywordSearch(
|
|
555
|
+
query: string,
|
|
556
|
+
options?: { topK?: number; minScore?: number }
|
|
557
|
+
): Promise<Array<{event: MemoryEvent; score: number}>> {
|
|
558
|
+
await this.initialize();
|
|
559
|
+
|
|
560
|
+
const results = await this.sqliteStore.keywordSearch(query, options?.topK ?? 10);
|
|
561
|
+
|
|
562
|
+
// Normalize FTS5 rank to a score (0-1 range)
|
|
563
|
+
// FTS5 rank is negative (higher is worse), so we convert it
|
|
564
|
+
const maxRank = Math.min(...results.map(r => r.rank), -0.001);
|
|
565
|
+
const minRank = Math.max(...results.map(r => r.rank), -1000);
|
|
566
|
+
const rankRange = maxRank - minRank || 1;
|
|
567
|
+
|
|
568
|
+
return results.map(r => ({
|
|
569
|
+
event: r.event,
|
|
570
|
+
score: 1 - (r.rank - minRank) / rankRange // Normalize to 0-1
|
|
571
|
+
})).filter(r => !options?.minScore || r.score >= options.minScore);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Rebuild FTS index (call after database upgrade)
|
|
576
|
+
*/
|
|
577
|
+
async rebuildFtsIndex(): Promise<number> {
|
|
578
|
+
await this.initialize();
|
|
579
|
+
return this.sqliteStore.rebuildFtsIndex();
|
|
580
|
+
}
|
|
581
|
+
|
|
537
582
|
/**
|
|
538
583
|
* Get session history
|
|
539
584
|
*/
|
|
@@ -811,6 +856,37 @@ export class MemoryService {
|
|
|
811
856
|
return this.consolidatedStore.getAll({ limit });
|
|
812
857
|
}
|
|
813
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Extract topic keywords from event content (markdown headings and key terms)
|
|
861
|
+
*/
|
|
862
|
+
private extractTopicsFromContent(content: string): string[] {
|
|
863
|
+
const topics: Set<string> = new Set();
|
|
864
|
+
|
|
865
|
+
// Extract markdown headings (## heading)
|
|
866
|
+
const headings = content.match(/^#{1,3}\s+(.+)$/gm);
|
|
867
|
+
if (headings) {
|
|
868
|
+
for (const h of headings.slice(0, 5)) {
|
|
869
|
+
const text = h.replace(/^#+\s+/, '').replace(/[*_`#]/g, '').trim();
|
|
870
|
+
if (text.length > 2 && text.length < 50) {
|
|
871
|
+
topics.add(text);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Extract bold terms (**term**)
|
|
877
|
+
const boldTerms = content.match(/\*\*([^*]+)\*\*/g);
|
|
878
|
+
if (boldTerms) {
|
|
879
|
+
for (const b of boldTerms.slice(0, 5)) {
|
|
880
|
+
const text = b.replace(/\*\*/g, '').trim();
|
|
881
|
+
if (text.length > 2 && text.length < 30) {
|
|
882
|
+
topics.add(text);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return Array.from(topics).slice(0, 5);
|
|
888
|
+
}
|
|
889
|
+
|
|
814
890
|
/**
|
|
815
891
|
* Increment access count for memories that were used in prompts
|
|
816
892
|
*/
|
|
@@ -839,7 +915,7 @@ export class MemoryService {
|
|
|
839
915
|
return events.map(event => ({
|
|
840
916
|
memoryId: event.id,
|
|
841
917
|
summary: event.content.substring(0, 200) + (event.content.length > 200 ? '...' : ''),
|
|
842
|
-
topics:
|
|
918
|
+
topics: this.extractTopicsFromContent(event.content),
|
|
843
919
|
accessCount: (event as any).access_count || 0,
|
|
844
920
|
lastAccessed: (event as any).last_accessed_at || null,
|
|
845
921
|
confidence: 1.0,
|
|
@@ -864,6 +940,51 @@ export class MemoryService {
|
|
|
864
940
|
return [];
|
|
865
941
|
}
|
|
866
942
|
|
|
943
|
+
/**
|
|
944
|
+
* Record a memory retrieval for helpfulness tracking
|
|
945
|
+
*/
|
|
946
|
+
async recordRetrieval(eventId: string, sessionId: string, score: number, query: string): Promise<void> {
|
|
947
|
+
await this.initialize();
|
|
948
|
+
await this.sqliteStore.recordRetrieval(eventId, sessionId, score, query);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Evaluate helpfulness of retrievals in a session (called at session end)
|
|
953
|
+
*/
|
|
954
|
+
async evaluateSessionHelpfulness(sessionId: string): Promise<void> {
|
|
955
|
+
await this.initialize();
|
|
956
|
+
await this.sqliteStore.evaluateSessionHelpfulness(sessionId);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Get most helpful memories ranked by helpfulness score
|
|
961
|
+
*/
|
|
962
|
+
async getHelpfulMemories(limit: number = 10): Promise<Array<{
|
|
963
|
+
eventId: string;
|
|
964
|
+
summary: string;
|
|
965
|
+
helpfulnessScore: number;
|
|
966
|
+
accessCount: number;
|
|
967
|
+
evaluationCount: number;
|
|
968
|
+
}>> {
|
|
969
|
+
await this.initialize();
|
|
970
|
+
return this.sqliteStore.getHelpfulMemories(limit);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Get helpfulness statistics for dashboard
|
|
975
|
+
*/
|
|
976
|
+
async getHelpfulnessStats(): Promise<{
|
|
977
|
+
avgScore: number;
|
|
978
|
+
totalEvaluated: number;
|
|
979
|
+
totalRetrievals: number;
|
|
980
|
+
helpful: number;
|
|
981
|
+
neutral: number;
|
|
982
|
+
unhelpful: number;
|
|
983
|
+
}> {
|
|
984
|
+
await this.initialize();
|
|
985
|
+
return this.sqliteStore.getHelpfulnessStats();
|
|
986
|
+
}
|
|
987
|
+
|
|
867
988
|
/**
|
|
868
989
|
* Mark a consolidated memory as accessed
|
|
869
990
|
*/
|
|
@@ -938,6 +1059,58 @@ export class MemoryService {
|
|
|
938
1059
|
};
|
|
939
1060
|
}
|
|
940
1061
|
|
|
1062
|
+
// ============================================================
|
|
1063
|
+
// Turn Grouping Methods
|
|
1064
|
+
// ============================================================
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Get events grouped by turn for a session
|
|
1068
|
+
*/
|
|
1069
|
+
async getSessionTurns(sessionId: string, options?: { limit?: number; offset?: number }): Promise<Array<{
|
|
1070
|
+
turnId: string;
|
|
1071
|
+
events: MemoryEvent[];
|
|
1072
|
+
startedAt: Date;
|
|
1073
|
+
promptPreview: string;
|
|
1074
|
+
eventCount: number;
|
|
1075
|
+
toolCount: number;
|
|
1076
|
+
hasResponse: boolean;
|
|
1077
|
+
}>> {
|
|
1078
|
+
await this.initialize();
|
|
1079
|
+
return this.sqliteStore.getSessionTurns(sessionId, options);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Get all events for a specific turn
|
|
1084
|
+
*/
|
|
1085
|
+
async getEventsByTurn(turnId: string): Promise<MemoryEvent[]> {
|
|
1086
|
+
await this.initialize();
|
|
1087
|
+
return this.sqliteStore.getEventsByTurn(turnId);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Count total turns for a session
|
|
1092
|
+
*/
|
|
1093
|
+
async countSessionTurns(sessionId: string): Promise<number> {
|
|
1094
|
+
await this.initialize();
|
|
1095
|
+
return this.sqliteStore.countSessionTurns(sessionId);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Backfill turn_ids from metadata for events stored before the migration
|
|
1100
|
+
*/
|
|
1101
|
+
async backfillTurnIds(): Promise<number> {
|
|
1102
|
+
await this.initialize();
|
|
1103
|
+
return this.sqliteStore.backfillTurnIds();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Delete all events for a session (for force reimport)
|
|
1108
|
+
*/
|
|
1109
|
+
async deleteSessionEvents(sessionId: string): Promise<number> {
|
|
1110
|
+
await this.initialize();
|
|
1111
|
+
return this.sqliteStore.deleteSessionEvents(sessionId);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
941
1114
|
/**
|
|
942
1115
|
* Format Endless Mode context for Claude
|
|
943
1116
|
*/
|
|
@@ -1129,6 +1302,32 @@ export function getMemoryServiceForSession(sessionId: string): MemoryService {
|
|
|
1129
1302
|
return getDefaultMemoryService();
|
|
1130
1303
|
}
|
|
1131
1304
|
|
|
1305
|
+
/**
|
|
1306
|
+
* Get a lightweight memory service for hooks
|
|
1307
|
+
* Only initializes SQLite - no embedder, no vector store, no workers
|
|
1308
|
+
* This is FAST (<100ms) compared to full initialization (3-5s)
|
|
1309
|
+
*/
|
|
1310
|
+
export function getLightweightMemoryService(sessionId: string): MemoryService {
|
|
1311
|
+
const projectInfo = getSessionProject(sessionId);
|
|
1312
|
+
const key = projectInfo ? `lightweight_${projectInfo.projectHash}` : 'lightweight_global';
|
|
1313
|
+
|
|
1314
|
+
if (!serviceCache.has(key)) {
|
|
1315
|
+
const storagePath = projectInfo
|
|
1316
|
+
? getProjectStoragePath(projectInfo.projectPath)
|
|
1317
|
+
: path.join(os.homedir(), '.claude-code', 'memory');
|
|
1318
|
+
|
|
1319
|
+
serviceCache.set(key, new MemoryService({
|
|
1320
|
+
storagePath,
|
|
1321
|
+
projectHash: projectInfo?.projectHash,
|
|
1322
|
+
lightweightMode: true, // Skip embedder/vector/workers
|
|
1323
|
+
analyticsEnabled: false,
|
|
1324
|
+
sharedStoreConfig: { enabled: false }
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return serviceCache.get(key)!;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1132
1331
|
export function createMemoryService(config: MemoryServiceConfig): MemoryService {
|
|
1133
1332
|
return new MemoryService(config);
|
|
1134
1333
|
}
|