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
|
@@ -58,7 +58,7 @@ const TOOL_USE_INPUT_PREVIEW_CHARS = 100;
|
|
|
58
58
|
|
|
59
59
|
// Old-message stripping config — applied to ALL requests (not just compaction)
|
|
60
60
|
// Only strips tool_results larger than this threshold in messages outside the recent window
|
|
61
|
-
const OLD_STRIP_THRESHOLD =
|
|
61
|
+
const OLD_STRIP_THRESHOLD = 100; // chars — only strip results bigger than this
|
|
62
62
|
const OLD_STRIP_PREVIEW_CHARS = 200; // chars — keep this much of the original
|
|
63
63
|
|
|
64
64
|
// Live neural MT compression config
|
|
@@ -533,6 +533,23 @@ function stripOldToolResults(messages) {
|
|
|
533
533
|
return stripped;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
// Strip specmem hook injection text blocks from old messages
|
|
537
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
538
|
+
const txt = block.text;
|
|
539
|
+
const isHookOutput = txt.includes('[SM-找]') || txt.includes('[SM-碼]') ||
|
|
540
|
+
txt.includes('[SM-檔改]') || txt.includes('[SPECMEM-SESSION]') ||
|
|
541
|
+
txt.includes('⚠️壓縮:繁中') || txt.includes('[SM-搜]');
|
|
542
|
+
if (isHookOutput && txt.length > liveConfig.OLD_STRIP_THRESHOLD) {
|
|
543
|
+
const removed = txt.length - OLD_STRIP_PREVIEW_CHARS;
|
|
544
|
+
charsRemoved += removed;
|
|
545
|
+
toolResultsStripped++;
|
|
546
|
+
return {
|
|
547
|
+
...block,
|
|
548
|
+
text: txt.slice(0, OLD_STRIP_PREVIEW_CHARS) + `...\n[HOOK-TRIMMED: ${txt.length} chars]`
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
536
553
|
return block;
|
|
537
554
|
});
|
|
538
555
|
|
|
@@ -789,6 +806,9 @@ async function compressMessagesLive(messages) {
|
|
|
789
806
|
|
|
790
807
|
// Standalone text blocks (user/assistant conversational text) → steno only
|
|
791
808
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
809
|
+
// Skip specmem hook injection text — already partially compressed Chinese, steno would garble
|
|
810
|
+
const _htxt = block.text;
|
|
811
|
+
if (_htxt.includes('[SM-') || _htxt.includes('[SPECMEM-SESSION]') || _htxt.includes('⚠️壓縮:繁中')) continue;
|
|
792
812
|
if (looksLikeNaturalLanguage(block.text)) {
|
|
793
813
|
originals.push(block.text);
|
|
794
814
|
textLocations.push({ msgIdx: i, blockIdx: j, mtEligible: false });
|
|
@@ -2313,8 +2313,22 @@ export class EmbeddingServerManager extends EventEmitter {
|
|
|
2313
2313
|
logger.warn({
|
|
2314
2314
|
failures: this.config.maxFailuresBeforeRestart,
|
|
2315
2315
|
restartCount: this.restartCount,
|
|
2316
|
-
}, '[EmbeddingServerManager] Too many consecutive failures in container mode -
|
|
2316
|
+
}, '[EmbeddingServerManager] Too many consecutive failures in container mode - attempting container restart');
|
|
2317
2317
|
this.emit('unhealthy_container', { failures: this.config.maxFailuresBeforeRestart });
|
|
2318
|
+
// FIX: Auto-restart brain container when embedding is dead
|
|
2319
|
+
try {
|
|
2320
|
+
const { getContainerManager } = require('../container/containerManager.js');
|
|
2321
|
+
const projectPath = process.env['SPECMEM_PROJECT_PATH'] || process.cwd();
|
|
2322
|
+
const cm = getContainerManager(projectPath);
|
|
2323
|
+
logger.info({ projectPath }, '[EmbeddingServerManager] Restarting brain container...');
|
|
2324
|
+
await cm.start();
|
|
2325
|
+
logger.info('[EmbeddingServerManager] Brain container restarted successfully');
|
|
2326
|
+
this.isRunning = true;
|
|
2327
|
+
this.startTime = Date.now();
|
|
2328
|
+
} catch (containerErr) {
|
|
2329
|
+
logger.error({ error: containerErr?.message || containerErr },
|
|
2330
|
+
'[EmbeddingServerManager] Failed to restart brain container');
|
|
2331
|
+
}
|
|
2318
2332
|
}
|
|
2319
2333
|
else {
|
|
2320
2334
|
logger.warn({
|
|
@@ -424,12 +424,30 @@ export class MCPProtocolHandler {
|
|
|
424
424
|
}
|
|
425
425
|
}
|
|
426
426
|
/**
|
|
427
|
-
* batch handle multiple tool calls -
|
|
427
|
+
* batch handle multiple tool calls - with concurrency limit
|
|
428
|
+
* prevents overwhelming the db pool when claude fires 5+ calls at once
|
|
428
429
|
*/
|
|
429
430
|
async handleBatchToolCalls(calls) {
|
|
430
431
|
const results = [];
|
|
431
|
-
//
|
|
432
|
-
|
|
432
|
+
// inline concurrency limiter - no npm deps needed
|
|
433
|
+
// max 2 concurrent to leave headroom on 4-core/8GB systems
|
|
434
|
+
const _limitConcurrency = (concurrency) => {
|
|
435
|
+
let active = 0;
|
|
436
|
+
const queue = [];
|
|
437
|
+
const next = () => {
|
|
438
|
+
while (active < concurrency && queue.length > 0) {
|
|
439
|
+
active++;
|
|
440
|
+
const { fn, resolve, reject } = queue.shift();
|
|
441
|
+
fn().then(resolve, reject).finally(() => { active--; next(); });
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
return (fn) => new Promise((resolve, reject) => {
|
|
445
|
+
queue.push({ fn, resolve, reject });
|
|
446
|
+
next();
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
const limit = _limitConcurrency(2);
|
|
450
|
+
const promises = calls.map((call) => limit(async () => {
|
|
433
451
|
try {
|
|
434
452
|
const result = await this.handleToolCall(call.name, call.args);
|
|
435
453
|
return { name: call.name, result };
|
|
@@ -440,7 +458,7 @@ export class MCPProtocolHandler {
|
|
|
440
458
|
error: error instanceof Error ? error.message : 'unknown error'
|
|
441
459
|
};
|
|
442
460
|
}
|
|
443
|
-
});
|
|
461
|
+
}));
|
|
444
462
|
const settled = await Promise.allSettled(promises);
|
|
445
463
|
for (const result of settled) {
|
|
446
464
|
if (result.status === 'fulfilled') {
|
|
@@ -236,8 +236,9 @@ export class SpecMemServer {
|
|
|
236
236
|
this.announceToOnStartup();
|
|
237
237
|
// Auto-start Codebook Learner (resource-capped background service)
|
|
238
238
|
this._startCodebookLearner();
|
|
239
|
-
//
|
|
240
|
-
//
|
|
239
|
+
// NOTE: _triggerCodebaseIndexing() is also called from deferredInitPromise.then()
|
|
240
|
+
// after DB migrations complete (which create codebase_files table).
|
|
241
|
+
// This early call may no-op if DB not ready yet — the post-DB call is the critical one.
|
|
241
242
|
this._triggerCodebaseIndexing();
|
|
242
243
|
};
|
|
243
244
|
// get that db connection no cap
|
|
@@ -1285,6 +1286,14 @@ export class SpecMemServer {
|
|
|
1285
1286
|
await this.initializeMiniCOTServerManager();
|
|
1286
1287
|
startupLog('Mini COT server manager initialized');
|
|
1287
1288
|
logger.info('SpecMem MCP server fully initialized — all components ready');
|
|
1289
|
+
// FIX: Trigger codebase indexing AFTER DB init (migrations create codebase_files table)
|
|
1290
|
+
// Previously only called in oninitialized which fires BEFORE deferred DB init,
|
|
1291
|
+
// causing checkCodebaseIndexStatus to see missing table → needsReindex=false → skip
|
|
1292
|
+
try {
|
|
1293
|
+
await this._triggerCodebaseIndexing();
|
|
1294
|
+
} catch (indexErr) {
|
|
1295
|
+
logger.warn({ error: indexErr?.message }, 'Post-DB-init codebase indexing failed (non-fatal)');
|
|
1296
|
+
}
|
|
1288
1297
|
// Run initial sync on startup — ensures codebase is fresh when Claude Code launches
|
|
1289
1298
|
await this._runStartupSync();
|
|
1290
1299
|
// Start idle sync timer — auto-syncs when no tool calls for 60s
|
|
@@ -1354,7 +1363,11 @@ export class SpecMemServer {
|
|
|
1354
1363
|
const checkResult = await CheckSyncStatus.execute({ detailed: false }, wm);
|
|
1355
1364
|
const syncScore = checkResult?.syncScore ?? 100;
|
|
1356
1365
|
// Only resync if drift detected (score < 100)
|
|
1357
|
-
if (syncScore
|
|
1366
|
+
// Skip resync if indexing is still pending (syncScore === -1)
|
|
1367
|
+
if (checkResult?.indexingPending) {
|
|
1368
|
+
process.stderr.write(`[SPECMEM IDLE-SYNC] Indexing still in progress, skipping resync\n`);
|
|
1369
|
+
}
|
|
1370
|
+
else if (syncScore < 100) {
|
|
1358
1371
|
process.stderr.write(`[SPECMEM IDLE-SYNC] Drift detected (${syncScore}%), resyncing...\n`);
|
|
1359
1372
|
const resyncResult = await ForceResync.execute({ dryRun: false }, wm);
|
|
1360
1373
|
const added = resyncResult?.stats?.filesAdded ?? 0;
|
package/dist/mcp/toolRegistry.js
CHANGED
|
@@ -94,32 +94,30 @@ _cacheCleanupTimer.unref();
|
|
|
94
94
|
/**
|
|
95
95
|
* Get the project-scoped embedding cache
|
|
96
96
|
*/
|
|
97
|
-
// HIGH-4:
|
|
98
|
-
|
|
97
|
+
// HIGH-4: Eviction uses while-loop to guarantee room before creating new entry.
|
|
98
|
+
// Old boolean _evictionInProgress flag was broken: when flag was true, eviction was
|
|
99
|
+
// skipped but new entry was still created at line 125, exceeding the 20-project limit.
|
|
99
100
|
function getProjectEmbeddingCache() {
|
|
100
101
|
const project = getProjectPath();
|
|
101
102
|
_EMBEDDING_CACHE_ACCESS_TIMES.set(project, Date.now());
|
|
102
103
|
if (!_EMBEDDING_CACHE_BY_PROJECT.has(project)) {
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (t < oldestTime) {
|
|
112
|
-
oldestTime = t;
|
|
113
|
-
oldestProject = p;
|
|
114
|
-
}
|
|
104
|
+
// Evict until there's room - loop guarantees we never exceed limit
|
|
105
|
+
while (_EMBEDDING_CACHE_BY_PROJECT.size >= 20) {
|
|
106
|
+
let oldestProject = null;
|
|
107
|
+
let oldestTime = Infinity;
|
|
108
|
+
for (const [p, t] of _EMBEDDING_CACHE_ACCESS_TIMES) {
|
|
109
|
+
if (t < oldestTime && p !== project) {
|
|
110
|
+
oldestTime = t;
|
|
111
|
+
oldestProject = p;
|
|
115
112
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
113
|
+
}
|
|
114
|
+
if (oldestProject) {
|
|
115
|
+
_EMBEDDING_CACHE_BY_PROJECT.delete(oldestProject);
|
|
116
|
+
_EMBEDDING_CACHE_ACCESS_TIMES.delete(oldestProject);
|
|
117
|
+
__debugLog('[MCP DEBUG]', Date.now(), 'CACHE_PROJECT_EVICTED', { evictedProject: oldestProject, reason: 'max_projects_reached' });
|
|
118
|
+
} else {
|
|
119
|
+
// Safety: no evictable project found (all entries are current project?), break to avoid infinite loop
|
|
120
|
+
break;
|
|
123
121
|
}
|
|
124
122
|
}
|
|
125
123
|
_EMBEDDING_CACHE_BY_PROJECT.set(project, new Map());
|
|
@@ -27,7 +27,10 @@ export class CheckSyncStatus {
|
|
|
27
27
|
const driftReport = await watcherManager.checkSync();
|
|
28
28
|
// build summary message
|
|
29
29
|
let summary;
|
|
30
|
-
if (driftReport.
|
|
30
|
+
if (driftReport.indexingPending) {
|
|
31
|
+
summary = `Codebase indexing in progress — sync score not yet available. ${driftReport.totalFiles} files on disk awaiting indexing.`;
|
|
32
|
+
}
|
|
33
|
+
else if (driftReport.inSync) {
|
|
31
34
|
summary = `Everything is in sync! ${driftReport.upToDate} files are up to date.`;
|
|
32
35
|
}
|
|
33
36
|
else {
|
|
@@ -45,7 +48,8 @@ export class CheckSyncStatus {
|
|
|
45
48
|
}
|
|
46
49
|
const result = {
|
|
47
50
|
inSync: driftReport.inSync,
|
|
48
|
-
syncScore: driftReport.syncScore,
|
|
51
|
+
syncScore: driftReport.indexingPending ? -1 : driftReport.syncScore,
|
|
52
|
+
indexingPending: !!driftReport.indexingPending,
|
|
49
53
|
driftPercentage: driftReport.driftPercentage,
|
|
50
54
|
summary,
|
|
51
55
|
stats: {
|
|
@@ -70,10 +74,12 @@ export class CheckSyncStatus {
|
|
|
70
74
|
contentMismatch: driftReport.contentMismatch
|
|
71
75
|
};
|
|
72
76
|
}
|
|
73
|
-
// Update statusbar sync score live
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
// Update statusbar sync score live (skip if indexing pending — don't write -1)
|
|
78
|
+
if (!driftReport.indexingPending) {
|
|
79
|
+
try {
|
|
80
|
+
await watcherManager.writeSyncScore(driftReport.syncScore);
|
|
81
|
+
} catch (e) { /* non-critical */ }
|
|
82
|
+
}
|
|
77
83
|
logger.info({ inSync: driftReport.inSync, syncScore: driftReport.syncScore }, 'sync check complete');
|
|
78
84
|
// Build human readable response
|
|
79
85
|
const drifted = driftReport.missingFromMcp.length + driftReport.missingFromDisk.length + driftReport.contentMismatch.length;
|
|
@@ -99,7 +105,8 @@ export class CheckSyncStatus {
|
|
|
99
105
|
if (more > 0) detailLines += `\n ... and ${more} more`;
|
|
100
106
|
}
|
|
101
107
|
}
|
|
102
|
-
const
|
|
108
|
+
const displayScore = driftReport.indexingPending ? 'Pending' : `${Math.round(driftReport.syncScore * 100)}%`;
|
|
109
|
+
const message = `Sync Score: ${displayScore}
|
|
103
110
|
${summary}
|
|
104
111
|
|
|
105
112
|
Stats:
|
|
@@ -55,6 +55,11 @@ export class WatchForChangesNoCap {
|
|
|
55
55
|
debounceCleanupTimer = null;
|
|
56
56
|
// FIX 7.14: Track pending flush promises so stop() can await them
|
|
57
57
|
pendingFlushPromises = new Set();
|
|
58
|
+
// PERF: Batch-level debounce — collect per-file handler results into batches
|
|
59
|
+
// so git operations changing many files don't fire hundreds of individual handler calls
|
|
60
|
+
_batchTimer = null;
|
|
61
|
+
_batchQueue = [];
|
|
62
|
+
_batchDebounceMs = 500; // collect events for 500ms before dispatching batch
|
|
58
63
|
// stats tracking
|
|
59
64
|
stats = {
|
|
60
65
|
filesWatched: 0,
|
|
@@ -75,6 +80,37 @@ export class WatchForChangesNoCap {
|
|
|
75
80
|
verbose: config.verbose ?? false
|
|
76
81
|
};
|
|
77
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* _enqueueBatchEvent - batch-level debounce for handler calls
|
|
85
|
+
*
|
|
86
|
+
* Instead of calling changeHandler() immediately per-file, queue events
|
|
87
|
+
* and dispatch the entire batch after 500ms of quiet. This prevents
|
|
88
|
+
* git operations (checkout, merge, rebase) from firing hundreds of
|
|
89
|
+
* individual handler calls that each trigger sync/DB work.
|
|
90
|
+
*/
|
|
91
|
+
_enqueueBatchEvent(event) {
|
|
92
|
+
this._batchQueue.push(event);
|
|
93
|
+
if (this._batchTimer) clearTimeout(this._batchTimer);
|
|
94
|
+
this._batchTimer = setTimeout(async () => {
|
|
95
|
+
this._batchTimer = null;
|
|
96
|
+
const batch = this._batchQueue.splice(0);
|
|
97
|
+
if (batch.length === 0 || !this.changeHandler) return;
|
|
98
|
+
const batchSize = batch.length;
|
|
99
|
+
if (batchSize > 5) {
|
|
100
|
+
logger.info({ batchSize }, `batch debounce: dispatching ${batchSize} file events as batch`);
|
|
101
|
+
}
|
|
102
|
+
// Process each event in the batch sequentially
|
|
103
|
+
// (changeHandler expects individual events)
|
|
104
|
+
for (const evt of batch) {
|
|
105
|
+
try {
|
|
106
|
+
await this.changeHandler(evt);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
logger.error({ error: err, path: evt.path }, 'batch handler error for file');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}, this._batchDebounceMs);
|
|
113
|
+
}
|
|
78
114
|
/**
|
|
79
115
|
* startWatching - fires up the file watcher
|
|
80
116
|
*
|
|
@@ -189,7 +225,7 @@ export class WatchForChangesNoCap {
|
|
|
189
225
|
depth: undefined, // watch all depths
|
|
190
226
|
// debouncing built into chokidar
|
|
191
227
|
awaitWriteFinish: {
|
|
192
|
-
stabilityThreshold:
|
|
228
|
+
stabilityThreshold: 500, // wait 500ms for file to stop changing (reduced CPU from rapid fire events)
|
|
193
229
|
pollInterval: 100 // check every 100ms
|
|
194
230
|
},
|
|
195
231
|
// dont follow symlinks (security)
|
|
@@ -240,6 +276,18 @@ export class WatchForChangesNoCap {
|
|
|
240
276
|
await Promise.allSettled([...this.pendingFlushPromises]);
|
|
241
277
|
this.pendingFlushPromises.clear();
|
|
242
278
|
}
|
|
279
|
+
// PERF: Clear batch timer and flush pending batch events
|
|
280
|
+
if (this._batchTimer) {
|
|
281
|
+
clearTimeout(this._batchTimer);
|
|
282
|
+
this._batchTimer = null;
|
|
283
|
+
}
|
|
284
|
+
if (this._pendingBatchEvents.length > 0 && this.changeHandler) {
|
|
285
|
+
// Flush remaining batch events before shutdown
|
|
286
|
+
const batch = this._pendingBatchEvents.splice(0);
|
|
287
|
+
for (const evt of batch) {
|
|
288
|
+
try { await this.changeHandler(evt); } catch { /* shutting down */ }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
243
291
|
// FIX MED-13: Cancel all debounced handlers before clearing to prevent memory leaks
|
|
244
292
|
// The debounce library's clear() method cancels pending timer execution
|
|
245
293
|
for (const handler of this.debouncedHandlers.values()) {
|
|
@@ -381,7 +429,9 @@ export class WatchForChangesNoCap {
|
|
|
381
429
|
if (latestEvent) {
|
|
382
430
|
// Update timestamp to reflect when we actually process the event
|
|
383
431
|
latestEvent.timestamp = new Date();
|
|
384
|
-
|
|
432
|
+
// PERF: Route through batch debounce instead of calling handler directly
|
|
433
|
+
// This prevents git operations (200+ files) from firing 200 individual handler calls
|
|
434
|
+
this._enqueueBatchEvent(latestEvent);
|
|
385
435
|
this.stats.eventsProcessed++;
|
|
386
436
|
this.stats.lastEventTime = new Date();
|
|
387
437
|
}
|
|
@@ -435,7 +485,8 @@ export class WatchForChangesNoCap {
|
|
|
435
485
|
const flushPromise = Promise.resolve().then(async () => {
|
|
436
486
|
try {
|
|
437
487
|
latestEvent.timestamp = new Date();
|
|
438
|
-
|
|
488
|
+
// PERF: Route through batch debounce
|
|
489
|
+
this._enqueueBatchEvent(latestEvent);
|
|
439
490
|
this.stats.eventsProcessed++;
|
|
440
491
|
this.stats.lastEventTime = new Date();
|
|
441
492
|
}
|
|
@@ -479,23 +530,9 @@ export class WatchForChangesNoCap {
|
|
|
479
530
|
const latestEvent = this.pendingEventData.get(key);
|
|
480
531
|
if (latestEvent && this.changeHandler) {
|
|
481
532
|
this.pendingEventData.delete(key);
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
latestEvent.timestamp = new Date();
|
|
486
|
-
await this.changeHandler(latestEvent);
|
|
487
|
-
this.stats.eventsProcessed++;
|
|
488
|
-
this.stats.lastEventTime = new Date();
|
|
489
|
-
}
|
|
490
|
-
catch (error) {
|
|
491
|
-
this.stats.errors++;
|
|
492
|
-
logger.error({ error, event: latestEvent }, 'error processing stale debounce entry');
|
|
493
|
-
}
|
|
494
|
-
finally {
|
|
495
|
-
this.pendingFlushPromises.delete(flushPromise);
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
this.pendingFlushPromises.add(flushPromise);
|
|
533
|
+
// PERF: Route stale entries through batch debounce too
|
|
534
|
+
latestEvent.timestamp = new Date();
|
|
535
|
+
this._enqueueBatchEvent(latestEvent);
|
|
499
536
|
}
|
|
500
537
|
else {
|
|
501
538
|
this.pendingEventData.delete(key);
|
package/dist/watcher/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export class WatcherManager {
|
|
|
29
29
|
syncInterval = null;
|
|
30
30
|
syncInProgress = false;
|
|
31
31
|
syncTimeout = null;
|
|
32
|
+
lastLowScoreResyncAt = 0;
|
|
32
33
|
constructor(config) {
|
|
33
34
|
// Create handler first - it's the core component
|
|
34
35
|
this.handler = new AutoUpdateTheMemories(config.handler);
|
|
@@ -114,6 +115,29 @@ export class WatcherManager {
|
|
|
114
115
|
try {
|
|
115
116
|
const report = await this.syncChecker.checkSync();
|
|
116
117
|
await this.writeSyncScore(report.syncScore);
|
|
118
|
+
// Guard: indexingPending (score < 0) — nothing to do yet
|
|
119
|
+
if (report.syncScore < 0) {
|
|
120
|
+
logger.debug('periodic sync: indexing pending, skipping resync');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Low-score trigger: if score drops below threshold, force resync w/ debounce
|
|
124
|
+
const LOW_SCORE_THRESHOLD = parseFloat(process.env['SPECMEM_LOW_SCORE_THRESHOLD'] || '0.85');
|
|
125
|
+
const LOW_SCORE_DEBOUNCE_MS = parseInt(process.env['SPECMEM_LOW_SCORE_DEBOUNCE_MS'] || String(15 * 60 * 1000), 10);
|
|
126
|
+
if (report.syncScore < LOW_SCORE_THRESHOLD) {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const debounceRemaining = LOW_SCORE_DEBOUNCE_MS - (now - this.lastLowScoreResyncAt);
|
|
129
|
+
if (debounceRemaining > 0) {
|
|
130
|
+
logger.warn({ syncScore: report.syncScore, debounceRemainingSec: Math.round(debounceRemaining / 1000) }, 'sync score below threshold but debounce active — skipping forced resync');
|
|
131
|
+
} else {
|
|
132
|
+
this.lastLowScoreResyncAt = now;
|
|
133
|
+
logger.warn({ syncScore: report.syncScore, threshold: LOW_SCORE_THRESHOLD }, 'sync score below threshold — forcing resync');
|
|
134
|
+
const resyncResult = await this.syncChecker.resyncEverythingFrFr();
|
|
135
|
+
logger.info({ filesAdded: resyncResult.filesAdded, filesUpdated: resyncResult.filesUpdated, errors: resyncResult.errors.length }, 'low-score forced resync complete');
|
|
136
|
+
const postReport = await this.syncChecker.checkSync();
|
|
137
|
+
await this.writeSyncScore(postReport.syncScore);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
117
141
|
// Skip resync if already at >=98% — chokidar handles incremental changes
|
|
118
142
|
if (report.syncScore >= 0.98) {
|
|
119
143
|
logger.debug({ syncScore: report.syncScore }, 'periodic sync: score >=98%, skipping resync');
|
|
@@ -198,6 +222,8 @@ export class WatcherManager {
|
|
|
198
222
|
* writeSyncScore - writes sync score to statusbar state file for display
|
|
199
223
|
*/
|
|
200
224
|
async writeSyncScore(syncScore) {
|
|
225
|
+
// Guard: indexingPending returns -1 — never write negative values to statusbar
|
|
226
|
+
if (syncScore < 0) return;
|
|
201
227
|
try {
|
|
202
228
|
const projectPath = process.env.SPECMEM_PROJECT_PATH || process.cwd();
|
|
203
229
|
const statusbarPath = join(projectPath, 'specmem', 'sockets', 'statusbar-state.json');
|
|
@@ -119,22 +119,26 @@ export class AreWeStillInSync {
|
|
|
119
119
|
const totalFiles = diskFiles.length;
|
|
120
120
|
const totalMemories = mcpFiles.length;
|
|
121
121
|
const totalDrift = missingFromMcp.length + missingFromDisk.length + contentMismatch.length;
|
|
122
|
+
// FIX: If codebase_files is empty but disk files exist, indexing hasn't completed yet.
|
|
123
|
+
// Don't report 0% sync — that's misleading. Report indexing-pending state instead.
|
|
124
|
+
const indexingPending = totalMemories === 0 && totalFiles > 0;
|
|
122
125
|
// Sync score = what % of disk files are correctly synced in MCP
|
|
123
126
|
// Deleted-from-disk files are cleanup work, not sync failures
|
|
124
127
|
const totalItems = totalFiles || 1;
|
|
125
|
-
const driftPercentage = totalItems > 0 ? (totalDrift / totalItems) * 100 : 0;
|
|
126
|
-
const syncScore = totalItems > 0 ? upToDate / totalItems : 1;
|
|
128
|
+
const driftPercentage = indexingPending ? 0 : (totalItems > 0 ? (totalDrift / totalItems) * 100 : 0);
|
|
129
|
+
const syncScore = indexingPending ? -1 : (totalItems > 0 ? upToDate / totalItems : 1);
|
|
127
130
|
const report = {
|
|
128
|
-
inSync: totalDrift === 0,
|
|
131
|
+
inSync: indexingPending ? false : totalDrift === 0,
|
|
129
132
|
lastChecked: new Date(),
|
|
130
133
|
totalFiles,
|
|
131
134
|
totalMemories,
|
|
132
|
-
missingFromMcp,
|
|
135
|
+
missingFromMcp: indexingPending ? [] : missingFromMcp,
|
|
133
136
|
missingFromDisk,
|
|
134
|
-
contentMismatch,
|
|
135
|
-
upToDate,
|
|
137
|
+
contentMismatch: indexingPending ? [] : contentMismatch,
|
|
138
|
+
upToDate: indexingPending ? 0 : upToDate,
|
|
136
139
|
driftPercentage,
|
|
137
|
-
syncScore
|
|
140
|
+
syncScore,
|
|
141
|
+
indexingPending
|
|
138
142
|
};
|
|
139
143
|
this.lastSyncCheck = report.lastChecked;
|
|
140
144
|
this.lastCheckTime = startTime; // FIX 7.03: Record check time for mtime optimization
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specmem-hardwicksoftware",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Your Claude Code sessions don't have to start from scratch anymore — SpecMem gives your AI real memory. It won't forget your conversations, your code, or your architecture decisions between sessions. That's the whole point. Semantic code indexing that actually works: TypeScript, JavaScript, Python, Go, Rust, Java, Kotlin, C, C++, HTML and more. It doesn't just track functions — it gets classes, methods, fields, constants, enums, macros, imports, structs, the whole codebase graph. There's chat memory too, powered by pgvector embeddings. You've also got token compression, team coordination, multi-agent comms, and file watching built in. 74+ MCP tools. Runs on PostgreSQL + Docker. It's kind of a big deal. justcalljon.pro",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -947,9 +947,14 @@ function adjustPgAuth() {
|
|
|
947
947
|
// Backup and modify
|
|
948
948
|
run(`sudo cp ${pgHbaPath} ${pgHbaPath}.backup`);
|
|
949
949
|
|
|
950
|
-
// Add password auth for our user
|
|
950
|
+
// Add password auth for our user — check if already exists to prevent duplicates
|
|
951
951
|
const authLine = `host ${DB_CONFIG.name} ${DB_CONFIG.user} 127.0.0.1/32 md5`;
|
|
952
|
-
run(`
|
|
952
|
+
const alreadyExists = run(`sudo grep -qF '${authLine}' ${pgHbaPath}`, { silent: true });
|
|
953
|
+
if (!alreadyExists.success) {
|
|
954
|
+
run(`echo '${authLine}' | sudo tee -a ${pgHbaPath}`);
|
|
955
|
+
} else {
|
|
956
|
+
log.info('pg_hba.conf auth line already present, skipping');
|
|
957
|
+
}
|
|
953
958
|
|
|
954
959
|
// Reload PostgreSQL
|
|
955
960
|
run('sudo systemctl reload postgresql 2>/dev/null || sudo -u postgres pg_ctl reload');
|
package/scripts/specmem-init.cjs
CHANGED
|
@@ -8799,9 +8799,14 @@ CREATE INDEX IF NOT EXISTS idx_embedding_queue_project ON embedding_queue (proje
|
|
|
8799
8799
|
? path.join(specmemPkg, 'mcp-proxy.cjs')
|
|
8800
8800
|
: path.join(specmemPkg, 'bootstrap.cjs');
|
|
8801
8801
|
// Container mode: postgres via unix socket in specmem/run/, user=specmem, trust auth
|
|
8802
|
+
// Socket dir bind-mounted to /data/run in container — PG socket appears here after container starts
|
|
8802
8803
|
// Legacy mode: postgres on localhost:5432, legacy credentials
|
|
8803
8804
|
const isContainerMode = process.env.SPECMEM_CONTAINER_MODE === 'true';
|
|
8804
8805
|
const runDir = path.join(projectPath, 'specmem', 'run');
|
|
8806
|
+
if (isContainerMode) {
|
|
8807
|
+
// Ensure socket directory exists on host — container bind-mounts dataDir:/data
|
|
8808
|
+
try { fs.mkdirSync(runDir, { recursive: true }); } catch (e) { /* may already exist */ }
|
|
8809
|
+
}
|
|
8805
8810
|
const dbEnv = isContainerMode ? {
|
|
8806
8811
|
SPECMEM_DB_HOST: runDir,
|
|
8807
8812
|
SPECMEM_DB_PORT: "5432",
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
"cpus": 8
|
|
14
14
|
},
|
|
15
15
|
"embedding": {
|
|
16
|
-
"batchSize":
|
|
16
|
+
"batchSize": 64,
|
|
17
17
|
"maxConcurrent": 5,
|
|
18
|
-
"timeout": 45000
|
|
18
|
+
"timeout": 45000,
|
|
19
|
+
"throttleDelayMs": 50
|
|
19
20
|
},
|
|
20
21
|
"watcher": {
|
|
21
22
|
"debounceMs": 750,
|
|
@@ -33,13 +34,13 @@
|
|
|
33
34
|
"maxChunks": 75
|
|
34
35
|
},
|
|
35
36
|
"resources": {
|
|
36
|
-
"cpuMin":
|
|
37
|
-
"cpuMax":
|
|
37
|
+
"cpuMin": 10,
|
|
38
|
+
"cpuMax": 45,
|
|
38
39
|
"cpuCoreMin": 1,
|
|
39
40
|
"cpuCoreMax": 4,
|
|
40
41
|
"ramMinMb": 4000,
|
|
41
|
-
"ramMaxMb":
|
|
42
|
-
"updatedAt": "2026-02-
|
|
42
|
+
"ramMaxMb": 15000,
|
|
43
|
+
"updatedAt": "2026-02-24T19:22:11.693Z"
|
|
43
44
|
},
|
|
44
45
|
"resourcePool": {
|
|
45
46
|
"embedding": {
|
|
@@ -79,5 +80,24 @@
|
|
|
79
80
|
"description": "Adaptive batch sizing based on CPU/RAM"
|
|
80
81
|
},
|
|
81
82
|
"enabledAt": "2026-02-12T23:19:17.948Z"
|
|
83
|
+
},
|
|
84
|
+
"powerMode": {
|
|
85
|
+
"level": "high",
|
|
86
|
+
"description": "Max Performance - for 16GB+ RAM systems",
|
|
87
|
+
"lazyLoading": false,
|
|
88
|
+
"diskCache": false,
|
|
89
|
+
"diskCacheMaxMb": 0,
|
|
90
|
+
"aggressiveCleanup": false,
|
|
91
|
+
"idleUnloadSeconds": 0,
|
|
92
|
+
"batchSize": 32,
|
|
93
|
+
"throttleDelayMs": 50,
|
|
94
|
+
"setAt": "2026-02-24T11:57:23.121Z"
|
|
95
|
+
},
|
|
96
|
+
"heavyOps": {
|
|
97
|
+
"enabled": true,
|
|
98
|
+
"enabledAt": "2026-02-24T11:57:35.508Z",
|
|
99
|
+
"originalBatchSize": 32,
|
|
100
|
+
"batchSizeMultiplier": 2,
|
|
101
|
+
"throttleReduction": 0.2
|
|
82
102
|
}
|
|
83
103
|
}
|
package/specmem/supervisord.conf
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
; ============================================
|
|
2
2
|
; SPECMEM BRAIN CONTAINER - DYNAMIC SUPERVISORD CONFIG
|
|
3
|
-
; Generated by specmem-init at 2026-02-
|
|
3
|
+
; Generated by specmem-init at 2026-02-24T19:19:59.260Z
|
|
4
4
|
; Thread counts from model-config.json resourcePool
|
|
5
5
|
; ============================================
|
|
6
6
|
|
package/specmem/user-config.json
CHANGED
|
@@ -7,5 +7,17 @@
|
|
|
7
7
|
"serviceMode": {
|
|
8
8
|
"enabled": false,
|
|
9
9
|
"disabledAt": "2026-02-18T21:38:50.526Z"
|
|
10
|
+
},
|
|
11
|
+
"powerMode": {
|
|
12
|
+
"level": "high",
|
|
13
|
+
"description": "Max Performance - for 16GB+ RAM systems",
|
|
14
|
+
"lazyLoading": false,
|
|
15
|
+
"diskCache": false,
|
|
16
|
+
"diskCacheMaxMb": 0,
|
|
17
|
+
"aggressiveCleanup": false,
|
|
18
|
+
"idleUnloadSeconds": 0,
|
|
19
|
+
"batchSize": 32,
|
|
20
|
+
"throttleDelayMs": 50,
|
|
21
|
+
"setAt": "2026-02-24T11:57:23.121Z"
|
|
10
22
|
}
|
|
11
23
|
}
|