/** * Cassette Key Generation * * Builds stable SHA256-based identifiers for cassettes. * Two spans are separate concerns: * - hashWorktreeFiles: fingerprints the worktree state at spawn time (for execute mode drift) * - buildCassetteKey: hashes all key components into a 32-char hex filename */ import { createHash } from 'node:crypto'; import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { CassetteKey } from './types.js'; /** * Recursively hash all non-hidden files in a directory. * * Hidden entries (starting with '.') are skipped — this excludes .git, .cw, etc. * Entries are processed in sorted order for determinism across platforms. * * Returns the first 16 hex chars of the SHA256, or 'empty' if the directory * is absent or contains no readable files. */ export function hashWorktreeFiles(dir: string): string { const hash = createHash('sha256'); let hasContent = false; function walkDir(currentDir: string): void { let entries; try { entries = readdirSync(currentDir, { withFileTypes: true }); } catch { return; } for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name.startsWith('.')) continue; const fullPath = join(currentDir, entry.name); const relPath = fullPath.slice(dir.length); if (entry.isDirectory()) { hash.update(`d:${relPath}\n`); walkDir(fullPath); } else if (entry.isFile()) { try { const content = readFileSync(fullPath); hash.update(`f:${relPath}:${content.length}\n`); hash.update(content); hasContent = true; } catch { // skip unreadable files } } } } walkDir(dir); return hasContent ? hash.digest('hex').slice(0, 16) : 'empty'; } /** * Compute a stable 32-char hex identifier for a cassette key. * * modelArgs are sorted before hashing so insertion order differences * between providers don't produce different cassettes. */ export function buildCassetteKey(key: CassetteKey): string { const canonical = JSON.stringify({ normalizedPrompt: key.normalizedPrompt, providerName: key.providerName, modelArgs: [...key.modelArgs].sort(), worktreeHash: key.worktreeHash, }); return createHash('sha256').update(canonical).digest('hex').slice(0, 32); }