@squidcode/forever-plugin 0.2.0 → 0.4.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/README.md +66 -7
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -2
- package/dist/files.d.ts +8 -0
- package/dist/files.js +31 -0
- package/dist/index.js +467 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) plugin for [Forever](https://forever.squidcode.com) — a centralized persistent memory layer for Claude Code instances.
|
|
4
4
|
|
|
5
|
-
Forever lets multiple Claude Code sessions share memory across machines, projects, and time. This plugin connects Claude Code to your Forever server via MCP.
|
|
5
|
+
Forever lets multiple Claude Code sessions share memory and files across machines, projects, and time. This plugin connects Claude Code to your Forever server via MCP.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -34,30 +34,41 @@ This registers the plugin as an MCP server that Claude Code will start automatic
|
|
|
34
34
|
|
|
35
35
|
## Tools
|
|
36
36
|
|
|
37
|
-
The plugin exposes
|
|
37
|
+
The plugin exposes the following MCP tools:
|
|
38
38
|
|
|
39
|
-
###
|
|
39
|
+
### Memory Tools
|
|
40
|
+
|
|
41
|
+
#### `memory_log`
|
|
40
42
|
|
|
41
43
|
Log an entry to Forever memory.
|
|
42
44
|
|
|
43
45
|
| Parameter | Type | Required | Description |
|
|
44
46
|
|-------------|----------|----------|--------------------------------------|
|
|
45
|
-
| `project` | string |
|
|
47
|
+
| `project` | string | no | Project name or git remote URL (auto-detected) |
|
|
46
48
|
| `type` | enum | yes | `summary`, `decision`, or `error` |
|
|
47
49
|
| `content` | string | yes | The content to log |
|
|
48
50
|
| `tags` | string[] | no | Tags for categorization |
|
|
49
51
|
| `sessionId` | string | no | Session ID for grouping entries |
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
#### `memory_get_recent`
|
|
52
54
|
|
|
53
55
|
Get recent memory entries for a project.
|
|
54
56
|
|
|
55
57
|
| Parameter | Type | Required | Description |
|
|
56
58
|
|-----------|--------|----------|--------------------------------|
|
|
57
|
-
| `project` | string |
|
|
59
|
+
| `project` | string | no | Project name or git remote URL (auto-detected) |
|
|
58
60
|
| `limit` | number | no | Number of entries (default 20) |
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
#### `memory_get_sessions`
|
|
63
|
+
|
|
64
|
+
Get recent sessions grouped by session with machine info. Use at startup to detect cross-machine handoffs.
|
|
65
|
+
|
|
66
|
+
| Parameter | Type | Required | Description |
|
|
67
|
+
|-----------|--------|----------|--------------------------------|
|
|
68
|
+
| `project` | string | no | Project name or git remote URL (auto-detected) |
|
|
69
|
+
| `limit` | number | no | Number of sessions (default 10) |
|
|
70
|
+
|
|
71
|
+
#### `memory_search`
|
|
61
72
|
|
|
62
73
|
Search memory entries across projects.
|
|
63
74
|
|
|
@@ -68,11 +79,59 @@ Search memory entries across projects.
|
|
|
68
79
|
| `type` | enum | no | Filter by entry type |
|
|
69
80
|
| `limit` | number | no | Max results (default 20) |
|
|
70
81
|
|
|
82
|
+
### File Tools
|
|
83
|
+
|
|
84
|
+
#### `memory_store_file`
|
|
85
|
+
|
|
86
|
+
Store a file in Forever for cross-machine access.
|
|
87
|
+
|
|
88
|
+
| Parameter | Type | Required | Description |
|
|
89
|
+
|------------|--------|----------|---------------------------------|
|
|
90
|
+
| `filePath` | string | yes | Path to the file (relative or absolute) |
|
|
91
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
92
|
+
|
|
93
|
+
#### `memory_restore_file`
|
|
94
|
+
|
|
95
|
+
Restore a file from Forever to the local disk.
|
|
96
|
+
|
|
97
|
+
| Parameter | Type | Required | Description |
|
|
98
|
+
|------------|--------|----------|---------------------------------|
|
|
99
|
+
| `filePath` | string | yes | Path of the file to restore |
|
|
100
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
101
|
+
|
|
102
|
+
#### `memory_share_file`
|
|
103
|
+
|
|
104
|
+
Mark a file for auto-sync across machines (also stores it immediately).
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Required | Description |
|
|
107
|
+
|------------|--------|----------|---------------------------------|
|
|
108
|
+
| `filePath` | string | yes | Path to the file to share |
|
|
109
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
110
|
+
|
|
111
|
+
#### `memory_unshare_file`
|
|
112
|
+
|
|
113
|
+
Stop auto-syncing a file across machines.
|
|
114
|
+
|
|
115
|
+
| Parameter | Type | Required | Description |
|
|
116
|
+
|------------|--------|----------|---------------------------------|
|
|
117
|
+
| `filePath` | string | yes | Path of the file to stop sharing |
|
|
118
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
119
|
+
|
|
120
|
+
#### `memory_sync_files`
|
|
121
|
+
|
|
122
|
+
Sync all shared files for a project — downloads newer versions, uploads local changes.
|
|
123
|
+
|
|
124
|
+
| Parameter | Type | Required | Description |
|
|
125
|
+
|-----------|--------|----------|------------------------------|
|
|
126
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
127
|
+
|
|
71
128
|
## How It Works
|
|
72
129
|
|
|
73
130
|
- The plugin runs as an MCP stdio server, started by Claude Code on demand.
|
|
74
131
|
- Each machine gets a unique ID (stored in `~/.forever/machine.json`) for tracking which machine produced each memory entry.
|
|
75
132
|
- All API calls are authenticated via JWT token obtained during login.
|
|
133
|
+
- Files up to 1MB are supported; binary files are automatically base64-encoded.
|
|
134
|
+
- File deduplication uses MD5 hashing — unchanged files are not re-uploaded.
|
|
76
135
|
|
|
77
136
|
## Development
|
|
78
137
|
|
package/dist/client.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ export declare function getCredentials(): Credentials | null;
|
|
|
11
11
|
export declare function saveCredentials(creds: Credentials): void;
|
|
12
12
|
export declare function getMachineConfig(): MachineConfig | null;
|
|
13
13
|
export declare function saveMachineConfig(config: MachineConfig): void;
|
|
14
|
-
export declare function createApiClient(
|
|
14
|
+
export declare function createApiClient(options?: {
|
|
15
|
+
timeout?: number;
|
|
16
|
+
}): AxiosInstance | null;
|
|
15
17
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -42,7 +42,7 @@ export function saveMachineConfig(config) {
|
|
|
42
42
|
mode: 0o600,
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
-
export function createApiClient() {
|
|
45
|
+
export function createApiClient(options) {
|
|
46
46
|
const creds = getCredentials();
|
|
47
47
|
if (!creds)
|
|
48
48
|
return null;
|
|
@@ -52,6 +52,6 @@ export function createApiClient() {
|
|
|
52
52
|
Authorization: `Bearer ${creds.token}`,
|
|
53
53
|
'Content-Type': 'application/json',
|
|
54
54
|
},
|
|
55
|
-
timeout: 10000,
|
|
55
|
+
timeout: options?.timeout ?? 10000,
|
|
56
56
|
});
|
|
57
57
|
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function computeMd5(buffer: Buffer): string;
|
|
2
|
+
export declare function isBinary(buffer: Buffer): boolean;
|
|
3
|
+
export declare function readAndEncodeFile(filePath: string): {
|
|
4
|
+
content: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
size: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function writeDecodedFile(filePath: string, content: string): void;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
export function computeMd5(buffer) {
|
|
5
|
+
return createHash('md5').update(buffer).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
export function isBinary(buffer) {
|
|
8
|
+
const sample = buffer.subarray(0, 8192);
|
|
9
|
+
return sample.includes(0);
|
|
10
|
+
}
|
|
11
|
+
export function readAndEncodeFile(filePath) {
|
|
12
|
+
const buffer = readFileSync(filePath);
|
|
13
|
+
if (buffer.length > 1_048_576) {
|
|
14
|
+
throw new Error(`File exceeds 1MB limit (${buffer.length} bytes)`);
|
|
15
|
+
}
|
|
16
|
+
const hash = computeMd5(buffer);
|
|
17
|
+
const content = isBinary(buffer)
|
|
18
|
+
? `base64:${buffer.toString('base64')}`
|
|
19
|
+
: buffer.toString('utf-8');
|
|
20
|
+
return { content, hash, size: buffer.length };
|
|
21
|
+
}
|
|
22
|
+
export function writeDecodedFile(filePath, content) {
|
|
23
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
24
|
+
if (content.startsWith('base64:')) {
|
|
25
|
+
const data = Buffer.from(content.slice(7), 'base64');
|
|
26
|
+
writeFileSync(filePath, data);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,12 @@ import { randomBytes } from 'crypto';
|
|
|
7
7
|
import { basename } from 'path';
|
|
8
8
|
import { createApiClient } from './client.js';
|
|
9
9
|
import { getOrCreateMachineId } from './machine.js';
|
|
10
|
+
import { readAndEncodeFile, writeDecodedFile, computeMd5 } from './files.js';
|
|
11
|
+
import { readFileSync, existsSync } from 'fs';
|
|
12
|
+
import { resolve } from 'path';
|
|
10
13
|
const server = new McpServer({
|
|
11
14
|
name: 'forever',
|
|
12
|
-
version: '0.
|
|
15
|
+
version: '0.4.0',
|
|
13
16
|
});
|
|
14
17
|
const machineId = getOrCreateMachineId();
|
|
15
18
|
const sessionId = `${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
@@ -177,6 +180,85 @@ server.tool('memory_get_recent', 'Get recent memory entries for a project', {
|
|
|
177
180
|
};
|
|
178
181
|
}
|
|
179
182
|
});
|
|
183
|
+
server.tool('memory_get_sessions', 'Get recent sessions for a project, grouped by session with machine info. Use at startup to detect cross-machine handoffs.', {
|
|
184
|
+
project: z
|
|
185
|
+
.string()
|
|
186
|
+
.optional()
|
|
187
|
+
.describe('Project name or git remote URL (auto-detected from git if omitted)'),
|
|
188
|
+
limit: z
|
|
189
|
+
.number()
|
|
190
|
+
.optional()
|
|
191
|
+
.default(10)
|
|
192
|
+
.describe('Number of recent sessions to fetch'),
|
|
193
|
+
}, async ({ project, limit }) => {
|
|
194
|
+
const api = createApiClient();
|
|
195
|
+
if (!api) {
|
|
196
|
+
return {
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: 'text',
|
|
200
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const resolvedProject = resolveProject(project);
|
|
206
|
+
if (!resolvedProject) {
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: 'text',
|
|
211
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const res = await api.get('/logs/sessions', {
|
|
218
|
+
params: { project: resolvedProject, machineId, limit },
|
|
219
|
+
});
|
|
220
|
+
const { sessions, hasRemoteActivity } = res.data;
|
|
221
|
+
if (!sessions.length) {
|
|
222
|
+
return {
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: 'text',
|
|
226
|
+
text: `No previous sessions found for "${resolvedProject}".`,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const lines = [];
|
|
232
|
+
if (hasRemoteActivity) {
|
|
233
|
+
lines.push('⚡ REMOTE ACTIVITY DETECTED — Sessions from other machines found for this project.\n');
|
|
234
|
+
}
|
|
235
|
+
for (const s of sessions) {
|
|
236
|
+
const machine = s.machineName || 'unknown';
|
|
237
|
+
const remote = s.isRemote ? ' [REMOTE]' : ' [LOCAL]';
|
|
238
|
+
const branch = s.gitBranch ? ` on ${s.gitBranch}` : '';
|
|
239
|
+
const commit = s.gitCommit ? ` @ ${s.gitCommit}` : '';
|
|
240
|
+
lines.push(`## Session ${s.sessionId}${remote}`);
|
|
241
|
+
lines.push(`Machine: ${machine}${branch}${commit}`);
|
|
242
|
+
lines.push(`Time: ${s.startedAt} → ${s.endedAt} (${s.logCount} logs)`);
|
|
243
|
+
if (s.directory)
|
|
244
|
+
lines.push(`Directory: ${s.directory}`);
|
|
245
|
+
if (s.summary)
|
|
246
|
+
lines.push(`Summary: ${s.summary}`);
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
249
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
return {
|
|
253
|
+
content: [
|
|
254
|
+
{
|
|
255
|
+
type: 'text',
|
|
256
|
+
text: `Failed to fetch sessions: ${err.message}`,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
});
|
|
180
262
|
server.tool('memory_search', 'Search memory entries across projects', {
|
|
181
263
|
query: z.string().describe('Search query'),
|
|
182
264
|
project: z.string().optional().describe('Filter by project'),
|
|
@@ -231,6 +313,390 @@ server.tool('memory_search', 'Search memory entries across projects', {
|
|
|
231
313
|
};
|
|
232
314
|
}
|
|
233
315
|
});
|
|
316
|
+
// --- File Storage & Sharing Tools ---
|
|
317
|
+
function resolveFilePath(filePath) {
|
|
318
|
+
return resolve(process.cwd(), filePath);
|
|
319
|
+
}
|
|
320
|
+
server.tool('memory_store_file', 'Store a file in Forever for cross-machine access', {
|
|
321
|
+
filePath: z
|
|
322
|
+
.string()
|
|
323
|
+
.describe('Path to the file to store (relative or absolute)'),
|
|
324
|
+
project: z
|
|
325
|
+
.string()
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
328
|
+
}, async ({ filePath, project }) => {
|
|
329
|
+
const api = createApiClient({ timeout: 30000 });
|
|
330
|
+
if (!api) {
|
|
331
|
+
return {
|
|
332
|
+
content: [
|
|
333
|
+
{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const resolvedProject = resolveProject(project);
|
|
341
|
+
if (!resolvedProject) {
|
|
342
|
+
return {
|
|
343
|
+
content: [
|
|
344
|
+
{
|
|
345
|
+
type: 'text',
|
|
346
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const absPath = resolveFilePath(filePath);
|
|
352
|
+
if (!existsSync(absPath)) {
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{ type: 'text', text: `File not found: ${absPath}` },
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const { content, hash, size } = readAndEncodeFile(absPath);
|
|
361
|
+
const res = await api.post('/files/store', {
|
|
362
|
+
project: resolvedProject,
|
|
363
|
+
filePath,
|
|
364
|
+
content,
|
|
365
|
+
contentHash: hash,
|
|
366
|
+
machineId,
|
|
367
|
+
sessionId,
|
|
368
|
+
});
|
|
369
|
+
const dedup = res.data.deduplicated ? ' (unchanged, skipped)' : '';
|
|
370
|
+
return {
|
|
371
|
+
content: [
|
|
372
|
+
{
|
|
373
|
+
type: 'text',
|
|
374
|
+
text: `Stored "${filePath}" (${size} bytes)${dedup}`,
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
const msg = err.response?.data?.message || err.message;
|
|
381
|
+
return {
|
|
382
|
+
content: [
|
|
383
|
+
{ type: 'text', text: `Failed to store file: ${msg}` },
|
|
384
|
+
],
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
server.tool('memory_restore_file', 'Restore a file from Forever to the local disk', {
|
|
389
|
+
filePath: z.string().describe('Path of the file to restore'),
|
|
390
|
+
project: z
|
|
391
|
+
.string()
|
|
392
|
+
.optional()
|
|
393
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
394
|
+
}, async ({ filePath, project }) => {
|
|
395
|
+
const api = createApiClient({ timeout: 30000 });
|
|
396
|
+
if (!api) {
|
|
397
|
+
return {
|
|
398
|
+
content: [
|
|
399
|
+
{
|
|
400
|
+
type: 'text',
|
|
401
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const resolvedProject = resolveProject(project);
|
|
407
|
+
if (!resolvedProject) {
|
|
408
|
+
return {
|
|
409
|
+
content: [
|
|
410
|
+
{
|
|
411
|
+
type: 'text',
|
|
412
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const res = await api.get('/files/latest', {
|
|
419
|
+
params: { project: resolvedProject, filePath },
|
|
420
|
+
});
|
|
421
|
+
if (!res.data) {
|
|
422
|
+
return {
|
|
423
|
+
content: [
|
|
424
|
+
{
|
|
425
|
+
type: 'text',
|
|
426
|
+
text: `No stored version found for "${filePath}"`,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const absPath = resolveFilePath(filePath);
|
|
432
|
+
writeDecodedFile(absPath, res.data.content);
|
|
433
|
+
return {
|
|
434
|
+
content: [
|
|
435
|
+
{
|
|
436
|
+
type: 'text',
|
|
437
|
+
text: `Restored "${filePath}" (hash: ${res.data.contentHash})`,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
const msg = err.response?.data?.message || err.message;
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{ type: 'text', text: `Failed to restore file: ${msg}` },
|
|
447
|
+
],
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
server.tool('memory_share_file', 'Mark a file for auto-sync across machines (also stores it immediately)', {
|
|
452
|
+
filePath: z.string().describe('Path to the file to share'),
|
|
453
|
+
project: z
|
|
454
|
+
.string()
|
|
455
|
+
.optional()
|
|
456
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
457
|
+
}, async ({ filePath, project }) => {
|
|
458
|
+
const api = createApiClient({ timeout: 30000 });
|
|
459
|
+
if (!api) {
|
|
460
|
+
return {
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: 'text',
|
|
464
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const resolvedProject = resolveProject(project);
|
|
470
|
+
if (!resolvedProject) {
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: 'text',
|
|
475
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const absPath = resolveFilePath(filePath);
|
|
481
|
+
if (!existsSync(absPath)) {
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{ type: 'text', text: `File not found: ${absPath}` },
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
// Store the file first
|
|
490
|
+
const { content, hash, size } = readAndEncodeFile(absPath);
|
|
491
|
+
await api.post('/files/store', {
|
|
492
|
+
project: resolvedProject,
|
|
493
|
+
filePath,
|
|
494
|
+
content,
|
|
495
|
+
contentHash: hash,
|
|
496
|
+
machineId,
|
|
497
|
+
sessionId,
|
|
498
|
+
});
|
|
499
|
+
// Mark as shared
|
|
500
|
+
await api.post('/files/share', {
|
|
501
|
+
project: resolvedProject,
|
|
502
|
+
filePath,
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
content: [
|
|
506
|
+
{
|
|
507
|
+
type: 'text',
|
|
508
|
+
text: `Shared "${filePath}" (${size} bytes) — will auto-sync across machines`,
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
const msg = err.response?.data?.message || err.message;
|
|
515
|
+
return {
|
|
516
|
+
content: [
|
|
517
|
+
{ type: 'text', text: `Failed to share file: ${msg}` },
|
|
518
|
+
],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
server.tool('memory_unshare_file', 'Stop auto-syncing a file across machines', {
|
|
523
|
+
filePath: z.string().describe('Path of the file to stop sharing'),
|
|
524
|
+
project: z
|
|
525
|
+
.string()
|
|
526
|
+
.optional()
|
|
527
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
528
|
+
}, async ({ filePath, project }) => {
|
|
529
|
+
const api = createApiClient();
|
|
530
|
+
if (!api) {
|
|
531
|
+
return {
|
|
532
|
+
content: [
|
|
533
|
+
{
|
|
534
|
+
type: 'text',
|
|
535
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const resolvedProject = resolveProject(project);
|
|
541
|
+
if (!resolvedProject) {
|
|
542
|
+
return {
|
|
543
|
+
content: [
|
|
544
|
+
{
|
|
545
|
+
type: 'text',
|
|
546
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
await api.post('/files/unshare', {
|
|
553
|
+
project: resolvedProject,
|
|
554
|
+
filePath,
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
content: [
|
|
558
|
+
{ type: 'text', text: `Stopped sharing "${filePath}"` },
|
|
559
|
+
],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
const msg = err.response?.data?.message || err.message;
|
|
564
|
+
return {
|
|
565
|
+
content: [
|
|
566
|
+
{ type: 'text', text: `Failed to unshare file: ${msg}` },
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
server.tool('memory_sync_files', 'Sync all shared files for a project — downloads newer versions, uploads local changes', {
|
|
572
|
+
project: z
|
|
573
|
+
.string()
|
|
574
|
+
.optional()
|
|
575
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
576
|
+
}, async ({ project }) => {
|
|
577
|
+
const api = createApiClient({ timeout: 30000 });
|
|
578
|
+
if (!api) {
|
|
579
|
+
return {
|
|
580
|
+
content: [
|
|
581
|
+
{
|
|
582
|
+
type: 'text',
|
|
583
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const resolvedProject = resolveProject(project);
|
|
589
|
+
if (!resolvedProject) {
|
|
590
|
+
return {
|
|
591
|
+
content: [
|
|
592
|
+
{
|
|
593
|
+
type: 'text',
|
|
594
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
// Get list of shared files
|
|
601
|
+
const sharedRes = await api.get('/files/shared', {
|
|
602
|
+
params: { project: resolvedProject },
|
|
603
|
+
});
|
|
604
|
+
const sharedFiles = sharedRes.data;
|
|
605
|
+
if (!sharedFiles.length) {
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: 'text',
|
|
610
|
+
text: `No shared files for "${resolvedProject}"`,
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
// Build local hash map
|
|
616
|
+
const localFiles = [];
|
|
617
|
+
for (const sf of sharedFiles) {
|
|
618
|
+
const absPath = resolveFilePath(sf.filePath);
|
|
619
|
+
if (existsSync(absPath)) {
|
|
620
|
+
const buffer = readFileSync(absPath);
|
|
621
|
+
localFiles.push({
|
|
622
|
+
filePath: sf.filePath,
|
|
623
|
+
contentHash: computeMd5(buffer),
|
|
624
|
+
exists: true,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
localFiles.push({
|
|
629
|
+
filePath: sf.filePath,
|
|
630
|
+
contentHash: '',
|
|
631
|
+
exists: false,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Check sync status
|
|
636
|
+
const syncRes = await api.post('/files/sync', {
|
|
637
|
+
project: resolvedProject,
|
|
638
|
+
files: localFiles.map((f) => ({
|
|
639
|
+
filePath: f.filePath,
|
|
640
|
+
contentHash: f.contentHash,
|
|
641
|
+
})),
|
|
642
|
+
});
|
|
643
|
+
const results = [];
|
|
644
|
+
let downloaded = 0;
|
|
645
|
+
let uploaded = 0;
|
|
646
|
+
let upToDate = 0;
|
|
647
|
+
for (const file of syncRes.data.files) {
|
|
648
|
+
const local = localFiles.find((f) => f.filePath === file.filePath);
|
|
649
|
+
if (file.status === 'download_needed' ||
|
|
650
|
+
(file.status === 'upload_needed' && !local?.exists)) {
|
|
651
|
+
// Download from server
|
|
652
|
+
const latestRes = await api.get('/files/latest', {
|
|
653
|
+
params: { project: resolvedProject, filePath: file.filePath },
|
|
654
|
+
});
|
|
655
|
+
if (latestRes.data) {
|
|
656
|
+
const absPath = resolveFilePath(file.filePath);
|
|
657
|
+
writeDecodedFile(absPath, latestRes.data.content);
|
|
658
|
+
results.push(`↓ ${file.filePath}`);
|
|
659
|
+
downloaded++;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else if (file.status === 'upload_needed' && local?.exists) {
|
|
663
|
+
// Upload to server
|
|
664
|
+
const { content, hash } = readAndEncodeFile(resolveFilePath(file.filePath));
|
|
665
|
+
await api.post('/files/store', {
|
|
666
|
+
project: resolvedProject,
|
|
667
|
+
filePath: file.filePath,
|
|
668
|
+
content,
|
|
669
|
+
contentHash: hash,
|
|
670
|
+
machineId,
|
|
671
|
+
sessionId,
|
|
672
|
+
});
|
|
673
|
+
results.push(`↑ ${file.filePath}`);
|
|
674
|
+
uploaded++;
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
upToDate++;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const summary = [`Synced ${sharedFiles.length} shared file(s):`];
|
|
681
|
+
if (downloaded)
|
|
682
|
+
summary.push(` ${downloaded} downloaded`);
|
|
683
|
+
if (uploaded)
|
|
684
|
+
summary.push(` ${uploaded} uploaded`);
|
|
685
|
+
if (upToDate)
|
|
686
|
+
summary.push(` ${upToDate} up to date`);
|
|
687
|
+
if (results.length)
|
|
688
|
+
summary.push('', ...results);
|
|
689
|
+
return {
|
|
690
|
+
content: [{ type: 'text', text: summary.join('\n') }],
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
const msg = err.response?.data?.message || err.message;
|
|
695
|
+
return {
|
|
696
|
+
content: [{ type: 'text', text: `Sync failed: ${msg}` }],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
});
|
|
234
700
|
// Login subcommand
|
|
235
701
|
if (process.argv[2] === 'login') {
|
|
236
702
|
const readline = await import('readline');
|