specmem-hardwicksoftware 3.7.29 → 3.7.31
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/bootstrap.cjs +19 -0
- package/claude-hooks/settings.json +99 -0
- package/claude-hooks/specmem-search-enforcer.cjs +229 -0
- package/claude-hooks/specmem-search-tracker.cjs +71 -0
- package/dist/config.js +11 -16
- package/dist/db/connectionPoolGoBrrr.js +3 -3
- package/dist/index.js +21 -4
- package/dist/mcp/compactionProxy.js +52 -17
- package/dist/mcp/embeddingServerManager.js +15 -1
- package/dist/mcp/mcpProtocolHandler.js +22 -4
- package/dist/mcp/specMemServer.js +16 -3
- package/dist/mcp/toolRegistry.js +19 -21
- package/dist/tools/goofy/checkSyncStatus.js +14 -7
- package/dist/watcher/fileWatcher.js +57 -20
- package/dist/watcher/syncChecker.js +11 -7
- package/package.json +1 -1
- package/scripts/global-postinstall.cjs +7 -2
- package/scripts/specmem-init.cjs +91 -111
- package/specmem/model-config.json +26 -6
- package/specmem/supervisord.conf +1 -1
- package/specmem/user-config.json +12 -0
package/bootstrap.cjs
CHANGED
|
@@ -4919,6 +4919,25 @@ async function autoInstallThisMf() {
|
|
|
4919
4919
|
// Non-fatal - MCP server will retry
|
|
4920
4920
|
}
|
|
4921
4921
|
|
|
4922
|
+
// Acquire socket lock so statusbar/health checks can detect us
|
|
4923
|
+
const projectPath_uf = getProjectPath();
|
|
4924
|
+
const lockAcquired_uf = tryAcquireSocketLock(projectPath_uf);
|
|
4925
|
+
if (lockAcquired_uf) {
|
|
4926
|
+
writeProjectPidFile(projectPath_uf, process.pid);
|
|
4927
|
+
writeInstanceState(projectPath_uf, {
|
|
4928
|
+
pid: process.pid,
|
|
4929
|
+
projectPath: projectPath_uf,
|
|
4930
|
+
projectHash: hashProjectPath(projectPath_uf),
|
|
4931
|
+
startTime: new Date().toISOString(),
|
|
4932
|
+
status: 'running',
|
|
4933
|
+
bootstrapVersion: '1.0.0',
|
|
4934
|
+
mode: 'ultra-fast'
|
|
4935
|
+
});
|
|
4936
|
+
startupLog('Ultra-fast path: socket lock acquired, PID file written');
|
|
4937
|
+
} else {
|
|
4938
|
+
startupLog('Ultra-fast path: could not acquire socket lock (non-fatal)');
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4922
4941
|
// Start server BEFORE any other operations
|
|
4923
4942
|
// The server handles its own deferred initialization
|
|
4924
4943
|
// CRITICAL: startServer() is now async and imports the ES module directly
|
|
@@ -55,6 +55,14 @@
|
|
|
55
55
|
{
|
|
56
56
|
"matcher": "Read",
|
|
57
57
|
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "node /root/.claude/hooks/specmem-search-enforcer.cjs",
|
|
61
|
+
"timeout": 2,
|
|
62
|
+
"env": {
|
|
63
|
+
"SPECMEM_PROJECT_PATH": "${cwd}"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
58
66
|
{
|
|
59
67
|
"type": "command",
|
|
60
68
|
"command": "node /root/.claude/hooks/team-comms-enforcer.cjs",
|
|
@@ -84,6 +92,14 @@
|
|
|
84
92
|
{
|
|
85
93
|
"matcher": "Edit",
|
|
86
94
|
"hooks": [
|
|
95
|
+
{
|
|
96
|
+
"type": "command",
|
|
97
|
+
"command": "node /root/.claude/hooks/specmem-search-enforcer.cjs",
|
|
98
|
+
"timeout": 2,
|
|
99
|
+
"env": {
|
|
100
|
+
"SPECMEM_PROJECT_PATH": "${cwd}"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
87
103
|
{
|
|
88
104
|
"type": "command",
|
|
89
105
|
"command": "node /root/.claude/hooks/team-comms-enforcer.cjs",
|
|
@@ -97,6 +113,11 @@
|
|
|
97
113
|
{
|
|
98
114
|
"matcher": "Write",
|
|
99
115
|
"hooks": [
|
|
116
|
+
{
|
|
117
|
+
"type": "command",
|
|
118
|
+
"command": "node /root/.claude/hooks/specmem-search-enforcer.cjs",
|
|
119
|
+
"timeout": 2
|
|
120
|
+
},
|
|
100
121
|
{
|
|
101
122
|
"type": "command",
|
|
102
123
|
"command": "node /root/.claude/hooks/team-comms-enforcer.cjs",
|
|
@@ -110,6 +131,11 @@
|
|
|
110
131
|
{
|
|
111
132
|
"matcher": "Grep",
|
|
112
133
|
"hooks": [
|
|
134
|
+
{
|
|
135
|
+
"type": "command",
|
|
136
|
+
"command": "node /root/.claude/hooks/specmem-search-enforcer.cjs",
|
|
137
|
+
"timeout": 2
|
|
138
|
+
},
|
|
113
139
|
{
|
|
114
140
|
"type": "command",
|
|
115
141
|
"command": "node /root/.claude/hooks/team-comms-enforcer.cjs",
|
|
@@ -147,6 +173,11 @@
|
|
|
147
173
|
{
|
|
148
174
|
"matcher": "Glob",
|
|
149
175
|
"hooks": [
|
|
176
|
+
{
|
|
177
|
+
"type": "command",
|
|
178
|
+
"command": "node /root/.claude/hooks/specmem-search-enforcer.cjs",
|
|
179
|
+
"timeout": 2
|
|
180
|
+
},
|
|
150
181
|
{
|
|
151
182
|
"type": "command",
|
|
152
183
|
"command": "node /root/.claude/hooks/team-comms-enforcer.cjs",
|
|
@@ -184,6 +215,14 @@
|
|
|
184
215
|
{
|
|
185
216
|
"matcher": "Bash",
|
|
186
217
|
"hooks": [
|
|
218
|
+
{
|
|
219
|
+
"type": "command",
|
|
220
|
+
"command": "node /root/.claude/hooks/specmem-search-enforcer.cjs",
|
|
221
|
+
"timeout": 2,
|
|
222
|
+
"env": {
|
|
223
|
+
"SPECMEM_PROJECT_PATH": "${cwd}"
|
|
224
|
+
}
|
|
225
|
+
},
|
|
187
226
|
{
|
|
188
227
|
"type": "command",
|
|
189
228
|
"command": "node /root/.claude/hooks/team-comms-enforcer.cjs",
|
|
@@ -306,6 +345,66 @@
|
|
|
306
345
|
}
|
|
307
346
|
],
|
|
308
347
|
"PostToolUse": [
|
|
348
|
+
{
|
|
349
|
+
"matcher": "Grep",
|
|
350
|
+
"hooks": [
|
|
351
|
+
{
|
|
352
|
+
"type": "command",
|
|
353
|
+
"command": "node /root/.claude/hooks/specmem-search-tracker.cjs",
|
|
354
|
+
"timeout": 5
|
|
355
|
+
}
|
|
356
|
+
]
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"matcher": "Glob",
|
|
360
|
+
"hooks": [
|
|
361
|
+
{
|
|
362
|
+
"type": "command",
|
|
363
|
+
"command": "node /root/.claude/hooks/specmem-search-tracker.cjs",
|
|
364
|
+
"timeout": 5
|
|
365
|
+
}
|
|
366
|
+
]
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"matcher": "Read",
|
|
370
|
+
"hooks": [
|
|
371
|
+
{
|
|
372
|
+
"type": "command",
|
|
373
|
+
"command": "node /root/.claude/hooks/specmem-search-tracker.cjs",
|
|
374
|
+
"timeout": 5
|
|
375
|
+
}
|
|
376
|
+
]
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
"matcher": "mcp__specmem__find_memory",
|
|
380
|
+
"hooks": [
|
|
381
|
+
{
|
|
382
|
+
"type": "command",
|
|
383
|
+
"command": "node /root/.claude/hooks/specmem-search-tracker.cjs",
|
|
384
|
+
"timeout": 5
|
|
385
|
+
}
|
|
386
|
+
]
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"matcher": "mcp__specmem__find_code_pointers",
|
|
390
|
+
"hooks": [
|
|
391
|
+
{
|
|
392
|
+
"type": "command",
|
|
393
|
+
"command": "node /root/.claude/hooks/specmem-search-tracker.cjs",
|
|
394
|
+
"timeout": 5
|
|
395
|
+
}
|
|
396
|
+
]
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"matcher": "mcp__specmem__drill_down",
|
|
400
|
+
"hooks": [
|
|
401
|
+
{
|
|
402
|
+
"type": "command",
|
|
403
|
+
"command": "node /root/.claude/hooks/specmem-search-tracker.cjs",
|
|
404
|
+
"timeout": 5
|
|
405
|
+
}
|
|
406
|
+
]
|
|
407
|
+
},
|
|
309
408
|
{
|
|
310
409
|
"matcher": "Task",
|
|
311
410
|
"hooks": [
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SPECMEM SEARCH ENFORCER - PreToolUse Hook
|
|
4
|
+
* ==========================================
|
|
5
|
+
*
|
|
6
|
+
* HARD BLOCKS agents that skip SpecMem semantic search.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* 1. Agents CANNOT do ANYTHING until they've called find_memory or find_code_pointers at least once
|
|
10
|
+
* 2. Every 3 searches (Grep/Glob/Read), agents MUST call find_code_pointers again
|
|
11
|
+
* 3. After find_code_pointers, agents MUST drill_down before continuing
|
|
12
|
+
* 4. 2nd search in a cycle: WARNING injected
|
|
13
|
+
* 5. 3rd search in a cycle: HARD BLOCK (deny)
|
|
14
|
+
* 6. Tool calls and other non-search tools DO NOT reset the counter
|
|
15
|
+
* 7. Main session (non-agent) gets suggestions, not blocks
|
|
16
|
+
*
|
|
17
|
+
* State: /tmp/specmem-search-enforcer-{session}.json
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
// --- Agent detection (inline, no require chain issues) ---
|
|
24
|
+
function isAgent() {
|
|
25
|
+
const markers = [
|
|
26
|
+
process.env.CLAUDE_AGENT === 'true',
|
|
27
|
+
process.env.CLAUDE_AGENT_TYPE,
|
|
28
|
+
process.env.TASK_ID,
|
|
29
|
+
(process.env.CLAUDE_WORKTREE || '').length > 0,
|
|
30
|
+
(process.env.CLAUDE_SESSION_ID || '').includes('task-'),
|
|
31
|
+
];
|
|
32
|
+
return markers.some(Boolean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Config ---
|
|
36
|
+
const SEARCH_TOOLS = ['Grep', 'Glob', 'Read', 'Bash'];
|
|
37
|
+
const WRITE_TOOLS = ['Edit', 'Write'];
|
|
38
|
+
const ALL_BLOCKED_TOOLS = [...SEARCH_TOOLS, ...WRITE_TOOLS];
|
|
39
|
+
|
|
40
|
+
const SPECMEM_SEARCH_TOOLS = [
|
|
41
|
+
'mcp__specmem__find_memory',
|
|
42
|
+
'mcp__specmem__find_code_pointers',
|
|
43
|
+
'mcp__specmem__smart_search',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const SPECMEM_DRILLDOWN_TOOLS = [
|
|
47
|
+
'mcp__specmem__drill_down',
|
|
48
|
+
'mcp__specmem__get_memory',
|
|
49
|
+
'mcp__specmem__get_memory_by_id',
|
|
50
|
+
'mcp__specmem__getMemoryFull',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const SPECMEM_CODE_POINTER_TOOLS = [
|
|
54
|
+
'mcp__specmem__find_code_pointers',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const SEARCH_CYCLE_LIMIT = 3; // block on 3rd search
|
|
58
|
+
const WARN_AT = 2; // warn on 2nd search
|
|
59
|
+
|
|
60
|
+
// --- State management ---
|
|
61
|
+
function getStateFile() {
|
|
62
|
+
const sessionId = process.env.CLAUDE_SESSION_ID || process.env.TASK_ID || 'default';
|
|
63
|
+
const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
64
|
+
return `/tmp/specmem-search-enforcer-${sanitized}.json`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getState() {
|
|
68
|
+
try {
|
|
69
|
+
const f = getStateFile();
|
|
70
|
+
if (fs.existsSync(f)) {
|
|
71
|
+
const data = JSON.parse(fs.readFileSync(f, 'utf-8'));
|
|
72
|
+
// Expire after 30 min
|
|
73
|
+
if (data.timestamp && (Date.now() - data.timestamp > 30 * 60 * 1000)) {
|
|
74
|
+
return freshState();
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
try { fs.unlinkSync(getStateFile()); } catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
return freshState();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function freshState() {
|
|
85
|
+
return {
|
|
86
|
+
hasUsedSpecmemSearch: false,
|
|
87
|
+
searchesSinceLastCodePointers: 0,
|
|
88
|
+
pendingDrilldown: false,
|
|
89
|
+
lastCodePointersQuery: null,
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function saveState(state) {
|
|
95
|
+
try {
|
|
96
|
+
state.timestamp = Date.now();
|
|
97
|
+
fs.writeFileSync(getStateFile(), JSON.stringify(state, null, 2));
|
|
98
|
+
} catch (e) { /* silent */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- stdin reader with timeout ---
|
|
102
|
+
function readStdinWithTimeout(timeoutMs = 5000) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
let input = '';
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
process.stdin.destroy();
|
|
107
|
+
resolve(input);
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
process.stdin.setEncoding('utf8');
|
|
110
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
111
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(input); });
|
|
112
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(input); });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Main ---
|
|
117
|
+
async function main() {
|
|
118
|
+
const inputData = await readStdinWithTimeout(5000);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const hookData = JSON.parse(inputData);
|
|
122
|
+
const toolName = hookData.tool_name || '';
|
|
123
|
+
const toolInput = hookData.tool_input || {};
|
|
124
|
+
const state = getState();
|
|
125
|
+
|
|
126
|
+
// --- SpecMem search tool used (find_memory, find_code_pointers, smart_search) ---
|
|
127
|
+
if (SPECMEM_SEARCH_TOOLS.includes(toolName)) {
|
|
128
|
+
state.hasUsedSpecmemSearch = true;
|
|
129
|
+
|
|
130
|
+
// find_code_pointers resets the search counter AND sets drilldown pending
|
|
131
|
+
if (SPECMEM_CODE_POINTER_TOOLS.includes(toolName)) {
|
|
132
|
+
state.searchesSinceLastCodePointers = 0;
|
|
133
|
+
state.pendingDrilldown = true;
|
|
134
|
+
state.lastCodePointersQuery = toolInput.query || '(unknown)';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
saveState(state);
|
|
138
|
+
process.exit(0); // allow
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Drilldown tool used (drill_down, get_memory, etc) ---
|
|
142
|
+
if (SPECMEM_DRILLDOWN_TOOLS.includes(toolName)) {
|
|
143
|
+
state.pendingDrilldown = false;
|
|
144
|
+
saveState(state);
|
|
145
|
+
process.exit(0); // allow
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Non-agent: suggest only, never block ---
|
|
149
|
+
if (!isAgent()) {
|
|
150
|
+
process.exit(0); // allow everything for main session
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// === AGENT ENFORCEMENT BELOW ===
|
|
154
|
+
|
|
155
|
+
// --- Rule 1: Agent hasn't used ANY specmem search yet -> HARD BLOCK everything ---
|
|
156
|
+
if (!state.hasUsedSpecmemSearch && ALL_BLOCKED_TOOLS.includes(toolName)) {
|
|
157
|
+
const output = {
|
|
158
|
+
hookSpecificOutput: {
|
|
159
|
+
hookEventName: 'PreToolUse',
|
|
160
|
+
permissionDecision: 'deny',
|
|
161
|
+
permissionDecisionReason: `BLOCKED: You MUST call find_memory or find_code_pointers BEFORE using ${toolName}. No Read/Write/Grep/Glob/Edit/Bash allowed until you search SpecMem first. Run: mcp__specmem__find_code_pointers({query: "your task description"})`
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
console.log(JSON.stringify(output));
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Rule 3: Pending drilldown after find_code_pointers -> BLOCK until drilled ---
|
|
169
|
+
if (state.pendingDrilldown && ALL_BLOCKED_TOOLS.includes(toolName)) {
|
|
170
|
+
const output = {
|
|
171
|
+
hookSpecificOutput: {
|
|
172
|
+
hookEventName: 'PreToolUse',
|
|
173
|
+
permissionDecision: 'deny',
|
|
174
|
+
permissionDecisionReason: `BLOCKED: You ran find_code_pointers("${state.lastCodePointersQuery}") but haven't drilled down into the results yet. You MUST call drill_down({drilldownID: N}) or get_memory({id: "ID"}) before using ${toolName}. Drill into the results first!`
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
console.log(JSON.stringify(output));
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Count searches for cycle enforcement ---
|
|
182
|
+
if (SEARCH_TOOLS.includes(toolName)) {
|
|
183
|
+
state.searchesSinceLastCodePointers++;
|
|
184
|
+
saveState(state);
|
|
185
|
+
|
|
186
|
+
// Rule 5: 3rd search -> HARD BLOCK
|
|
187
|
+
if (state.searchesSinceLastCodePointers >= SEARCH_CYCLE_LIMIT) {
|
|
188
|
+
const output = {
|
|
189
|
+
hookSpecificOutput: {
|
|
190
|
+
hookEventName: 'PreToolUse',
|
|
191
|
+
permissionDecision: 'deny',
|
|
192
|
+
permissionDecisionReason: `BLOCKED: You've done ${state.searchesSinceLastCodePointers} searches without calling find_code_pointers. Every 3 searches you MUST call mcp__specmem__find_code_pointers to refresh your semantic context. Do it now before continuing.`
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
console.log(JSON.stringify(output));
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Rule 4: 2nd search -> WARNING
|
|
200
|
+
if (state.searchesSinceLastCodePointers >= WARN_AT) {
|
|
201
|
+
const output = {
|
|
202
|
+
hookSpecificOutput: {
|
|
203
|
+
hookEventName: 'PreToolUse',
|
|
204
|
+
permissionDecision: 'allow',
|
|
205
|
+
permissionDecisionReason: `WARNING: ${state.searchesSinceLastCodePointers}/${SEARCH_CYCLE_LIMIT} searches used. You MUST call find_code_pointers before your next search or you'll be blocked. Consider running it now.`,
|
|
206
|
+
additionalContext: `\u26a0\ufe0f SEARCH LIMIT WARNING: ${state.searchesSinceLastCodePointers}/${SEARCH_CYCLE_LIMIT} searches since last find_code_pointers. Next search WILL BE BLOCKED. Run mcp__specmem__find_code_pointers now.`
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
console.log(JSON.stringify(output));
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- Write tools increment search counter too (they shouldn't write blind) ---
|
|
215
|
+
if (WRITE_TOOLS.includes(toolName)) {
|
|
216
|
+
// Don't count writes toward search limit, but they're allowed if we passed the checks above
|
|
217
|
+
saveState(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Allow everything else (Task, ToolSearch, MCP tools, etc)
|
|
221
|
+
process.exit(0);
|
|
222
|
+
|
|
223
|
+
} catch (error) {
|
|
224
|
+
// Parse failure = allow (don't break the session)
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SPECMEM SEARCH TRACKER - PostToolUse Hook
|
|
4
|
+
* ===========================================
|
|
5
|
+
*
|
|
6
|
+
* Tracks when agents use SpecMem semantic tools (find_memory, find_code_pointers, drill_down)
|
|
7
|
+
* and resets the search counter so enforcer unblocks.
|
|
8
|
+
*
|
|
9
|
+
* Also tracks Grep/Glob calls to increment search counter.
|
|
10
|
+
*
|
|
11
|
+
* AGENTS ONLY - main session skipped.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
// Agent detection
|
|
18
|
+
function isAgent() {
|
|
19
|
+
const e = process.env;
|
|
20
|
+
if (e.CLAUDE_AGENT === '1' || e.CLAUDE_AGENT === 'true') return true;
|
|
21
|
+
if (e.TASK_ID || e.AGENT_ID || e.WORKTREE_PATH) return true;
|
|
22
|
+
if (e.CLAUDE_CODE_ENTRYPOINT === 'task') return true;
|
|
23
|
+
const ppid = e.CLAUDE_PARENT_PID || e.PARENT_PID;
|
|
24
|
+
if (ppid && ppid !== '1' && ppid !== String(process.pid)) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function main() {
|
|
29
|
+
if (!isAgent()) {
|
|
30
|
+
console.log(JSON.stringify({ permissionDecision: 'allow' }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const toolName = process.env.TOOL_NAME || '';
|
|
35
|
+
const sessionId = process.env.SESSION_ID || process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || 'unknown';
|
|
36
|
+
const stateDir = '/tmp/specmem-enforcer';
|
|
37
|
+
const stateFile = path.join(stateDir, `${sessionId}.json`);
|
|
38
|
+
|
|
39
|
+
try { fs.mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
40
|
+
|
|
41
|
+
let state = { searchCount: 0, specmemUsed: false, drilldownRequired: false, drilldownDone: false };
|
|
42
|
+
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch {}
|
|
43
|
+
|
|
44
|
+
const isSpecmemSearch = /find_memory|find_code_pointers/i.test(toolName);
|
|
45
|
+
const isDrilldown = /drill_down/i.test(toolName);
|
|
46
|
+
const isSearchTool = /^(Grep|Glob)$/i.test(toolName);
|
|
47
|
+
|
|
48
|
+
if (isSpecmemSearch) {
|
|
49
|
+
state.specmemUsed = true;
|
|
50
|
+
state.searchCount = 0; // Reset search counter
|
|
51
|
+
// find_code_pointers requires drill_down after
|
|
52
|
+
if (/find_code_pointers/i.test(toolName)) {
|
|
53
|
+
state.drilldownRequired = true;
|
|
54
|
+
state.drilldownDone = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isDrilldown) {
|
|
59
|
+
state.drilldownDone = true;
|
|
60
|
+
state.drilldownRequired = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isSearchTool) {
|
|
64
|
+
state.searchCount = (state.searchCount || 0) + 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try { fs.writeFileSync(stateFile, JSON.stringify(state)); } catch {}
|
|
68
|
+
console.log(JSON.stringify({ permissionDecision: 'allow' }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main();
|
package/dist/config.js
CHANGED
|
@@ -837,24 +837,19 @@ export function loadConfig() {
|
|
|
837
837
|
const parsedUrl = parseDatabaseUrl();
|
|
838
838
|
// Priority: DATABASE_URL > ENV VAR > .specmemrc > default
|
|
839
839
|
// Per-project isolation still applies if DATABASE_URL not set
|
|
840
|
-
// Container mode:
|
|
841
|
-
//
|
|
842
|
-
// FIX: Try both {projectPath}/specmem/run and {projectPath}/run to handle
|
|
843
|
-
// the case where projectPath IS the specmem dir (avoids specmem/specmem/run)
|
|
840
|
+
// Container mode: postgres via unix socket in specmem/run/ (bind-mounted from container /data/run)
|
|
841
|
+
// Socket appears after container starts PG — dir must exist, socket arrives when PG is ready
|
|
844
842
|
let containerRunDir = path.join(projectPath, 'specmem', 'run');
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (fs.existsSync(path.join(altRunDir, '.s.PGSQL.5432'))) {
|
|
851
|
-
containerRunDir = altRunDir;
|
|
852
|
-
return true;
|
|
853
|
-
}
|
|
854
|
-
return false;
|
|
855
|
-
} catch { return false; }
|
|
856
|
-
})();
|
|
843
|
+
// Also check projectPath/run in case projectPath IS the specmem dir
|
|
844
|
+
if (!fs.existsSync(containerRunDir) && fs.existsSync(path.join(projectPath, 'run'))) {
|
|
845
|
+
containerRunDir = path.join(projectPath, 'run');
|
|
846
|
+
}
|
|
847
|
+
const containerSocketExists = fs.existsSync(path.join(containerRunDir, '.s.PGSQL.5432'));
|
|
857
848
|
const isContainerMode = process.env['SPECMEM_CONTAINER_MODE'] === 'true' || containerSocketExists;
|
|
849
|
+
if (isContainerMode) {
|
|
850
|
+
// Ensure socket directory exists on host — container bind-mounts here
|
|
851
|
+
try { fs.mkdirSync(containerRunDir, { recursive: true }); } catch (e) { /* may already exist */ }
|
|
852
|
+
}
|
|
858
853
|
const defaultDbHost = isContainerMode ? containerRunDir : 'localhost';
|
|
859
854
|
const dbHost = parsedUrl?.host || process.env['SPECMEM_DB_HOST'] || getRcValue(rc, 'database.host', defaultDbHost);
|
|
860
855
|
const dbPort = parsedUrl?.port || projectDbPort;
|
|
@@ -17,10 +17,10 @@ types.setTypeParser(20, (val) => {
|
|
|
17
17
|
return Number.isSafeInteger(n) ? n : BigInt(val);
|
|
18
18
|
}); // bigint - safe for values > 2^53
|
|
19
19
|
const DEFAULT_POOL_SETTINGS = {
|
|
20
|
-
maxConnections:
|
|
21
|
-
minConnections:
|
|
20
|
+
maxConnections: 6, // tuned for 4-core 8GB laptop - 20 was exhausting PG under concurrent tool calls
|
|
21
|
+
minConnections: 2, // keep a couple warm, don't hog connections
|
|
22
22
|
idleTimeoutMs: 30000, // 30 sec timeout on idle connections
|
|
23
|
-
connectionTimeoutMs:
|
|
23
|
+
connectionTimeoutMs: 10000, // 10 sec to establish connection - fail fast under load
|
|
24
24
|
statementTimeoutMs: 30000, // 30 sec statement timeout
|
|
25
25
|
queryTimeoutMs: 60000, // 1 min query timeout for thicc queries
|
|
26
26
|
healthCheckIntervalMs: 30000, // health check every 30 sec
|
package/dist/index.js
CHANGED
|
@@ -1608,11 +1608,28 @@ class LocalEmbeddingProvider {
|
|
|
1608
1608
|
* Runs in background to not block embedding requests
|
|
1609
1609
|
*/
|
|
1610
1610
|
tryRestartContainer() {
|
|
1611
|
-
// Container mode: brain container
|
|
1612
|
-
// Self-healing attempts override sandboxSocketPath to wrong path (embeddings.sock vs embed.sock)
|
|
1613
|
-
// and try to start conflicting Docker containers, causing CPU/RAM waste and socket confusion.
|
|
1611
|
+
// Container mode: restart the brain container instead of spawning a new process
|
|
1614
1612
|
if (process.env.SPECMEM_CONTAINER_MODE === 'true') {
|
|
1615
|
-
|
|
1613
|
+
const now = Date.now();
|
|
1614
|
+
if (now - this.lastRestartAttempt < LocalEmbeddingProvider.RESTART_COOLDOWN_MS) {
|
|
1615
|
+
logger.debug('container restart cooldown active, skipping');
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
this.lastRestartAttempt = now;
|
|
1619
|
+
try {
|
|
1620
|
+
const { getContainerManager } = require('./container/containerManager.js');
|
|
1621
|
+
const projectPath = process.env.SPECMEM_PROJECT_PATH || process.cwd();
|
|
1622
|
+
const cm = getContainerManager(projectPath);
|
|
1623
|
+
logger.info({ projectPath }, '[LocalEmbeddingProvider] Restarting brain container...');
|
|
1624
|
+
cm.start().then(() => {
|
|
1625
|
+
logger.info('[LocalEmbeddingProvider] Brain container restarted');
|
|
1626
|
+
this.restartAttempts = 0;
|
|
1627
|
+
}).catch(err => {
|
|
1628
|
+
logger.error({ error: err?.message }, '[LocalEmbeddingProvider] Brain container restart failed');
|
|
1629
|
+
});
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
logger.error({ error: err?.message }, '[LocalEmbeddingProvider] Failed to get container manager');
|
|
1632
|
+
}
|
|
1616
1633
|
return;
|
|
1617
1634
|
}
|
|
1618
1635
|
const now = Date.now();
|
|
@@ -1254,25 +1254,57 @@ async function handleRequest(req, res) {
|
|
|
1254
1254
|
|
|
1255
1255
|
pushEvent('info', `POST /v1/messages model=${body.model || '?'} msgs=${messageCount} size=${(originalSize / 1024).toFixed(0)}KB`);
|
|
1256
1256
|
|
|
1257
|
+
const isCompaction = isCompactionRequest(body);
|
|
1258
|
+
const isPassthrough = !isCompaction && (dontCompress || messageCount <= liveConfig.PRESERVE_RECENT_MESSAGES);
|
|
1259
|
+
let sysPromptModified = false;
|
|
1260
|
+
|
|
1257
1261
|
// === SYSTEM PROMPT COMPRESSION ===
|
|
1262
|
+
// Always compress system prompt if not dontCompress — cache makes repeat calls free.
|
|
1263
|
+
// Cache-miss: fire-and-forget on passthrough (don't block forwarding), await on compaction/live paths.
|
|
1258
1264
|
if (!dontCompress && body.system) {
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1265
|
+
// Build hash to check cache without calling async function
|
|
1266
|
+
const _sysKey = typeof body.system === 'string' ? body.system
|
|
1267
|
+
: Array.isArray(body.system) ? body.system.map(b => typeof b === 'string' ? b : (b?.text || '')).join('')
|
|
1268
|
+
: JSON.stringify(body.system);
|
|
1269
|
+
const _sysHash = require('crypto').createHash('md5').update(_sysKey).digest('hex');
|
|
1270
|
+
const _sysCached = _sysPromptCache.get(_sysHash);
|
|
1271
|
+
|
|
1272
|
+
if (_sysCached) {
|
|
1273
|
+
// Cache hit — zero latency, always apply
|
|
1274
|
+
if (_sysCached.charsSaved > 0) {
|
|
1275
|
+
body.system = _sysCached.system;
|
|
1276
|
+
sysPromptModified = true;
|
|
1277
|
+
stats.sysPromptCharsSaved += _sysCached.charsSaved;
|
|
1264
1278
|
stats.sysPromptCompressed++;
|
|
1265
|
-
stats.tokensStripped += Math.floor(
|
|
1266
|
-
stats.bytesStripped +=
|
|
1267
|
-
log('compress', `SYSPROMPT: ${
|
|
1268
|
-
pushEvent('compress', `System prompt: -${
|
|
1279
|
+
stats.tokensStripped += Math.floor(_sysCached.charsSaved / 4);
|
|
1280
|
+
stats.bytesStripped += _sysCached.charsSaved;
|
|
1281
|
+
log('compress', `SYSPROMPT (cache hit): ${_sysCached.charsSaved} chars saved`);
|
|
1282
|
+
pushEvent('compress', `System prompt (cached): -${_sysCached.charsSaved} chars`);
|
|
1283
|
+
}
|
|
1284
|
+
} else if (isPassthrough) {
|
|
1285
|
+
// Cache miss + passthrough: fire-and-forget on new thread — populates cache for next request
|
|
1286
|
+
compressSystemPrompt(body.system).catch(() => {});
|
|
1287
|
+
} else {
|
|
1288
|
+
// Cache miss + compaction/live: must await (need compressed body)
|
|
1289
|
+
try {
|
|
1290
|
+
const sysResult = await compressSystemPrompt(body.system);
|
|
1291
|
+
if (sysResult.charsSaved > 0) {
|
|
1292
|
+
body.system = sysResult.system;
|
|
1293
|
+
sysPromptModified = true;
|
|
1294
|
+
stats.sysPromptCharsSaved += sysResult.charsSaved;
|
|
1295
|
+
stats.sysPromptCompressed++;
|
|
1296
|
+
stats.tokensStripped += Math.floor(sysResult.charsSaved / 4);
|
|
1297
|
+
stats.bytesStripped += sysResult.charsSaved;
|
|
1298
|
+
log('compress', `SYSPROMPT: ${sysResult.charsSaved} chars saved`);
|
|
1299
|
+
pushEvent('compress', `System prompt: -${sysResult.charsSaved} chars`);
|
|
1300
|
+
}
|
|
1301
|
+
} catch (e) {
|
|
1302
|
+
log('warn', `System prompt compression failed: ${e.message}`);
|
|
1269
1303
|
}
|
|
1270
|
-
} catch (e) {
|
|
1271
|
-
log('warn', `System prompt compression failed: ${e.message}`);
|
|
1272
1304
|
}
|
|
1273
1305
|
}
|
|
1274
1306
|
|
|
1275
|
-
if (
|
|
1307
|
+
if (isCompaction) {
|
|
1276
1308
|
// === COMPACTION DETECTED — strip tool bodies ===
|
|
1277
1309
|
stats.compactionRequests++;
|
|
1278
1310
|
stats.lastCompaction = new Date().toISOString();
|
|
@@ -1284,7 +1316,7 @@ async function handleRequest(req, res) {
|
|
|
1284
1316
|
const { strippedMessages, strippingStats } = stripMessages(body.messages);
|
|
1285
1317
|
body.messages = strippedMessages;
|
|
1286
1318
|
|
|
1287
|
-
//
|
|
1319
|
+
// Run steno+MT compression in parallel (independent of strip)
|
|
1288
1320
|
if (!dontCompress) {
|
|
1289
1321
|
const { messages: compressed, blocksCompressed, charsCompressed, verifiedCount = 0, stenoOnlyCount = 0, tmHits: hits = 0, samples: compSamples = [] } = await compressMessagesLive(body.messages);
|
|
1290
1322
|
body.messages = compressed;
|
|
@@ -1294,7 +1326,6 @@ async function handleRequest(req, res) {
|
|
|
1294
1326
|
stats.zhRejected += stenoOnlyCount;
|
|
1295
1327
|
stats.stenoOnly += (blocksCompressed - verifiedCount - stenoOnlyCount);
|
|
1296
1328
|
stats.tmHits += hits;
|
|
1297
|
-
// Store translation samples for preview
|
|
1298
1329
|
if (compSamples.length > 0) stats._lastSamples = compSamples;
|
|
1299
1330
|
if (blocksCompressed > 0) {
|
|
1300
1331
|
pushEvent('compress', `${blocksCompressed} blocks, ${charsCompressed} chars (${verifiedCount} zh, ${stenoOnlyCount} steno, ${hits} TM)`);
|
|
@@ -1318,11 +1349,15 @@ async function handleRequest(req, res) {
|
|
|
1318
1349
|
return;
|
|
1319
1350
|
}
|
|
1320
1351
|
|
|
1321
|
-
// === NON-COMPACTION —
|
|
1322
|
-
if (
|
|
1352
|
+
// === NON-COMPACTION — passthrough if below threshold ===
|
|
1353
|
+
if (isPassthrough) {
|
|
1323
1354
|
stats.passthrough++;
|
|
1324
1355
|
pushEvent('pass', `msgs=${messageCount} (below threshold ${liveConfig.PRESERVE_RECENT_MESSAGES})`);
|
|
1325
|
-
|
|
1356
|
+
// Use modified body if sys prompt was compressed (cache hit), else rawBody
|
|
1357
|
+
const passthroughBody = sysPromptModified
|
|
1358
|
+
? Buffer.from(JSON.stringify(body), 'utf8')
|
|
1359
|
+
: rawBody;
|
|
1360
|
+
forwardRequest(req, res, passthroughBody);
|
|
1326
1361
|
return;
|
|
1327
1362
|
}
|
|
1328
1363
|
|