@stackmemoryai/stackmemory
Version:
Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a
175 lines (151 loc) • 4.39 kB
JavaScript
/**
* Auto-Checkpoint Hook (PostToolUse)
*
* Counts tool calls per project session. Every 25 calls, writes a lightweight
* checkpoint to ~/.stackmemory/checkpoints/{project-hash}/latest.json.
* Must complete in <50ms -- pure file I/O only.
*
* State is keyed per-session (process.ppid) to avoid race conditions
* when multiple Claude instances run concurrently.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const CHECKPOINT_INTERVAL = 25;
const SAVE_INTERVAL = 5;
const MAX_RECENT_TOOLS = 20;
const MAX_FILES_TRACKED = 50;
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const HOME = process.env.HOME || '/tmp';
const SESSION_ID = process.env.CLAUDE_INSTANCE_ID || String(process.ppid);
const STATE_FILE = path.join(
HOME,
'.stackmemory',
`checkpoint-state-${SESSION_ID}.json`
);
function projectHash(cwd) {
return crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function safeWriteFile(filePath, content) {
const tmp = filePath + '.tmp';
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filePath);
}
function loadState() {
try {
if (fs.existsSync(STATE_FILE)) {
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
const cutoff = Date.now() - TTL_MS;
for (const [cwd, proj] of Object.entries(state.projects)) {
if (
proj.sessionStart &&
new Date(proj.sessionStart).getTime() < cutoff
) {
delete state.projects[cwd];
}
}
return state;
}
} catch {
// Corrupted state, start fresh
}
return { projects: {} };
}
function saveState(state) {
try {
ensureDir(path.dirname(STATE_FILE));
safeWriteFile(STATE_FILE, JSON.stringify(state));
} catch {
// Best-effort
}
}
function getProjectState(state, cwd) {
if (!state.projects[cwd]) {
state.projects[cwd] = {
toolCount: 0,
checkpointCount: 0,
lastCheckpoint: null,
sessionStart: new Date().toISOString(),
filesModified: [],
recentTools: [],
};
}
return state.projects[cwd];
}
function writeCheckpoint(proj, cwd) {
const hash = projectHash(cwd);
const cpDir = path.join(HOME, '.stackmemory', 'checkpoints', hash);
ensureDir(cpDir);
const checkpoint = {
toolCount: proj.toolCount,
checkpointNumber: proj.checkpointCount,
timestamp: new Date().toISOString(),
filesModified: proj.filesModified.slice(-MAX_FILES_TRACKED),
recentTools: proj.recentTools.slice(-MAX_RECENT_TOOLS),
cwd,
};
safeWriteFile(
path.join(cpDir, 'latest.json'),
JSON.stringify(checkpoint, null, 2)
);
}
function trackFile(proj, toolInput) {
const filePath = toolInput?.file_path;
if (filePath && !proj.filesModified.includes(filePath)) {
proj.filesModified.push(filePath);
if (proj.filesModified.length > MAX_FILES_TRACKED) {
proj.filesModified = proj.filesModified.slice(-MAX_FILES_TRACKED);
}
}
}
async function readInput() {
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}
return JSON.parse(input);
}
async function main() {
try {
const input = await readInput();
const { tool_name, tool_input } = input;
const cwd = process.cwd();
const state = loadState();
const proj = getProjectState(state, cwd);
// Increment counter
proj.toolCount++;
// Track tool name
proj.recentTools.push(tool_name);
if (proj.recentTools.length > MAX_RECENT_TOOLS) {
proj.recentTools = proj.recentTools.slice(-MAX_RECENT_TOOLS);
}
// Track modified files from Edit/Write
if (
tool_name === 'Edit' ||
tool_name === 'Write' ||
tool_name === 'MultiEdit'
) {
trackFile(proj, tool_input);
}
// Checkpoint every N calls
const isCheckpoint = proj.toolCount % CHECKPOINT_INTERVAL === 0;
if (isCheckpoint) {
proj.checkpointCount++;
proj.lastCheckpoint = new Date().toISOString();
writeCheckpoint(proj, cwd);
}
// Only persist state every SAVE_INTERVAL calls or on checkpoint boundaries
if (isCheckpoint || proj.toolCount % SAVE_INTERVAL === 0) {
saveState(state);
}
} catch {
// Silent fail -- never block the agent
}
}
main();