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.
- package/CHANGELOG.md +22 -0
- package/README.md +12 -0
- package/bootstrap.cjs +19 -0
- package/claude-hooks/bash-call-enforcer.cjs +140 -0
- package/claude-hooks/settings.json +132 -0
- package/claude-hooks/specmem-drilldown-hook.cjs +49 -2
- package/claude-hooks/specmem-drilldown-hook.js +49 -2
- package/claude-hooks/specmem-drilldown-hook.js.bak +495 -0
- package/claude-hooks/specmem-precompact.cjs +13 -36
- package/claude-hooks/specmem-precompact.js +3 -7
- package/claude-hooks/specmem-search-enforcer.cjs +229 -0
- package/claude-hooks/specmem-search-tracker.cjs +71 -0
- package/claude-hooks/specmem-session-start.cjs +38 -50
- package/claude-hooks/specmem-session-start.js +19 -60
- package/dist/config.js +11 -16
- package/dist/db/connectionPoolGoBrrr.js +3 -3
- package/dist/index.js +21 -4
- package/dist/mcp/compactionProxy.js +21 -1
- 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/index.js +26 -0
- package/dist/watcher/syncChecker.js +11 -7
- package/package.json +1 -1
- package/scripts/global-postinstall.cjs +7 -2
- package/scripts/specmem-init.cjs +5 -0
- package/specmem/model-config.json +26 -6
- package/specmem/supervisord.conf +1 -1
- package/specmem/user-config.json +12 -0
- package/svg-sections/readme-install.svg +35 -29
- 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
|
-
//
|
|
125
|
-
//
|
|
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
|
-
//
|
|
134
|
-
|
|
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
|
-
//
|
|
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${
|
|
137
|
+
`Context before compaction:\n${input.slice(0, 2000)}`,
|
|
157
138
|
['precompact', 'context', path.basename(projectPath)]
|
|
158
139
|
);
|
|
159
140
|
}
|
|
160
141
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
//
|
|
129
|
-
//
|
|
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
|