claude-mneme 2.9.1

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,609 @@
1
+ /**
2
+ * Sync Client for claude-mneme
3
+ *
4
+ * Handles synchronization with the optional mneme-server.
5
+ * All operations fail gracefully to local-only mode.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { hostname } from 'os';
11
+ import { randomUUID } from 'crypto';
12
+ import http from 'http';
13
+ import https from 'https';
14
+ import { ensureMemoryDirs, getProjectName, logError } from './utils.mjs';
15
+
16
+ // ============================================================================
17
+ // Client ID Management
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Get or create a persistent client ID for this machine.
22
+ * Stored in ~/.claude-mneme/.client-id
23
+ */
24
+ function getClientId(basePath) {
25
+ const clientIdPath = join(basePath, '.client-id');
26
+
27
+ if (existsSync(clientIdPath)) {
28
+ try {
29
+ return readFileSync(clientIdPath, 'utf-8').trim();
30
+ } catch (e) {
31
+ logError(e, 'sync:readClientId');
32
+ }
33
+ }
34
+
35
+ // Generate new client ID: hostname + random UUID
36
+ const clientId = `${hostname()}-${randomUUID().slice(0, 8)}`;
37
+ try {
38
+ writeFileSync(clientIdPath, clientId);
39
+ } catch (e) {
40
+ logError(e, 'sync:writeClientId');
41
+ }
42
+
43
+ return clientId;
44
+ }
45
+
46
+ // ============================================================================
47
+ // HTTP Client
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Simple HTTP client using Node.js built-in http/https modules
52
+ */
53
+ class HttpClient {
54
+ constructor(baseUrl, apiKey = null, timeoutMs = 10000, retries = 3) {
55
+ this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
56
+ this.apiKey = apiKey;
57
+ this.timeoutMs = timeoutMs;
58
+ this.retries = retries;
59
+
60
+ // Determine protocol
61
+ const url = new URL(baseUrl);
62
+ this.protocol = url.protocol === 'https:' ? 'https' : 'http';
63
+ }
64
+
65
+ _singleRequest(method, path, body = null, headers = {}) {
66
+ const url = new URL(path, this.baseUrl);
67
+
68
+ // Add auth header if API key is set
69
+ if (this.apiKey) {
70
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
71
+ }
72
+
73
+ // Add content type for bodies
74
+ if (body) {
75
+ headers['Content-Type'] = 'application/json';
76
+ }
77
+
78
+ return new Promise((resolve, reject) => {
79
+ const httpModule = this.protocol === 'https' ? https : http;
80
+
81
+ const options = {
82
+ method,
83
+ hostname: url.hostname,
84
+ port: url.port || (this.protocol === 'https' ? 443 : 80),
85
+ path: url.pathname + url.search,
86
+ headers,
87
+ timeout: this.timeoutMs
88
+ };
89
+
90
+ const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10 MB
91
+ const req = httpModule.request(options, (res) => {
92
+ const chunks = [];
93
+ let totalSize = 0;
94
+ res.on('data', chunk => {
95
+ totalSize += chunk.length;
96
+ if (totalSize > MAX_RESPONSE_SIZE) {
97
+ req.destroy();
98
+ reject(new Error('Response body too large'));
99
+ return;
100
+ }
101
+ chunks.push(chunk);
102
+ });
103
+ res.on('end', () => {
104
+ const body = Buffer.concat(chunks).toString();
105
+ let data = null;
106
+ try {
107
+ data = JSON.parse(body);
108
+ } catch {
109
+ data = { raw: body };
110
+ }
111
+ resolve({
112
+ status: res.statusCode,
113
+ data
114
+ });
115
+ });
116
+ });
117
+
118
+ req.on('error', reject);
119
+ req.on('timeout', () => {
120
+ req.destroy();
121
+ reject(new Error('Request timeout'));
122
+ });
123
+
124
+ if (body) {
125
+ req.write(JSON.stringify(body));
126
+ }
127
+ req.end();
128
+ });
129
+ }
130
+
131
+ async request(method, path, body = null, headers = {}) {
132
+ let lastError;
133
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
134
+ try {
135
+ return await this._singleRequest(method, path, body, { ...headers });
136
+ } catch (err) {
137
+ lastError = err;
138
+ if (attempt < this.retries) {
139
+ // Exponential backoff: 500ms, 1000ms, 2000ms...
140
+ await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt)));
141
+ }
142
+ }
143
+ }
144
+ throw lastError;
145
+ }
146
+
147
+ get(path, headers = {}) {
148
+ return this.request('GET', path, null, headers);
149
+ }
150
+
151
+ post(path, body = null, headers = {}) {
152
+ return this.request('POST', path, body, headers);
153
+ }
154
+
155
+ put(path, body, headers = {}) {
156
+ return this.request('PUT', path, body, headers);
157
+ }
158
+
159
+ delete(path, headers = {}) {
160
+ return this.request('DELETE', path, null, headers);
161
+ }
162
+ }
163
+
164
+ // ============================================================================
165
+ // Sync Client
166
+ // ============================================================================
167
+
168
+ /**
169
+ * SyncClient handles all communication with the mneme-server
170
+ */
171
+ class SyncClient {
172
+ constructor(config, cwd) {
173
+ const syncConfig = config.sync || {};
174
+
175
+ this.enabled = syncConfig.enabled === true && !!syncConfig.serverUrl;
176
+ this.serverUrl = syncConfig.serverUrl;
177
+ this.apiKey = syncConfig.apiKey || null;
178
+ this.timeoutMs = syncConfig.timeoutMs || 10000;
179
+ this.retries = syncConfig.retries || 3;
180
+
181
+ this.cwd = cwd;
182
+ this.paths = ensureMemoryDirs(cwd);
183
+ this.projectId = syncConfig.projectId || getProjectName(cwd);
184
+ this.clientId = getClientId(this.paths.base);
185
+
186
+ this.http = this.enabled
187
+ ? new HttpClient(this.serverUrl, this.apiKey, this.timeoutMs, this.retries)
188
+ : null;
189
+ }
190
+
191
+ /**
192
+ * Check if sync is enabled and server is reachable
193
+ */
194
+ async checkHealth() {
195
+ if (!this.enabled) {
196
+ return { ok: false, reason: 'sync_disabled' };
197
+ }
198
+
199
+ try {
200
+ const res = await this.http.get('/health');
201
+ if (res.status === 200 && res.data.status === 'ok') {
202
+ return { ok: true, authRequired: res.data.authRequired };
203
+ }
204
+ return { ok: false, reason: 'server_error', status: res.status };
205
+ } catch (err) {
206
+ return { ok: false, reason: 'unreachable', error: err.message };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Acquire lock for this project
212
+ */
213
+ async acquireLock() {
214
+ if (!this.enabled) return { success: false, reason: 'sync_disabled' };
215
+
216
+ try {
217
+ const res = await this.http.post(
218
+ `/projects/${encodeURIComponent(this.projectId)}/lock`,
219
+ null,
220
+ { 'X-Client-Id': this.clientId }
221
+ );
222
+
223
+ if (res.status === 200) {
224
+ return { success: true, lock: res.data.lock };
225
+ }
226
+
227
+ if (res.status === 409) {
228
+ return {
229
+ success: false,
230
+ reason: 'locked_by_other',
231
+ lock: res.data.lock
232
+ };
233
+ }
234
+
235
+ return { success: false, reason: 'server_error', status: res.status };
236
+ } catch (err) {
237
+ return { success: false, reason: 'unreachable', error: err.message };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Release lock for this project
243
+ */
244
+ async releaseLock() {
245
+ if (!this.enabled) return { success: true };
246
+
247
+ try {
248
+ const res = await this.http.delete(
249
+ `/projects/${encodeURIComponent(this.projectId)}/lock`,
250
+ { 'X-Client-Id': this.clientId }
251
+ );
252
+
253
+ return { success: res.status === 200 };
254
+ } catch {
255
+ // Ignore errors on release - lock will expire
256
+ return { success: true };
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Send heartbeat to extend lock TTL
262
+ */
263
+ async heartbeat() {
264
+ if (!this.enabled) return { success: false };
265
+
266
+ try {
267
+ const res = await this.http.post(
268
+ `/projects/${encodeURIComponent(this.projectId)}/lock/heartbeat`,
269
+ null,
270
+ { 'X-Client-Id': this.clientId }
271
+ );
272
+
273
+ return { success: res.status === 200 };
274
+ } catch {
275
+ return { success: false };
276
+ }
277
+ }
278
+
279
+ /**
280
+ * List files on server with mtimes
281
+ */
282
+ async listServerFiles() {
283
+ if (!this.enabled) return { success: false };
284
+
285
+ try {
286
+ const res = await this.http.get(
287
+ `/projects/${encodeURIComponent(this.projectId)}/files`
288
+ );
289
+
290
+ if (res.status === 200) {
291
+ return { success: true, files: res.data.files || [] };
292
+ }
293
+ return { success: false };
294
+ } catch {
295
+ return { success: false };
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Download a file from server
301
+ */
302
+ async downloadFile(fileName) {
303
+ if (!this.enabled) return { success: false };
304
+
305
+ try {
306
+ const res = await this.http.get(
307
+ `/projects/${encodeURIComponent(this.projectId)}/files/${encodeURIComponent(fileName)}`
308
+ );
309
+
310
+ if (res.status === 200) {
311
+ return {
312
+ success: true,
313
+ content: res.data.content,
314
+ mtime: res.data.mtime
315
+ };
316
+ }
317
+ return { success: false, status: res.status };
318
+ } catch {
319
+ return { success: false };
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Upload a file to server
325
+ */
326
+ async uploadFile(fileName, content) {
327
+ if (!this.enabled) return { success: false };
328
+
329
+ try {
330
+ const res = await this.http.put(
331
+ `/projects/${encodeURIComponent(this.projectId)}/files/${encodeURIComponent(fileName)}`,
332
+ { content },
333
+ { 'X-Client-Id': this.clientId }
334
+ );
335
+
336
+ if (res.status === 200) {
337
+ return { success: true, mtime: res.data.mtime };
338
+ }
339
+ return { success: false, error: res.data?.error };
340
+ } catch {
341
+ return { success: false };
342
+ }
343
+ }
344
+ }
345
+
346
+ // ============================================================================
347
+ // Files to Sync
348
+ // ============================================================================
349
+
350
+ const FILES_TO_SYNC = [
351
+ { name: 'log.jsonl', key: 'log' },
352
+ { name: 'summary.json', key: 'summaryJson' },
353
+ { name: 'remembered.json', key: 'remembered' },
354
+ { name: 'entities.json', key: 'entities' }
355
+ ];
356
+
357
+ /**
358
+ * Get local file info (mtime)
359
+ */
360
+ function getLocalFileInfo(filePath) {
361
+ if (!existsSync(filePath)) {
362
+ return null;
363
+ }
364
+ try {
365
+ const stat = statSync(filePath);
366
+ return {
367
+ mtime: stat.mtime.toISOString(),
368
+ mtimeMs: stat.mtimeMs
369
+ };
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ // ============================================================================
376
+ // Heartbeat Management
377
+ // ============================================================================
378
+
379
+ let heartbeatInterval = null;
380
+ let heartbeatClient = null;
381
+
382
+ /**
383
+ * Start heartbeat to keep lock alive
384
+ */
385
+ export function startHeartbeat(cwd, config) {
386
+ if (heartbeatInterval) {
387
+ return; // Already running
388
+ }
389
+
390
+ const syncConfig = config.sync || {};
391
+ if (!syncConfig.enabled || !syncConfig.serverUrl) {
392
+ return;
393
+ }
394
+
395
+ heartbeatClient = new SyncClient(config, cwd);
396
+
397
+ // Send heartbeat every 5 minutes (default lock TTL is 30 min)
398
+ const intervalMs = 5 * 60 * 1000;
399
+
400
+ heartbeatInterval = setInterval(async () => {
401
+ const result = await heartbeatClient.heartbeat();
402
+ if (!result.success) {
403
+ // Lost lock - stop heartbeat
404
+ console.error('[mneme-sync] Lost lock, stopping heartbeat');
405
+ stopHeartbeat();
406
+ }
407
+ }, intervalMs);
408
+
409
+ // Don't prevent process from exiting
410
+ heartbeatInterval.unref();
411
+ }
412
+
413
+ /**
414
+ * Stop heartbeat
415
+ */
416
+ export function stopHeartbeat() {
417
+ if (heartbeatInterval) {
418
+ clearInterval(heartbeatInterval);
419
+ heartbeatInterval = null;
420
+ }
421
+ heartbeatClient = null;
422
+ }
423
+
424
+ // ============================================================================
425
+ // Pull / Push Operations
426
+ // ============================================================================
427
+
428
+ /**
429
+ * Pull files from server at session start
430
+ *
431
+ * @param {string} cwd - Working directory
432
+ * @param {object} config - Full config
433
+ * @returns {object} { synced: boolean, lockAcquired: boolean, files: string[], message: string }
434
+ */
435
+ export async function pullIfEnabled(cwd, config) {
436
+ const syncConfig = config.sync || {};
437
+
438
+ if (!syncConfig.enabled || !syncConfig.serverUrl) {
439
+ return { synced: false, lockAcquired: false, message: 'Sync disabled' };
440
+ }
441
+
442
+ const client = new SyncClient(config, cwd);
443
+
444
+ // Check server health
445
+ const health = await client.checkHealth();
446
+ if (!health.ok) {
447
+ console.error(`[mneme-sync] Server unreachable, using local memory`);
448
+ logError(new Error(`Sync server unreachable: ${health.error || health.reason}`), 'sync-pull');
449
+ return { synced: false, lockAcquired: false, message: 'Server unreachable' };
450
+ }
451
+
452
+ // Try to acquire lock
453
+ const lockResult = await client.acquireLock();
454
+ if (!lockResult.success) {
455
+ if (lockResult.reason === 'locked_by_other') {
456
+ console.error(`[mneme-sync] Project locked by another machine, using local copy`);
457
+ return {
458
+ synced: false,
459
+ lockAcquired: false,
460
+ message: `Locked by ${lockResult.lock?.clientId}`
461
+ };
462
+ }
463
+ console.error(`[mneme-sync] Failed to acquire lock, using local memory`);
464
+ return { synced: false, lockAcquired: false, message: 'Lock failed' };
465
+ }
466
+
467
+ // Wrap post-lock operations in try/finally to release lock on failure
468
+ try {
469
+ // List server files
470
+ const serverFiles = await client.listServerFiles();
471
+ if (!serverFiles.success) {
472
+ console.error(`[mneme-sync] Failed to list server files`);
473
+ return { synced: false, lockAcquired: true, message: 'List files failed' };
474
+ }
475
+
476
+ // Build map of server files by name
477
+ const serverFileMap = new Map();
478
+ for (const f of serverFiles.files) {
479
+ serverFileMap.set(f.name, f);
480
+ }
481
+
482
+ // Download files that are newer on server
483
+ const pulledFiles = [];
484
+ const paths = ensureMemoryDirs(cwd);
485
+
486
+ for (const { name, key } of FILES_TO_SYNC) {
487
+ const localPath = paths[key];
488
+ if (!localPath) continue;
489
+
490
+ const serverFile = serverFileMap.get(name);
491
+ if (!serverFile) continue; // File doesn't exist on server
492
+
493
+ const localInfo = getLocalFileInfo(localPath);
494
+ const serverMtimeMs = new Date(serverFile.mtime).getTime();
495
+
496
+ // Download if server is newer or local doesn't exist
497
+ if (!localInfo || serverMtimeMs > localInfo.mtimeMs) {
498
+ const download = await client.downloadFile(name);
499
+ if (download.success) {
500
+ try {
501
+ // Backup existing local file before overwriting
502
+ if (existsSync(localPath)) {
503
+ writeFileSync(localPath + '.bak', readFileSync(localPath));
504
+ }
505
+ writeFileSync(localPath, download.content);
506
+ pulledFiles.push(name);
507
+ } catch (err) {
508
+ console.error(`[mneme-sync] Failed to write ${name}: ${err.message}`);
509
+ logError(err, 'sync-pull-write');
510
+ }
511
+ }
512
+ }
513
+ }
514
+
515
+ if (pulledFiles.length > 0) {
516
+ console.error(`[mneme-sync] Synced from server: ${pulledFiles.join(', ')}`);
517
+ }
518
+
519
+ return {
520
+ synced: true,
521
+ lockAcquired: true,
522
+ files: pulledFiles,
523
+ message: pulledFiles.length > 0 ? 'Synced from server' : 'Already up to date'
524
+ };
525
+ } catch (err) {
526
+ // Release lock on unexpected failure so the project isn't locked for 30 minutes
527
+ console.error(`[mneme-sync] Pull failed, releasing lock: ${err.message}`);
528
+ logError(err, 'sync-pull');
529
+ try { await client.releaseLock(); } catch {}
530
+ return { synced: false, lockAcquired: false, message: `Pull failed: ${err.message}` };
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Push files to server at session end
536
+ *
537
+ * @param {string} cwd - Working directory
538
+ * @param {object} config - Full config
539
+ * @returns {object} { pushed: boolean, files: string[], message: string }
540
+ */
541
+ export async function pushIfEnabled(cwd, config) {
542
+ const syncConfig = config.sync || {};
543
+
544
+ if (!syncConfig.enabled || !syncConfig.serverUrl) {
545
+ return { pushed: false, files: [], message: 'Sync disabled' };
546
+ }
547
+
548
+ const client = new SyncClient(config, cwd);
549
+
550
+ // Check server health
551
+ const health = await client.checkHealth();
552
+ if (!health.ok) {
553
+ console.error(`[mneme-sync] Server unreachable, changes saved locally only`);
554
+ logError(new Error('Sync server unreachable during push'), 'sync-push');
555
+ return { pushed: false, files: [], message: 'Server unreachable' };
556
+ }
557
+
558
+ // List server files to compare mtimes
559
+ const serverFiles = await client.listServerFiles();
560
+ const serverFileMap = new Map();
561
+ if (serverFiles.success) {
562
+ for (const f of serverFiles.files) {
563
+ serverFileMap.set(f.name, f);
564
+ }
565
+ }
566
+
567
+ // Upload files that are newer locally
568
+ const pushedFiles = [];
569
+ const paths = ensureMemoryDirs(cwd);
570
+
571
+ for (const { name, key } of FILES_TO_SYNC) {
572
+ const localPath = paths[key];
573
+ if (!localPath || !existsSync(localPath)) continue;
574
+
575
+ const localInfo = getLocalFileInfo(localPath);
576
+ if (!localInfo) continue;
577
+
578
+ const serverFile = serverFileMap.get(name);
579
+ const serverMtimeMs = serverFile ? new Date(serverFile.mtime).getTime() : 0;
580
+
581
+ // Upload if local is newer
582
+ if (localInfo.mtimeMs > serverMtimeMs) {
583
+ try {
584
+ const content = readFileSync(localPath, 'utf-8');
585
+ const upload = await client.uploadFile(name, content);
586
+ if (upload.success) {
587
+ pushedFiles.push(name);
588
+ } else if (upload.error) {
589
+ console.error(`[mneme-sync] Failed to upload ${name}: ${upload.error}`);
590
+ }
591
+ } catch (err) {
592
+ console.error(`[mneme-sync] Failed to read ${name}: ${err.message}`);
593
+ }
594
+ }
595
+ }
596
+
597
+ // Release lock
598
+ await client.releaseLock();
599
+
600
+ if (pushedFiles.length > 0) {
601
+ console.error(`[mneme-sync] Pushed to server: ${pushedFiles.join(', ')}`);
602
+ }
603
+
604
+ return {
605
+ pushed: true,
606
+ files: pushedFiles,
607
+ message: pushedFiles.length > 0 ? 'Pushed to server' : 'No changes to push'
608
+ };
609
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UserPromptSubmit Hook
4
+ * Captures user prompts to provide context for memory summarization
5
+ *
6
+ * Only logs prompts that are substantial (>10 chars) and not just commands
7
+ */
8
+
9
+ import { ensureMemoryDirs, appendLogEntry, logError } from './utils.mjs';
10
+
11
+ // Read hook input from stdin
12
+ let input = '';
13
+ process.stdin.setEncoding('utf8');
14
+ process.stdin.on('data', chunk => input += chunk);
15
+ process.stdin.on('end', () => {
16
+ try {
17
+ const hookData = JSON.parse(input);
18
+ processPrompt(hookData);
19
+ } catch (e) {
20
+ logError(e, 'user-prompt-submit');
21
+ process.exit(0);
22
+ }
23
+ });
24
+
25
+ function processPrompt(hookData) {
26
+ const { prompt, cwd } = hookData;
27
+
28
+ if (!prompt || typeof prompt !== 'string') {
29
+ process.exit(0);
30
+ return;
31
+ }
32
+
33
+ const trimmedPrompt = prompt.trim();
34
+
35
+ // Skip very short prompts (likely just "yes", "ok", "continue", etc.)
36
+ // The confirmation patterns below catch specific short phrases, so this
37
+ // threshold only needs to filter truly meaningless fragments.
38
+ if (trimmedPrompt.length < 10) {
39
+ process.exit(0);
40
+ return;
41
+ }
42
+
43
+ // Skip slash commands (they're logged elsewhere or not meaningful for memory)
44
+ if (trimmedPrompt.startsWith('/')) {
45
+ process.exit(0);
46
+ return;
47
+ }
48
+
49
+ // Skip prompts that are just confirmations
50
+ const confirmationPatterns = [
51
+ /^(yes|no|ok|okay|sure|yep|nope|y|n)[\s.,!?]*$/i,
52
+ /^(continue|proceed|go ahead|do it|sounds good)[\s.,!?]*$/i,
53
+ /^(thanks|thank you|thx)[\s.,!?]*$/i,
54
+ ];
55
+
56
+ if (confirmationPatterns.some(p => p.test(trimmedPrompt))) {
57
+ process.exit(0);
58
+ return;
59
+ }
60
+
61
+ // Truncate very long prompts to first 500 chars
62
+ const content = trimmedPrompt.length > 500
63
+ ? trimmedPrompt.substring(0, 500) + '...'
64
+ : trimmedPrompt;
65
+
66
+ const entry = {
67
+ ts: new Date().toISOString(),
68
+ type: 'prompt',
69
+ content
70
+ };
71
+
72
+ appendLogEntry(entry, cwd || process.cwd());
73
+ process.exit(0);
74
+ }
75
+
76
+ // Timeout fallback
77
+ setTimeout(() => process.exit(0), 5000);