claude-memory-layer 1.0.10 → 1.0.12

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 (142) hide show
  1. package/AGENTS.md +60 -0
  2. package/README.md +166 -2
  3. package/bootstrap-kb/decisions/decisions.md +244 -0
  4. package/bootstrap-kb/glossary/glossary.md +46 -0
  5. package/bootstrap-kb/modules/.claude-plugin.md +22 -0
  6. package/bootstrap-kb/modules/agents.md.md +15 -0
  7. package/bootstrap-kb/modules/claude.md.md +15 -0
  8. package/bootstrap-kb/modules/context.md.md +15 -0
  9. package/bootstrap-kb/modules/docs.md +18 -0
  10. package/bootstrap-kb/modules/handoff.md.md +15 -0
  11. package/bootstrap-kb/modules/package-lock.json.md +15 -0
  12. package/bootstrap-kb/modules/package.json.md +15 -0
  13. package/bootstrap-kb/modules/plan.md.md +15 -0
  14. package/bootstrap-kb/modules/readme.md.md +15 -0
  15. package/bootstrap-kb/modules/scripts.md +26 -0
  16. package/bootstrap-kb/modules/spec.md.md +15 -0
  17. package/bootstrap-kb/modules/specs.md +20 -0
  18. package/bootstrap-kb/modules/src.md +51 -0
  19. package/bootstrap-kb/modules/tests.md +42 -0
  20. package/bootstrap-kb/modules/tsconfig.json.md +15 -0
  21. package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
  22. package/bootstrap-kb/overview/overview.md +40 -0
  23. package/bootstrap-kb/sources/manifest.json +950 -0
  24. package/bootstrap-kb/sources/manifest.md +227 -0
  25. package/bootstrap-kb/timeline/timeline.md +57 -0
  26. package/d.sh +3 -0
  27. package/deploy.sh +3 -0
  28. package/dist/cli/index.js +3577 -389
  29. package/dist/cli/index.js.map +4 -4
  30. package/dist/core/index.js +1383 -138
  31. package/dist/core/index.js.map +4 -4
  32. package/dist/hooks/post-tool-use.js +1917 -214
  33. package/dist/hooks/post-tool-use.js.map +4 -4
  34. package/dist/hooks/session-end.js +1813 -231
  35. package/dist/hooks/session-end.js.map +4 -4
  36. package/dist/hooks/session-start.js +1802 -205
  37. package/dist/hooks/session-start.js.map +4 -4
  38. package/dist/hooks/stop.js +1909 -248
  39. package/dist/hooks/stop.js.map +4 -4
  40. package/dist/hooks/user-prompt-submit.js +1861 -206
  41. package/dist/hooks/user-prompt-submit.js.map +4 -4
  42. package/dist/server/api/index.js +2341 -217
  43. package/dist/server/api/index.js.map +4 -4
  44. package/dist/server/index.js +2350 -226
  45. package/dist/server/index.js.map +4 -4
  46. package/dist/services/memory-service.js +1805 -206
  47. package/dist/services/memory-service.js.map +4 -4
  48. package/dist/ui/app.js +1447 -55
  49. package/dist/ui/index.html +318 -147
  50. package/dist/ui/style.css +892 -0
  51. package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
  52. package/docs/MEMU_ADOPTION.md +40 -0
  53. package/docs/OPERATIONS.md +18 -0
  54. package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
  55. package/memory/_index.md +405 -0
  56. package/memory/default/uncategorized/2026-02-25.md +4839 -0
  57. package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
  58. package/memory/specs/citations-system/2026-02-25.md +1121 -0
  59. package/memory/specs/endless-mode/2026-02-25.md +1392 -0
  60. package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
  61. package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
  62. package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
  63. package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
  64. package/memory/specs/private-tags/2026-02-25.md +1057 -0
  65. package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
  66. package/memory/specs/task-entity-system/2026-02-25.md +924 -0
  67. package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
  68. package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
  69. package/package.json +9 -2
  70. package/scripts/build.ts +6 -0
  71. package/scripts/fix-sync-gap.js +32 -0
  72. package/scripts/heartbeat-memory-orchestrator.sh +28 -0
  73. package/scripts/report-sync-gap.js +26 -0
  74. package/scripts/review-queue-auto-resolve.js +21 -0
  75. package/scripts/sync-gap-auto-heal.sh +17 -0
  76. package/specs/20260207-dashboard-upgrade/context.md +38 -0
  77. package/specs/20260207-dashboard-upgrade/spec.md +96 -0
  78. package/src/cli/index.ts +391 -60
  79. package/src/core/consolidated-store.ts +63 -1
  80. package/src/core/consolidation-worker.ts +115 -6
  81. package/src/core/event-store.ts +14 -0
  82. package/src/core/index.ts +1 -0
  83. package/src/core/ingest-interceptor.ts +80 -0
  84. package/src/core/markdown-mirror.ts +70 -0
  85. package/src/core/md-mirror.ts +92 -0
  86. package/src/core/mongo-sync-config.ts +165 -0
  87. package/src/core/mongo-sync-worker.ts +381 -0
  88. package/src/core/retriever.ts +540 -150
  89. package/src/core/sqlite-event-store.ts +794 -7
  90. package/src/core/sqlite-wrapper.ts +8 -0
  91. package/src/core/tag-taxonomy.ts +51 -0
  92. package/src/core/turn-state.ts +159 -0
  93. package/src/core/types.ts +51 -8
  94. package/src/core/vector-store.ts +21 -3
  95. package/src/hooks/post-tool-use.ts +68 -23
  96. package/src/hooks/session-end.ts +8 -3
  97. package/src/hooks/stop.ts +96 -25
  98. package/src/hooks/user-prompt-submit.ts +44 -5
  99. package/src/server/api/chat.ts +244 -0
  100. package/src/server/api/citations.ts +3 -3
  101. package/src/server/api/events.ts +30 -5
  102. package/src/server/api/health.ts +53 -0
  103. package/src/server/api/index.ts +9 -1
  104. package/src/server/api/projects.ts +74 -0
  105. package/src/server/api/search.ts +3 -3
  106. package/src/server/api/sessions.ts +3 -3
  107. package/src/server/api/stats.ts +89 -8
  108. package/src/server/api/turns.ts +143 -0
  109. package/src/server/api/utils.ts +46 -0
  110. package/src/services/bootstrap-organizer.ts +443 -0
  111. package/src/services/codex-session-history-importer.ts +474 -0
  112. package/src/services/memory-service.ts +508 -71
  113. package/src/services/session-history-importer.ts +215 -51
  114. package/src/ui/app.js +1447 -55
  115. package/src/ui/index.html +318 -147
  116. package/src/ui/style.css +892 -0
  117. package/tests/bootstrap-organizer.test.ts +111 -0
  118. package/tests/consolidation-worker.test.ts +75 -0
  119. package/tests/ingest-interceptor.test.ts +38 -0
  120. package/tests/markdown-mirror.test.ts +85 -0
  121. package/tests/md-mirror.test.ts +50 -0
  122. package/tests/retriever-fallback-chain.test.ts +223 -0
  123. package/tests/retriever-strategy-scope.test.ts +97 -0
  124. package/tests/retriever.memu-adoption.test.ts +122 -0
  125. package/tests/sqlite-event-store-replication.test.ts +92 -0
  126. package/.claude/settings.local.json +0 -27
  127. package/.claude-memory/test.sqlite +0 -0
  128. package/.history/package_20260201112328.json +0 -45
  129. package/.history/package_20260201113602.json +0 -45
  130. package/.history/package_20260201113713.json +0 -45
  131. package/.history/package_20260201114110.json +0 -45
  132. package/.history/package_20260201114632.json +0 -46
  133. package/.history/package_20260201133143.json +0 -45
  134. package/.history/package_20260201134319.json +0 -45
  135. package/.history/package_20260201134326.json +0 -45
  136. package/.history/package_20260201134334.json +0 -45
  137. package/.history/package_20260201134912.json +0 -45
  138. package/.history/package_20260201142928.json +0 -46
  139. package/.history/package_20260201192048.json +0 -47
  140. package/.history/package_20260202114053.json +0 -49
  141. package/.history/package_20260202121115.json +0 -49
  142. package/test_access.js +0 -49
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import Database from 'better-sqlite3';
7
+ import * as fs from 'fs';
8
+ import * as nodePath from 'path';
7
9
 
8
10
  export type SQLiteDatabase = Database.Database;
9
11
 
@@ -16,6 +18,12 @@ export interface SQLiteOptions {
16
18
  * Creates a new SQLite database with WAL mode
17
19
  */
18
20
  export function createSQLiteDatabase(path: string, options?: SQLiteOptions): SQLiteDatabase {
21
+ // Ensure parent directory exists
22
+ const dir = nodePath.dirname(path);
23
+ if (!fs.existsSync(dir)) {
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ }
26
+
19
27
  const db = new Database(path, {
20
28
  readonly: options?.readonly ?? false,
21
29
  });
@@ -0,0 +1,51 @@
1
+ export const TAG_NAMESPACES = {
2
+ SYSTEM: 'sys:',
3
+ QUALITY: 'q:',
4
+ PROJECT: 'proj:',
5
+ TOPIC: 'topic:',
6
+ TEMPORAL: 't:',
7
+ USER: 'user:',
8
+ AGENT: 'agent:'
9
+ } as const;
10
+
11
+ export const VALID_TAG_NAMESPACES = new Set<string>(Object.values(TAG_NAMESPACES));
12
+
13
+ export function parseTag(tag: string): { namespace?: string; value: string } {
14
+ const value = (tag || '').trim();
15
+ const idx = value.indexOf(':');
16
+ if (idx <= 0) return { value };
17
+
18
+ const namespace = `${value.slice(0, idx)}:`;
19
+ const tagValue = value.slice(idx + 1);
20
+ if (!tagValue) return { value };
21
+
22
+ return { namespace, value: tagValue };
23
+ }
24
+
25
+ export function validateTag(tag: string): boolean {
26
+ const normalized = (tag || '').trim();
27
+ if (!normalized) return false;
28
+
29
+ const { namespace } = parseTag(normalized);
30
+ if (!namespace) return true; // backward compatibility for legacy tags
31
+ return VALID_TAG_NAMESPACES.has(namespace);
32
+ }
33
+
34
+ export function withNamespace(value: string, namespace: string): string {
35
+ const clean = parseTag(value).value.trim();
36
+ return `${namespace}${clean}`;
37
+ }
38
+
39
+ export function normalizeTags(tags: unknown): string[] {
40
+ if (!Array.isArray(tags)) return [];
41
+
42
+ const dedup = new Set<string>();
43
+ for (const item of tags) {
44
+ if (typeof item !== 'string') continue;
45
+ const normalized = item.trim();
46
+ if (!validateTag(normalized)) continue;
47
+ dedup.add(normalized);
48
+ }
49
+
50
+ return [...dedup];
51
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Turn State Management
3
+ *
4
+ * Manages a per-session turn_id state file that links events within a conversation turn.
5
+ *
6
+ * Flow:
7
+ * 1. UserPromptSubmit generates a new turn_id and writes it to a state file
8
+ * 2. PostToolUse reads the current turn_id to associate tool observations with the turn
9
+ * 3. Stop reads the turn_id to associate agent responses, then cleans up
10
+ *
11
+ * State file location: ~/.claude-code/memory/.turn-state-{session_id}.json
12
+ *
13
+ * The file is small (just a JSON with turnId + timestamp) and uses atomic writes
14
+ * to prevent corruption from concurrent hook execution.
15
+ */
16
+
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import * as os from 'os';
20
+
21
+ const TURN_STATE_DIR = path.join(os.homedir(), '.claude-code', 'memory');
22
+
23
+ interface TurnState {
24
+ turnId: string;
25
+ sessionId: string;
26
+ createdAt: string;
27
+ }
28
+
29
+ /**
30
+ * Get the state file path for a session
31
+ */
32
+ function getStatePath(sessionId: string): string {
33
+ return path.join(TURN_STATE_DIR, `.turn-state-${sessionId}.json`);
34
+ }
35
+
36
+ /**
37
+ * Write a new turn state for a session.
38
+ * Called by UserPromptSubmit hook when a new user prompt arrives.
39
+ */
40
+ export function writeTurnState(sessionId: string, turnId: string): void {
41
+ try {
42
+ // Ensure directory exists
43
+ if (!fs.existsSync(TURN_STATE_DIR)) {
44
+ fs.mkdirSync(TURN_STATE_DIR, { recursive: true });
45
+ }
46
+
47
+ const state: TurnState = {
48
+ turnId,
49
+ sessionId,
50
+ createdAt: new Date().toISOString()
51
+ };
52
+
53
+ const filePath = getStatePath(sessionId);
54
+ const tempPath = filePath + '.tmp';
55
+
56
+ // Atomic write: write to temp file then rename
57
+ fs.writeFileSync(tempPath, JSON.stringify(state));
58
+ fs.renameSync(tempPath, filePath);
59
+ } catch (error) {
60
+ // Non-critical: if we can't write turn state, events just won't be grouped
61
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
62
+ console.error('Failed to write turn state:', error);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read the current turn_id for a session.
69
+ * Called by PostToolUse and Stop hooks to associate events with the current turn.
70
+ * Returns null if no turn state exists (events won't be grouped).
71
+ */
72
+ export function readTurnState(sessionId: string): string | null {
73
+ try {
74
+ const filePath = getStatePath(sessionId);
75
+
76
+ if (!fs.existsSync(filePath)) {
77
+ return null;
78
+ }
79
+
80
+ const data = fs.readFileSync(filePath, 'utf-8');
81
+ const state: TurnState = JSON.parse(data);
82
+
83
+ // Validate the state belongs to this session
84
+ if (state.sessionId !== sessionId) {
85
+ return null;
86
+ }
87
+
88
+ // Check staleness: if the turn state is older than 30 minutes, ignore it
89
+ const createdAt = new Date(state.createdAt).getTime();
90
+ const now = Date.now();
91
+ if (now - createdAt > 30 * 60 * 1000) {
92
+ // Stale turn state, clean up
93
+ clearTurnState(sessionId);
94
+ return null;
95
+ }
96
+
97
+ return state.turnId;
98
+ } catch (error) {
99
+ // Non-critical: return null if we can't read
100
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
101
+ console.error('Failed to read turn state:', error);
102
+ }
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Clear the turn state for a session.
109
+ * Called by Stop hook after processing agent responses.
110
+ */
111
+ export function clearTurnState(sessionId: string): void {
112
+ try {
113
+ const filePath = getStatePath(sessionId);
114
+ if (fs.existsSync(filePath)) {
115
+ fs.unlinkSync(filePath);
116
+ }
117
+ } catch (error) {
118
+ // Non-critical
119
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
120
+ console.error('Failed to clear turn state:', error);
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Clean up stale turn state files (older than 1 hour).
127
+ * Can be called periodically to prevent file accumulation.
128
+ */
129
+ export function cleanupStaleTurnStates(): number {
130
+ let cleaned = 0;
131
+
132
+ try {
133
+ if (!fs.existsSync(TURN_STATE_DIR)) return 0;
134
+
135
+ const files = fs.readdirSync(TURN_STATE_DIR);
136
+ const now = Date.now();
137
+
138
+ for (const file of files) {
139
+ if (!file.startsWith('.turn-state-') || !file.endsWith('.json')) continue;
140
+
141
+ const filePath = path.join(TURN_STATE_DIR, file);
142
+
143
+ try {
144
+ const stat = fs.statSync(filePath);
145
+ // Remove files older than 1 hour
146
+ if (now - stat.mtimeMs > 60 * 60 * 1000) {
147
+ fs.unlinkSync(filePath);
148
+ cleaned++;
149
+ }
150
+ } catch {
151
+ // Skip files we can't stat
152
+ }
153
+ }
154
+ } catch {
155
+ // Non-critical
156
+ }
157
+
158
+ return cleaned;
159
+ }
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
  // ============================================================
@@ -803,6 +818,34 @@ export interface ConsolidatedMemoryInput {
803
818
  confidence: number;
804
819
  }
805
820
 
821
+ // Long-term Rule (promoted from stable summaries)
822
+ export const ConsolidationRuleSchema = z.object({
823
+ ruleId: z.string(),
824
+ rule: z.string(),
825
+ topics: z.array(z.string()),
826
+ sourceMemoryIds: z.array(z.string()),
827
+ sourceEvents: z.array(z.string()),
828
+ confidence: z.number(),
829
+ createdAt: z.date()
830
+ });
831
+ export type ConsolidationRule = z.infer<typeof ConsolidationRuleSchema>;
832
+
833
+ export interface ConsolidationRuleInput {
834
+ rule: string;
835
+ topics: string[];
836
+ sourceMemoryIds: string[];
837
+ sourceEvents: string[];
838
+ confidence: number;
839
+ }
840
+
841
+ export interface ConsolidationCostQualityReport {
842
+ beforeTokenEstimate: number;
843
+ afterTokenEstimate: number;
844
+ reductionRatio: number;
845
+ qualityGuardPassed: boolean;
846
+ details: string;
847
+ }
848
+
806
849
  // Event Group (for consolidation)
807
850
  export interface EventGroup {
808
851
  topics: string[];
@@ -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
  }