@squidcode/forever-plugin 0.3.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 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 three MCP tools:
37
+ The plugin exposes the following MCP tools:
38
38
 
39
- ### `memory_log`
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 | yes | Project name or git remote URL |
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
- ### `memory_get_recent`
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 | yes | Project name or git remote URL |
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
- ### `memory_search`
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(): AxiosInstance | null;
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
  }
@@ -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.3.0',
15
+ version: '0.4.0',
13
16
  });
14
17
  const machineId = getOrCreateMachineId();
15
18
  const sessionId = `${Date.now()}-${randomBytes(4).toString('hex')}`;
@@ -310,6 +313,390 @@ server.tool('memory_search', 'Search memory entries across projects', {
310
313
  };
311
314
  }
312
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
+ });
313
700
  // Login subcommand
314
701
  if (process.argv[2] === 'login') {
315
702
  const readline = await import('readline');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squidcode/forever-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP plugin for Forever - Claude Memory System",
5
5
  "type": "module",
6
6
  "bin": {