@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
435 lines (376 loc) • 9.65 kB
JavaScript
/**
* Post-Edit Sweep Hook for Claude Code
*
* Runs Sweep 1.5B predictions after file edits to suggest next changes.
* Tracks recent diffs and provides context-aware predictions.
*/
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SWEEP_STATE_DIR =
process.env.SWEEP_STATE_DIR ||
path.join(process.env.HOME || '/tmp', '.stackmemory');
const CONFIG = {
enabled: process.env.SWEEP_ENABLED !== 'false',
maxRecentDiffs: 5,
predictionTimeout: 30000,
minEditSize: 10,
debounceMs: 2000,
minDiffsForPrediction: 2,
cooldownMs: 10000,
codeExtensions: [
'.ts',
'.tsx',
'.js',
'.jsx',
'.py',
'.go',
'.rs',
'.java',
'.c',
'.cpp',
'.h',
'.hpp',
'.cs',
'.rb',
'.php',
'.swift',
'.kt',
'.scala',
'.vue',
'.svelte',
'.astro',
],
stateFile: path.join(SWEEP_STATE_DIR, 'sweep-state.json'),
logFile: path.join(SWEEP_STATE_DIR, 'sweep-predictions.log'),
// Python script stays at shared location (shared server)
pythonScript: path.join(
process.env.HOME || '/tmp',
'.stackmemory',
'sweep',
'sweep_predict.py'
),
};
// Fallback locations for sweep_predict.py
const SCRIPT_LOCATIONS = [
CONFIG.pythonScript,
path.join(
process.cwd(),
'packages',
'sweep-addon',
'python',
'sweep_predict.py'
),
path.join(
process.cwd(),
'node_modules',
'@stackmemoryai',
'sweep-addon',
'python',
'sweep_predict.py'
),
];
function findPythonScript() {
for (const loc of SCRIPT_LOCATIONS) {
if (fs.existsSync(loc)) {
return loc;
}
}
return null;
}
function loadState() {
try {
if (fs.existsSync(CONFIG.stateFile)) {
return JSON.parse(fs.readFileSync(CONFIG.stateFile, 'utf-8'));
}
} catch {
// Ignore errors
}
return {
recentDiffs: [],
lastPrediction: null,
pendingPrediction: null,
fileContents: {},
};
}
function saveState(state) {
try {
const dir = path.dirname(CONFIG.stateFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CONFIG.stateFile, JSON.stringify(state, null, 2));
} catch {
// Ignore errors
}
}
function log(message, data = {}) {
try {
const dir = path.dirname(CONFIG.logFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const entry = {
timestamp: new Date().toISOString(),
message,
...data,
};
fs.appendFileSync(CONFIG.logFile, JSON.stringify(entry) + '\n');
} catch {
// Ignore
}
}
async function runPrediction(filePath, currentContent, recentDiffs) {
const scriptPath = findPythonScript();
if (!scriptPath) {
log('Sweep script not found');
return null;
}
const input = {
file_path: filePath,
current_content: currentContent,
recent_diffs: recentDiffs,
};
return new Promise((resolve) => {
const proc = spawn('python3', [scriptPath], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: CONFIG.predictionTimeout,
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => (stdout += data));
proc.stderr.on('data', (data) => (stderr += data));
const timeout = setTimeout(() => {
proc.kill();
resolve(null);
}, CONFIG.predictionTimeout);
proc.on('close', (code) => {
clearTimeout(timeout);
try {
if (stdout.trim()) {
const result = JSON.parse(stdout.trim());
resolve(result);
} else {
resolve(null);
}
} catch {
resolve(null);
}
});
proc.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
proc.stdin.write(JSON.stringify(input));
proc.stdin.end();
});
}
async function readInput() {
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}
return JSON.parse(input);
}
function isCodeFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return CONFIG.codeExtensions.includes(ext);
}
function shouldRunPrediction(state, filePath) {
if (state.recentDiffs.length < CONFIG.minDiffsForPrediction) {
return false;
}
if (state.lastPrediction) {
const timeSince = Date.now() - state.lastPrediction.timestamp;
if (timeSince < CONFIG.cooldownMs) {
return false;
}
}
if (state.pendingPrediction) {
const timeSince = Date.now() - state.pendingPrediction;
if (timeSince < CONFIG.debounceMs) {
return false;
}
}
return true;
}
async function handleEdit(toolInput, toolResult) {
if (!CONFIG.enabled) return;
const { file_path, old_string, new_string } = toolInput;
if (!file_path || !old_string || !new_string) return;
if (!isCodeFile(file_path)) {
log('Skipping non-code file', { file_path });
return;
}
if (
new_string.length < CONFIG.minEditSize &&
old_string.length < CONFIG.minEditSize
) {
return;
}
const state = loadState();
const diff = {
file_path,
original: old_string,
updated: new_string,
timestamp: Date.now(),
};
state.recentDiffs.unshift(diff);
state.recentDiffs = state.recentDiffs.slice(0, CONFIG.maxRecentDiffs);
try {
if (fs.existsSync(file_path)) {
state.fileContents[file_path] = fs.readFileSync(file_path, 'utf-8');
}
} catch {
// Ignore
}
saveState(state);
log('Edit recorded', { file_path, diffSize: new_string.length });
if (shouldRunPrediction(state, file_path)) {
state.pendingPrediction = Date.now();
saveState(state);
setTimeout(() => {
runPredictionAsync(file_path, loadState());
}, CONFIG.debounceMs);
}
}
async function runPredictionAsync(filePath, state) {
try {
const currentContent = state.fileContents[filePath] || '';
if (!currentContent) {
state.pendingPrediction = null;
saveState(state);
return;
}
const result = await runPrediction(
filePath,
currentContent,
state.recentDiffs
);
state.pendingPrediction = null;
if (result && result.success && result.predicted_content) {
state.lastPrediction = {
file_path: filePath,
prediction: result.predicted_content,
latency_ms: result.latency_ms,
timestamp: Date.now(),
};
saveState(state);
log('Prediction complete', {
file_path: filePath,
latency_ms: result.latency_ms,
tokens: result.tokens_generated,
});
const hint = formatPredictionHint(result);
if (hint) {
console.error(hint);
}
} else {
saveState(state);
}
} catch (error) {
state.pendingPrediction = null;
saveState(state);
log('Prediction error', { error: error.message });
}
}
function formatPredictionHint(result) {
if (!result.predicted_content || result.predicted_content.trim().length < 5) {
return null;
}
const preview = result.predicted_content
.trim()
.split('\n')
.slice(0, 3)
.join('\n');
const truncated = result.predicted_content.length > 200;
return `
[Sweep Prediction] Next edit suggestion (${result.latency_ms}ms):
${preview}${truncated ? '\n...' : ''}
`;
}
async function handleWrite(toolInput, toolResult) {
if (!CONFIG.enabled) return;
const { file_path, content } = toolInput;
if (!file_path || !content) return;
if (!isCodeFile(file_path)) {
return;
}
const state = loadState();
state.fileContents[file_path] = content;
saveState(state);
log('Write recorded', { file_path, size: content.length });
}
async function main() {
try {
const input = await readInput();
const { tool_name, tool_input, tool_result, event_type } = input;
// Only handle post-tool-use events
if (event_type !== 'post_tool_use') {
process.exit(0);
}
// Handle different tools
switch (tool_name) {
case 'Edit':
await handleEdit(tool_input, tool_result);
break;
case 'Write':
await handleWrite(tool_input, tool_result);
break;
}
// Success
console.log(JSON.stringify({ status: 'ok' }));
} catch (error) {
log('Hook error', { error: error.message });
console.log(JSON.stringify({ status: 'error', message: error.message }));
}
}
// Handle info request
if (process.argv.includes('--info')) {
console.log(
JSON.stringify({
hook: 'post-edit-sweep',
version: '1.0.0',
description: 'Runs Sweep 1.5B predictions after file edits',
config: {
enabled: CONFIG.enabled,
maxRecentDiffs: CONFIG.maxRecentDiffs,
predictionTimeout: CONFIG.predictionTimeout,
},
})
);
process.exit(0);
}
// Handle status request
if (process.argv.includes('--status')) {
const state = loadState();
const scriptPath = findPythonScript();
console.log(
JSON.stringify(
{
enabled: CONFIG.enabled,
scriptFound: !!scriptPath,
scriptPath,
recentDiffs: state.recentDiffs.length,
lastPrediction: state.lastPrediction,
},
null,
2
)
);
process.exit(0);
}
// Handle clear request
if (process.argv.includes('--clear')) {
saveState({ recentDiffs: [], lastPrediction: null, fileContents: {} });
console.log('Sweep state cleared');
process.exit(0);
}
main().catch((error) => {
console.error(JSON.stringify({ status: 'error', message: error.message }));
process.exit(1);
});