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 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: use unix socket dir as host for postgres connection
841
- // When SPECMEM_CONTAINER_MODE is set, or container run dir has postgres socket
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
- const containerSocketExists = (() => {
846
- try {
847
- if (fs.existsSync(path.join(containerRunDir, '.s.PGSQL.5432'))) return true;
848
- // Fallback: projectPath might BE the specmem dir
849
- const altRunDir = path.join(projectPath, 'run');
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: 20, // safe default - each project creates its own pool, 100 would exhaust PG max_connections
21
- minConnections: 5, // keep some warm connections fr
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: 30000, // 30 sec to establish connection
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 manages embedding server do NOT interfere.
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
- logger.debug('container mode active — brain manages embedding server, skipping self-heal restart');
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
- try {
1260
- const sysResult = await compressSystemPrompt(body.system);
1261
- if (sysResult.charsSaved > 0) {
1262
- body.system = sysResult.system;
1263
- stats.sysPromptCharsSaved += sysResult.charsSaved;
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(sysResult.charsSaved / 4);
1266
- stats.bytesStripped += sysResult.charsSaved;
1267
- log('compress', `SYSPROMPT: ${sysResult.charsSaved} chars saved`);
1268
- pushEvent('compress', `System prompt: -${sysResult.charsSaved} chars`);
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 (isCompactionRequest(body)) {
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
- // Also apply steno+MT compression on compaction requests
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 — strip old tool_results + live MT compression ===
1322
- if (dontCompress || messageCount <= liveConfig.PRESERVE_RECENT_MESSAGES) {
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
- forwardRequest(req, res, rawBody);
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