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.
- package/.claude-plugin/plugin.json +17 -0
- package/CLAUDE.md +98 -0
- package/CONFIG_REFERENCE.md +495 -0
- package/README.md +40 -0
- package/commands/entity.md +64 -0
- package/commands/forget.md +69 -0
- package/commands/remember.md +60 -0
- package/commands/status.md +90 -0
- package/commands/summarize.md +69 -0
- package/hooks/hooks.json +123 -0
- package/package.json +12 -0
- package/scripts/mem-add.mjs +59 -0
- package/scripts/mem-entity.mjs +143 -0
- package/scripts/mem-forget.mjs +245 -0
- package/scripts/mem-status.mjs +319 -0
- package/scripts/mem-summarize.mjs +338 -0
- package/scripts/post-compact.mjs +132 -0
- package/scripts/post-tool-use.mjs +353 -0
- package/scripts/pre-compact.mjs +491 -0
- package/scripts/session-start.mjs +283 -0
- package/scripts/session-stop.mjs +31 -0
- package/scripts/stop-capture.mjs +294 -0
- package/scripts/subagent-stop.mjs +203 -0
- package/scripts/summarize.mjs +428 -0
- package/scripts/sync.mjs +609 -0
- package/scripts/user-prompt-submit.mjs +77 -0
- package/scripts/utils.mjs +2142 -0
- package/scripts/utils.test.mjs +1465 -0
package/scripts/sync.mjs
ADDED
|
@@ -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);
|