@stackmemoryai/stackmemory 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/claude-sm.js +65 -0
- package/dist/src/integrations/mcp/server.js +109 -0
- package/dist/src/integrations/mcp/tool-definitions.js +53 -1
- package/dist/src/utils/hook-installer.js +155 -0
- package/package.json +2 -2
- package/scripts/gepa/.before-optimize.md +159 -0
- package/scripts/gepa/generations/gen-000/baseline.md +159 -124
- package/scripts/gepa/generations/gen-001/baseline.md +159 -0
- package/scripts/gepa/generations/gen-001/variant-a.md +166 -0
- package/scripts/gepa/generations/gen-001/variant-b.md +237 -0
- package/scripts/gepa/generations/gen-001/variant-c.md +61 -0
- package/scripts/gepa/generations/gen-001/variant-d.md +119 -0
- package/scripts/gepa/results/eval-1-baseline.json +41 -0
- package/scripts/gepa/results/eval-1-variant-a.json +41 -0
- package/scripts/gepa/results/eval-1-variant-b.json +41 -0
- package/scripts/gepa/results/eval-1-variant-c.json +41 -0
- package/scripts/gepa/results/eval-1-variant-d.json +41 -0
- package/scripts/gepa/state.json +41 -2
- package/scripts/install-claude-hooks-auto.js +176 -44
- package/templates/claude-hooks/auto-checkpoint.js +174 -0
- package/templates/claude-hooks/chime-on-stop.sh +22 -0
- package/templates/claude-hooks/session-rescue.sh +15 -0
- package/templates/claude-hooks/stop-checkpoint.js +120 -0
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* This runs as a postinstall script to set up tracing hooks and daemon
|
|
6
6
|
*
|
|
7
7
|
* INTERACTIVE: Asks for user consent before modifying ~/.claude
|
|
8
|
+
*
|
|
9
|
+
* Writes to ~/.claude/settings.json (the current Claude Code config format).
|
|
10
|
+
* Uses CANONICAL_HOOKS from hook-installer when dist is available, otherwise
|
|
11
|
+
* falls back to an inline definition.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import {
|
|
@@ -13,6 +17,8 @@ import {
|
|
|
13
17
|
copyFileSync,
|
|
14
18
|
readFileSync,
|
|
15
19
|
writeFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
chmodSync,
|
|
16
22
|
} from 'fs';
|
|
17
23
|
import { join } from 'path';
|
|
18
24
|
import { homedir } from 'os';
|
|
@@ -25,11 +31,144 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
25
31
|
const __dirname = dirname(__filename);
|
|
26
32
|
|
|
27
33
|
const claudeHooksDir = join(homedir(), '.claude', 'hooks');
|
|
28
|
-
const
|
|
34
|
+
const claudeSettingsFile = join(homedir(), '.claude', 'settings.json');
|
|
29
35
|
const templatesDir = join(__dirname, '..', 'templates', 'claude-hooks');
|
|
30
36
|
const stackmemoryBinDir = join(homedir(), '.stackmemory', 'bin');
|
|
31
37
|
const distDir = join(__dirname, '..', 'dist');
|
|
32
38
|
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Try to import from compiled hook-installer; fall back to inline definitions
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** @type {Array<{scriptName: string, eventType: string, matcher?: string, timeout?: number, commandPrefix?: string, required: boolean}>} */
|
|
44
|
+
let CANONICAL_HOOKS;
|
|
45
|
+
let mergeSettingsFn;
|
|
46
|
+
let readSettingsFn;
|
|
47
|
+
let writeSettingsAtomicFn;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const mod = await import('../dist/src/utils/hook-installer.js');
|
|
51
|
+
CANONICAL_HOOKS = mod.CANONICAL_HOOKS;
|
|
52
|
+
mergeSettingsFn = mod.mergeSettings;
|
|
53
|
+
readSettingsFn = mod.readSettings;
|
|
54
|
+
writeSettingsAtomicFn = mod.writeSettingsAtomic;
|
|
55
|
+
} catch {
|
|
56
|
+
// dist not built yet — use inline fallback
|
|
57
|
+
CANONICAL_HOOKS = [
|
|
58
|
+
{
|
|
59
|
+
scriptName: 'session-rescue.sh',
|
|
60
|
+
eventType: 'Stop',
|
|
61
|
+
timeout: 12,
|
|
62
|
+
required: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
scriptName: 'stop-checkpoint.js',
|
|
66
|
+
eventType: 'Stop',
|
|
67
|
+
timeout: 5,
|
|
68
|
+
commandPrefix: 'node',
|
|
69
|
+
required: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
scriptName: 'chime-on-stop.sh',
|
|
73
|
+
eventType: 'Stop',
|
|
74
|
+
timeout: 2,
|
|
75
|
+
required: true,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
scriptName: 'auto-checkpoint.js',
|
|
79
|
+
eventType: 'PostToolUse',
|
|
80
|
+
timeout: 2,
|
|
81
|
+
commandPrefix: 'node',
|
|
82
|
+
required: true,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const DEAD_HOOKS = ['sms-response-handler.js'];
|
|
87
|
+
|
|
88
|
+
readSettingsFn = (p) => {
|
|
89
|
+
try {
|
|
90
|
+
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8'));
|
|
91
|
+
} catch {
|
|
92
|
+
/* parse error */
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
writeSettingsAtomicFn = (settings, p) => {
|
|
98
|
+
const dir = dirname(p);
|
|
99
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
100
|
+
const tmp = p + '.tmp';
|
|
101
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
102
|
+
renameSync(tmp, p);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
mergeSettingsFn = (existing, hooksDir) => {
|
|
106
|
+
const merged = JSON.parse(JSON.stringify(existing));
|
|
107
|
+
if (!merged.hooks) merged.hooks = {};
|
|
108
|
+
|
|
109
|
+
// Remove dead hooks
|
|
110
|
+
for (const eventType of Object.keys(merged.hooks)) {
|
|
111
|
+
const groups = merged.hooks[eventType];
|
|
112
|
+
for (const group of groups) {
|
|
113
|
+
group.hooks = group.hooks.filter((h) => {
|
|
114
|
+
for (const dead of DEAD_HOOKS) {
|
|
115
|
+
if (h.command.includes(dead)) return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
merged.hooks[eventType] = groups.filter((g) => g.hooks.length > 0);
|
|
121
|
+
if (merged.hooks[eventType].length === 0) delete merged.hooks[eventType];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Add missing canonical hooks
|
|
125
|
+
for (const entry of CANONICAL_HOOKS) {
|
|
126
|
+
const exists = (merged.hooks[entry.eventType] || []).some((group) =>
|
|
127
|
+
group.hooks.some((h) => h.command.includes(entry.scriptName))
|
|
128
|
+
);
|
|
129
|
+
if (exists) continue;
|
|
130
|
+
|
|
131
|
+
const scriptPath = join(hooksDir, entry.scriptName);
|
|
132
|
+
const command = entry.commandPrefix
|
|
133
|
+
? `${entry.commandPrefix} ${scriptPath}`
|
|
134
|
+
: scriptPath;
|
|
135
|
+
const hookCmd = { type: 'command', command };
|
|
136
|
+
if (entry.timeout) hookCmd.timeout = entry.timeout;
|
|
137
|
+
|
|
138
|
+
const eventGroups = merged.hooks[entry.eventType] || [];
|
|
139
|
+
const matcherValue = entry.matcher;
|
|
140
|
+
let targetGroup = eventGroups.find((g) =>
|
|
141
|
+
matcherValue ? g.matcher === matcherValue : !g.matcher
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (targetGroup) {
|
|
145
|
+
targetGroup.hooks.push(hookCmd);
|
|
146
|
+
} else {
|
|
147
|
+
const newGroup = { hooks: [hookCmd] };
|
|
148
|
+
if (matcherValue) newGroup.matcher = matcherValue;
|
|
149
|
+
eventGroups.push(newGroup);
|
|
150
|
+
}
|
|
151
|
+
merged.hooks[entry.eventType] = eventGroups;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return merged;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Legacy hook files (still installed for backward-compat tooling hooks)
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const LEGACY_HOOK_FILES = [
|
|
163
|
+
'tool-use-trace.js',
|
|
164
|
+
'on-startup.js',
|
|
165
|
+
'on-clear.js',
|
|
166
|
+
'on-task-complete.js',
|
|
167
|
+
'skill-eval.sh',
|
|
168
|
+
'skill-eval.cjs',
|
|
169
|
+
];
|
|
170
|
+
const LEGACY_DATA_FILES = ['skill-rules.json'];
|
|
171
|
+
|
|
33
172
|
/**
|
|
34
173
|
* Ask user for confirmation before installing hooks
|
|
35
174
|
* Returns true if user consents, false otherwise
|
|
@@ -51,14 +190,13 @@ async function askForConsent() {
|
|
|
51
190
|
return false;
|
|
52
191
|
}
|
|
53
192
|
|
|
54
|
-
console.log('\
|
|
193
|
+
console.log('\nStackMemory Post-Install\n');
|
|
55
194
|
console.log(
|
|
56
195
|
'StackMemory can integrate with Claude Code by installing hooks that:'
|
|
57
196
|
);
|
|
58
197
|
console.log(' - Track tool usage for better context');
|
|
59
198
|
console.log(' - Enable session persistence across restarts');
|
|
60
|
-
console.log(' -
|
|
61
|
-
console.log(' - Auto-update PROMPT_PLAN on task completion');
|
|
199
|
+
console.log(' - Auto-checkpoint and session rescue');
|
|
62
200
|
console.log(' - Recommend relevant skills based on your prompts');
|
|
63
201
|
console.log('\nThis will modify files in ~/.claude/\n');
|
|
64
202
|
|
|
@@ -91,24 +229,38 @@ async function installClaudeHooks() {
|
|
|
91
229
|
console.log('Created ~/.claude/hooks directory');
|
|
92
230
|
}
|
|
93
231
|
|
|
94
|
-
// Copy hook files (scripts + data files)
|
|
95
|
-
const hookFiles = [
|
|
96
|
-
'tool-use-trace.js',
|
|
97
|
-
'on-startup.js',
|
|
98
|
-
'on-clear.js',
|
|
99
|
-
'on-task-complete.js',
|
|
100
|
-
'skill-eval.sh',
|
|
101
|
-
'skill-eval.cjs',
|
|
102
|
-
];
|
|
103
|
-
const dataFiles = ['skill-rules.json'];
|
|
104
232
|
let installed = 0;
|
|
105
233
|
|
|
106
|
-
|
|
234
|
+
// --- Install canonical session-persistence hooks ---
|
|
235
|
+
for (const entry of CANONICAL_HOOKS) {
|
|
236
|
+
const srcPath = join(templatesDir, entry.scriptName);
|
|
237
|
+
const destPath = join(claudeHooksDir, entry.scriptName);
|
|
238
|
+
|
|
239
|
+
if (existsSync(srcPath)) {
|
|
240
|
+
// Backup existing hook if it exists
|
|
241
|
+
if (existsSync(destPath)) {
|
|
242
|
+
const backupPath = `${destPath}.backup-${Date.now()}`;
|
|
243
|
+
copyFileSync(destPath, backupPath);
|
|
244
|
+
console.log(` Backed up: ${entry.scriptName}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
copyFileSync(srcPath, destPath);
|
|
248
|
+
try {
|
|
249
|
+
chmodSync(destPath, 0o755);
|
|
250
|
+
} catch {
|
|
251
|
+
// Silent fail on chmod
|
|
252
|
+
}
|
|
253
|
+
installed++;
|
|
254
|
+
console.log(` Installed: ${entry.scriptName}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Install legacy tooling hooks ---
|
|
259
|
+
for (const hookFile of [...LEGACY_HOOK_FILES, ...LEGACY_DATA_FILES]) {
|
|
107
260
|
const srcPath = join(templatesDir, hookFile);
|
|
108
261
|
const destPath = join(claudeHooksDir, hookFile);
|
|
109
262
|
|
|
110
263
|
if (existsSync(srcPath)) {
|
|
111
|
-
// Backup existing hook if it exists
|
|
112
264
|
if (existsSync(destPath)) {
|
|
113
265
|
const backupPath = `${destPath}.backup-${Date.now()}`;
|
|
114
266
|
copyFileSync(destPath, backupPath);
|
|
@@ -117,11 +269,9 @@ async function installClaudeHooks() {
|
|
|
117
269
|
|
|
118
270
|
copyFileSync(srcPath, destPath);
|
|
119
271
|
|
|
120
|
-
|
|
121
|
-
if (!dataFiles.includes(hookFile)) {
|
|
272
|
+
if (!LEGACY_DATA_FILES.includes(hookFile)) {
|
|
122
273
|
try {
|
|
123
|
-
|
|
124
|
-
execSync(`chmod +x "${destPath}"`, { stdio: 'ignore' });
|
|
274
|
+
chmodSync(destPath, 0o755);
|
|
125
275
|
} catch {
|
|
126
276
|
// Silent fail on chmod
|
|
127
277
|
}
|
|
@@ -132,30 +282,14 @@ async function installClaudeHooks() {
|
|
|
132
282
|
}
|
|
133
283
|
}
|
|
134
284
|
|
|
135
|
-
// Update hooks.json
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
hooksConfig = JSON.parse(readFileSync(claudeConfigFile, 'utf8'));
|
|
140
|
-
} catch {
|
|
141
|
-
// Start fresh if parse fails
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Add our hooks (don't overwrite existing hooks unless they're ours)
|
|
146
|
-
const newHooksConfig = {
|
|
147
|
-
...hooksConfig,
|
|
148
|
-
'tool-use-approval': join(claudeHooksDir, 'tool-use-trace.js'),
|
|
149
|
-
'on-startup': join(claudeHooksDir, 'on-startup.js'),
|
|
150
|
-
'on-clear': join(claudeHooksDir, 'on-clear.js'),
|
|
151
|
-
'on-task-complete': join(claudeHooksDir, 'on-task-complete.js'),
|
|
152
|
-
'user-prompt-submit': join(claudeHooksDir, 'skill-eval.sh'),
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
writeFileSync(claudeConfigFile, JSON.stringify(newHooksConfig, null, 2));
|
|
285
|
+
// --- Update settings.json (not the deprecated hooks.json) ---
|
|
286
|
+
const currentSettings = readSettingsFn(claudeSettingsFile);
|
|
287
|
+
const merged = mergeSettingsFn(currentSettings, claudeHooksDir);
|
|
288
|
+
writeSettingsAtomicFn(merged, claudeSettingsFile);
|
|
156
289
|
|
|
157
290
|
if (installed > 0) {
|
|
158
291
|
console.log(`\n[OK] Installed ${installed} Claude hooks`);
|
|
292
|
+
console.log(` Config: ~/.claude/settings.json`);
|
|
159
293
|
console.log(` Traces: ~/.stackmemory/traces/`);
|
|
160
294
|
console.log(' To disable: set DEBUG_TRACE=false in .env');
|
|
161
295
|
}
|
|
@@ -189,10 +323,8 @@ async function installSessionDaemon() {
|
|
|
189
323
|
if (existsSync(daemonSrc)) {
|
|
190
324
|
copyFileSync(daemonSrc, daemonDest);
|
|
191
325
|
|
|
192
|
-
// Make executable
|
|
193
326
|
try {
|
|
194
|
-
|
|
195
|
-
execSync(`chmod +x "${daemonDest}"`, { stdio: 'ignore' });
|
|
327
|
+
chmodSync(daemonDest, 0o755);
|
|
196
328
|
} catch {
|
|
197
329
|
// Silent fail on chmod
|
|
198
330
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-Checkpoint Hook (PostToolUse)
|
|
5
|
+
*
|
|
6
|
+
* Counts tool calls per project session. Every 25 calls, writes a lightweight
|
|
7
|
+
* checkpoint to ~/.stackmemory/checkpoints/{project-hash}/latest.json.
|
|
8
|
+
* Must complete in <50ms -- pure file I/O only.
|
|
9
|
+
*
|
|
10
|
+
* State is keyed per-session (process.ppid) to avoid race conditions
|
|
11
|
+
* when multiple Claude instances run concurrently.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
const CHECKPOINT_INTERVAL = 25;
|
|
19
|
+
const SAVE_INTERVAL = 5;
|
|
20
|
+
const MAX_RECENT_TOOLS = 20;
|
|
21
|
+
const MAX_FILES_TRACKED = 50;
|
|
22
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
23
|
+
|
|
24
|
+
const HOME = process.env.HOME || '/tmp';
|
|
25
|
+
const SESSION_ID = process.env.CLAUDE_INSTANCE_ID || String(process.ppid);
|
|
26
|
+
const STATE_FILE = path.join(
|
|
27
|
+
HOME,
|
|
28
|
+
'.stackmemory',
|
|
29
|
+
`checkpoint-state-${SESSION_ID}.json`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
function projectHash(cwd) {
|
|
33
|
+
return crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureDir(dir) {
|
|
37
|
+
if (!fs.existsSync(dir)) {
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeWriteFile(filePath, content) {
|
|
43
|
+
const tmp = filePath + '.tmp';
|
|
44
|
+
fs.writeFileSync(tmp, content);
|
|
45
|
+
fs.renameSync(tmp, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadState() {
|
|
49
|
+
try {
|
|
50
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
51
|
+
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
52
|
+
const cutoff = Date.now() - TTL_MS;
|
|
53
|
+
for (const [cwd, proj] of Object.entries(state.projects)) {
|
|
54
|
+
if (
|
|
55
|
+
proj.sessionStart &&
|
|
56
|
+
new Date(proj.sessionStart).getTime() < cutoff
|
|
57
|
+
) {
|
|
58
|
+
delete state.projects[cwd];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Corrupted state, start fresh
|
|
65
|
+
}
|
|
66
|
+
return { projects: {} };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveState(state) {
|
|
70
|
+
try {
|
|
71
|
+
ensureDir(path.dirname(STATE_FILE));
|
|
72
|
+
safeWriteFile(STATE_FILE, JSON.stringify(state));
|
|
73
|
+
} catch {
|
|
74
|
+
// Best-effort
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getProjectState(state, cwd) {
|
|
79
|
+
if (!state.projects[cwd]) {
|
|
80
|
+
state.projects[cwd] = {
|
|
81
|
+
toolCount: 0,
|
|
82
|
+
checkpointCount: 0,
|
|
83
|
+
lastCheckpoint: null,
|
|
84
|
+
sessionStart: new Date().toISOString(),
|
|
85
|
+
filesModified: [],
|
|
86
|
+
recentTools: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return state.projects[cwd];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeCheckpoint(proj, cwd) {
|
|
93
|
+
const hash = projectHash(cwd);
|
|
94
|
+
const cpDir = path.join(HOME, '.stackmemory', 'checkpoints', hash);
|
|
95
|
+
ensureDir(cpDir);
|
|
96
|
+
|
|
97
|
+
const checkpoint = {
|
|
98
|
+
toolCount: proj.toolCount,
|
|
99
|
+
checkpointNumber: proj.checkpointCount,
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
filesModified: proj.filesModified.slice(-MAX_FILES_TRACKED),
|
|
102
|
+
recentTools: proj.recentTools.slice(-MAX_RECENT_TOOLS),
|
|
103
|
+
cwd,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
safeWriteFile(
|
|
107
|
+
path.join(cpDir, 'latest.json'),
|
|
108
|
+
JSON.stringify(checkpoint, null, 2)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trackFile(proj, toolInput) {
|
|
113
|
+
const filePath = toolInput?.file_path;
|
|
114
|
+
if (filePath && !proj.filesModified.includes(filePath)) {
|
|
115
|
+
proj.filesModified.push(filePath);
|
|
116
|
+
if (proj.filesModified.length > MAX_FILES_TRACKED) {
|
|
117
|
+
proj.filesModified = proj.filesModified.slice(-MAX_FILES_TRACKED);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readInput() {
|
|
123
|
+
let input = '';
|
|
124
|
+
for await (const chunk of process.stdin) {
|
|
125
|
+
input += chunk;
|
|
126
|
+
}
|
|
127
|
+
return JSON.parse(input);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function main() {
|
|
131
|
+
try {
|
|
132
|
+
const input = await readInput();
|
|
133
|
+
const { tool_name, tool_input } = input;
|
|
134
|
+
|
|
135
|
+
const cwd = process.cwd();
|
|
136
|
+
const state = loadState();
|
|
137
|
+
const proj = getProjectState(state, cwd);
|
|
138
|
+
|
|
139
|
+
// Increment counter
|
|
140
|
+
proj.toolCount++;
|
|
141
|
+
|
|
142
|
+
// Track tool name
|
|
143
|
+
proj.recentTools.push(tool_name);
|
|
144
|
+
if (proj.recentTools.length > MAX_RECENT_TOOLS) {
|
|
145
|
+
proj.recentTools = proj.recentTools.slice(-MAX_RECENT_TOOLS);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Track modified files from Edit/Write
|
|
149
|
+
if (
|
|
150
|
+
tool_name === 'Edit' ||
|
|
151
|
+
tool_name === 'Write' ||
|
|
152
|
+
tool_name === 'MultiEdit'
|
|
153
|
+
) {
|
|
154
|
+
trackFile(proj, tool_input);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Checkpoint every N calls
|
|
158
|
+
const isCheckpoint = proj.toolCount % CHECKPOINT_INTERVAL === 0;
|
|
159
|
+
if (isCheckpoint) {
|
|
160
|
+
proj.checkpointCount++;
|
|
161
|
+
proj.lastCheckpoint = new Date().toISOString();
|
|
162
|
+
writeCheckpoint(proj, cwd);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Only persist state every SAVE_INTERVAL calls or on checkpoint boundaries
|
|
166
|
+
if (isCheckpoint || proj.toolCount % SAVE_INTERVAL === 0) {
|
|
167
|
+
saveState(state);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Silent fail -- never block the agent
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Chime once when Claude Code needs user input
|
|
3
|
+
# Uses macOS system sound
|
|
4
|
+
|
|
5
|
+
# Prevent multiple chimes by checking if we recently played
|
|
6
|
+
CHIME_LOCK="/tmp/claude-chime-lock"
|
|
7
|
+
CHIME_COOLDOWN=2 # seconds
|
|
8
|
+
|
|
9
|
+
if [ -f "$CHIME_LOCK" ]; then
|
|
10
|
+
LAST_CHIME=$(cat "$CHIME_LOCK")
|
|
11
|
+
NOW=$(date +%s)
|
|
12
|
+
DIFF=$((NOW - LAST_CHIME))
|
|
13
|
+
if [ "$DIFF" -lt "$CHIME_COOLDOWN" ]; then
|
|
14
|
+
exit 0 # Skip chime if too recent
|
|
15
|
+
fi
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Record chime time
|
|
19
|
+
date +%s > "$CHIME_LOCK"
|
|
20
|
+
|
|
21
|
+
# Play system sound (Glass is a nice subtle chime)
|
|
22
|
+
afplay /System/Library/Sounds/Glass.aiff &
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# session-rescue.sh — Auto-capture stackmemory handoff on session close
|
|
3
|
+
# Runs as a Stop hook. Silent on failure. Skips non-stackmemory projects.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
# Only run in stackmemory-initialized projects
|
|
8
|
+
[ -d ".stackmemory" ] || exit 0
|
|
9
|
+
|
|
10
|
+
# Require stackmemory CLI
|
|
11
|
+
command -v stackmemory >/dev/null 2>&1 || exit 0
|
|
12
|
+
|
|
13
|
+
# Capture without committing, compact format, 10s timeout
|
|
14
|
+
timeout 10 stackmemory capture --no-commit --format compact \
|
|
15
|
+
-m "auto-rescue on session close" >/dev/null 2>&1 || true
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stop-Checkpoint Hook
|
|
5
|
+
*
|
|
6
|
+
* Fires on session stop. Reads checkpoint state for current project.
|
|
7
|
+
* If 3+ checkpoints accumulated, outputs a "context heavy" message.
|
|
8
|
+
* Writes a final checkpoint with session stop metadata.
|
|
9
|
+
*
|
|
10
|
+
* State is keyed per-session (process.ppid) to avoid race conditions
|
|
11
|
+
* when multiple Claude instances run concurrently.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
const HOME = process.env.HOME || '/tmp';
|
|
19
|
+
const SESSION_ID = process.env.CLAUDE_INSTANCE_ID || String(process.ppid);
|
|
20
|
+
const STATE_FILE = path.join(
|
|
21
|
+
HOME,
|
|
22
|
+
'.stackmemory',
|
|
23
|
+
`checkpoint-state-${SESSION_ID}.json`
|
|
24
|
+
);
|
|
25
|
+
const MAX_FILES_TRACKED = 50;
|
|
26
|
+
const MAX_RECENT_TOOLS = 20;
|
|
27
|
+
|
|
28
|
+
function projectHash(cwd) {
|
|
29
|
+
return crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureDir(dir) {
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeWriteFile(filePath, content) {
|
|
39
|
+
const tmp = filePath + '.tmp';
|
|
40
|
+
fs.writeFileSync(tmp, content);
|
|
41
|
+
fs.renameSync(tmp, filePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadState() {
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
47
|
+
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
48
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
49
|
+
for (const [cwd, proj] of Object.entries(state.projects)) {
|
|
50
|
+
if (
|
|
51
|
+
proj.sessionStart &&
|
|
52
|
+
new Date(proj.sessionStart).getTime() < cutoff
|
|
53
|
+
) {
|
|
54
|
+
delete state.projects[cwd];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Corrupted state, start fresh
|
|
61
|
+
}
|
|
62
|
+
return { projects: {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function main() {
|
|
66
|
+
try {
|
|
67
|
+
const cwd = process.cwd();
|
|
68
|
+
const state = loadState();
|
|
69
|
+
const proj = state.projects?.[cwd];
|
|
70
|
+
|
|
71
|
+
if (!proj) return;
|
|
72
|
+
|
|
73
|
+
const { toolCount, checkpointCount, sessionStart, filesModified } = proj;
|
|
74
|
+
|
|
75
|
+
// Write final checkpoint
|
|
76
|
+
const hash = projectHash(cwd);
|
|
77
|
+
const cpDir = path.join(HOME, '.stackmemory', 'checkpoints', hash);
|
|
78
|
+
ensureDir(cpDir);
|
|
79
|
+
|
|
80
|
+
// NaN duration guard
|
|
81
|
+
const startMs = sessionStart ? new Date(sessionStart).getTime() : 0;
|
|
82
|
+
const duration =
|
|
83
|
+
startMs > 0 ? Math.round((Date.now() - startMs) / 1000) : 0;
|
|
84
|
+
|
|
85
|
+
const finalCheckpoint = {
|
|
86
|
+
reason: 'session_stop',
|
|
87
|
+
toolCount,
|
|
88
|
+
checkpointNumber: checkpointCount,
|
|
89
|
+
totalFiles: filesModified?.length || 0,
|
|
90
|
+
duration,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
filesModified: (filesModified || []).slice(-MAX_FILES_TRACKED),
|
|
93
|
+
recentTools: (proj.recentTools || []).slice(-MAX_RECENT_TOOLS),
|
|
94
|
+
cwd,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
safeWriteFile(
|
|
98
|
+
path.join(cpDir, 'latest.json'),
|
|
99
|
+
JSON.stringify(finalCheckpoint, null, 2)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Output context-heavy message if 3+ checkpoints
|
|
103
|
+
if (checkpointCount >= 3) {
|
|
104
|
+
console.error(
|
|
105
|
+
`\nContext heavy (${checkpointCount} checkpoints, ${toolCount} tool calls). State saved. Consider /clear to reload from checkpoint.\n`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Clean up session state file since session is ending
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(STATE_FILE);
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort cleanup
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Silent fail -- never block the agent
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main();
|