Skip to content

Commit 482770d

Browse files
author
StackMemory Bot (CLI)
committed
feat(hooks): token optimization — dedup escalation, auto-route, prewarm, script-suggest
- dedup-reads: escalate to [STOP] at 5+ reads (was soft-only at 3+) - desire-path-hook: auto-route Bash→Glob/Read/Grep with inline suggestions - prewarm-tools: SessionStart hook emits top deferred tool pre-fetch hint - script-suggest: detects multi-tool patterns matching existing scripts
1 parent fdbf38c commit 482770d

4 files changed

Lines changed: 403 additions & 0 deletions

File tree

src/hooks/dedup-reads.cjs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env node
2+
// dedup-reads.cjs — PostToolUse hook for Claude Code
3+
//
4+
// Detects duplicate file reads in a session and warns when a file is read 3+
5+
// times without being modified in between. Helps reduce wasted tool calls.
6+
//
7+
// Install in ~/.claude/settings.json (or .claude/settings.local.json per-project):
8+
//
9+
// {
10+
// "hooks": {
11+
// "PostToolUse": [
12+
// {
13+
// "matcher": "Read",
14+
// "hooks": [
15+
// { "type": "command", "command": "node /Users/jwu/Dev/stackmemory/src/hooks/dedup-reads.cjs" }
16+
// ]
17+
// }
18+
// ]
19+
// }
20+
// }
21+
//
22+
// Opt out: STACKMEMORY_DEDUP_READS=0
23+
24+
'use strict';
25+
26+
const fs = require('fs');
27+
const path = require('path');
28+
29+
if (process.env.STACKMEMORY_DEDUP_READS === '0' || process.env.STACKMEMORY_DEDUP_READS === 'false') {
30+
process.exit(0);
31+
}
32+
33+
const SM_DIR = path.join(process.env.HOME || '', '.stackmemory');
34+
const DP_DIR = path.join(SM_DIR, 'desire-paths');
35+
36+
function run() {
37+
let raw = '';
38+
try {
39+
raw = fs.readFileSync(0, 'utf-8');
40+
} catch {
41+
return;
42+
}
43+
44+
let input;
45+
try {
46+
input = JSON.parse(raw);
47+
} catch {
48+
return;
49+
}
50+
51+
const toolName = input.tool_name || input.toolName;
52+
const toolInput = input.tool_input || input.input || {};
53+
54+
let filePath;
55+
56+
if (toolName === 'Read') {
57+
filePath = toolInput.file_path || toolInput.filePath;
58+
} else if (toolName === 'Bash') {
59+
// Codex reads files via Bash (cat, sed, head, nl, etc.) — extract file path
60+
const cmd = toolInput.command || '';
61+
const readMatch = cmd.match(/^(?:cat|head|tail|sed\s+-n|nl)\s+['"]?([^\s'";<>|&]+)/);
62+
if (readMatch && readMatch[1] && !readMatch[1].startsWith('-')) {
63+
filePath = readMatch[1];
64+
}
65+
}
66+
67+
if (!filePath) return;
68+
69+
const sessionId = input.session_id || input.sessionId
70+
|| process.env.STACKMEMORY_SESSION || process.env.CLAUDE_SESSION_ID
71+
|| 'default';
72+
73+
// Get current mtime
74+
let mtimeMs = 0;
75+
try {
76+
mtimeMs = fs.statSync(filePath).mtimeMs;
77+
} catch {
78+
// File may not exist (e.g., error read) — skip tracking
79+
return;
80+
}
81+
82+
fs.mkdirSync(DP_DIR, { recursive: true });
83+
84+
const stateFile = path.join(DP_DIR, `dedup-${sessionId}.json`);
85+
const lockFile = stateFile + '.lock';
86+
87+
// Acquire lock (spin up to 200ms)
88+
let lockFd;
89+
const deadline = Date.now() + 200;
90+
while (Date.now() < deadline) {
91+
try {
92+
lockFd = fs.openSync(lockFile, 'wx');
93+
break;
94+
} catch {
95+
// Lock held — spin briefly
96+
const wait = Date.now() + 5;
97+
while (Date.now() < wait) {} // busy-wait 5ms (no setTimeout in sync hook)
98+
}
99+
}
100+
if (lockFd === undefined) return; // couldn't acquire lock — skip silently
101+
102+
try {
103+
// Load state under lock
104+
let state = {};
105+
try {
106+
state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
107+
} catch {
108+
// First call or corrupted — start fresh
109+
}
110+
111+
const entry = state[filePath];
112+
113+
if (!entry) {
114+
state[filePath] = { count: 1, lastMtime: mtimeMs };
115+
} else if (mtimeMs !== entry.lastMtime) {
116+
state[filePath] = { count: 1, lastMtime: mtimeMs };
117+
} else {
118+
entry.count += 1;
119+
entry.lastMtime = mtimeMs;
120+
121+
if (entry.count >= 3) {
122+
const basename = path.basename(filePath);
123+
let msg;
124+
if (entry.count >= 5) {
125+
msg = `[STOP] ${basename} read ${entry.count}x (unchanged). You already have this content. Do NOT read again — use what you have.`;
126+
} else {
127+
msg = `[dedup] ${basename} read ${entry.count}x this session (unchanged) — use cached content`;
128+
}
129+
process.stdout.write(JSON.stringify({ systemMessage: msg }) + '\n');
130+
}
131+
}
132+
133+
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf-8');
134+
} finally {
135+
// Release lock
136+
try { fs.closeSync(lockFd); } catch {}
137+
try { fs.unlinkSync(lockFile); } catch {}
138+
}
139+
}
140+
141+
try {
142+
run();
143+
} catch {
144+
// Non-fatal — never crash the hook pipeline
145+
}

src/hooks/desire-path-hook.sh

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,35 @@ fi
6262
# Append entry (no content/data — just tool + target pattern)
6363
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
6464
echo "{\"ts\":\"${TIMESTAMP}\",\"sid\":\"${SESSION_ID}\",\"tool\":\"${TOOL_NAME}\",\"target\":\"${FIRST_ARG}\",\"dur\":${DURATION}}" >> "$STREAM_FILE"
65+
66+
# --- Script suggestions: detect patterns that match existing scripts ---
67+
echo "$INPUT" | node "$(dirname "$0")/script-suggest.cjs" 2>/dev/null
68+
69+
# --- Auto-route: suggest dedicated tools for replaceable Bash calls ---
70+
if [ "$TOOL_NAME" = "Bash" ] && [ -n "$FIRST_ARG" ]; then
71+
SUGGESTION=$(echo "$FIRST_ARG" | node -e "
72+
const cmd = require('fs').readFileSync(0,'utf-8').trim();
73+
// ls/find → Glob
74+
if (/^ls\s/.test(cmd) || /^find\s/.test(cmd)) {
75+
const dir = cmd.replace(/^(ls|find)\s+/, '').split(/\s/)[0] || '.';
76+
console.log('[route] Use Glob instead of \"' + cmd.slice(0,40) + '\" — e.g. Glob(pattern=\"**/*\", path=\"' + dir + '\")');
77+
}
78+
// cat/head/tail → Read
79+
else if (/^(cat|head|tail|sed\s+-n|nl)\s/.test(cmd)) {
80+
const file = cmd.replace(/^(cat|head|tail|sed\s+-n|nl)\s+/, '').split(/\s/)[0] || '';
81+
if (file && !file.startsWith('-')) {
82+
console.log('[route] Use Read instead of \"' + cmd.slice(0,40) + '\" — Read(file_path=\"' + file + '\")');
83+
}
84+
}
85+
// grep/rg → Grep
86+
else if (/^(grep|rg|ag)\s/.test(cmd)) {
87+
const parts = cmd.split(/\s+/);
88+
const pattern = parts[1] || '';
89+
console.log('[route] Use Grep instead of \"' + cmd.slice(0,40) + '\" — Grep(pattern=\"' + pattern + '\")');
90+
}
91+
" 2>/dev/null)
92+
93+
if [ -n "$SUGGESTION" ]; then
94+
echo "{\"systemMessage\":\"$SUGGESTION\"}"
95+
fi
96+
fi

src/hooks/prewarm-tools.cjs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
/**
3+
* prewarm-tools.cjs — SessionStart hook
4+
*
5+
* Emits a system message telling Claude to pre-fetch schemas for
6+
* the most frequently used deferred MCP tools, avoiding repeated
7+
* ToolSearch calls mid-conversation.
8+
*
9+
* Data source: ~/.stackmemory/desire-paths/action-stream.jsonl
10+
* Learns from actual usage — top N deferred tools by frequency.
11+
*/
12+
13+
'use strict';
14+
15+
const fs = require('fs');
16+
const path = require('path');
17+
18+
const SM_DIR = path.join(process.env.HOME || '', '.stackmemory');
19+
const STREAM_FILE = path.join(SM_DIR, 'desire-paths', 'action-stream.jsonl');
20+
const CACHE_FILE = path.join(SM_DIR, 'desire-paths', 'prewarm-cache.json');
21+
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24h
22+
23+
// Known deferred tool prefixes (MCP tools that need ToolSearch)
24+
const DEFERRED_PREFIXES = ['mcp__', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'WebFetch', 'WebSearch'];
25+
26+
function isDeferred(tool) {
27+
return DEFERRED_PREFIXES.some(p => tool.startsWith(p));
28+
}
29+
30+
function getTopTools() {
31+
// Check cache first
32+
try {
33+
const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
34+
if (Date.now() - cache.ts < CACHE_TTL && cache.tools?.length > 0) {
35+
return cache.tools;
36+
}
37+
} catch {}
38+
39+
// Parse action stream
40+
if (!fs.existsSync(STREAM_FILE)) return [];
41+
42+
const counts = {};
43+
const lines = fs.readFileSync(STREAM_FILE, 'utf-8').split('\n');
44+
45+
for (const line of lines) {
46+
if (!line) continue;
47+
try {
48+
const d = JSON.parse(line);
49+
const tool = d.tool || '';
50+
if (isDeferred(tool)) {
51+
counts[tool] = (counts[tool] || 0) + 1;
52+
}
53+
} catch {}
54+
}
55+
56+
// Sort by frequency, take top 8
57+
const sorted = Object.entries(counts)
58+
.sort((a, b) => b[1] - a[1])
59+
.slice(0, 8)
60+
.map(([tool]) => tool);
61+
62+
// Cache result
63+
try {
64+
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
65+
fs.writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), tools: sorted }));
66+
} catch {}
67+
68+
return sorted;
69+
}
70+
71+
function main() {
72+
const tools = getTopTools();
73+
if (tools.length === 0) return;
74+
75+
// Group by prefix for efficient ToolSearch queries
76+
const mcpTools = tools.filter(t => t.startsWith('mcp__'));
77+
const builtinTools = tools.filter(t => !t.startsWith('mcp__'));
78+
79+
const parts = [];
80+
if (mcpTools.length > 0) {
81+
parts.push(`select:${mcpTools.join(',')}`);
82+
}
83+
if (builtinTools.length > 0) {
84+
parts.push(`select:${builtinTools.join(',')}`);
85+
}
86+
87+
const msg = `[prewarm] Frequently used deferred tools detected. Pre-fetch with: ToolSearch(query="${parts[0]}", max_results=${tools.length})`;
88+
process.stdout.write(JSON.stringify({ systemMessage: msg }) + '\n');
89+
}
90+
91+
try {
92+
main();
93+
} catch {}

0 commit comments

Comments
 (0)