claude-memory-layer 1.0.9 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-layer",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Claude Code plugin that learns from conversations to provide personalized assistance",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -283,6 +283,28 @@ export class SQLiteEventStore {
283
283
  CREATE INDEX IF NOT EXISTS idx_consolidated_confidence ON consolidated_memories(confidence);
284
284
  CREATE INDEX IF NOT EXISTS idx_continuity_created ON continuity_log(created_at);
285
285
  CREATE INDEX IF NOT EXISTS idx_embedding_outbox_status ON embedding_outbox(status);
286
+
287
+ -- FTS5 Full-Text Search for fast keyword search
288
+ CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
289
+ content,
290
+ event_id UNINDEXED,
291
+ content='events',
292
+ content_rowid='rowid'
293
+ );
294
+
295
+ -- Triggers to keep FTS in sync with events table
296
+ CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
297
+ INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
298
+ END;
299
+
300
+ CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
301
+ INSERT INTO events_fts(events_fts, rowid, content, event_id) VALUES('delete', OLD.rowid, OLD.content, OLD.id);
302
+ END;
303
+
304
+ CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
305
+ INSERT INTO events_fts(events_fts, rowid, content, event_id) VALUES('delete', OLD.rowid, OLD.content, OLD.id);
306
+ INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
307
+ END;
286
308
  `);
287
309
 
288
310
  // Migrate existing events table to add access tracking columns if they don't exist
@@ -807,6 +829,82 @@ export class SQLiteEventStore {
807
829
  return rows.map(row => this.rowToEvent(row));
808
830
  }
809
831
 
832
+ /**
833
+ * Fast keyword search using FTS5
834
+ * Returns events matching the search query, ranked by relevance
835
+ */
836
+ async keywordSearch(query: string, limit: number = 10): Promise<Array<{event: MemoryEvent; rank: number}>> {
837
+ await this.initialize();
838
+
839
+ // Escape special FTS5 characters and prepare search terms
840
+ const searchTerms = query
841
+ .replace(/['"(){}[\]^~*?:\\/-]/g, ' ') // Remove special chars
842
+ .split(/\s+/)
843
+ .filter(term => term.length > 1) // Filter short terms
844
+ .map(term => `"${term}"*`) // Prefix matching
845
+ .join(' OR ');
846
+
847
+ if (!searchTerms) {
848
+ return [];
849
+ }
850
+
851
+ try {
852
+ const rows = sqliteAll<Record<string, unknown>>(
853
+ this.db,
854
+ `SELECT e.*, fts.rank
855
+ FROM events_fts fts
856
+ JOIN events e ON e.id = fts.event_id
857
+ WHERE events_fts MATCH ?
858
+ ORDER BY fts.rank
859
+ LIMIT ?`,
860
+ [searchTerms, limit]
861
+ );
862
+
863
+ return rows.map(row => ({
864
+ event: this.rowToEvent(row),
865
+ rank: row.rank as number
866
+ }));
867
+ } catch (error: any) {
868
+ // FTS table might not exist yet (old database)
869
+ // Fallback to LIKE search
870
+ const likePattern = `%${query}%`;
871
+ const rows = sqliteAll<Record<string, unknown>>(
872
+ this.db,
873
+ `SELECT *, 0 as rank FROM events
874
+ WHERE content LIKE ?
875
+ ORDER BY timestamp DESC
876
+ LIMIT ?`,
877
+ [likePattern, limit]
878
+ );
879
+
880
+ return rows.map(row => ({
881
+ event: this.rowToEvent(row),
882
+ rank: 0
883
+ }));
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Rebuild FTS index from existing events
889
+ * Call this once after upgrading to FTS5
890
+ */
891
+ async rebuildFtsIndex(): Promise<number> {
892
+ await this.initialize();
893
+
894
+ // Get count of events to index
895
+ const countRow = sqliteGet<{count: number}>(this.db, 'SELECT COUNT(*) as count FROM events', []);
896
+ const totalEvents = countRow?.count ?? 0;
897
+
898
+ // Clear and rebuild FTS index
899
+ sqliteExec(this.db, `
900
+ DELETE FROM events_fts;
901
+ INSERT INTO events_fts(rowid, content, event_id)
902
+ SELECT rowid, content, id FROM events;
903
+ `);
904
+
905
+ return totalEvents;
906
+ }
907
+
810
908
  /**
811
909
  * Get database instance for direct access
812
910
  */
@@ -1,93 +1,67 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * User Prompt Submit Hook
4
- * Called when user submits a prompt - retrieves relevant memories
4
+ * Called when user submits a prompt - retrieves relevant memories using fast keyword search
5
+ *
6
+ * Uses SQLite FTS5 for fast keyword-based search (no ML model needed)
7
+ * Much faster than vector search (~100ms vs 3-5s)
5
8
  */
6
9
 
7
- import { getMemoryServiceForSession } from '../services/memory-service.js';
10
+ import { getLightweightMemoryService } from '../services/memory-service.js';
8
11
  import type { UserPromptSubmitInput, UserPromptSubmitOutput } from '../core/types.js';
9
12
 
13
+ // Configuration
14
+ const MAX_MEMORIES = parseInt(process.env.CLAUDE_MEMORY_MAX_COUNT || '5');
15
+ const MIN_SCORE = parseFloat(process.env.CLAUDE_MEMORY_MIN_SCORE || '0.3');
16
+ const ENABLE_SEARCH = process.env.CLAUDE_MEMORY_SEARCH !== 'false';
17
+
10
18
  async function main(): Promise<void> {
11
19
  // Read input from stdin
12
20
  const inputData = await readStdin();
13
21
  const input: UserPromptSubmitInput = JSON.parse(inputData);
14
22
 
15
- // Get project-specific memory service via session lookup
16
- const memoryService = getMemoryServiceForSession(input.session_id);
17
-
18
- // Configuration from environment variables
19
- const config = {
20
- candidateCount: parseInt(process.env.CLAUDE_MEMORY_CANDIDATES || '10'),
21
- minScore: parseFloat(process.env.CLAUDE_MEMORY_MIN_SCORE || '0.7'),
22
- maxMemories: parseInt(process.env.CLAUDE_MEMORY_MAX_COUNT || '5'),
23
- dynamicThresholdRatio: parseFloat(process.env.CLAUDE_MEMORY_THRESHOLD_RATIO || '0.85'),
24
- strictMinScore: parseFloat(process.env.CLAUDE_MEMORY_STRICT_MIN || '0.75')
25
- };
23
+ // Use lightweight service (SQLite only, no embedder/vector - FAST!)
24
+ const memoryService = getLightweightMemoryService(input.session_id);
26
25
 
27
26
  try {
28
- // Check if shared store is enabled
29
- const includeShared = memoryService.isSharedStoreEnabled();
30
-
31
- // Retrieve relevant memories for the prompt (including shared if enabled)
32
- // First, get more candidates to filter from
33
- const retrievalResult = await memoryService.retrieveMemories(input.prompt, {
34
- topK: config.candidateCount, // Get more candidates initially
35
- minScore: config.minScore,
36
- includeShared
37
- });
38
-
39
27
  // Store the user prompt for future retrieval
40
28
  await memoryService.storeUserPrompt(
41
29
  input.session_id,
42
30
  input.prompt
43
31
  );
44
32
 
45
- // Filter memories based on relevance and confidence
46
- let relevantMemories = [];
47
- if (retrievalResult.memories && retrievalResult.memories.length > 0) {
48
- // Dynamic threshold based on the best score
49
- const bestScore = Math.max(...retrievalResult.memories.map(m => m.score));
50
- const dynamicThreshold = Math.max(config.strictMinScore, bestScore * config.dynamicThresholdRatio);
51
-
52
- // Filter memories that meet the dynamic threshold
53
- relevantMemories = retrievalResult.memories.filter(m => m.score >= dynamicThreshold);
33
+ let context = '';
54
34
 
55
- // Limit to configured max memories
56
- relevantMemories = relevantMemories.slice(0, config.maxMemories);
35
+ // Fast keyword search if enabled
36
+ if (ENABLE_SEARCH && input.prompt.length > 10) {
37
+ const results = await memoryService.keywordSearch(input.prompt, {
38
+ topK: MAX_MEMORIES,
39
+ minScore: MIN_SCORE
40
+ });
57
41
 
58
- // Check if we have enough highly relevant memories
59
- if (relevantMemories.length === 0) {
60
- // If no memories meet the high threshold, take top 3 with relaxed threshold
61
- const fallbackCount = Math.min(3, config.maxMemories);
62
- relevantMemories = retrievalResult.memories
63
- .filter(m => m.score >= config.minScore)
64
- .slice(0, fallbackCount);
65
- }
42
+ if (results.length > 0) {
43
+ // Increment access count for found memories
44
+ const eventIds = results.map(r => r.event.id);
45
+ await memoryService.incrementMemoryAccess(eventIds);
66
46
 
67
- // Log filtering statistics for debugging
68
- if (process.env.CLAUDE_MEMORY_DEBUG) {
69
- console.error(`Memory filtering: ${retrievalResult.memories.length} candidates -> ${relevantMemories.length} selected`);
70
- console.error(`Threshold: ${dynamicThreshold.toFixed(3)}, Best score: ${bestScore.toFixed(3)}`);
71
- }
47
+ // Format context
48
+ const memories = results.map(r => {
49
+ const preview = r.event.content.length > 300
50
+ ? r.event.content.substring(0, 300) + '...'
51
+ : r.event.content;
52
+ return `- [${r.event.eventType}] ${preview}`;
53
+ });
72
54
 
73
- // Increment access count only for memories that will actually be included
74
- if (relevantMemories.length > 0) {
75
- const eventIds = relevantMemories.map(m => m.event.id);
76
- await memoryService.incrementMemoryAccess(eventIds);
55
+ context = `💡 **Related memories found:**\n\n${memories.join('\n\n')}`;
77
56
  }
78
57
  }
79
58
 
80
- // Format context for Claude with only the relevant memories
81
- const filteredResult = {
82
- ...retrievalResult,
83
- memories: relevantMemories
84
- };
85
- const context = memoryService.formatAsContext(filteredResult);
86
-
87
59
  const output: UserPromptSubmitOutput = { context };
88
60
  console.log(JSON.stringify(output));
89
61
  } catch (error) {
90
- console.error('Memory hook error:', error);
62
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
63
+ console.error('Memory hook error:', error);
64
+ }
91
65
  console.log(JSON.stringify({ context: '' }));
92
66
  }
93
67
  }
@@ -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
  // ============================================================
@@ -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 {
@@ -517,10 +528,8 @@ export class MemoryService {
517
528
  ): Promise<UnifiedRetrievalResult> {
518
529
  await this.initialize();
519
530
 
520
- // Process any pending embeddings first
521
- if (this.vectorWorker) {
522
- await this.vectorWorker.processAll();
523
- }
531
+ // Note: Pending embeddings are processed by the background worker
532
+ // Don't block retrieval - search with whatever vectors are available
524
533
 
525
534
  // Use unified retrieval if shared search is requested
526
535
  if (options?.includeShared && this.sharedStore) {
@@ -534,6 +543,38 @@ export class MemoryService {
534
543
  return this.retriever.retrieve(query, options);
535
544
  }
536
545
 
546
+ /**
547
+ * Fast keyword search using SQLite FTS5
548
+ * Much faster than vector search - no embedding model needed
549
+ */
550
+ async keywordSearch(
551
+ query: string,
552
+ options?: { topK?: number; minScore?: number }
553
+ ): Promise<Array<{event: MemoryEvent; score: number}>> {
554
+ await this.initialize();
555
+
556
+ const results = await this.sqliteStore.keywordSearch(query, options?.topK ?? 10);
557
+
558
+ // Normalize FTS5 rank to a score (0-1 range)
559
+ // FTS5 rank is negative (higher is worse), so we convert it
560
+ const maxRank = Math.min(...results.map(r => r.rank), -0.001);
561
+ const minRank = Math.max(...results.map(r => r.rank), -1000);
562
+ const rankRange = maxRank - minRank || 1;
563
+
564
+ return results.map(r => ({
565
+ event: r.event,
566
+ score: 1 - (r.rank - minRank) / rankRange // Normalize to 0-1
567
+ })).filter(r => !options?.minScore || r.score >= options.minScore);
568
+ }
569
+
570
+ /**
571
+ * Rebuild FTS index (call after database upgrade)
572
+ */
573
+ async rebuildFtsIndex(): Promise<number> {
574
+ await this.initialize();
575
+ return this.sqliteStore.rebuildFtsIndex();
576
+ }
577
+
537
578
  /**
538
579
  * Get session history
539
580
  */
@@ -1129,6 +1170,32 @@ export function getMemoryServiceForSession(sessionId: string): MemoryService {
1129
1170
  return getDefaultMemoryService();
1130
1171
  }
1131
1172
 
1173
+ /**
1174
+ * Get a lightweight memory service for hooks
1175
+ * Only initializes SQLite - no embedder, no vector store, no workers
1176
+ * This is FAST (<100ms) compared to full initialization (3-5s)
1177
+ */
1178
+ export function getLightweightMemoryService(sessionId: string): MemoryService {
1179
+ const projectInfo = getSessionProject(sessionId);
1180
+ const key = projectInfo ? `lightweight_${projectInfo.projectHash}` : 'lightweight_global';
1181
+
1182
+ if (!serviceCache.has(key)) {
1183
+ const storagePath = projectInfo
1184
+ ? getProjectStoragePath(projectInfo.projectPath)
1185
+ : path.join(os.homedir(), '.claude-code', 'memory');
1186
+
1187
+ serviceCache.set(key, new MemoryService({
1188
+ storagePath,
1189
+ projectHash: projectInfo?.projectHash,
1190
+ lightweightMode: true, // Skip embedder/vector/workers
1191
+ analyticsEnabled: false,
1192
+ sharedStoreConfig: { enabled: false }
1193
+ }));
1194
+ }
1195
+
1196
+ return serviceCache.get(key)!;
1197
+ }
1198
+
1132
1199
  export function createMemoryService(config: MemoryServiceConfig): MemoryService {
1133
1200
  return new MemoryService(config);
1134
1201
  }