@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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/bin/setup.js +418 -0
- package/bin/uninstall.js +122 -0
- package/config.default.js +49 -0
- package/config.js +7 -0
- package/hooks/post-tool-memory-hook.js +151 -0
- package/hooks/pre-compact-hook.js +61 -0
- package/hooks/pre-tool-memory-hook.js +148 -0
- package/hooks/session-end-hook.js +159 -0
- package/hooks/user-prompt-hook.js +151 -0
- package/lib/compact-analyzer.js +319 -0
- package/lib/embedding-client.js +113 -0
- package/lib/llm-client.js +61 -0
- package/lib/memory-db.js +1310 -0
- package/lib/utils.js +92 -0
- package/package.json +44 -0
- package/services/embedding-server.js +108 -0
- package/services/llm-server.js +421 -0
- package/services/memory-mcp-server.js +252 -0
- package/tools/rebuild-vectors.js +27 -0
|
@@ -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 };
|