claude-memory-layer 1.0.24 → 1.0.26

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.
Files changed (44) hide show
  1. package/.claude/settings.local.json +15 -1
  2. package/dist/cli/index.js +156 -972
  3. package/dist/cli/index.js.map +4 -4
  4. package/dist/core/index.js +33 -67
  5. package/dist/core/index.js.map +3 -3
  6. package/dist/hooks/post-tool-use.js +183 -968
  7. package/dist/hooks/post-tool-use.js.map +4 -4
  8. package/dist/hooks/semantic-daemon.js +150 -966
  9. package/dist/hooks/semantic-daemon.js.map +4 -4
  10. package/dist/hooks/session-end.js +150 -966
  11. package/dist/hooks/session-end.js.map +4 -4
  12. package/dist/hooks/session-start.js +152 -966
  13. package/dist/hooks/session-start.js.map +4 -4
  14. package/dist/hooks/stop.js +158 -966
  15. package/dist/hooks/stop.js.map +4 -4
  16. package/dist/hooks/user-prompt-submit.js +152 -968
  17. package/dist/hooks/user-prompt-submit.js.map +4 -4
  18. package/dist/server/api/index.js +151 -967
  19. package/dist/server/api/index.js.map +4 -4
  20. package/dist/server/index.js +151 -967
  21. package/dist/server/index.js.map +4 -4
  22. package/dist/services/memory-service.js +150 -966
  23. package/dist/services/memory-service.js.map +4 -4
  24. package/memory/_index.md +2 -0
  25. package/memory/agent_response/uncategorized/2026-03-04.md +276 -1
  26. package/memory/agent_response/uncategorized/2026-03-05.md +48 -0
  27. package/memory/session_summary/uncategorized/2026-03-04.md +20 -1
  28. package/memory/tool_observation/uncategorized/2026-03-04.md +245 -1
  29. package/memory/tool_observation/uncategorized/2026-03-05.md +29 -0
  30. package/memory/user_prompt/uncategorized/2026-03-04.md +193 -1
  31. package/package.json +1 -2
  32. package/specs/memory-utilization-improvements/context.md +145 -0
  33. package/specs/memory-utilization-improvements/plan.md +361 -0
  34. package/specs/memory-utilization-improvements/spec.md +361 -0
  35. package/specs/optional-duckdb/context.md +77 -0
  36. package/specs/optional-duckdb/plan.md +142 -0
  37. package/specs/optional-duckdb/spec.md +35 -0
  38. package/src/core/db-wrapper.ts +18 -73
  39. package/src/core/sqlite-event-store.ts +32 -4
  40. package/src/hooks/post-tool-use.ts +25 -0
  41. package/src/hooks/session-start.ts +4 -0
  42. package/src/hooks/stop.ts +14 -0
  43. package/src/server/api/utils.ts +1 -1
  44. package/src/services/memory-service.ts +62 -58
@@ -1,34 +1,14 @@
1
1
  /**
2
- * DuckDB Promise Wrapper
3
- * Wraps the callback-based DuckDB API with Promise-based async/await interface
2
+ * SQLite Database Wrapper
3
+ * Provides Promise-based interface over better-sqlite3 synchronous API
4
4
  */
5
5
 
6
- import duckdb from 'duckdb';
6
+ import BetterSqlite3 from 'better-sqlite3';
7
7
 
8
- export type Database = duckdb.Database;
9
-
10
- /**
11
- * Converts BigInt values to Number in an object
12
- * DuckDB returns BigInt for COUNT(*) and other aggregate functions
13
- */
14
- function convertBigInts<T>(obj: T): T {
15
- if (obj === null || obj === undefined) return obj;
16
- if (typeof obj === 'bigint') return Number(obj) as unknown as T;
17
- if (obj instanceof Date) return obj; // Preserve Date objects
18
- if (Array.isArray(obj)) return obj.map(convertBigInts) as unknown as T;
19
- if (typeof obj === 'object') {
20
- const result: Record<string, unknown> = {};
21
- for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
22
- result[key] = convertBigInts(value);
23
- }
24
- return result as T;
25
- }
26
- return obj;
27
- }
8
+ export type Database = BetterSqlite3.Database;
28
9
 
29
10
  /**
30
11
  * Safely converts a value to a Date object
31
- * Handles both Date objects and string timestamps from DuckDB
32
12
  */
33
13
  export function toDate(value: unknown): Date {
34
14
  if (value instanceof Date) return value;
@@ -42,78 +22,43 @@ export interface DatabaseOptions {
42
22
  }
43
23
 
44
24
  /**
45
- * Creates a new DuckDB database with Promise-based API
25
+ * Creates a new SQLite database connection
46
26
  */
47
- export function createDatabase(path: string, options?: DatabaseOptions): Database {
48
- if (options?.readOnly) {
49
- return new duckdb.Database(path, { access_mode: 'READ_ONLY' });
50
- }
51
- return new duckdb.Database(path);
27
+ export function createDatabase(dbPath: string, options?: DatabaseOptions): Database {
28
+ return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
52
29
  }
53
30
 
54
31
  /**
55
- * Promisified db.run() - executes a statement that doesn't return rows
32
+ * Executes a statement that doesn't return rows
56
33
  */
57
34
  export function dbRun(db: Database, sql: string, params: unknown[] = []): Promise<void> {
58
- return new Promise((resolve, reject) => {
59
- if (params.length === 0) {
60
- db.run(sql, (err: Error | null) => {
61
- if (err) reject(err);
62
- else resolve();
63
- });
64
- } else {
65
- db.run(sql, ...params, (err: Error | null) => {
66
- if (err) reject(err);
67
- else resolve();
68
- });
69
- }
70
- });
35
+ db.prepare(sql).run(...(params as never[]));
36
+ return Promise.resolve();
71
37
  }
72
38
 
73
39
  /**
74
- * Promisified db.all() - executes a query and returns all rows
75
- * Automatically converts BigInt values to Number
40
+ * Executes a query and returns all rows
76
41
  */
77
42
  export function dbAll<T = Record<string, unknown>>(
78
43
  db: Database,
79
44
  sql: string,
80
45
  params: unknown[] = []
81
46
  ): Promise<T[]> {
82
- return new Promise((resolve, reject) => {
83
- if (params.length === 0) {
84
- db.all(sql, (err: Error | null, rows: T[]) => {
85
- if (err) reject(err);
86
- else resolve(convertBigInts(rows || []));
87
- });
88
- } else {
89
- db.all(sql, ...params, (err: Error | null, rows: T[]) => {
90
- if (err) reject(err);
91
- else resolve(convertBigInts(rows || []));
92
- });
93
- }
94
- });
47
+ return Promise.resolve(db.prepare(sql).all(...(params as never[])) as T[]);
95
48
  }
96
49
 
97
50
  /**
98
- * Promisified db.close() - closes the database connection
51
+ * Closes the database connection
99
52
  */
100
53
  export function dbClose(db: Database): Promise<void> {
101
- return new Promise((resolve, reject) => {
102
- db.close((err: Error | null) => {
103
- if (err) reject(err);
104
- else resolve();
105
- });
106
- });
54
+ db.close();
55
+ return Promise.resolve();
107
56
  }
108
57
 
109
58
  /**
110
- * Promisified db.exec() - executes multiple statements
59
+ * Executes multiple statements
111
60
  */
112
61
  export function dbExec(db: Database, sql: string): Promise<void> {
113
- return new Promise((resolve, reject) => {
114
- db.exec(sql, (err: Error | null) => {
115
- if (err) reject(err);
116
- else resolve();
117
- });
118
- });
62
+ db.exec(sql);
63
+ return Promise.resolve();
119
64
  }
@@ -531,6 +531,30 @@ export class SQLiteEventStore {
531
531
  }
532
532
  }
533
533
 
534
+ /**
535
+ * Get session IDs that have events but no session_summary event.
536
+ * Used to backfill summaries for sessions that ended without Stop hook.
537
+ */
538
+ async getSessionsWithoutSummary(currentSessionId: string, limit = 5): Promise<string[]> {
539
+ await this.initialize();
540
+ const rows = sqliteAll<{ session_id: string }>(
541
+ this.db,
542
+ `SELECT DISTINCT e.session_id
543
+ FROM events e
544
+ WHERE e.session_id != ?
545
+ AND e.event_type != 'session_summary'
546
+ AND e.session_id NOT IN (
547
+ SELECT DISTINCT session_id FROM events WHERE event_type = 'session_summary'
548
+ )
549
+ GROUP BY e.session_id
550
+ HAVING COUNT(*) >= 3
551
+ ORDER BY MAX(e.timestamp) DESC
552
+ LIMIT ?`,
553
+ [currentSessionId, limit]
554
+ );
555
+ return rows.map((r) => r.session_id);
556
+ }
557
+
534
558
  /**
535
559
  * Get events by session ID
536
560
  */
@@ -1228,12 +1252,16 @@ export class SQLiteEventStore {
1228
1252
  }
1229
1253
 
1230
1254
  // Calculate helpfulness score
1255
+ // Weights tuned for shopping-assistant-like corpora where sessions
1256
+ // continue on the same topic (was_reasked was over-penalising normal conversation flow)
1231
1257
  const retrievalScore = retrieval.retrieval_score as number || 0;
1258
+ // More prompts after retrieval = memory was actually useful to the conversation
1259
+ const promptNorm = Math.min(promptCountAfter / 2, 1.0);
1232
1260
  const helpfulnessScore = (
1233
- 0.30 * Math.min(retrievalScore, 1.0) +
1234
- 0.25 * (sessionContinued ? 1.0 : 0.0) +
1235
- 0.25 * toolSuccessRatio +
1236
- 0.20 * (wasReasked ? 0.0 : 1.0)
1261
+ 0.40 * Math.min(retrievalScore, 1.0) +
1262
+ 0.30 * promptNorm +
1263
+ 0.20 * toolSuccessRatio +
1264
+ 0.10 * (sessionContinued ? 1.0 : 0.0)
1237
1265
  );
1238
1266
 
1239
1267
  sqliteRun(
@@ -40,9 +40,33 @@ const ALWAYS_STORE_TOOLS = new Set([
40
40
  'Write', 'Edit', 'MultiEdit', 'Agent', 'Task', 'ExitPlanMode'
41
41
  ]);
42
42
 
43
+ // Keywords that indicate a Bash output is worth storing
44
+ const IMPORTANT_BASH_KEYWORDS = [
45
+ 'error', 'failed', 'exception', 'traceback', 'panic',
46
+ 'warning', 'deprecated',
47
+ 'test passed', 'test failed', 'tests passed', 'tests failed',
48
+ 'coverage', 'assert',
49
+ 'published', 'deployed', 'built successfully', 'build complete',
50
+ 'successfully installed', 'successfully created',
51
+ ];
52
+
53
+ /**
54
+ * For Bash commands, only store output that is significant:
55
+ * - Has stderr content
56
+ * - Contains important keywords (errors, test results, deploy events)
57
+ * - Output is very long (> 2000 chars), indicating meaningful work
58
+ */
59
+ function isBashSignificant(output: string, response: PostToolUseInput['tool_response']): boolean {
60
+ if (response?.stderr && response.stderr.trim().length > 20) return true;
61
+ const lower = output.toLowerCase();
62
+ if (IMPORTANT_BASH_KEYWORDS.some((kw) => lower.includes(kw))) return true;
63
+ return output.trim().length > 2000;
64
+ }
65
+
43
66
  /**
44
67
  * Determine if a tool output is significant enough to store.
45
68
  * Always-store tools bypass the length check.
69
+ * Bash uses keyword-based significance detection.
46
70
  * Other tools require non-empty stderr or output length >= minLen.
47
71
  */
48
72
  function hasSignificantOutput(
@@ -52,6 +76,7 @@ function hasSignificantOutput(
52
76
  minLen: number
53
77
  ): boolean {
54
78
  if (ALWAYS_STORE_TOOLS.has(toolName)) return true;
79
+ if (toolName === 'Bash') return isBashSignificant(output, response);
55
80
  if (response?.stderr && response.stderr.trim().length > 0) return true;
56
81
  return output.trim().length >= minLen;
57
82
  }
@@ -32,6 +32,10 @@ async function main(): Promise<void> {
32
32
  // Start session in memory service
33
33
  await memoryService.startSession(input.session_id, input.cwd);
34
34
 
35
+ // Backfill session summaries for recent sessions that ended without Stop hook
36
+ // (crash, force-close, etc.). Run in background - non-blocking.
37
+ memoryService.backfillMissingSummaries(input.session_id, 5).catch(() => {});
38
+
35
39
  // Get recent context for this project (now automatically scoped)
36
40
  const recentEvents = await memoryService.getRecentEvents(10);
37
41
 
package/src/hooks/stop.ts CHANGED
@@ -136,6 +136,20 @@ async function main(): Promise<void> {
136
136
  // Clean up turn state file after processing
137
137
  clearTurnState(input.session_id);
138
138
 
139
+ // Evaluate helpfulness of retrieved memories for this session
140
+ try {
141
+ await memoryService.evaluateSessionHelpfulness(input.session_id);
142
+ } catch {
143
+ // non-critical
144
+ }
145
+
146
+ // Generate session summary from recent events (rule-based, no LLM needed)
147
+ try {
148
+ await memoryService.generateSessionSummary(input.session_id);
149
+ } catch {
150
+ // non-critical
151
+ }
152
+
139
153
  // Embeddings enqueued in SQLite - will be processed by vector worker when server runs
140
154
  await memoryService.processPendingEmbeddings();
141
155
 
@@ -19,7 +19,7 @@ import { MemoryService } from '../../services/memory-service.js';
19
19
  * VectorWorker lifecycle issues with per-request services.
20
20
  */
21
21
  export function getServiceFromQuery(c: Context): MemoryService {
22
- const project = c.req.query('project');
22
+ const project = c.req.query('project') || c.req.query('projectId');
23
23
  if (project) {
24
24
  // Check if it's a hash (8 hex chars) or a path
25
25
  const isHash = /^[a-f0-9]{8}$/.test(project);
@@ -10,7 +10,6 @@ import * as crypto from 'crypto';
10
10
 
11
11
  import { EventStore } from '../core/event-store.js';
12
12
  import { SQLiteEventStore } from '../core/sqlite-event-store.js';
13
- import { SyncWorker } from '../core/sync-worker.js';
14
13
  import { VectorStore } from '../core/vector-store.js';
15
14
  import { Embedder, getDefaultEmbedder } from '../core/embedder.js';
16
15
  import { VectorWorker, createVectorWorker } from '../core/vector-worker.js';
@@ -182,9 +181,6 @@ export function getSessionProject(sessionId: string): SessionRegistryEntry | nul
182
181
  export class MemoryService {
183
182
  // Primary store: SQLite (WAL mode) - for hooks, always available
184
183
  private readonly sqliteStore: SQLiteEventStore;
185
- // Analytics store: DuckDB - for server reads (optional, synced from SQLite)
186
- private readonly analyticsStore: EventStore | null;
187
- private syncWorker: SyncWorker | null = null;
188
184
 
189
185
  private readonly vectorStore: VectorStore;
190
186
  private readonly embedder: Embedder;
@@ -247,32 +243,6 @@ export class MemoryService {
247
243
  }
248
244
  );
249
245
 
250
- // Initialize ANALYTICS store: DuckDB (optional, for server reads)
251
- // Hooks set analyticsEnabled=false to avoid DuckDB lock conflicts
252
- const analyticsEnabled = config.analyticsEnabled ?? this.readOnly; // Default: enabled only for read-only (server)
253
-
254
- if (!analyticsEnabled) {
255
- // Hook mode: skip DuckDB entirely to avoid lock conflicts
256
- this.analyticsStore = null;
257
- } else if (this.readOnly) {
258
- // Server mode: try to use DuckDB for analytics, will fallback to SQLite
259
- try {
260
- this.analyticsStore = new EventStore(
261
- path.join(storagePath, 'analytics.duckdb'),
262
- { readOnly: true }
263
- );
264
- } catch {
265
- // DuckDB not available, will use SQLite for reads
266
- this.analyticsStore = null;
267
- }
268
- } else {
269
- // Writer mode with analytics: create DuckDB for sync target
270
- this.analyticsStore = new EventStore(
271
- path.join(storagePath, 'analytics.duckdb'),
272
- { readOnly: false }
273
- );
274
- }
275
-
276
246
  this.vectorStore = new VectorStore(path.join(storagePath, 'vectors'));
277
247
  const embeddingModel = config.embeddingModel || process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
278
248
  this.embedder = embeddingModel
@@ -306,16 +276,6 @@ export class MemoryService {
306
276
  return;
307
277
  }
308
278
 
309
- // Initialize analytics store if available (DuckDB)
310
- if (this.analyticsStore) {
311
- try {
312
- await this.analyticsStore.initialize();
313
- } catch (error) {
314
- console.warn('[MemoryService] Analytics store (DuckDB) initialization failed, using SQLite for reads:', error);
315
- // Continue without analytics - SQLite will be used for reads
316
- }
317
- }
318
-
319
279
  await this.vectorStore.initialize();
320
280
  await this.embedder.initialize();
321
281
 
@@ -340,15 +300,6 @@ export class MemoryService {
340
300
  );
341
301
  this.graduationWorker.start();
342
302
 
343
- // Start sync worker (SQLite -> DuckDB) if analytics store is available
344
- if (this.analyticsStore) {
345
- this.syncWorker = new SyncWorker(
346
- this.sqliteStore,
347
- this.analyticsStore,
348
- { intervalMs: 30000, batchSize: 500 }
349
- );
350
- this.syncWorker.start();
351
- }
352
303
  }
353
304
 
354
305
  // Load endless mode setting
@@ -602,6 +553,67 @@ export class MemoryService {
602
553
  );
603
554
  }
604
555
 
556
+ /**
557
+ * Backfill session summaries for recent sessions that are missing them.
558
+ * Called from session-start hook to catch sessions that ended without Stop hook.
559
+ */
560
+ async backfillMissingSummaries(currentSessionId: string, limit = 5): Promise<void> {
561
+ await this.initialize();
562
+
563
+ // Get recent sessions that don't have a summary event
564
+ const recentSessionIds = await this.sqliteStore.getSessionsWithoutSummary(currentSessionId, limit);
565
+ for (const sid of recentSessionIds) {
566
+ try {
567
+ await this.generateSessionSummary(sid);
568
+ } catch {
569
+ // non-critical
570
+ }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Generate a rule-based session summary from stored events.
576
+ * Called at session end (Stop hook) when no LLM-generated summary exists.
577
+ * Skips if a summary already exists for this session.
578
+ */
579
+ async generateSessionSummary(sessionId: string): Promise<void> {
580
+ await this.initialize();
581
+
582
+ const events = await this.sqliteStore.getSessionEvents(sessionId);
583
+ if (events.length < 3) return; // Too short to summarize
584
+
585
+ // Skip if summary already exists
586
+ const hasSummary = events.some((e) => e.eventType === 'session_summary');
587
+ if (hasSummary) return;
588
+
589
+ const prompts = events.filter((e) => e.eventType === 'user_prompt');
590
+ const toolObs = events.filter((e) => e.eventType === 'tool_observation');
591
+ const toolNames = [...new Set(
592
+ toolObs.map((e) => (e.metadata as Record<string, unknown>)?.toolName as string).filter(Boolean)
593
+ )];
594
+ const errorObs = toolObs.filter((e) => {
595
+ const meta = e.metadata as Record<string, unknown>;
596
+ return meta?.exitCode !== undefined && meta.exitCode !== 0;
597
+ });
598
+
599
+ const datePart = events[0].timestamp.toISOString().split('T')[0];
600
+ const parts: string[] = [`[${datePart}] ${prompts.length}턴 세션.`];
601
+
602
+ if (prompts.length > 0) {
603
+ const firstPrompt = prompts[0].content.slice(0, 120).replace(/\n/g, ' ');
604
+ parts.push(`주요 작업: ${firstPrompt}`);
605
+ }
606
+ if (toolNames.length > 0) {
607
+ parts.push(`사용 툴: ${toolNames.slice(0, 6).join(', ')}`);
608
+ }
609
+ if (errorObs.length > 0) {
610
+ parts.push(`오류 ${errorObs.length}건 발생`);
611
+ }
612
+
613
+ const summary = parts.join('. ');
614
+ await this.storeSessionSummary(sessionId, summary, { generated: 'rule-based', eventCount: events.length });
615
+ }
616
+
605
617
  /**
606
618
  * Store a tool observation
607
619
  */
@@ -1275,6 +1287,7 @@ export class MemoryService {
1275
1287
  await this.initialize();
1276
1288
  await this.sqliteStore.recordRetrievalTrace({
1277
1289
  ...input,
1290
+ projectHash: this.projectHash || undefined,
1278
1291
  candidateDetails: [],
1279
1292
  selectedDetails: [],
1280
1293
  fallbackTrace: [],
@@ -1652,11 +1665,6 @@ export class MemoryService {
1652
1665
  this.vectorWorker.stop();
1653
1666
  }
1654
1667
 
1655
- // Stop sync worker
1656
- if (this.syncWorker) {
1657
- this.syncWorker.stop();
1658
- }
1659
-
1660
1668
  // Close shared store
1661
1669
  if (this.sharedEventStore) {
1662
1670
  await this.sharedEventStore.close();
@@ -1665,10 +1673,6 @@ export class MemoryService {
1665
1673
  // Close primary store (SQLite)
1666
1674
  await this.sqliteStore.close();
1667
1675
 
1668
- // Close analytics store (DuckDB)
1669
- if (this.analyticsStore) {
1670
- await this.analyticsStore.close();
1671
- }
1672
1676
  }
1673
1677
 
1674
1678
  /**