feat(memory): share project memory across account instances

This commit is contained in:
Tam Nhu Tran
2026-02-23 23:18:34 +07:00
parent 9464615e38
commit e2623e632c
3 changed files with 365 additions and 0 deletions
+3
View File
@@ -37,6 +37,9 @@ class InstanceManager {
// Validate structure (auto-fix missing dirs)
this.validateInstance(instancePath);
// Keep project memory shared across instances.
this.sharedManager.syncProjectMemories(instancePath);
return instancePath;
}
+249
View File
@@ -201,6 +201,99 @@ class SharedManager {
this.normalizePluginRegistryPaths();
}
/**
* Ensure all project memory directories for an instance are shared.
*
* Source layout (isolated):
* ~/.ccs/instances/<profile>/projects/<project>/memory/
*
* Shared layout (canonical):
* ~/.ccs/shared/memory/<project>/
*/
syncProjectMemories(instancePath: string): void {
const projectsDir = path.join(instancePath, 'projects');
if (!fs.existsSync(projectsDir)) {
return;
}
if (!fs.existsSync(this.sharedDir)) {
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
}
const sharedMemoryRoot = path.join(this.sharedDir, 'memory');
if (!fs.existsSync(sharedMemoryRoot)) {
fs.mkdirSync(sharedMemoryRoot, { recursive: true, mode: 0o700 });
}
const projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((entry) => {
return entry.isDirectory();
});
if (projects.length === 0) {
return;
}
let migrated = 0;
let merged = 0;
let linked = 0;
const instanceName = path.basename(instancePath);
for (const project of projects) {
const projectDir = path.join(projectsDir, project.name);
const projectMemoryPath = path.join(projectDir, 'memory');
const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name);
if (!fs.existsSync(projectMemoryPath)) {
if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++;
}
continue;
}
const projectMemoryStats = fs.lstatSync(projectMemoryPath);
if (projectMemoryStats.isSymbolicLink()) {
if (this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) {
continue;
}
fs.unlinkSync(projectMemoryPath);
if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++;
}
continue;
}
if (!projectMemoryStats.isDirectory()) {
continue;
}
if (!fs.existsSync(sharedProjectMemoryPath)) {
this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath);
migrated++;
} else {
merged += this.mergeDirectoryWithConflictCopies(
projectMemoryPath,
sharedProjectMemoryPath,
instanceName
);
fs.rmSync(projectMemoryPath, { recursive: true, force: true });
}
if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++;
}
}
if (migrated > 0 || merged > 0 || linked > 0) {
console.log(
ok(
`Synced shared project memory: ${migrated} migrated, ${merged} merged conflict(s), ${linked} linked`
)
);
}
}
/**
* Normalize plugin registry paths to use canonical ~/.claude/ paths
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
@@ -420,6 +513,162 @@ class SharedManager {
console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`));
}
/**
* Ensure memory path is linked to shared memory root.
* Returns true when a link/copy was created or updated.
*/
private ensureProjectMemoryLink(linkPath: string, targetPath: string): boolean {
if (!fs.existsSync(targetPath)) {
fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
}
if (fs.existsSync(linkPath)) {
const stats = fs.lstatSync(linkPath);
if (stats.isSymbolicLink() && this.isSymlinkTarget(linkPath, targetPath)) {
return false;
}
if (stats.isDirectory()) {
fs.rmSync(linkPath, { recursive: true, force: true });
} else {
fs.unlinkSync(linkPath);
}
}
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try {
fs.symlinkSync(linkTarget, linkPath, symlinkType);
return true;
} catch (_err) {
if (process.platform === 'win32') {
this.copyDirectoryFallback(targetPath, linkPath);
console.log(
warn(`Symlink failed for project memory, copied instead (enable Developer Mode)`)
);
return true;
}
throw _err;
}
}
/**
* Check whether symlink points to expected target.
*/
private isSymlinkTarget(linkPath: string, expectedTarget: string): boolean {
try {
const stats = fs.lstatSync(linkPath);
if (!stats.isSymbolicLink()) {
return false;
}
const currentTarget = fs.readlinkSync(linkPath);
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
const resolvedExpectedTarget = path.resolve(expectedTarget);
return resolvedCurrentTarget === resolvedExpectedTarget;
} catch (_err) {
return false;
}
}
/**
* Move directory, with cross-device fallback.
*/
private moveDirectory(src: string, dest: string): void {
try {
fs.renameSync(src, dest);
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EXDEV') {
throw err;
}
fs.cpSync(src, dest, { recursive: true });
fs.rmSync(src, { recursive: true, force: true });
}
}
/**
* Merge source into target. On file conflicts, keep target and copy source
* as "<name>.migrated-from-<instance>[-N]" to avoid data loss.
*/
private mergeDirectoryWithConflictCopies(
sourceDir: string,
targetDir: string,
instanceName: string
): number {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
}
let conflicts = 0;
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
conflicts += this.mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName);
continue;
}
if (entry.isFile()) {
if (!fs.existsSync(targetPath)) {
fs.copyFileSync(sourcePath, targetPath);
continue;
}
if (this.fileContentsEqual(sourcePath, targetPath)) {
continue;
}
const conflictPath = this.getConflictCopyPath(targetPath, instanceName);
fs.copyFileSync(sourcePath, conflictPath);
conflicts++;
}
}
return conflicts;
}
/**
* Compare two files byte-for-byte.
*/
private fileContentsEqual(fileA: string, fileB: string): boolean {
try {
const statA = fs.statSync(fileA);
const statB = fs.statSync(fileB);
if (statA.size !== statB.size) {
return false;
}
const contentA = fs.readFileSync(fileA);
const contentB = fs.readFileSync(fileB);
return contentA.equals(contentB);
} catch (_err) {
return false;
}
}
/**
* Build a non-destructive conflict copy path.
*/
private getConflictCopyPath(existingTargetPath: string, instanceName: string): string {
const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
const baseSuffix = `.migrated-from-${safeInstanceName}`;
let candidate = `${existingTargetPath}${baseSuffix}`;
let sequence = 1;
while (fs.existsSync(candidate)) {
candidate = `${existingTargetPath}${baseSuffix}-${sequence}`;
sequence++;
}
return candidate;
}
/**
* Copy directory as fallback (Windows without Developer Mode)
*/
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import SharedManager from '../../src/management/shared-manager';
function getTestCcsDir(): string {
if (!process.env.CCS_HOME) {
throw new Error('CCS_HOME must be set in tests');
}
return path.join(path.resolve(process.env.CCS_HOME), '.ccs');
}
describe('SharedManager project memory sync', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-memory-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
const isolatedHome = path.join(tempRoot, 'home');
fs.mkdirSync(isolatedHome, { recursive: true });
process.env.HOME = isolatedHome;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
afterEach(() => {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('migrates existing project memory and replaces it with a shared symlink', () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectName = '-tmp-my-project';
const projectMemoryPath = path.join(instancePath, 'projects', projectName, 'memory');
fs.mkdirSync(projectMemoryPath, { recursive: true });
fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8');
const manager = new SharedManager();
manager.syncProjectMemories(instancePath);
const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md');
expect(fs.existsSync(sharedMemoryFile)).toBe(true);
expect(fs.readFileSync(sharedMemoryFile, 'utf8')).toBe('instance knowledge');
const linkStats = fs.lstatSync(projectMemoryPath);
expect(linkStats.isSymbolicLink()).toBe(true);
const resolvedTarget = path.resolve(path.dirname(projectMemoryPath), fs.readlinkSync(projectMemoryPath));
expect(resolvedTarget).toBe(path.join(ccsDir, 'shared', 'memory', projectName));
});
it('preserves canonical memory and writes conflict copy when contents differ', () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectName = '-tmp-shared-project';
const projectMemoryPath = path.join(instancePath, 'projects', projectName, 'memory');
const sharedProjectMemoryPath = path.join(ccsDir, 'shared', 'memory', projectName);
fs.mkdirSync(projectMemoryPath, { recursive: true });
fs.mkdirSync(sharedProjectMemoryPath, { recursive: true });
fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance memory', 'utf8');
fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8');
const manager = new SharedManager();
manager.syncProjectMemories(instancePath);
const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md');
expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory');
const conflictFile = path.join(sharedProjectMemoryPath, 'MEMORY.md.migrated-from-work');
expect(fs.existsSync(conflictFile)).toBe(true);
expect(fs.readFileSync(conflictFile, 'utf8')).toBe('instance memory');
const linkStats = fs.lstatSync(projectMemoryPath);
expect(linkStats.isSymbolicLink()).toBe(true);
});
it('creates shared memory link for projects that do not have memory directory yet', () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectName = '-tmp-new-project';
const projectPath = path.join(instancePath, 'projects', projectName);
const projectMemoryPath = path.join(projectPath, 'memory');
fs.mkdirSync(projectPath, { recursive: true });
const manager = new SharedManager();
manager.syncProjectMemories(instancePath);
const linkStats = fs.lstatSync(projectMemoryPath);
expect(linkStats.isSymbolicLink()).toBe(true);
const sharedProjectMemoryPath = path.join(ccsDir, 'shared', 'memory', projectName);
expect(fs.existsSync(sharedProjectMemoryPath)).toBe(true);
});
});