agentMemorySnapshot.ts
1 import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 2 import { join } from 'path' 3 import { z } from 'zod/v4' 4 import { getCwd } from '../../utils/cwd.js' 5 import { logForDebugging } from '../../utils/debug.js' 6 import { lazySchema } from '../../utils/lazySchema.js' 7 import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 8 import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js' 9 10 const SNAPSHOT_BASE = 'agent-memory-snapshots' 11 const SNAPSHOT_JSON = 'snapshot.json' 12 const SYNCED_JSON = '.snapshot-synced.json' 13 14 const snapshotMetaSchema = lazySchema(() => 15 z.object({ 16 updatedAt: z.string().min(1), 17 }), 18 ) 19 20 const syncedMetaSchema = lazySchema(() => 21 z.object({ 22 syncedFrom: z.string().min(1), 23 }), 24 ) 25 type SyncedMeta = z.infer<ReturnType<typeof syncedMetaSchema>> 26 27 /** 28 * Returns the path to the snapshot directory for an agent in the current project. 29 * e.g., <cwd>/.claude/agent-memory-snapshots/<agentType>/ 30 */ 31 export function getSnapshotDirForAgent(agentType: string): string { 32 return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType) 33 } 34 35 function getSnapshotJsonPath(agentType: string): string { 36 return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON) 37 } 38 39 function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string { 40 return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON) 41 } 42 43 async function readJsonFile<T>( 44 path: string, 45 schema: z.ZodType<T>, 46 ): Promise<T | null> { 47 try { 48 const content = await readFile(path, { encoding: 'utf-8' }) 49 const result = schema.safeParse(jsonParse(content)) 50 return result.success ? result.data : null 51 } catch { 52 return null 53 } 54 } 55 56 async function copySnapshotToLocal( 57 agentType: string, 58 scope: AgentMemoryScope, 59 ): Promise<void> { 60 const snapshotMemDir = getSnapshotDirForAgent(agentType) 61 const localMemDir = getAgentMemoryDir(agentType, scope) 62 63 await mkdir(localMemDir, { recursive: true }) 64 65 try { 66 const files = await readdir(snapshotMemDir, { withFileTypes: true }) 67 for (const dirent of files) { 68 if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue 69 const content = await readFile(join(snapshotMemDir, dirent.name), { 70 encoding: 'utf-8', 71 }) 72 await writeFile(join(localMemDir, dirent.name), content) 73 } 74 } catch (e) { 75 logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`) 76 } 77 } 78 79 async function saveSyncedMeta( 80 agentType: string, 81 scope: AgentMemoryScope, 82 snapshotTimestamp: string, 83 ): Promise<void> { 84 const syncedPath = getSyncedJsonPath(agentType, scope) 85 const localMemDir = getAgentMemoryDir(agentType, scope) 86 await mkdir(localMemDir, { recursive: true }) 87 const meta: SyncedMeta = { syncedFrom: snapshotTimestamp } 88 try { 89 await writeFile(syncedPath, jsonStringify(meta)) 90 } catch (e) { 91 logForDebugging(`Failed to save snapshot sync metadata: ${e}`) 92 } 93 } 94 95 /** 96 * Check if a snapshot exists and whether it's newer than what we last synced. 97 */ 98 export async function checkAgentMemorySnapshot( 99 agentType: string, 100 scope: AgentMemoryScope, 101 ): Promise<{ 102 action: 'none' | 'initialize' | 'prompt-update' 103 snapshotTimestamp?: string 104 }> { 105 const snapshotMeta = await readJsonFile( 106 getSnapshotJsonPath(agentType), 107 snapshotMetaSchema(), 108 ) 109 110 if (!snapshotMeta) { 111 return { action: 'none' } 112 } 113 114 const localMemDir = getAgentMemoryDir(agentType, scope) 115 116 let hasLocalMemory = false 117 try { 118 const dirents = await readdir(localMemDir, { withFileTypes: true }) 119 hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md')) 120 } catch { 121 // Directory doesn't exist 122 } 123 124 if (!hasLocalMemory) { 125 return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt } 126 } 127 128 const syncedMeta = await readJsonFile( 129 getSyncedJsonPath(agentType, scope), 130 syncedMetaSchema(), 131 ) 132 133 if ( 134 !syncedMeta || 135 new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom) 136 ) { 137 return { 138 action: 'prompt-update', 139 snapshotTimestamp: snapshotMeta.updatedAt, 140 } 141 } 142 143 return { action: 'none' } 144 } 145 146 /** 147 * Initialize local agent memory from a snapshot (first-time setup). 148 */ 149 export async function initializeFromSnapshot( 150 agentType: string, 151 scope: AgentMemoryScope, 152 snapshotTimestamp: string, 153 ): Promise<void> { 154 logForDebugging( 155 `Initializing agent memory for ${agentType} from project snapshot`, 156 ) 157 await copySnapshotToLocal(agentType, scope) 158 await saveSyncedMeta(agentType, scope, snapshotTimestamp) 159 } 160 161 /** 162 * Replace local agent memory with the snapshot. 163 */ 164 export async function replaceFromSnapshot( 165 agentType: string, 166 scope: AgentMemoryScope, 167 snapshotTimestamp: string, 168 ): Promise<void> { 169 logForDebugging( 170 `Replacing agent memory for ${agentType} with project snapshot`, 171 ) 172 // Remove existing .md files before copying to avoid orphans 173 const localMemDir = getAgentMemoryDir(agentType, scope) 174 try { 175 const existing = await readdir(localMemDir, { withFileTypes: true }) 176 for (const dirent of existing) { 177 if (dirent.isFile() && dirent.name.endsWith('.md')) { 178 await unlink(join(localMemDir, dirent.name)) 179 } 180 } 181 } catch { 182 // Directory may not exist yet 183 } 184 await copySnapshotToLocal(agentType, scope) 185 await saveSyncedMeta(agentType, scope, snapshotTimestamp) 186 } 187 188 /** 189 * Mark the current snapshot as synced without changing local memory. 190 */ 191 export async function markSnapshotSynced( 192 agentType: string, 193 scope: AgentMemoryScope, 194 snapshotTimestamp: string, 195 ): Promise<void> { 196 await saveSyncedMeta(agentType, scope, snapshotTimestamp) 197 }