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
|
@@ -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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
// ============================================================
|
package/src/core/vector-store.ts
CHANGED
|
@@ -65,8 +65,17 @@ export class VectorStore {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
if (!this.table) {
|
|
68
|
-
// Create table with first record
|
|
69
|
-
|
|
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
|
-
|
|
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 {
|
|
14
|
+
import { getLightweightMemoryService } from '../services/memory-service.js';
|
|
8
15
|
import { applyPrivacyFilter, maskSensitiveInput, truncateOutput } from '../core/privacy/index.js';
|
|
9
|
-
import { extractMetadata
|
|
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
|
|
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
|
-
*
|
|
41
|
+
* Extract text output from tool_response object
|
|
34
42
|
*/
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
62
|
-
const
|
|
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 =
|
|
106
|
+
const memoryService = getLightweightMemoryService(input.session_id);
|
|
70
107
|
|
|
71
|
-
//
|
|
108
|
+
// 5. Mask sensitive data in input
|
|
72
109
|
const maskedInput = maskSensitiveInput(input.tool_input);
|
|
73
110
|
|
|
74
|
-
//
|
|
75
|
-
const filterResult = applyPrivacyFilter(
|
|
111
|
+
// 6. Apply privacy filter to output
|
|
112
|
+
const filterResult = applyPrivacyFilter(toolOutput, privacyConfig);
|
|
76
113
|
const maskedOutput = filterResult.content;
|
|
77
114
|
|
|
78
|
-
//
|
|
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
|
-
//
|
|
121
|
+
// 8. Extract metadata
|
|
85
122
|
const metadata = extractMetadata(
|
|
86
123
|
input.tool_name,
|
|
87
124
|
maskedInput,
|
|
88
|
-
|
|
125
|
+
toolOutput,
|
|
89
126
|
success
|
|
90
127
|
);
|
|
91
128
|
|
|
92
|
-
// 8.
|
|
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:
|
|
137
|
+
durationMs: 0, // Claude Code doesn't provide timing info
|
|
98
138
|
success,
|
|
99
|
-
errorMessage: input.
|
|
100
|
-
metadata
|
|
139
|
+
errorMessage: input.tool_response?.stderr || undefined,
|
|
140
|
+
metadata: {
|
|
141
|
+
...metadata,
|
|
142
|
+
...(turnId ? { turnId } : {})
|
|
143
|
+
}
|
|
101
144
|
};
|
|
102
145
|
|
|
103
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/hooks/session-end.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Called when session ends - generates and stores session summary
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
-
//
|
|
16
|
-
const memoryService =
|
|
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
|
|
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
|
|
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
|
-
//
|
|
29
|
-
const memoryService =
|
|
87
|
+
// Use lightweight service (SQLite only, no embedder/vector - FAST!)
|
|
88
|
+
const memoryService = getLightweightMemoryService(input.session_id);
|
|
30
89
|
|
|
31
90
|
try {
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|