claude-memory-layer 1.0.10 → 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.
Files changed (74) hide show
  1. package/dist/cli/index.js +1266 -181
  2. package/dist/cli/index.js.map +4 -4
  3. package/dist/core/index.js +367 -7
  4. package/dist/core/index.js.map +2 -2
  5. package/dist/hooks/post-tool-use.js +598 -40
  6. package/dist/hooks/post-tool-use.js.map +4 -4
  7. package/dist/hooks/session-end.js +486 -49
  8. package/dist/hooks/session-end.js.map +3 -3
  9. package/dist/hooks/session-start.js +474 -22
  10. package/dist/hooks/session-start.js.map +3 -3
  11. package/dist/hooks/stop.js +586 -70
  12. package/dist/hooks/stop.js.map +4 -4
  13. package/dist/hooks/user-prompt-submit.js +537 -27
  14. package/dist/hooks/user-prompt-submit.js.map +4 -4
  15. package/dist/server/api/index.js +938 -39
  16. package/dist/server/api/index.js.map +4 -4
  17. package/dist/server/index.js +947 -48
  18. package/dist/server/index.js.map +4 -4
  19. package/dist/services/memory-service.js +475 -22
  20. package/dist/services/memory-service.js.map +3 -3
  21. package/dist/ui/app.js +1380 -55
  22. package/dist/ui/index.html +311 -148
  23. package/dist/ui/style.css +892 -0
  24. package/docs/OPERATIONS.md +18 -0
  25. package/package.json +8 -2
  26. package/scripts/fix-sync-gap.js +32 -0
  27. package/scripts/heartbeat-memory-orchestrator.sh +28 -0
  28. package/scripts/report-sync-gap.js +26 -0
  29. package/scripts/review-queue-auto-resolve.js +21 -0
  30. package/scripts/sync-gap-auto-heal.sh +17 -0
  31. package/specs/20260207-dashboard-upgrade/context.md +38 -0
  32. package/specs/20260207-dashboard-upgrade/spec.md +96 -0
  33. package/src/cli/index.ts +110 -58
  34. package/src/core/sqlite-event-store.ts +444 -6
  35. package/src/core/sqlite-wrapper.ts +8 -0
  36. package/src/core/turn-state.ts +159 -0
  37. package/src/core/types.ts +23 -8
  38. package/src/core/vector-store.ts +21 -3
  39. package/src/hooks/post-tool-use.ts +68 -23
  40. package/src/hooks/session-end.ts +8 -3
  41. package/src/hooks/stop.ts +96 -25
  42. package/src/hooks/user-prompt-submit.ts +44 -5
  43. package/src/server/api/chat.ts +244 -0
  44. package/src/server/api/citations.ts +3 -3
  45. package/src/server/api/events.ts +30 -5
  46. package/src/server/api/index.ts +7 -1
  47. package/src/server/api/projects.ts +74 -0
  48. package/src/server/api/search.ts +3 -3
  49. package/src/server/api/sessions.ts +3 -3
  50. package/src/server/api/stats.ts +43 -7
  51. package/src/server/api/turns.ts +143 -0
  52. package/src/server/api/utils.ts +46 -0
  53. package/src/services/memory-service.ts +137 -5
  54. package/src/services/session-history-importer.ts +215 -51
  55. package/src/ui/app.js +1380 -55
  56. package/src/ui/index.html +311 -148
  57. package/src/ui/style.css +892 -0
  58. package/.claude/settings.local.json +0 -27
  59. package/.claude-memory/test.sqlite +0 -0
  60. package/.history/package_20260201112328.json +0 -45
  61. package/.history/package_20260201113602.json +0 -45
  62. package/.history/package_20260201113713.json +0 -45
  63. package/.history/package_20260201114110.json +0 -45
  64. package/.history/package_20260201114632.json +0 -46
  65. package/.history/package_20260201133143.json +0 -45
  66. package/.history/package_20260201134319.json +0 -45
  67. package/.history/package_20260201134326.json +0 -45
  68. package/.history/package_20260201134334.json +0 -45
  69. package/.history/package_20260201134912.json +0 -45
  70. package/.history/package_20260201142928.json +0 -46
  71. package/.history/package_20260201192048.json +0 -47
  72. package/.history/package_20260202114053.json +0 -49
  73. package/.history/package_20260202121115.json +0 -49
  74. package/test_access.js +0 -49
package/src/core/types.ts CHANGED
@@ -256,25 +256,40 @@ export interface UserPromptSubmitOutput {
256
256
  context?: string;
257
257
  }
258
258
 
259
+ // Stop Hook Input (matches actual Claude Code hook format)
259
260
  export interface StopInput {
260
261
  session_id: string;
261
- stop_reason: string;
262
- messages: Array<{ role: string; content: string }>;
262
+ transcript_path: string;
263
+ cwd: string;
264
+ permission_mode: string;
265
+ hook_event_name: string;
266
+ stop_hook_active: boolean;
263
267
  }
264
268
 
265
269
  export interface SessionEndInput {
266
270
  session_id: string;
267
271
  }
268
272
 
269
- // PostToolUse Hook Input
273
+ // PostToolUse Hook Input (matches actual Claude Code hook format)
270
274
  export interface PostToolUseInput {
275
+ session_id: string;
276
+ hook_event_name: string;
271
277
  tool_name: string;
272
278
  tool_input: Record<string, unknown>;
273
- tool_output: string;
274
- tool_error?: string;
275
- session_id: string;
276
- started_at: string;
277
- ended_at: string;
279
+ tool_use_id: string;
280
+ // Claude Code sends tool_response as an object, not tool_output as string
281
+ tool_response: {
282
+ stdout?: string;
283
+ stderr?: string;
284
+ content?: string;
285
+ interrupted?: boolean;
286
+ isImage?: boolean;
287
+ // For non-Bash tools, response may be a plain string or other format
288
+ [key: string]: unknown;
289
+ };
290
+ cwd: string;
291
+ transcript_path: string;
292
+ permission_mode: string;
278
293
  }
279
294
 
280
295
  // ============================================================
@@ -65,8 +65,17 @@ export class VectorStore {
65
65
  };
66
66
 
67
67
  if (!this.table) {
68
- // Create table with first record
69
- this.table = await this.db.createTable(this.tableName, [data]);
68
+ // Create table with first record (handle race condition)
69
+ try {
70
+ this.table = await this.db.createTable(this.tableName, [data]);
71
+ } catch (e: any) {
72
+ if (e?.message?.includes('already exists')) {
73
+ this.table = await this.db.openTable(this.tableName);
74
+ await this.table.add([data]);
75
+ } else {
76
+ throw e;
77
+ }
78
+ }
70
79
  } else {
71
80
  await this.table.add([data]);
72
81
  }
@@ -96,7 +105,16 @@ export class VectorStore {
96
105
  }));
97
106
 
98
107
  if (!this.table) {
99
- this.table = await this.db.createTable(this.tableName, data);
108
+ try {
109
+ this.table = await this.db.createTable(this.tableName, data);
110
+ } catch (e: any) {
111
+ if (e?.message?.includes('already exists')) {
112
+ this.table = await this.db.openTable(this.tableName);
113
+ await this.table.add(data);
114
+ } else {
115
+ throw e;
116
+ }
117
+ }
100
118
  } else {
101
119
  await this.table.add(data);
102
120
  }
@@ -2,14 +2,22 @@
2
2
  /**
3
3
  * PostToolUse Hook
4
4
  * Called after each tool execution - stores tool observations
5
+ *
6
+ * Actual Claude Code input format:
7
+ * {
8
+ * session_id, tool_name, tool_input, tool_use_id,
9
+ * tool_response: { stdout?, stderr?, content?, interrupted?, isImage? },
10
+ * cwd, transcript_path, permission_mode, hook_event_name
11
+ * }
5
12
  */
6
13
 
7
- import { getDefaultMemoryService } from '../services/memory-service.js';
14
+ import { getLightweightMemoryService } from '../services/memory-service.js';
8
15
  import { applyPrivacyFilter, maskSensitiveInput, truncateOutput } from '../core/privacy/index.js';
9
- import { extractMetadata, createToolObservationEmbedding } from '../core/metadata-extractor.js';
16
+ import { extractMetadata } from '../core/metadata-extractor.js';
17
+ import { readTurnState } from '../core/turn-state.js';
10
18
  import type { PostToolUseInput, ToolObservationPayload, Config } from '../core/types.js';
11
19
 
12
- // Default config (will be overridden by actual config when available)
20
+ // Default config
13
21
  const DEFAULT_CONFIG: Config['toolObservation'] = {
14
22
  enabled: true,
15
23
  excludedTools: ['TodoWrite', 'TodoRead'],
@@ -30,12 +38,38 @@ const DEFAULT_PRIVACY_CONFIG: Config['privacy'] = {
30
38
  };
31
39
 
32
40
  /**
33
- * Calculate duration from ISO timestamps
41
+ * Extract text output from tool_response object
34
42
  */
35
- function calculateDuration(startedAt: string, endedAt: string): number {
36
- const start = new Date(startedAt).getTime();
37
- const end = new Date(endedAt).getTime();
38
- return end - start;
43
+ function extractToolOutput(response: PostToolUseInput['tool_response']): string {
44
+ if (!response) return '';
45
+
46
+ // Bash tools: stdout + stderr
47
+ if (response.stdout !== undefined) {
48
+ const parts: string[] = [];
49
+ if (response.stdout) parts.push(response.stdout);
50
+ if (response.stderr) parts.push(`[stderr] ${response.stderr}`);
51
+ return parts.join('\n') || '';
52
+ }
53
+
54
+ // Other tools may have content field
55
+ if (response.content !== undefined) {
56
+ return typeof response.content === 'string'
57
+ ? response.content
58
+ : JSON.stringify(response.content);
59
+ }
60
+
61
+ // Fallback: stringify the whole response
62
+ return JSON.stringify(response);
63
+ }
64
+
65
+ /**
66
+ * Determine if the tool execution was successful
67
+ */
68
+ function isToolSuccess(response: PostToolUseInput['tool_response']): boolean {
69
+ if (!response) return false;
70
+ if (response.interrupted) return false;
71
+ // If stderr has content but stdout also has content, still consider success
72
+ return true;
39
73
  }
40
74
 
41
75
  async function main(): Promise<void> {
@@ -58,55 +92,66 @@ async function main(): Promise<void> {
58
92
  return;
59
93
  }
60
94
 
61
- // 3. Check success filter
62
- const success = !input.tool_error;
95
+ // 3. Extract output from tool_response object
96
+ const toolOutput = extractToolOutput(input.tool_response);
97
+ const success = isToolSuccess(input.tool_response);
98
+
99
+ // 4. Check success filter
63
100
  if (!success && config.storeOnlyOnSuccess) {
64
101
  console.log(JSON.stringify({}));
65
102
  return;
66
103
  }
67
104
 
68
105
  try {
69
- const memoryService = getDefaultMemoryService();
106
+ const memoryService = getLightweightMemoryService(input.session_id);
70
107
 
71
- // 4. Mask sensitive data in input
108
+ // 5. Mask sensitive data in input
72
109
  const maskedInput = maskSensitiveInput(input.tool_input);
73
110
 
74
- // 5. Apply privacy filter to output
75
- const filterResult = applyPrivacyFilter(input.tool_output, privacyConfig);
111
+ // 6. Apply privacy filter to output
112
+ const filterResult = applyPrivacyFilter(toolOutput, privacyConfig);
76
113
  const maskedOutput = filterResult.content;
77
114
 
78
- // 6. Truncate output
115
+ // 7. Truncate output
79
116
  const truncatedOutput = truncateOutput(maskedOutput, {
80
117
  maxLength: config.maxOutputLength,
81
118
  maxLines: config.maxOutputLines
82
119
  });
83
120
 
84
- // 7. Extract metadata
121
+ // 8. Extract metadata
85
122
  const metadata = extractMetadata(
86
123
  input.tool_name,
87
124
  maskedInput,
88
- input.tool_output,
125
+ toolOutput,
89
126
  success
90
127
  );
91
128
 
92
- // 8. Create payload
129
+ // 8.5. Read current turn_id from state file
130
+ const turnId = readTurnState(input.session_id);
131
+
132
+ // 9. Create payload (include turnId in metadata for grouping)
93
133
  const payload: ToolObservationPayload = {
94
134
  toolName: input.tool_name,
95
135
  toolInput: maskedInput,
96
136
  toolOutput: truncatedOutput,
97
- durationMs: calculateDuration(input.started_at, input.ended_at),
137
+ durationMs: 0, // Claude Code doesn't provide timing info
98
138
  success,
99
- errorMessage: input.tool_error,
100
- metadata
139
+ errorMessage: input.tool_response?.stderr || undefined,
140
+ metadata: {
141
+ ...metadata,
142
+ ...(turnId ? { turnId } : {})
143
+ }
101
144
  };
102
145
 
103
- // 9. Store observation
146
+ // 10. Store observation
104
147
  await memoryService.storeToolObservation(input.session_id, payload);
105
148
 
106
149
  // Output empty (hook doesn't return context)
107
150
  console.log(JSON.stringify({}));
108
151
  } catch (error) {
109
- console.error('PostToolUse hook error:', error);
152
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
153
+ console.error('PostToolUse hook error:', error);
154
+ }
110
155
  console.log(JSON.stringify({}));
111
156
  }
112
157
  }
@@ -4,7 +4,7 @@
4
4
  * Called when session ends - generates and stores session summary
5
5
  */
6
6
 
7
- import { getMemoryServiceForSession } from '../services/memory-service.js';
7
+ import { getLightweightMemoryService } from '../services/memory-service.js';
8
8
  import type { SessionEndInput } from '../core/types.js';
9
9
 
10
10
  async function main(): Promise<void> {
@@ -12,8 +12,8 @@ async function main(): Promise<void> {
12
12
  const inputData = await readStdin();
13
13
  const input: SessionEndInput = JSON.parse(inputData);
14
14
 
15
- // Get project-specific memory service via session lookup
16
- const memoryService = getMemoryServiceForSession(input.session_id);
15
+ // Use lightweight service (SQLite only, no embedder/vector - FAST!)
16
+ const memoryService = getLightweightMemoryService(input.session_id);
17
17
 
18
18
  try {
19
19
  // Get session history
@@ -29,6 +29,11 @@ async function main(): Promise<void> {
29
29
  // End session with summary
30
30
  await memoryService.endSession(input.session_id, summary);
31
31
 
32
+ // Evaluate helpfulness of memory retrievals in this session
33
+ try {
34
+ await memoryService.evaluateSessionHelpfulness(input.session_id);
35
+ } catch { /* non-critical */ }
36
+
32
37
  // Process any pending embeddings
33
38
  await memoryService.processPendingEmbeddings();
34
39
  }
package/src/hooks/stop.ts CHANGED
@@ -1,11 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Stop Hook
4
- * Called when agent stops - stores the conversation messages
4
+ * Called when agent stops - reads transcript and stores assistant responses
5
+ *
6
+ * Actual Claude Code input format:
7
+ * {
8
+ * session_id, transcript_path, cwd, permission_mode,
9
+ * hook_event_name: "Stop", stop_hook_active
10
+ * }
11
+ *
12
+ * NOTE: Claude Code does NOT send messages in the Stop hook.
13
+ * We read them from the transcript JSONL file instead.
5
14
  */
6
15
 
7
- import { getMemoryServiceForSession } from '../services/memory-service.js';
16
+ import * as fs from 'fs';
17
+ import * as readline from 'readline';
18
+ import { getLightweightMemoryService } from '../services/memory-service.js';
8
19
  import { applyPrivacyFilter } from '../core/privacy/index.js';
20
+ import { readTurnState, clearTurnState } from '../core/turn-state.js';
9
21
  import type { StopInput, Config } from '../core/types.js';
10
22
 
11
23
  // Default privacy config
@@ -20,45 +32,104 @@ const DEFAULT_PRIVACY_CONFIG: Config['privacy'] = {
20
32
  }
21
33
  };
22
34
 
35
+ /**
36
+ * Extract assistant text messages from transcript JSONL.
37
+ * Only reads the last N lines to avoid processing entire transcript.
38
+ */
39
+ async function extractAssistantMessages(transcriptPath: string): Promise<string[]> {
40
+ if (!fs.existsSync(transcriptPath)) return [];
41
+
42
+ const messages: string[] = [];
43
+
44
+ // Read last portion of file (last ~200KB should cover recent messages)
45
+ const stats = fs.statSync(transcriptPath);
46
+ const readStart = Math.max(0, stats.size - 200 * 1024);
47
+
48
+ const stream = fs.createReadStream(transcriptPath, {
49
+ start: readStart,
50
+ encoding: 'utf8'
51
+ });
52
+
53
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
54
+
55
+ for await (const line of rl) {
56
+ try {
57
+ const entry = JSON.parse(line);
58
+
59
+ // Only process assistant messages with text content
60
+ if (entry.type !== 'assistant') continue;
61
+
62
+ const content = entry.message?.content;
63
+ if (!Array.isArray(content)) continue;
64
+
65
+ // Extract text blocks from content array
66
+ const textParts = content
67
+ .filter((c: { type: string }) => c.type === 'text')
68
+ .map((c: { text: string }) => c.text)
69
+ .filter(Boolean);
70
+
71
+ if (textParts.length > 0) {
72
+ messages.push(textParts.join('\n'));
73
+ }
74
+ } catch {
75
+ // Skip malformed lines (e.g., partial first line from readStart offset)
76
+ }
77
+ }
78
+
79
+ return messages;
80
+ }
81
+
23
82
  async function main(): Promise<void> {
24
83
  // Read input from stdin
25
84
  const inputData = await readStdin();
26
85
  const input: StopInput = JSON.parse(inputData);
27
86
 
28
- // Get project-specific memory service via session lookup
29
- const memoryService = getMemoryServiceForSession(input.session_id);
87
+ // Use lightweight service (SQLite only, no embedder/vector - FAST!)
88
+ const memoryService = getLightweightMemoryService(input.session_id);
30
89
 
31
90
  try {
32
- // Store agent responses from the conversation
33
- for (const message of input.messages) {
34
- if (message.role === 'assistant' && message.content) {
35
- // Apply privacy filter
36
- const filterResult = applyPrivacyFilter(message.content, DEFAULT_PRIVACY_CONFIG);
37
- let content = filterResult.content;
38
-
39
- // Truncate very long responses
40
- if (content.length > 5000) {
41
- content = content.slice(0, 5000) + '...[truncated]';
42
- }
91
+ // Read current turn_id from state file
92
+ const turnId = readTurnState(input.session_id);
93
+
94
+ // Read assistant messages from transcript
95
+ const assistantMessages = await extractAssistantMessages(input.transcript_path);
43
96
 
44
- await memoryService.storeAgentResponse(
45
- input.session_id,
46
- content,
47
- {
48
- stopReason: input.stop_reason,
49
- privacy: filterResult.metadata
50
- }
51
- );
97
+ // Store each assistant response
98
+ for (const text of assistantMessages) {
99
+ // Apply privacy filter
100
+ const filterResult = applyPrivacyFilter(text, DEFAULT_PRIVACY_CONFIG);
101
+ let content = filterResult.content;
102
+
103
+ // Truncate very long responses
104
+ if (content.length > 5000) {
105
+ content = content.slice(0, 5000) + '...[truncated]';
52
106
  }
107
+
108
+ // Skip very short responses (likely just tool calls)
109
+ if (content.trim().length < 10) continue;
110
+
111
+ await memoryService.storeAgentResponse(
112
+ input.session_id,
113
+ content,
114
+ {
115
+ privacy: filterResult.metadata,
116
+ ...(turnId ? { turnId } : {})
117
+ }
118
+ );
53
119
  }
54
120
 
55
- // Process embeddings immediately
121
+ // Clean up turn state file after processing
122
+ clearTurnState(input.session_id);
123
+
124
+ // Embeddings enqueued in SQLite - will be processed by vector worker when server runs
56
125
  await memoryService.processPendingEmbeddings();
57
126
 
58
127
  // Output empty (stop hook doesn't return context)
59
128
  console.log(JSON.stringify({}));
60
129
  } catch (error) {
61
- console.error('Memory hook error:', error);
130
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
131
+ console.error('Stop hook error:', error);
132
+ }
62
133
  console.log(JSON.stringify({}));
63
134
  }
64
135
  }
@@ -5,9 +5,14 @@
5
5
  *
6
6
  * Uses SQLite FTS5 for fast keyword-based search (no ML model needed)
7
7
  * Much faster than vector search (~100ms vs 3-5s)
8
+ *
9
+ * Turn Grouping: Generates a turn_id and persists it to a state file
10
+ * so PostToolUse and Stop hooks can associate their events with this turn.
8
11
  */
9
12
 
13
+ import { randomUUID } from 'crypto';
10
14
  import { getLightweightMemoryService } from '../services/memory-service.js';
15
+ import { writeTurnState } from '../core/turn-state.js';
11
16
  import type { UserPromptSubmitInput, UserPromptSubmitOutput } from '../core/types.js';
12
17
 
13
18
  // Configuration
@@ -15,20 +20,42 @@ const MAX_MEMORIES = parseInt(process.env.CLAUDE_MEMORY_MAX_COUNT || '5');
15
20
  const MIN_SCORE = parseFloat(process.env.CLAUDE_MEMORY_MIN_SCORE || '0.3');
16
21
  const ENABLE_SEARCH = process.env.CLAUDE_MEMORY_SEARCH !== 'false';
17
22
 
23
+ /**
24
+ * Determine if a prompt is worth storing as a memory.
25
+ * Filters slash commands, very short inputs, and trivial patterns.
26
+ */
27
+ function shouldStorePrompt(prompt: string): boolean {
28
+ const trimmed = prompt.trim();
29
+ if (trimmed.startsWith('/')) return false;
30
+ if (trimmed.length < 15) return false;
31
+ if (!/[a-zA-Z가-힣]{2,}/.test(trimmed)) return false;
32
+ return true;
33
+ }
34
+
18
35
  async function main(): Promise<void> {
19
36
  // Read input from stdin
20
37
  const inputData = await readStdin();
21
38
  const input: UserPromptSubmitInput = JSON.parse(inputData);
22
39
 
40
+ // Generate a new turn_id for this user prompt
41
+ // This groups the prompt with subsequent tool calls and the final agent response
42
+ const turnId = randomUUID();
43
+
44
+ // Persist turn state so PostToolUse and Stop hooks can read it
45
+ writeTurnState(input.session_id, turnId);
46
+
23
47
  // Use lightweight service (SQLite only, no embedder/vector - FAST!)
24
48
  const memoryService = getLightweightMemoryService(input.session_id);
25
49
 
26
50
  try {
27
- // Store the user prompt for future retrieval
28
- await memoryService.storeUserPrompt(
29
- input.session_id,
30
- input.prompt
31
- );
51
+ // Store only non-trivial prompts (skip /commands, short inputs)
52
+ if (shouldStorePrompt(input.prompt)) {
53
+ await memoryService.storeUserPrompt(
54
+ input.session_id,
55
+ input.prompt,
56
+ { turnId }
57
+ );
58
+ }
32
59
 
33
60
  let context = '';
34
61
 
@@ -44,6 +71,18 @@ async function main(): Promise<void> {
44
71
  const eventIds = results.map(r => r.event.id);
45
72
  await memoryService.incrementMemoryAccess(eventIds);
46
73
 
74
+ // Record each retrieval for helpfulness tracking
75
+ for (const r of results) {
76
+ try {
77
+ await memoryService.recordRetrieval(
78
+ r.event.id,
79
+ input.session_id,
80
+ r.score,
81
+ input.prompt
82
+ );
83
+ } catch { /* non-critical */ }
84
+ }
85
+
47
86
  // Format context
48
87
  const memories = results.map(r => {
49
88
  const preview = r.event.content.length > 300