@alex900530/claude-persistent-memory 1.0.0

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.
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Compact Analyzer - LLM-driven session analysis
4
+ *
5
+ * Replaces the old 900-line regex/keyword logic in pre-compact.js.
6
+ * Sends the entire session transcript to LLM for one-shot analysis,
7
+ * extracting memories worth saving.
8
+ *
9
+ * Usage:
10
+ * CLI: node compact-analyzer.js <transcriptPath> <sessionId> [cwd]
11
+ * Module: const { analyzeAndSave, loadMessagesFromTranscript } = require('./compact-analyzer')
12
+ *
13
+ * Flow:
14
+ * 1. Read transcript JSONL -> parse messages
15
+ * 2. Condense messages into concise text (~6000 chars)
16
+ * 3. Call llm-client.analyzeSession() -> Azure OpenAI
17
+ * 4. Parse returned <memory> blocks
18
+ * 5. Save to database via memory-db.save()
19
+ */
20
+
21
+ const path = require('path');
22
+ const fs = require('fs');
23
+ const readline = require('readline');
24
+
25
+ const config = require('../config');
26
+ const DATA_DIR = config.dataDir;
27
+ const LOG_FILE = path.join(config.logDir, 'compact-analyzer.log');
28
+
29
+ function log(msg) {
30
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
31
+ try { fs.appendFileSync(LOG_FILE, line); } catch (e) {}
32
+ }
33
+
34
+ function truncate(text, maxLen) {
35
+ if (!text) return '';
36
+ const cleaned = text.replace(/\s+/g, ' ').trim();
37
+ return cleaned.length <= maxLen ? cleaned : cleaned.slice(0, maxLen) + '...';
38
+ }
39
+
40
+ // --- Transcript loading ---
41
+
42
+ /**
43
+ * Load messages from a transcript JSONL file
44
+ */
45
+ async function loadMessagesFromTranscript(transcriptPath) {
46
+ const messages = [];
47
+
48
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
49
+ log(`[Analyzer] Transcript not found: ${transcriptPath}`);
50
+ return messages;
51
+ }
52
+
53
+ const fileStream = fs.createReadStream(transcriptPath);
54
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
55
+
56
+ for await (const line of rl) {
57
+ if (!line.trim()) continue;
58
+ try {
59
+ const entry = JSON.parse(line);
60
+ let msg = entry.message || entry.data?.message || entry.data?.message?.message;
61
+ if (msg && msg.content) {
62
+ messages.push({ role: msg.role, content: msg.content });
63
+ }
64
+ } catch (e) {
65
+ // Skip unparseable lines
66
+ }
67
+ }
68
+
69
+ return messages;
70
+ }
71
+
72
+ // --- Transcript condensation ---
73
+
74
+ /**
75
+ * Convert message list into LLM-analyzable text
76
+ *
77
+ * Strategy: preserve original conversation structure, only filter large file content
78
+ * (Read/Grep/Glob return values)
79
+ * - Pass 1: collect tool_use id -> tool name mapping
80
+ * - Pass 2: decide how to handle tool_result based on tool type
81
+ */
82
+ function condenseTranscript(messages) {
83
+ // Pass 1: build tool_use_id -> { toolName, filePath } mapping
84
+ const toolMap = {};
85
+ for (const msg of messages) {
86
+ if (!Array.isArray(msg.content)) continue;
87
+ for (const block of msg.content) {
88
+ if (block.type === 'tool_use' && block.id) {
89
+ toolMap[block.id] = {
90
+ name: block.name,
91
+ filePath: block.input?.file_path || block.input?.path || ''
92
+ };
93
+ }
94
+ }
95
+ }
96
+
97
+ // Pass 2: build output
98
+ const lines = [];
99
+
100
+ for (const msg of messages) {
101
+ const content = msg.content;
102
+
103
+ // Plain text messages
104
+ if (typeof content === 'string') {
105
+ if (msg.role === 'user') {
106
+ // Filter system-injected messages
107
+ if (/<(task-notification|system-reminder|antml:|function_results)/i.test(content)) continue;
108
+ lines.push(`[User] ${content}`);
109
+ } else if (msg.role === 'assistant') {
110
+ if (content.length > 10) {
111
+ lines.push(`[Assistant] ${content}`);
112
+ }
113
+ }
114
+ continue;
115
+ }
116
+
117
+ // Array content (tool_use / tool_result / text mix)
118
+ if (!Array.isArray(content)) continue;
119
+
120
+ for (const block of content) {
121
+ if (block.type === 'text' && block.text) {
122
+ if (msg.role === 'assistant' && block.text.length > 10) {
123
+ lines.push(`[Assistant] ${block.text}`);
124
+ }
125
+ } else if (block.type === 'tool_use') {
126
+ const name = block.name;
127
+ const input = block.input || {};
128
+ if (name === 'Bash') {
129
+ lines.push(`[Bash] ${input.command || ''}`);
130
+ } else if (name === 'Edit') {
131
+ lines.push(`[Edit] ${shortPath(input.file_path)}`);
132
+ } else if (name === 'Write') {
133
+ lines.push(`[Write] ${shortPath(input.file_path)}`);
134
+ } else if (name === 'Read') {
135
+ lines.push(`[Read] ${shortPath(input.file_path)}`);
136
+ } else if (name === 'Grep') {
137
+ lines.push(`[Grep] pattern="${input.pattern || ''}" path=${shortPath(input.path)}`);
138
+ } else if (name === 'Glob') {
139
+ lines.push(`[Glob] ${input.pattern || ''}`);
140
+ }
141
+ // Other tools (Task, WebSearch, etc.) skipped
142
+ } else if (block.type === 'tool_result') {
143
+ const toolInfo = toolMap[block.tool_use_id] || {};
144
+ const toolName = toolInfo.name || '';
145
+ const output = typeof block.content === 'string'
146
+ ? block.content
147
+ : JSON.stringify(block.content || '');
148
+
149
+ // Handle return values by tool type
150
+ if (['Read', 'Grep', 'Glob'].includes(toolName)) {
151
+ // File content: only keep a one-line marker
152
+ lines.push(` -> [${toolName} returned ${output.length} chars, omitted]`);
153
+ } else if (toolName === 'Bash') {
154
+ // Bash output: keep detailed info for errors, truncate for success
155
+ if (block.is_error || /error|exception|traceback|failed|denied/i.test(output.slice(0, 500))) {
156
+ lines.push(` -> ERROR: ${output}`);
157
+ } else if (output.length > 0) {
158
+ lines.push(` -> ${output}`);
159
+ }
160
+ } else if (toolName === 'Edit' || toolName === 'Write') {
161
+ // Edit/Write results are usually short
162
+ if (output.length > 0 && output.length < 200) {
163
+ lines.push(` -> ${output}`);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return lines.join('\n');
171
+ }
172
+
173
+ function shortPath(filePath) {
174
+ if (!filePath) return '';
175
+ return filePath.split('/').slice(-3).join('/');
176
+ }
177
+
178
+ // --- Core analysis ---
179
+
180
+ /**
181
+ * Analyze transcript and save extracted memories
182
+ *
183
+ * @param {string} transcriptPath - transcript JSONL path
184
+ * @param {string} sessionId - session ID
185
+ * @param {object} options
186
+ * @param {string} options.cwd - working directory
187
+ * @param {object} options.memoryDb - optional, externally provided memory-db instance
188
+ */
189
+ async function analyzeAndSave(transcriptPath, sessionId, options = {}) {
190
+ const { cwd } = options;
191
+
192
+ // Load memory-db
193
+ let db = options.memoryDb;
194
+ if (!db) {
195
+ try {
196
+ db = require('./memory-db');
197
+ } catch (e) {
198
+ log(`[Analyzer] Failed to load memory-db: ${e.message}`);
199
+ return { saved: 0 };
200
+ }
201
+ }
202
+
203
+ // Incremental state
204
+ const stateFile = path.join(DATA_DIR, `compact-state-${sessionId}.json`);
205
+ let lastProcessedLine = 0;
206
+ try {
207
+ if (fs.existsSync(stateFile)) {
208
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
209
+ lastProcessedLine = state.lastLine || 0;
210
+ }
211
+ } catch (e) {}
212
+
213
+ // Read transcript
214
+ const allMessages = await loadMessagesFromTranscript(transcriptPath);
215
+ const newMessages = allMessages.slice(lastProcessedLine);
216
+
217
+ log(`[Analyzer] Session ${sessionId}: total=${allMessages.length}, lastProcessed=${lastProcessedLine}, new=${newMessages.length}`);
218
+
219
+ if (newMessages.length < 5) {
220
+ log(`[Analyzer] Skipping: only ${newMessages.length} new messages`);
221
+ return { saved: 0 };
222
+ }
223
+
224
+ // Condense transcript
225
+ const condensed = condenseTranscript(newMessages);
226
+ if (condensed.length < 100) {
227
+ log(`[Analyzer] Skipping: condensed transcript too short (${condensed.length} chars)`);
228
+ return { saved: 0 };
229
+ }
230
+
231
+ log(`[Analyzer] Condensed transcript: ${condensed.length} chars`);
232
+
233
+ // Call LLM for analysis
234
+ let llmClient;
235
+ try {
236
+ llmClient = require('./llm-client');
237
+ const available = await llmClient.isAvailable();
238
+ if (!available) {
239
+ log(`[Analyzer] LLM service not available`);
240
+ return { saved: 0 };
241
+ }
242
+ } catch (e) {
243
+ log(`[Analyzer] Failed to load llm-client: ${e.message}`);
244
+ return { saved: 0 };
245
+ }
246
+
247
+ const result = await llmClient.analyzeSession(condensed);
248
+ const memories = result.memories || [];
249
+
250
+ log(`[Analyzer] LLM returned ${memories.length} memories`);
251
+
252
+ // Save memories
253
+ let saved = 0;
254
+ for (const mem of memories) {
255
+ try {
256
+ const saveResult = await db.save(mem.summary, {
257
+ type: mem.type,
258
+ domain: mem.domain,
259
+ confidence: mem.confidence || 0.8,
260
+ source: 'compact-analyzer',
261
+ skipStructurize: true,
262
+ structuredContent: mem.structuredContent
263
+ });
264
+
265
+ if (saveResult.action === 'created') {
266
+ saved++;
267
+ log(`[Analyzer] Saved: #${saveResult.id} ${mem.type}/${mem.domain} - ${mem.summary}`);
268
+ } else {
269
+ log(`[Analyzer] ${saveResult.action}: ${mem.summary}`);
270
+ }
271
+ } catch (e) {
272
+ log(`[Analyzer] Save error: ${e.message}`);
273
+ }
274
+ }
275
+
276
+ // Update incremental state
277
+ try {
278
+ fs.writeFileSync(stateFile, JSON.stringify({
279
+ sessionId,
280
+ lastLine: allMessages.length,
281
+ updatedAt: new Date().toISOString()
282
+ }));
283
+ } catch (e) {
284
+ log(`[Analyzer] Failed to save state: ${e.message}`);
285
+ }
286
+
287
+ // Close DB (only when loaded by CLI mode itself)
288
+ if (!options.memoryDb && db.closeDb) {
289
+ db.closeDb();
290
+ }
291
+
292
+ log(`[Analyzer] Done: saved ${saved}/${memories.length} memories`);
293
+ return { saved, total: memories.length };
294
+ }
295
+
296
+ // --- Exports ---
297
+
298
+ module.exports = { analyzeAndSave, loadMessagesFromTranscript };
299
+
300
+ // --- CLI mode ---
301
+
302
+ if (require.main === module) {
303
+ const [transcriptPath, sessionId, cwd] = process.argv.slice(2);
304
+
305
+ if (!transcriptPath || !sessionId) {
306
+ console.error('Usage: node compact-analyzer.js <transcriptPath> <sessionId> [cwd]');
307
+ process.exit(1);
308
+ }
309
+
310
+ analyzeAndSave(transcriptPath, sessionId, { cwd })
311
+ .then(result => {
312
+ log(`[Analyzer-CLI] Finished: ${JSON.stringify(result)}`);
313
+ process.exit(0);
314
+ })
315
+ .catch(e => {
316
+ log(`[Analyzer-CLI] Error: ${e.message}`);
317
+ process.exit(1);
318
+ });
319
+ }
@@ -0,0 +1,113 @@
1
+ const net = require('net');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const config = require('../config');
5
+ const { ensureDir } = require('./utils');
6
+
7
+ const PORT = config.embeddingPort;
8
+ const HOST = '127.0.0.1';
9
+ const TIMEOUT_MS = config.timeout.embeddingClient;
10
+ const LOG_FILE = path.join(config.logDir, 'embedding-calls.log');
11
+
12
+ ensureDir(path.dirname(LOG_FILE));
13
+
14
+ function str(v) { return v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v); }
15
+
16
+ function log(msg) {
17
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
18
+ try { fs.appendFileSync(LOG_FILE, line); } catch (e) {}
19
+ }
20
+
21
+ async function sendRequest(req, timeout = TIMEOUT_MS) {
22
+ const startTime = Date.now();
23
+ log(`[REQ] action=${req.action} query=${str(req.query)} limit=${req.limit || '-'}`);
24
+
25
+ return new Promise((resolve, reject) => {
26
+ const socket = new net.Socket();
27
+ let buffer = '';
28
+ let resolved = false;
29
+
30
+ const done = (type, value) => {
31
+ const duration = Date.now() - startTime;
32
+ if (type === 'resolve') {
33
+ const resultCount = value.results ? value.results.length : '-';
34
+ log(`[RES] action=${req.action} duration=${duration}ms results=${resultCount} response=${str(value)}`);
35
+ } else {
36
+ log(`[ERR] action=${req.action} duration=${duration}ms error=${value.message || value}`);
37
+ }
38
+ type === 'resolve' ? resolve(value) : reject(value);
39
+ };
40
+
41
+ const timer = setTimeout(() => {
42
+ if (!resolved) {
43
+ resolved = true;
44
+ socket.destroy();
45
+ done('reject', new Error('Request timeout'));
46
+ }
47
+ }, timeout);
48
+
49
+ socket.connect(PORT, HOST, () => {
50
+ socket.write(JSON.stringify(req) + '\n');
51
+ });
52
+
53
+ socket.on('data', (data) => {
54
+ buffer += data.toString();
55
+ const lines = buffer.split('\n');
56
+ for (const line of lines) {
57
+ if (line.trim() && !resolved) {
58
+ resolved = true;
59
+ clearTimeout(timer);
60
+ socket.destroy();
61
+ try {
62
+ const response = JSON.parse(line);
63
+ if (response.success) {
64
+ done('resolve', response);
65
+ } else {
66
+ done('reject', new Error(response.error || 'Unknown error'));
67
+ }
68
+ } catch (e) {
69
+ done('reject', new Error('Invalid response'));
70
+ }
71
+ }
72
+ }
73
+ });
74
+
75
+ socket.on('error', (err) => {
76
+ if (!resolved) {
77
+ resolved = true;
78
+ clearTimeout(timer);
79
+ done('reject', err);
80
+ }
81
+ });
82
+ });
83
+ }
84
+
85
+ async function search(query, limit = 3, options = {}) {
86
+ const response = await sendRequest({ action: 'search', query, limit, options });
87
+ return response.results;
88
+ }
89
+
90
+ async function quickSearch(query, limit = 3, options = {}) {
91
+ const response = await sendRequest({ action: 'quickSearch', query, limit, options });
92
+ return response.results;
93
+ }
94
+
95
+ async function ping() {
96
+ try {
97
+ const response = await sendRequest({ action: 'ping' }, 200);
98
+ return response.ready === true;
99
+ } catch (e) {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function getStats() {
105
+ const response = await sendRequest({ action: 'stats' });
106
+ return response.stats;
107
+ }
108
+
109
+ async function isServerRunning() {
110
+ return await ping();
111
+ }
112
+
113
+ module.exports = { search, quickSearch, ping, getStats, isServerRunning, PORT, HOST };
@@ -0,0 +1,61 @@
1
+ const net = require('net');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const config = require('../config');
6
+ const LLM_PORT = config.llmPort;
7
+ const TIMEOUT_MS = config.timeout.llmDefault;
8
+ const LOG_FILE = path.join(config.logDir, 'llm-calls.log');
9
+
10
+ const { ensureDir } = require('./utils');
11
+ ensureDir(path.dirname(LOG_FILE));
12
+
13
+ function str(v) { return v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v); }
14
+
15
+ function log(msg) {
16
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
17
+ try { fs.appendFileSync(LOG_FILE, line); } catch (e) {}
18
+ }
19
+
20
+ async function request(action, params, timeout = TIMEOUT_MS) {
21
+ const startTime = Date.now();
22
+ log(`[REQ] action=${action} params=${str(params)}`);
23
+ return new Promise((resolve, reject) => {
24
+ const client = new net.Socket();
25
+ let response = '';
26
+ let resolved = false;
27
+ const done = (type, value) => {
28
+ const duration = Date.now() - startTime;
29
+ if (type === 'resolve') { log(`[RES] action=${action} duration=${duration}ms response=${str(value)}`); }
30
+ else { log(`[ERR] action=${action} duration=${duration}ms error=${value.message || value}`); }
31
+ type === 'resolve' ? resolve(value) : reject(value);
32
+ };
33
+ const timer = setTimeout(() => {
34
+ if (!resolved) { resolved = true; client.destroy(); done('reject', new Error('LLM service timeout')); }
35
+ }, timeout);
36
+ client.connect(LLM_PORT, '127.0.0.1', () => { client.write(JSON.stringify({ action, ...params }) + '\n'); });
37
+ client.on('data', (data) => {
38
+ response += data.toString();
39
+ if (response.endsWith('\n')) {
40
+ clearTimeout(timer);
41
+ if (!resolved) {
42
+ resolved = true; client.destroy();
43
+ try {
44
+ const result = JSON.parse(response.trim());
45
+ if (result.success) { done('resolve', result); }
46
+ else { done('reject', new Error(result.error || 'LLM request failed')); }
47
+ } catch (e) { done('reject', new Error('Invalid response from LLM service')); }
48
+ }
49
+ }
50
+ });
51
+ client.on('error', (err) => { clearTimeout(timer); if (!resolved) { resolved = true; done('reject', err); } });
52
+ client.on('close', () => { clearTimeout(timer); if (!resolved) { resolved = true; done('reject', new Error('Connection closed')); } });
53
+ });
54
+ }
55
+
56
+ async function isAvailable() { try { const result = await request('ping', {}, 2000); return result.success; } catch (e) { return false; } }
57
+ async function structurize(text, type) { try { const result = await request('structurize', { text, type }, 15000); if (result.success && result.structured) return result.structured; return null; } catch (e) { return null; } }
58
+ async function merge(memories, domain) { try { const result = await request('merge', { memories, domain }, 20000); if (result.success && result.merged) return result.merged; return null; } catch (e) { return null; } }
59
+ async function analyzeSession(transcript) { try { const result = await request('analyzeSession', { transcript }, 30000); return { memories: result.memories || [] }; } catch (e) { return { memories: [] }; } }
60
+
61
+ module.exports = { isAvailable, structurize, merge, analyzeSession, LLM_PORT };