specmem-hardwicksoftware 3.7.30 → 3.7.32

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +12 -0
  3. package/bootstrap.cjs +19 -0
  4. package/claude-hooks/bash-call-enforcer.cjs +140 -0
  5. package/claude-hooks/settings.json +132 -0
  6. package/claude-hooks/specmem-drilldown-hook.cjs +49 -2
  7. package/claude-hooks/specmem-drilldown-hook.js +49 -2
  8. package/claude-hooks/specmem-drilldown-hook.js.bak +495 -0
  9. package/claude-hooks/specmem-precompact.cjs +13 -36
  10. package/claude-hooks/specmem-precompact.js +3 -7
  11. package/claude-hooks/specmem-search-enforcer.cjs +229 -0
  12. package/claude-hooks/specmem-search-tracker.cjs +71 -0
  13. package/claude-hooks/specmem-session-start.cjs +38 -50
  14. package/claude-hooks/specmem-session-start.js +19 -60
  15. package/dist/config.js +11 -16
  16. package/dist/db/connectionPoolGoBrrr.js +3 -3
  17. package/dist/index.js +21 -4
  18. package/dist/mcp/compactionProxy.js +21 -1
  19. package/dist/mcp/embeddingServerManager.js +15 -1
  20. package/dist/mcp/mcpProtocolHandler.js +22 -4
  21. package/dist/mcp/specMemServer.js +16 -3
  22. package/dist/mcp/toolRegistry.js +19 -21
  23. package/dist/tools/goofy/checkSyncStatus.js +14 -7
  24. package/dist/watcher/fileWatcher.js +57 -20
  25. package/dist/watcher/index.js +26 -0
  26. package/dist/watcher/syncChecker.js +11 -7
  27. package/package.json +1 -1
  28. package/scripts/global-postinstall.cjs +7 -2
  29. package/scripts/specmem-init.cjs +5 -0
  30. package/specmem/model-config.json +26 -6
  31. package/specmem/supervisord.conf +1 -1
  32. package/specmem/user-config.json +12 -0
  33. package/svg-sections/readme-install.svg +35 -29
  34. package/svg-sections/readme-whats-new.svg +120 -114
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SPECMEM DRILLDOWN HOOK
4
+ * ======================
5
+ *
6
+ * Advanced hook that:
7
+ * 1. Searches SpecMem for context on every prompt
8
+ * 2. Injects related memories
9
+ * 3. Can trigger interactive drilldown if needed
10
+ *
11
+ * This runs as a UserPromptSubmit hook.
12
+ */
13
+
14
+ const { spawn } = require('child_process');
15
+ const net = require('net');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ // Import shared path resolution utilities AND Pool
20
+ const {
21
+ expandCwd,
22
+ getSpecmemPkg,
23
+ getSpecmemHome,
24
+ getProjectSocketDir,
25
+ getEmbeddingSocket,
26
+ getPool,
27
+ getSchemaName
28
+ } = require('./specmem-paths.cjs');
29
+
30
+ const Pool = getPool();
31
+
32
+ // Token compressor for output
33
+ let compressHookOutput;
34
+ try {
35
+ compressHookOutput = require('./token-compressor.cjs').compressHookOutput;
36
+ } catch (e) {
37
+ // Fallback if compressor not available
38
+ compressHookOutput = (text) => text;
39
+ }
40
+
41
+ // Context deduplication to prevent double injection
42
+ let contextDedup;
43
+ try {
44
+ contextDedup = require('./context-dedup.cjs');
45
+ } catch (e) {
46
+ // Fallback if dedup not available
47
+ contextDedup = {
48
+ shouldSkipInjection: () => false,
49
+ markInjected: () => {}
50
+ };
51
+ }
52
+
53
+ // Use shared module for path resolution
54
+ const SPECMEM_HOME = getSpecmemHome();
55
+ const SPECMEM_PKG = getSpecmemPkg();
56
+ const SPECMEM_RUN_DIR = expandCwd(process.env.SPECMEM_RUN_DIR) || getProjectSocketDir();
57
+
58
+ // Project path will be set from 's hook input (cwd field)
59
+ // Fallback: 1. SPECMEM_PROJECT_PATH env var, 2. process.cwd()
60
+ let PROJECT_PATH = expandCwd(process.env.SPECMEM_PROJECT_PATH) || process.cwd() || '/';
61
+
62
+ // Configuration
63
+ const CONFIG = {
64
+ // SpecMem settings
65
+ searchLimit: parseInt(process.env.SPECMEM_SEARCH_LIMIT || '5'),
66
+ // ACCURACY FIX: Raised threshold from 0.3 to 0.4 to reduce false positives
67
+ // Local embeddings score lower, but 0.4 filters out noise while keeping relevant results
68
+ threshold: parseFloat(process.env.SPECMEM_THRESHOLD || '0.4'),
69
+ // ACCURACY FIX: Increased content length from 300 to 500 for better context
70
+ maxContentLength: parseInt(process.env.SPECMEM_MAX_CONTENT || '500'),
71
+ enabled: process.env.SPECMEM_ENABLED !== 'false',
72
+ // Project filtering - can be disabled with SPECMEM_PROJECT_FILTER=false
73
+ projectFilterEnabled: process.env.SPECMEM_PROJECT_FILTER !== 'false',
74
+
75
+ // Database connection (for direct queries)
76
+ // Note: expandCwd applied for consistency, though DB params typically don't contain ${cwd}
77
+ dbHost: expandCwd(process.env.SPECMEM_DB_HOST) || 'localhost',
78
+ dbPort: parseInt(expandCwd(process.env.SPECMEM_DB_PORT) || '5432'),
79
+ dbName: expandCwd(process.env.SPECMEM_DB_NAME) || 'specmem_westayunprofessional',
80
+ dbUser: expandCwd(process.env.SPECMEM_DB_USER) || 'specmem_westayunprofessional',
81
+ dbPassword: 'SPECMEM_DB_PASSWORD' in process.env ? process.env.SPECMEM_DB_PASSWORD : 'specmem_westayunprofessional',
82
+
83
+ // Embedding socket
84
+ embeddingSocket: expandCwd(process.env.SPECMEM_EMBEDDING_SOCKET) || path.join(SPECMEM_RUN_DIR, 'embeddings.sock')
85
+ };
86
+
87
+ /**
88
+ * Check if embedding socket exists and is a socket file
89
+ * OPTIMIZED: Just fs.existsSync + statSync.isSocket() for hook performance
90
+ * The full health check (nc connection) is too slow for hooks (~50-100ms)
91
+ * If server is dead but socket exists, embedding calls will fail and trigger restart
92
+ */
93
+ function isSocketHealthy(socketPath) {
94
+ const fs = require('fs');
95
+ try {
96
+ if (!fs.existsSync(socketPath)) {
97
+ return false;
98
+ }
99
+ const stat = fs.statSync(socketPath);
100
+ return stat.isSocket();
101
+ } catch (e) {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Start embedding service on-demand if not running or unhealthy
108
+ * NON-BLOCKING: Spawns detached process and returns immediately
109
+ */
110
+ function ensureEmbeddingServiceRunning() {
111
+ // Check if socket is healthy (not just exists) - fast path
112
+ if (isSocketHealthy(CONFIG.embeddingSocket)) {
113
+ return true;
114
+ }
115
+
116
+ // Try to start on-demand using warm-start.sh - NON-BLOCKING
117
+ const starterScript = path.join(SPECMEM_PKG, 'embedding-sandbox', 'warm-start.sh');
118
+ const fs = require('fs');
119
+
120
+ if (fs.existsSync(starterScript)) {
121
+ try {
122
+ // Spawn detached process - don't wait for it
123
+ const child = spawn('bash', [starterScript], {
124
+ detached: true,
125
+ stdio: 'ignore',
126
+ env: {
127
+ ...process.env,
128
+ SPECMEM_PROJECT_PATH: PROJECT_PATH
129
+ }
130
+ });
131
+ // Unref so parent can exit without waiting
132
+ child.unref();
133
+ // Return false - embedding service is starting but not ready yet
134
+ // The calling code will timeout/fail gracefully and retry next prompt
135
+ return false;
136
+ } catch (e) {
137
+ // Silent fail - embedding service startup is non-critical
138
+ }
139
+ }
140
+
141
+ return false;
142
+ }
143
+
144
+ /**
145
+ * Generate embedding via sandboxed container
146
+ */
147
+ async function generateEmbedding(text) {
148
+ // Don't try to start services from a hook — just fail gracefully
149
+ if (!isSocketHealthy(CONFIG.embeddingSocket)) {
150
+ throw new Error('Embedding socket not available');
151
+ }
152
+
153
+ // Truncate — embeddings only need ~512 chars max
154
+ const truncated = text.length > 512 ? text.slice(0, 512) : text;
155
+
156
+ return new Promise((resolve, reject) => {
157
+ const socket = new net.Socket();
158
+ let buffer = '';
159
+
160
+ socket.setTimeout(10000); // 10s timeout
161
+
162
+ socket.connect(CONFIG.embeddingSocket, () => {
163
+ socket.write(JSON.stringify({ type: 'embed', text: truncated }) + '\n');
164
+ });
165
+
166
+ socket.on('data', (data) => {
167
+ buffer += data.toString();
168
+ // Server sends multiple lines: first status, then embedding
169
+ const lines = buffer.split('\n').filter(l => l.trim());
170
+ for (const line of lines) {
171
+ try {
172
+ const response = JSON.parse(line);
173
+ if (response.embedding) {
174
+ socket.end();
175
+ resolve(response.embedding);
176
+ return;
177
+ } else if (response.error) {
178
+ socket.end();
179
+ reject(new Error(response.error));
180
+ return;
181
+ }
182
+ } catch (e) { /* partial JSON, keep buffering */ }
183
+ }
184
+ });
185
+
186
+ socket.on('error', reject);
187
+ socket.on('timeout', () => reject(new Error('Embedding timeout')));
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Get project schema name for isolation (matches session-start.cjs)
193
+ */
194
+ function getProjectSchema(projectPath) {
195
+ const basename = require('path').basename(projectPath || process.cwd()).toLowerCase().replace(/[^a-z0-9]/g, '');
196
+ return `specmem_${basename}`;
197
+ }
198
+
199
+
200
+ // Lazy-initialized pg Pool (like smart-context-hook.js)
201
+ let dbPool = null;
202
+ function createDbPool() {
203
+ if (!dbPool) {
204
+ dbPool = new Pool({
205
+ host: CONFIG.dbHost,
206
+ port: CONFIG.dbPort,
207
+ database: CONFIG.dbName,
208
+ user: CONFIG.dbUser,
209
+ password: CONFIG.dbPassword,
210
+ max: 3,
211
+ idleTimeoutMillis: 5000,
212
+ connectionTimeoutMillis: 5000
213
+ });
214
+ }
215
+ return dbPool;
216
+ }
217
+
218
+ /**
219
+ * Search SpecMem directly via PostgreSQL with schema isolation
220
+ * Uses pg Pool for reliable parsing (no pipe delimiter issues)
221
+ */
222
+ async function searchSpecMem(query) {
223
+ try {
224
+ // Generate embedding for query
225
+ const embedding = await generateEmbedding(query);
226
+ const embeddingStr = `[${embedding.join(',')}]`;
227
+
228
+ const pool = createDbPool();
229
+
230
+ // CRITICAL: Set search_path BEFORE any queries for project schema isolation
231
+ const schemaName = getProjectSchema(PROJECT_PATH);
232
+ await pool.query('SET search_path TO ' + schemaName + ', public');
233
+
234
+ // Build query with parameterized values
235
+ const params = [embeddingStr, CONFIG.threshold, CONFIG.searchLimit, PROJECT_PATH];
236
+
237
+ // ACCURACY FIX: Added importance weighting to ORDER BY
238
+ // High importance memories rank higher even with slightly lower similarity
239
+ // importance_rank: critical=5, high=4, medium=3, low=2, trivial=1
240
+ const sql = `
241
+ SELECT
242
+ id::text,
243
+ LEFT(content, ${CONFIG.maxContentLength}) as content,
244
+ importance,
245
+ tags,
246
+ metadata->>'role' as role,
247
+ ROUND((1 - (embedding <=> $1::vector))::numeric, 3) as similarity,
248
+ CASE importance
249
+ WHEN 'critical' THEN 5
250
+ WHEN 'high' THEN 4
251
+ WHEN 'medium' THEN 3
252
+ WHEN 'low' THEN 2
253
+ WHEN 'trivial' THEN 1
254
+ ELSE 3
255
+ END as importance_rank
256
+ FROM memories
257
+ WHERE 1 - (embedding <=> $1::vector) > $2
258
+ AND project_path = $4
259
+ AND content IS NOT NULL
260
+ AND length(content) > 10
261
+ AND embedding IS NOT NULL
262
+ ORDER BY importance_rank DESC, similarity DESC
263
+ LIMIT $3
264
+ `;
265
+
266
+ const result = await pool.query(sql, params);
267
+
268
+ // Filter and deduplicate - proper typed access, no parsing issues
269
+ const memories = result.rows.filter(row => {
270
+ if (!row.content) return false;
271
+ if (row.content === 'undefined' || row.content === 'null') return false;
272
+ if (row.content.trim().length < 5) return false;
273
+ // Filter out 0 similarity results
274
+ if (row.similarity <= 0) return false;
275
+ return true;
276
+ }).map(row => ({
277
+ id: row.id,
278
+ content: row.content,
279
+ importance: row.importance,
280
+ tags: row.tags || [],
281
+ role: row.role,
282
+ similarity: parseFloat(row.similarity) || 0
283
+ }));
284
+
285
+ // Deduplicate by content (first 100 chars)
286
+ const seen = new Set();
287
+ return memories.filter(m => {
288
+ const key = (m.content || '').trim().slice(0, 100);
289
+ if (seen.has(key)) return false;
290
+ seen.add(key);
291
+ return true;
292
+ });
293
+
294
+ } catch (error) {
295
+ return [];
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Format memories for injection - FLATTENED single-line format
301
+ * Matches grep hook format: [SM-FIND] ... | ... | [/SM-FIND] drill_down(N)
302
+ * Sorted by similarity (highest first) with drilldown IDs
303
+ * Uses pipe separators instead of newlines to avoid breaking 's formatting
304
+ */
305
+ function formatMemories(memories) {
306
+ if (!memories.length) return '';
307
+
308
+ // Sort by similarity descending (ensure proper ordering)
309
+ const sorted = [...memories].sort((a, b) => {
310
+ const simA = typeof a.similarity === 'number' ? a.similarity : 0;
311
+ const simB = typeof b.similarity === 'number' ? b.similarity : 0;
312
+ return simB - simA;
313
+ });
314
+
315
+ // FLATTENED format: single line with pipe separators
316
+ // Format like Read tool output with clear role labels
317
+ const parts = [];
318
+ parts.push('[SM-找] ' + sorted.length + ' 回憶:');
319
+
320
+ sorted.forEach((mem, i) => {
321
+ // Handle similarity: 0 is valid, only undefined/NaN should show ?
322
+ const simValue = typeof mem.similarity === 'number' && !isNaN(mem.similarity) ? mem.similarity : null;
323
+ const sim = simValue !== null ? (simValue * 100).toFixed(1) + '%' : '?';
324
+ const tags = mem.tags && mem.tags.length ? '[' + mem.tags.filter(t => t).slice(0, 2).join(',') + ']' : '';
325
+
326
+ // FIXED: Extract user and claude parts separately for 70 chars each
327
+ // Parse [USER] and [CLAUDE] tags from content BEFORE truncation
328
+ const rawContent = (mem.content || '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
329
+
330
+ // Try to find [USER] and [CLAUDE] parts
331
+ const userMatch = rawContent.match(/\[USER\]\s*([^\[]*)/i);
332
+ const claudeMatch = rawContent.match(/\[CLAUDE\]\s*([^\[]*)/i);
333
+
334
+ let formattedContent;
335
+ if (userMatch || claudeMatch) {
336
+ // Found structured content - show 70 chars each
337
+ const userPart = userMatch ? userMatch[1].trim().slice(0, 70) : '';
338
+ const claudePart = claudeMatch ? claudeMatch[1].trim().slice(0, 70) : '';
339
+ formattedContent = '';
340
+ if (userPart) formattedContent += '[戶②] ' + userPart + '...';
341
+ if (claudePart) formattedContent += (userPart ? ' ' : '') + '[克勞德] ' + claudePart + '...';
342
+ } else {
343
+ // No structure - use role tag and show 200 chars total (increased from 140 for accuracy)
344
+ const roleTag = mem.role === 'user' ? '[戶②]' :
345
+ mem.role === 'assistant' ? '[克勞德]' :
346
+ '[系統]';
347
+ formattedContent = roleTag + ' ' + rawContent.slice(0, 200) + '...';
348
+ }
349
+
350
+ // ACCURACY FIX: Include actual memory UUID instead of fake drilldown IDs
351
+ // The hook can't register IDs with MCP server's in-memory registry
352
+ // So we show the UUID for use with get_memory({ id: "uuid" })
353
+ const memId = mem.id ? mem.id.substring(0, 8) : '?';
354
+
355
+ // Format: N.[sim%] content [tags] (id:short_uuid)
356
+ parts.push((i + 1) + '.[' + sim + '] ' + formattedContent + ' ' + tags + ' (id:' + memId + ')');
357
+ });
358
+
359
+ // ACCURACY FIX: Updated instruction - getMemoryFull uses full UUID
360
+ // User can copy the short ID prefix and find_memory can locate it
361
+ parts.push('[/SM-找] 查看完整: find_memory({query:"id:短碼"}) 或 getMemoryFull({id:"完整UUID"})');
362
+
363
+ // Join with pipe separator instead of newlines
364
+ return parts.join(' | ');
365
+ }
366
+
367
+ /**
368
+ * Read stdin with timeout to prevent indefinite hangs
369
+ * CRIT-07 FIX: All hooks must use this instead of raw for-await
370
+ */
371
+ function readStdinWithTimeout(timeoutMs = 5000) {
372
+ return new Promise((resolve) => {
373
+ let input = '';
374
+ const timer = setTimeout(() => {
375
+ process.stdin.destroy();
376
+ resolve(input);
377
+ }, timeoutMs);
378
+
379
+ process.stdin.setEncoding('utf8');
380
+ process.stdin.on('data', (chunk) => { input += chunk; });
381
+ process.stdin.on('end', () => {
382
+ clearTimeout(timer);
383
+ resolve(input);
384
+ });
385
+ process.stdin.on('error', () => {
386
+ clearTimeout(timer);
387
+ resolve(input);
388
+ });
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Main hook handler
394
+ */
395
+ async function main() {
396
+ // CRIT-07 FIX: Read input with timeout instead of indefinite for-await
397
+ let input = await readStdinWithTimeout(5000);
398
+
399
+ // Parse input - passes { sessionId, prompt, cwd, ... }
400
+ let prompt = '';
401
+ let sessionId = 'unknown';
402
+ let eventName = '';
403
+ try {
404
+ const data = JSON.parse(input);
405
+ prompt = data.prompt || data.message || data.content || '';
406
+ sessionId = data.sessionId || data.session_id || 'unknown';
407
+ eventName = data.hookEventName || '';
408
+
409
+ // SKIP Stop events - don't process agent transcripts as search queries
410
+ // This prevents giant outputs when agents complete
411
+ if (eventName === 'Stop' || eventName === 'SubagentStop') {
412
+ process.exit(0);
413
+ }
414
+
415
+ // Use cwd from 's input for project filtering (critical for multi-project support!)
416
+ // CRITICAL: Also update socket path dynamically - it was set at module load time with wrong cwd!
417
+ if (data.cwd) {
418
+ PROJECT_PATH = data.cwd;
419
+ // Recalculate socket path based on actual project using shared module
420
+ CONFIG.embeddingSocket = getEmbeddingSocket(data.cwd);
421
+ }
422
+ } catch (parseErr) {
423
+ prompt = input.trim();
424
+ }
425
+
426
+ // Session-scoped deduplication - prevent double injection of same context
427
+ if (contextDedup.shouldSkipInjection(PROJECT_PATH, sessionId, prompt)) {
428
+ process.exit(0);
429
+ }
430
+
431
+ // Skip if disabled or prompt too short
432
+ if (!CONFIG.enabled || !prompt || prompt.length < 10) {
433
+ process.exit(0);
434
+ }
435
+
436
+ // Skip slash commands
437
+ if (prompt.startsWith('/') || prompt.startsWith('!')) {
438
+ process.exit(0);
439
+ }
440
+
441
+ // Debounce: skip if ran within last 5 seconds
442
+ const DRILLDOWN_DEBOUNCE_FILE = path.join(SPECMEM_RUN_DIR, '.drilldown-debounce');
443
+ try {
444
+ const fs = require('fs');
445
+ if (fs.existsSync(DRILLDOWN_DEBOUNCE_FILE)) {
446
+ const last = parseInt(fs.readFileSync(DRILLDOWN_DEBOUNCE_FILE, 'utf8').trim(), 10);
447
+ if (Date.now() - last < 5000) process.exit(0);
448
+ }
449
+ fs.writeFileSync(DRILLDOWN_DEBOUNCE_FILE, String(Date.now()));
450
+ } catch (e) {}
451
+
452
+ // Skip task notifications (background agent completions treated as prompts)
453
+ if (prompt.includes('<task-notification>') || prompt.includes('</task-notification>')) {
454
+ process.exit(0);
455
+ }
456
+
457
+ try {
458
+ // Search SpecMem
459
+ const memories = await searchSpecMem(prompt);
460
+
461
+ // Output context if found - compressed for token efficiency
462
+ // Use flattenOutput to avoid newlines breaking 's formatting
463
+ if (memories.length > 0) {
464
+ const formatted = formatMemories(memories);
465
+ // Compress with flattenOutput to avoid newlines
466
+ const compressed = compressHookOutput(formatted, {
467
+ threshold: 0.50, // Less aggressive compression
468
+ minLength: 100, // Don't compress short sections
469
+ flattenOutput: true // FLATTENED: Join with pipe instead of newlines
470
+ });
471
+ // Prepend reminder for to read the compressed Chinese
472
+ console.log(`⚠️壓縮:繁中→EN │ ${compressed}`);
473
+
474
+ // Mark as injected to prevent duplicate injection this session
475
+ contextDedup.markInjected(PROJECT_PATH, sessionId, prompt);
476
+ }
477
+ } catch (error) {
478
+ // Silently fail - don't break the prompt
479
+ }
480
+
481
+ // Close pool before exit
482
+ if (dbPool) {
483
+ await dbPool.end().catch(() => {});
484
+ }
485
+
486
+ process.exit(0);
487
+ }
488
+
489
+ main().catch(async () => {
490
+ // Close pool on error too
491
+ if (dbPool) {
492
+ await dbPool.end().catch(() => {});
493
+ }
494
+ process.exit(0);
495
+ });
@@ -31,9 +31,6 @@ try {
31
31
  contextDedup = { clearCache: () => {} };
32
32
  }
33
33
 
34
-
35
-
36
-
37
34
  // Config - all paths are project-relative by default
38
35
  const SPECMEM_HOME = specmemPaths.getSpecmemHome();
39
36
  const SPECMEM_PKG = specmemPaths.getSpecmemPkg();
@@ -121,53 +118,33 @@ async function main() {
121
118
  // Use defaults
122
119
  }
123
120
 
124
- // COMPACTION = SESSION BOUNDARY - clear context injection cache
125
- // This ensures pre-tool-use hooks can inject fresh context after compaction
126
- contextDedup.clearCache(projectPath);
121
+ // Dedup cache intentionally NOT cleared at compaction — prevents re-injection spike post-compaction.
122
+ // Cache expires naturally after 2 hours via TTL in context-dedup.cjs.
123
+ // contextDedup.clearCache(projectPath); // DISABLED
127
124
 
128
125
  // Calculate percentage remaining
129
126
  const percentRemaining = compactionTarget > 0
130
127
  ? Math.round((1 - tokenCount / compactionTarget) * 100)
131
128
  : 100;
132
129
 
133
- // Build output
134
- let output = '';
135
-
136
- // Warning at 5% or less
137
- if (percentRemaining <= 5) {
138
- output += `\n⚠️ COMPACTION IMMINENT (${percentRemaining}% context remaining)\n`;
139
- output += `Messages: ${messageCount} | Tokens: ${tokenCount}/${compactionTarget}\n`;
140
- output += `Critical context will be saved to SpecMem.\n\n`;
141
-
142
- // Save compaction event
143
- const summary = `Pre-compaction state: ${messageCount} messages, ${tokenCount} tokens, project: ${projectPath}`;
144
- saveCriticalMemory(summary, ['compaction', 'system', path.basename(projectPath)]);
145
- } else if (percentRemaining <= 15) {
146
- output += `\n📊 Context usage: ${100 - percentRemaining}% (${tokenCount}/${compactionTarget} tokens)\n`;
147
- output += `Consider using /specmem-remember to save important context.\n\n`;
148
- }
130
+ // Save compaction event to DB (silent — don't output to context)
131
+ const summary = `Pre-compaction: ${messageCount} msgs, ${tokenCount}/${compactionTarget} tokens, ${percentRemaining}% remaining, project: ${projectPath}`;
132
+ saveCriticalMemory(summary, ['compaction', 'system', path.basename(projectPath)]);
149
133
 
150
- // If we have actual content to preserve, save it
151
- // The hook receives conversation summary in some cases
134
+ // Save truncated conversation context if available
152
135
  if (input.length > 500) {
153
- // Save truncated version of current context
154
- const contextSummary = input.slice(0, 2000);
155
136
  saveCriticalMemory(
156
- `Context before compaction:\n${contextSummary}`,
137
+ `Context before compaction:\n${input.slice(0, 2000)}`,
157
138
  ['precompact', 'context', path.basename(projectPath)]
158
139
  );
159
140
  }
160
141
 
161
- // Output warning/status with compression
162
- if (output) {
163
- // Compress but skip warning header since this IS a warning
164
- const compressed = compressHookOutput(output, {
165
- threshold: 0.70,
166
- minLength: 30,
167
- includeWarning: true // Still remind Claude to respond in English
168
- });
169
- console.log(compressed);
142
+ // MINIMAL output don't bloat the context that's about to be compacted
143
+ // Only output a tiny marker so compaction summary knows SpecMem saved context
144
+ if (percentRemaining <= 5) {
145
+ console.log('[SM] Context saved.');
170
146
  }
147
+ // Otherwise: complete silence — adding text here makes compaction SLOWER
171
148
 
172
149
  process.exit(0);
173
150
  }
@@ -30,10 +30,6 @@ try {
30
30
  } catch (e) {
31
31
  contextDedup = { clearCache: () => {} };
32
32
  }
33
-
34
-
35
-
36
-
37
33
  // Config - all paths are project-relative by default
38
34
  const SPECMEM_HOME = specmemPaths.getSpecmemHome();
39
35
  const SPECMEM_PKG = specmemPaths.getSpecmemPkg();
@@ -125,9 +121,9 @@ async function main() {
125
121
  // Use defaults
126
122
  }
127
123
 
128
- // COMPACTION = SESSION BOUNDARY - clear context injection cache
129
- // This ensures pre-tool-use hooks can inject fresh context after compaction
130
- contextDedup.clearCache(projectPath);
124
+ // Dedup cache intentionally NOT cleared at compaction — prevents re-injection spike post-compaction.
125
+ // Cache expires naturally after 2 hours via TTL in context-dedup.cjs.
126
+ // contextDedup.clearCache(projectPath); // DISABLED
131
127
 
132
128
  // Calculate percentage remaining
133
129
  const percentRemaining = compactionTarget > 0