specmem-hardwicksoftware 3.7.36 → 3.7.38

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.
@@ -261,9 +261,9 @@ function configureMcpServer() {
261
261
  SPECMEM_PROJECT_PATH: '${PWD}',
262
262
  SPECMEM_WATCHER_ROOT_PATH: '${PWD}',
263
263
  SPECMEM_CODEBASE_PATH: '${PWD}',
264
- // Database (use environment values or defaults)
265
- SPECMEM_DB_HOST: process.env.SPECMEM_DB_HOST || 'localhost',
266
- SPECMEM_DB_PORT: process.env.SPECMEM_DB_PORT || '5432',
264
+ // Database - use ${PWD} for project isolation
265
+ SPECMEM_DB_HOST: '${PWD}/specmem/run',
266
+ SPECMEM_DB_PORT: '5432',
267
267
  // Watchers enabled by default
268
268
  SPECMEM_SESSION_WATCHER_ENABLED: 'true',
269
269
  SPECMEM_WATCHER_ENABLED: 'true',
@@ -367,8 +367,8 @@ function fixProjectMcpConfigs() {
367
367
  SPECMEM_PROJECT_PATH: '${PWD}',
368
368
  SPECMEM_WATCHER_ROOT_PATH: '${PWD}',
369
369
  SPECMEM_CODEBASE_PATH: '${PWD}',
370
- SPECMEM_DB_HOST: process.env.SPECMEM_DB_HOST || 'localhost',
371
- SPECMEM_DB_PORT: process.env.SPECMEM_DB_PORT || '5432',
370
+ SPECMEM_DB_HOST: '${PWD}/specmem/run',
371
+ SPECMEM_DB_PORT: '5432',
372
372
  SPECMEM_DB_PASSWORD: 'SPECMEM_DB_PASSWORD' in process.env ? process.env.SPECMEM_DB_PASSWORD : undefined,
373
373
  SPECMEM_SESSION_WATCHER_ENABLED: 'true',
374
374
  SPECMEM_WATCHER_ENABLED: 'true',
@@ -904,6 +904,9 @@ function hasHook(hooks, commandSubstring, matcher) {
904
904
  }
905
905
  function configureSettings() {
906
906
  const settings = safeReadJson(SETTINGS_PATH, {});
907
+ // Preserve user's custom top-level env (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, model overrides, etc.)
908
+ // We explicitly capture and restore this to guard against any future code accidentally touching it.
909
+ const _userCustomEnv = settings.env;
907
910
  const permissionsAdded = [];
908
911
  const hooksAdded = [];
909
912
  let needsUpdate = false;
@@ -959,8 +962,24 @@ function configureSettings() {
959
962
  if (hooksAdded.length > 0) {
960
963
  logger.info({ hooksAdded }, '[ConfigInjector] Adding hooks to settings.json');
961
964
  }
965
+ // Restore user's custom env - NEVER clobber ANTHROPIC_BASE_URL, model overrides, etc.
966
+ if (_userCustomEnv !== undefined) {
967
+ settings.env = _userCustomEnv;
968
+ }
969
+ // Inject Claude Code env flags (append-if-missing, never clobber existing values)
970
+ const REQUIRED_CLAUDE_ENV = {
971
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
972
+ };
973
+ if (!settings.env) settings.env = {};
974
+ for (const [key, value] of Object.entries(REQUIRED_CLAUDE_ENV)) {
975
+ if (!(key in settings.env)) {
976
+ settings.env[key] = value;
977
+ needsUpdate = true;
978
+ logger.info({ key, value }, '[ConfigInjector] Injected Claude env flag');
979
+ }
980
+ }
962
981
  if (safeWriteJson(SETTINGS_PATH, settings)) {
963
- logger.info({ path: SETTINGS_PATH, permissionsAdded, hooksAdded }, '[ConfigInjector] Settings updated');
982
+ logger.info({ path: SETTINGS_PATH, permissionsAdded, hooksAdded }, '[ConfigInjector] Settings updated (custom env preserved)');
964
983
  return { updated: true, permissionsAdded, hooksAdded };
965
984
  }
966
985
  return { updated: false, permissionsAdded: [], hooksAdded: [], error: 'Failed to write settings.json' };
@@ -274,6 +274,8 @@ export async function configureHooks() {
274
274
  logger.warn({ err }, 'could not parse existing settings, starting fresh');
275
275
  }
276
276
  }
277
+ // Preserve user's custom env BEFORE any modifications (ANTHROPIC_BASE_URL, model overrides, etc.)
278
+ const _userCustomEnv = settings.env;
277
279
  // ============================================================
278
280
  // GOD MODE PERMISSIONS - Allow all SpecMem tools without asking
279
281
  // ============================================================
@@ -426,9 +428,13 @@ export async function configureHooks() {
426
428
  if (userPromptHooks.length > 0) {
427
429
  addHooksToEvent('SessionStart', userPromptHooks.slice(0, 1)); // Just the drilldown hook
428
430
  }
431
+ // Restore user's custom env - NEVER clobber ANTHROPIC_BASE_URL, model overrides, etc.
432
+ if (_userCustomEnv !== undefined) {
433
+ settings.env = _userCustomEnv;
434
+ }
429
435
  // Write settings
430
436
  fs.writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2));
431
- logger.info('GOD MODE: hooks configured successfully');
437
+ logger.info('GOD MODE: hooks configured successfully (custom env preserved)');
432
438
  logger.info({
433
439
  permissions: specmemPermissions.length,
434
440
  hooks: Object.keys(settings.hooks).length,
@@ -40,8 +40,73 @@ const OG_SYS_PROMPT_FILE = join(CLAUDE_DIR, '.og-sys-prompt.json');
40
40
  // Per-user port: env var > default 4080. Multiple users on same machine
41
41
  // should set COMPACTION_PROXY_PORT or rely on the port file mechanism.
42
42
  const PROXY_PORT = parseInt(process.env.COMPACTION_PROXY_PORT || '4080', 10);
43
- const UPSTREAM_HOST = process.env.COMPACTION_PROXY_UPSTREAM || 'api.anthropic.com';
44
- const UPSTREAM_PORT = 443;
43
+
44
+ // ============================================================================
45
+ // Custom Upstream Detection — ENV VARS ONLY (no settings.json scanning)
46
+ // ============================================================================
47
+ // For custom APIs (MiniMax, etc.), set these env vars on the MCP server config:
48
+ // COMPACTION_PROXY_UPSTREAM — full URL of the real API (e.g., https://api.minimax.io/anthropic)
49
+ // COMPACTION_PROXY_API_KEY — API key for the custom upstream
50
+ // COMPACTION_PROXY_MODEL — model name to inject (e.g., MiniMax-Text-01)
51
+ //
52
+ // When NONE of these are set, the proxy is transparent: it forwards to api.anthropic.com
53
+ // with all original headers (OAuth Bearer, anthropic-beta, etc.) intact.
54
+ // This is the "native Claude" mode — fast mode, thinking, etc. all work.
55
+
56
+ // Custom upstream API key (for MiniMax, etc.)
57
+ const UPSTREAM_API_KEY = process.env.COMPACTION_PROXY_API_KEY
58
+ || process.env.ANTHROPIC_AUTH_TOKEN
59
+ || process.env.ANTHROPIC_API_KEY
60
+ || null;
61
+
62
+ // Custom model override (for MiniMax, etc.)
63
+ const UPSTREAM_MODEL = process.env.COMPACTION_PROXY_MODEL
64
+ || process.env.ANTHROPIC_MODEL
65
+ || null;
66
+
67
+ // Parse upstream URL — env var only, no settings.json scanning
68
+ // Settings.json scanning caused stale MiniMax configs to break native Claude mode.
69
+ function parseUpstreamUrl() {
70
+ const upstream = process.env.COMPACTION_PROXY_UPSTREAM || null;
71
+ if (upstream) {
72
+ try {
73
+ const url = new URL(upstream.startsWith('http') ? upstream : 'https://' + upstream);
74
+ return {
75
+ host: url.hostname,
76
+ port: parseInt(url.port, 10) || 443,
77
+ isHttps: url.protocol === 'https:',
78
+ path: url.pathname || '/'
79
+ };
80
+ } catch (e) {
81
+ return { host: upstream, port: 443, isHttps: true, path: '/' };
82
+ }
83
+ }
84
+ // Default: Anthropic API — headers pass through untouched (OAuth + fast mode work)
85
+ return { host: 'api.anthropic.com', port: 443, isHttps: true, path: '/' };
86
+ }
87
+
88
+ const UPSTREAM = parseUpstreamUrl();
89
+ const UPSTREAM_HOST = UPSTREAM.host;
90
+ const UPSTREAM_PORT = UPSTREAM.port;
91
+ const UPSTREAM_IS_HTTPS = UPSTREAM.isHttps;
92
+ const UPSTREAM_PATH = UPSTREAM.path || '/';
93
+
94
+ // FIX: Get the original model name (like "opus") to use in responses so Claude accepts them
95
+ // When using custom upstream (MiniMax), ALWAYS return "opus" in response
96
+ function getOriginalModel() {
97
+ // If using custom upstream (MiniMax), always return opus for the response
98
+ if (UPSTREAM_MODEL && UPSTREAM_HOST !== 'api.anthropic.com') {
99
+ log('proxy', `Using opus for response (custom upstream: ${UPSTREAM_MODEL})`);
100
+ return 'opus';
101
+ }
102
+ // Otherwise return what's in env (for normal Anthropic)
103
+ return process.env.ANTHROPIC_MODEL || null;
104
+ }
105
+
106
+ const ORIGINAL_MODEL = getOriginalModel();
107
+
108
+ // Helper to pick HTTP or HTTPS request based on upstream
109
+ const upstreamRequest = UPSTREAM_IS_HTTPS ? httpsRequest : httpRequest;
45
110
  const LOG_FILE = join('/tmp', `compaction-proxy-${process.getuid?.() ?? 'default'}.log`);
46
111
 
47
112
  // Orphan detection — track last request time for daemon watchdog
@@ -1889,21 +1954,168 @@ function collectBody(req) {
1889
1954
  });
1890
1955
  }
1891
1956
 
1957
+ // FIX: Convert Anthropic /v1/messages format to OpenAI /v1/chat/completions format
1958
+ function convertToOpenAIFormat(anthropicBody, model) {
1959
+ try {
1960
+ // Anthropic format: { model, messages: [{role, content}], system, ... }
1961
+ // OpenAI format: { model, messages: [{role, content}], ... }
1962
+ const body = JSON.parse(anthropicBody.toString('utf8'));
1963
+
1964
+ // Convert to OpenAI format
1965
+ const openai = {
1966
+ model: model,
1967
+ messages: []
1968
+ };
1969
+
1970
+ // Handle system prompt - add as first message with role: system
1971
+ if (body.system) {
1972
+ const systemContent = Array.isArray(body.system)
1973
+ ? body.system.map(s => typeof s === 'string' ? s : s.text || '').join('\n')
1974
+ : (typeof body.system === 'string' ? body.system : '');
1975
+ if (systemContent) {
1976
+ openai.messages.push({ role: 'system', content: systemContent });
1977
+ }
1978
+ }
1979
+
1980
+ // Convert messages
1981
+ if (body.messages) {
1982
+ for (const msg of body.messages) {
1983
+ // Anthropic uses "user" and "assistant", OpenAI uses same
1984
+ let role = msg.role;
1985
+ let content = msg.content;
1986
+
1987
+ // Handle content blocks - convert to string
1988
+ if (Array.isArray(content)) {
1989
+ content = content.map(c => {
1990
+ if (typeof c === 'string') return c;
1991
+ return c.text || c.type || '';
1992
+ }).join('\n');
1993
+ }
1994
+
1995
+ // Skip thinking blocks
1996
+ if (role === 'system' && openai.messages.some(m => m.role === 'system')) {
1997
+ continue; // Already added
1998
+ }
1999
+
2000
+ openai.messages.push({ role, content });
2001
+ }
2002
+ }
2003
+
2004
+ // Copy other fields
2005
+ if (body.max_tokens) openai.max_tokens = body.max_tokens;
2006
+ if (body.temperature) openai.temperature = body.temperature;
2007
+ if (body.top_p) openai.top_p = body.top_p;
2008
+ if (body.stream) openai.stream = body.stream;
2009
+ if (body.stop) openai.stop = body.stop;
2010
+
2011
+ log('proxy', `Converted Anthropic format to OpenAI format for model: ${model}`);
2012
+ return Buffer.from(JSON.stringify(openai), 'utf8');
2013
+ } catch (e) {
2014
+ log('proxy', `Format conversion error: ${e.message}`);
2015
+ return anthropicBody; // Fallback to original
2016
+ }
2017
+ }
2018
+
2019
+ // FIX: Convert OpenAI /v1/chat/completions response to Anthropic /v1/messages response format
2020
+ function convertFromOpenAIFormat(openaiBody) {
2021
+ try {
2022
+ const body = JSON.parse(openaiBody.toString('utf8'));
2023
+
2024
+ // OpenAI format: { id, model, choices: [{message: {role, content}}], usage, ... }
2025
+ // Anthropic format: { id, type: "message", role: "assistant", model, content: [{type: "text", text: "..."}], usage }
2026
+
2027
+ if (!body.choices || !body.choices[0]) {
2028
+ return openaiBody; // Not a valid response
2029
+ }
2030
+
2031
+ const choice = body.choices[0];
2032
+ const openaiMsg = choice.message || {};
2033
+
2034
+ // Convert to Anthropic format
2035
+ const anthropic = {
2036
+ id: body.id || `msg_${Date.now()}`,
2037
+ type: 'message',
2038
+ role: 'assistant',
2039
+ model: ORIGINAL_MODEL || body.model, // Use original model so Claude accepts it
2040
+ content: []
2041
+ };
2042
+
2043
+ // Handle content - OpenAI returns message.content as string, Anthropic wants array of blocks
2044
+ if (openaiMsg.content) {
2045
+ anthropic.content.push({
2046
+ type: 'text',
2047
+ text: openaiMsg.content
2048
+ });
2049
+ }
2050
+
2051
+ // Usage mapping
2052
+ if (body.usage) {
2053
+ anthropic.usage = {
2054
+ input_tokens: body.usage.prompt_tokens || 0,
2055
+ output_tokens: body.usage.completion_tokens || 0
2056
+ };
2057
+ }
2058
+
2059
+ // Stop reason
2060
+ if (choice.finish_reason) {
2061
+ anthropic.stop_reason = choice.finish_reason === 'length' ? 'max_tokens' : 'end_turn';
2062
+ }
2063
+
2064
+ log('proxy', `Converted OpenAI response to Anthropic format`);
2065
+ return Buffer.from(JSON.stringify(anthropic), 'utf8');
2066
+ } catch (e) {
2067
+ log('proxy', `Response conversion error: ${e.message}`);
2068
+ return openaiBody; // Fallback to original
2069
+ }
2070
+ }
2071
+
1892
2072
  function forwardRequest(req, res, bodyBuffer) {
2073
+ let modifiedBody = bodyBuffer;
2074
+ let modifiedPath = req.url;
2075
+
2076
+ // Detect custom upstream mode (MiniMax, etc.) — env-var driven only
2077
+ const isCustomUpstream = UPSTREAM_HOST !== 'api.anthropic.com';
2078
+
2079
+ // Prepend base path from upstream URL (e.g., /anthropic for MiniMax)
2080
+ if (UPSTREAM_PATH && UPSTREAM_PATH !== '/') {
2081
+ modifiedPath = UPSTREAM_PATH + (req.url.startsWith('/') ? '' : '/') + req.url;
2082
+ }
2083
+
2084
+ // Custom upstream: inject model name into request body
2085
+ if (isCustomUpstream && UPSTREAM_MODEL) {
2086
+ try {
2087
+ const body = JSON.parse(bodyBuffer.toString('utf8'));
2088
+ if (body.model) {
2089
+ body.model = UPSTREAM_MODEL;
2090
+ modifiedBody = Buffer.from(JSON.stringify(body), 'utf8');
2091
+ }
2092
+ } catch (e) {
2093
+ log('proxy', `Failed to replace model: ${e.message}`);
2094
+ }
2095
+ }
2096
+
1893
2097
  const upstreamHeaders = { ...req.headers };
1894
2098
  upstreamHeaders.host = UPSTREAM_HOST;
1895
- upstreamHeaders['content-length'] = bodyBuffer.length;
2099
+ upstreamHeaders['content-length'] = modifiedBody.length;
1896
2100
  delete upstreamHeaders['proxy-connection'];
1897
2101
  delete upstreamHeaders['proxy-authorization'];
1898
2102
 
1899
- const upstreamReq = httpsRequest({
2103
+ // Custom upstream: inject API key (MiniMax, etc.)
2104
+ // Native mode: leave original headers untouched (OAuth Bearer + fast mode work)
2105
+ if (isCustomUpstream && UPSTREAM_API_KEY) {
2106
+ upstreamHeaders['x-api-key'] = UPSTREAM_API_KEY;
2107
+ upstreamHeaders['Authorization'] = `Bearer ${UPSTREAM_API_KEY}`;
2108
+ }
2109
+
2110
+ const upstreamReq = upstreamRequest({
1900
2111
  hostname: UPSTREAM_HOST,
1901
2112
  port: UPSTREAM_PORT,
1902
- path: req.url,
2113
+ path: modifiedPath,
1903
2114
  method: req.method,
1904
2115
  headers: upstreamHeaders,
1905
2116
  timeout: 300000
1906
2117
  }, (upstreamRes) => {
2118
+ // Always write headers — both regular Anthropic and custom upstream (MiniMax) need them
1907
2119
  res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
1908
2120
  upstreamRes.pipe(res);
1909
2121
  upstreamRes.on('error', (err) => {
@@ -1931,7 +2143,7 @@ function forwardRequest(req, res, bodyBuffer) {
1931
2143
  }
1932
2144
  });
1933
2145
 
1934
- upstreamReq.write(bodyBuffer);
2146
+ upstreamReq.write(modifiedBody);
1935
2147
  upstreamReq.end();
1936
2148
  }
1937
2149
 
@@ -47,13 +47,22 @@ const DEFAULT_CONFIG = {
47
47
  startupTimeoutMs: parseInt(process.env['SPECMEM_EMBEDDING_STARTUP_TIMEOUT'] || '45000', 10),
48
48
  maxRestartAttempts: parseInt(process.env['SPECMEM_EMBEDDING_MAX_RESTARTS'] || '5', 10),
49
49
  autoStart: process.env['SPECMEM_EMBEDDING_AUTO_START'] !== 'false',
50
- killStaleOnStart: process.env['SPECMEM_EMBEDDING_KILL_STALE'] !== 'false',
50
+ // FIX: Default to false - only kill if THIS project's socket/PID exists
51
+ // This prevents cross-project conflicts when multiple projects have embedding servers
52
+ killStaleOnStart: process.env['SPECMEM_EMBEDDING_KILL_STALE'] === 'true',
53
+ // New: Strict isolation mode - skip ALL cross-project process checks
54
+ // Set SPECMEM_EMBEDDING_STRICT_ISOLATION=1 to enable
55
+ strictIsolation: process.env['SPECMEM_EMBEDDING_STRICT_ISOLATION'] === '1',
51
56
  maxProcessAgeHours: parseFloat(process.env['SPECMEM_EMBEDDING_MAX_AGE_HOURS'] || '1'),
52
57
  // Circuit breaker configuration (Issue #10)
53
58
  cbRestartWindowMs: parseInt(process.env['SPECMEM_RESTART_WINDOW_MS'] || '300000', 10),
54
59
  cbMaxRestartsInWindow: parseInt(process.env['SPECMEM_RESTART_MAX_IN_WINDOW'] || '5', 10),
55
60
  cbCooldownMs: parseInt(process.env['SPECMEM_RESTART_COOLDOWN_MS'] || '60000', 10),
56
61
  cbMaxCooldownMs: parseInt(process.env['SPECMEM_RESTART_MAX_COOLDOWN_MS'] || '600000', 10),
62
+ // FIX: Status stability - require 3 consecutive failures before marking offline to prevent flickering
63
+ statusStabilityThreshold: parseInt(process.env['SPECMEM_STATUS_STABILITY_THRESHOLD'] || '3', 10),
64
+ // FIX: Reduce CPU by throttling duplicate detection to every 5th health check
65
+ duplicateCheckInterval: parseInt(process.env['SPECMEM_DUPLICATE_CHECK_INTERVAL'] || '5', 10),
57
66
  };
58
67
  // ============================================================================
59
68
  // EMBEDDING SERVER MANAGER
@@ -75,6 +84,27 @@ export class EmbeddingServerManager extends EventEmitter {
75
84
  healthCheckTimer = null;
76
85
  isRunning = false;
77
86
  consecutiveFailures = 0;
87
+ healthCheckCycle = 0; // FIX: Track health check cycles for throttling duplicate detection
88
+ // FIX: Unique instance ID to distinguish this SpecMem installation from others
89
+ instanceId = null;
90
+ // FIX: Generate unique instance ID per SpecMem installation
91
+ _ensureInstanceId() {
92
+ if (this.instanceId) return this.instanceId;
93
+ // Try to load from file
94
+ const instanceIdFile = path.join(this.dataDir || this.projectPath, '.specmem-instance-id');
95
+ try {
96
+ if (existsSync(instanceIdFile)) {
97
+ this.instanceId = readFileSync(instanceIdFile, 'utf-8').trim();
98
+ return this.instanceId;
99
+ }
100
+ } catch (e) { /* ignore */ }
101
+ // Generate new ID
102
+ this.instanceId = `inst-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
103
+ try {
104
+ writeFileSync(instanceIdFile, this.instanceId);
105
+ } catch (e) { /* ignore */ }
106
+ return this.instanceId;
107
+ }
78
108
  restartCount = 0;
79
109
  lastRestartTime = 0;
80
110
  startTime = null;
@@ -156,9 +186,17 @@ export class EmbeddingServerManager extends EventEmitter {
156
186
  this.startKysHeartbeat();
157
187
  return;
158
188
  }
159
- // Step 1: Kill any stale processes
160
- if (this.config.killStaleOnStart) {
189
+ // FIX: Only kill stale processes if THIS project's PID file exists
190
+ // This prevents cross-project conflicts when multiple projects have embedding servers
191
+ const pidFileExists = existsSync(this.pidFilePath);
192
+ // Step 1: Kill any stale processes (only if our PID file exists or explicitly enabled)
193
+ // skip if strictIsolation is enabled to prevent ANY cross-project checks
194
+ if (this.config.strictIsolation) {
195
+ logger.info('[EmbeddingServerManager] Strict isolation mode - skipping stale process cleanup');
196
+ } else if (this.config.killStaleOnStart && pidFileExists) {
161
197
  await this.killStaleProcesses();
198
+ } else if (this.config.killStaleOnStart) {
199
+ logger.debug('[EmbeddingServerManager] killStaleOnStart enabled but no PID file - skipping cleanup');
162
200
  }
163
201
  // Step 2: Start the embedding server if auto-start is enabled
164
202
  if (this.config.autoStart) {
@@ -563,6 +601,7 @@ export class EmbeddingServerManager extends EventEmitter {
563
601
  SPECMEM_SOCKET_DIR: socketDir,
564
602
  SPECMEM_EMBEDDING_SOCKET: this.socketPath,
565
603
  SPECMEM_EMBEDDING_IDLE_TIMEOUT: '0',
604
+ SPECMEM_INSTANCE_ID: this.instanceId || this._ensureInstanceId(),
566
605
  SPECMEM_DB_SCHEMA: projectSchema,
567
606
  ...configEnv,
568
607
  });
@@ -926,12 +965,12 @@ export class EmbeddingServerManager extends EventEmitter {
926
965
  if (response.error) {
927
966
  clearTimeout(timeout);
928
967
  resolved = true;
929
- socket.end();
968
+ socket.destroy();
930
969
  reject(new Error(response.error));
931
970
  return;
932
971
  }
933
- // Skip "processing" status messages - wait for actual embedding
934
- if (response.status === 'processing') {
972
+ // Skip "processing"/"working" status messages - wait for actual embedding
973
+ if (response.status === 'working' || response.status === 'processing') {
935
974
  logger.debug({ textLength: response.text_length }, '[EmbeddingServerManager] Embedding request queued, waiting for result...');
936
975
  continue; // Keep reading for the actual embedding
937
976
  }
@@ -939,7 +978,7 @@ export class EmbeddingServerManager extends EventEmitter {
939
978
  if (response.embedding && Array.isArray(response.embedding)) {
940
979
  clearTimeout(timeout);
941
980
  resolved = true;
942
- socket.end();
981
+ socket.destroy();
943
982
  resolve(response.embedding);
944
983
  return;
945
984
  }
@@ -955,6 +994,7 @@ export class EmbeddingServerManager extends EventEmitter {
955
994
  clearTimeout(timeout);
956
995
  if (!resolved) {
957
996
  resolved = true;
997
+ socket.destroy();
958
998
  reject(err);
959
999
  }
960
1000
  });
@@ -995,17 +1035,17 @@ export class EmbeddingServerManager extends EventEmitter {
995
1035
  if (response.error) {
996
1036
  clearTimeout(timeout);
997
1037
  resolved = true;
998
- socket.end();
1038
+ socket.destroy();
999
1039
  reject(new Error(response.error));
1000
1040
  return;
1001
1041
  }
1002
- if (response.status === 'processing') {
1042
+ if (response.status === 'working' || response.status === 'processing') {
1003
1043
  continue; // Wait for actual result
1004
1044
  }
1005
1045
  if (response.embeddings && Array.isArray(response.embeddings)) {
1006
1046
  clearTimeout(timeout);
1007
1047
  resolved = true;
1008
- socket.end();
1048
+ socket.destroy();
1009
1049
  resolve(response.embeddings);
1010
1050
  return;
1011
1051
  }
@@ -1019,6 +1059,7 @@ export class EmbeddingServerManager extends EventEmitter {
1019
1059
  clearTimeout(timeout);
1020
1060
  if (!resolved) {
1021
1061
  resolved = true;
1062
+ socket.destroy();
1022
1063
  reject(err);
1023
1064
  }
1024
1065
  });
@@ -1075,11 +1116,11 @@ export class EmbeddingServerManager extends EventEmitter {
1075
1116
  if (response.error) {
1076
1117
  clearTimeout(timeout);
1077
1118
  resolved = true;
1078
- socket.end();
1119
+ socket.destroy();
1079
1120
  reject(new Error(response.error));
1080
1121
  return;
1081
1122
  }
1082
- if (response.status === 'processing') {
1123
+ if (response.status === 'working' || response.status === 'processing') {
1083
1124
  logger.debug('[EmbeddingServerManager] Server-side processing in progress...');
1084
1125
  continue;
1085
1126
  }
@@ -1087,7 +1128,7 @@ export class EmbeddingServerManager extends EventEmitter {
1087
1128
  if (response.total_processed !== undefined || response.processed !== undefined) {
1088
1129
  clearTimeout(timeout);
1089
1130
  resolved = true;
1090
- socket.end();
1131
+ socket.destroy();
1091
1132
  logger.info({ response }, '[EmbeddingServerManager] Server-side processing complete');
1092
1133
  resolve(response);
1093
1134
  return;
@@ -1923,10 +1964,38 @@ export class EmbeddingServerManager extends EventEmitter {
1923
1964
  const envVars = environ.split('\0');
1924
1965
  const projectPath = this.projectPath || process.cwd();
1925
1966
  const socketPath = this.socketPath;
1967
+ let foundInstanceId = null;
1968
+ for (const v of envVars) {
1969
+ // FIX: Check SPECMEM_INSTANCE_ID - if both have it and they match, it's duplicate
1970
+ if (v.startsWith('SPECMEM_INSTANCE_ID=')) {
1971
+ foundInstanceId = v.replace('SPECMEM_INSTANCE_ID=', '');
1972
+ }
1973
+ }
1974
+ // If both have instance IDs and they match → definitely same instance = duplicate
1975
+ if (foundInstanceId && this.instanceId && foundInstanceId === this.instanceId) {
1976
+ return true;
1977
+ }
1978
+ // If instance IDs differ → different installations → NOT duplicate
1979
+ if (foundInstanceId && this.instanceId && foundInstanceId !== this.instanceId) {
1980
+ return false;
1981
+ }
1982
+ // At least one is legacy (no instance ID) → use path-based matching
1926
1983
  for (const v of envVars) {
1927
- if (v.startsWith('SPECMEM_PROJECT_PATH=') && v.includes(projectPath)) return true;
1928
- if (v.startsWith('SPECMEM_SOCKET_PATH=') && v.includes(projectPath)) return true;
1929
- if (v.startsWith('SPECMEM_EMBEDDING_SOCKET=') && socketPath && v.includes(socketPath)) return true;
1984
+ // FIX: Use exact path match or path separator to prevent false positives
1985
+ // Previously used .includes() which matched partial paths like /specmem/abc matching /specmem
1986
+ if (v.startsWith('SPECMEM_PROJECT_PATH=')) {
1987
+ const envProjectPath = v.replace('SPECMEM_PROJECT_PATH=', '');
1988
+ // Match exact path or child path (with / separator)
1989
+ if (envProjectPath === projectPath || envProjectPath.startsWith(projectPath + '/')) return true;
1990
+ }
1991
+ if (v.startsWith('SPECMEM_SOCKET_PATH=')) {
1992
+ const envSocketPath = v.replace('SPECMEM_SOCKET_PATH=', '');
1993
+ if (envSocketPath === socketPath || envSocketPath.startsWith(projectPath + '/')) return true;
1994
+ }
1995
+ if (v.startsWith('SPECMEM_EMBEDDING_SOCKET=') && socketPath) {
1996
+ const envEmbSocket = v.replace('SPECMEM_EMBEDDING_SOCKET=', '');
1997
+ if (envEmbSocket === socketPath) return true;
1998
+ }
1930
1999
  }
1931
2000
  return false;
1932
2001
  }
@@ -2392,9 +2461,14 @@ export class EmbeddingServerManager extends EventEmitter {
2392
2461
  }
2393
2462
  }
2394
2463
  }
2464
+ // FIX: Increment health check cycle counter for throttling duplicate detection
2465
+ this.healthCheckCycle++;
2466
+ // FIX: Only run duplicate detection every N health checks (default every 5th = 2.5 min)
2467
+ const shouldCheckDuplicates = this.healthCheckCycle % this.config.duplicateCheckInterval === 0;
2395
2468
  // FIX 4: Duplicate process detection during health monitoring
2396
2469
  // Check for multiple embedding server processes FOR THIS PROJECT and kill extras
2397
2470
  // PROJECT ISOLATION: Filter to only this project's processes before killing duplicates
2471
+ if (!shouldCheckDuplicates) return; // Throttle: skip this cycle
2398
2472
  try {
2399
2473
  const runningServers = await this.findRunningEmbeddingServers();
2400
2474
  // PROJECT ISOLATION: Only consider processes belonging to this project
@@ -410,6 +410,23 @@ export class FindCodePointers {
410
410
  attribution: SPECMEM_ATTRIBUTION
411
411
  };
412
412
  }
413
+ // FAST FAIL: Invalid query detection - return immediately with format hint
414
+ const queryTrimmed = params.query.trim();
415
+ // Check for natural language questions (not code terms)
416
+ const isQuestion = /^(how|what|why|when|where|who|can|does|is|should|would|could)\s/i.test(queryTrimmed);
417
+ if (isQuestion && queryTrimmed.length > 50) {
418
+ // Likely a natural language question instead of code terms - fail fast with hint
419
+ logger.warn({ query: params.query }, '[CodePointers] Invalid query format detected - failing fast with hint');
420
+ return {
421
+ results: [],
422
+ query: params.query,
423
+ total_found: 0,
424
+ search_type: 'semantic',
425
+ attribution: SPECMEM_ATTRIBUTION,
426
+ error: 'INVALID_QUERY_FORMAT',
427
+ error_hint: 'Query appears to be a natural language question. For code search, use CODE TERMS like "admin login auth" NOT "how does admin login work". See /specmem/HOW_TO_USE.md for examples.'
428
+ };
429
+ }
413
430
  // MODE SELECTION: Return options if user wants to choose
414
431
  if (params.galleryMode === 'ask') {
415
432
  return this.returnModeOptions(params.query);
@@ -690,6 +690,25 @@ export class FindWhatISaid {
690
690
  highlights: []
691
691
  }];
692
692
  }
693
+ // FAST FAIL: Whitespace-only or clearly invalid query
694
+ const queryTrimmed = params.query.trim();
695
+ if (queryTrimmed.length < 2) {
696
+ logger.warn({ query: params.query }, '[find_memory] Query too short - failing fast');
697
+ return [{
698
+ memory: {
699
+ id: 'error',
700
+ content: 'Query too short. Provide at least 2 characters for meaningful search.',
701
+ createdAt: new Date(),
702
+ updatedAt: new Date(),
703
+ tags: ['error'],
704
+ importance: 'low',
705
+ memoryType: 'semantic',
706
+ metadata: { _isError: true }
707
+ },
708
+ similarity: 0,
709
+ highlights: []
710
+ }];
711
+ }
693
712
  logger.debug({ query: params.query, limit: params.limit }, 'searching memories fr');
694
713
  // Broadcast COT to dashboard
695
714
  cotStart('find_memory', params.query || 'browsing');
@@ -3916,20 +3916,21 @@ class EmbeddingServer:
3916
3916
  # Extract requestId for persistent socket multiplexing
3917
3917
  request_id = request.get('requestId')
3918
3918
 
3919
- # Send "processing" heartbeat ONLY for embedding requests (not health/kys/ready)
3919
+ # Send "working" status ONLY for embedding requests (not health/kys/ready)
3920
+ # "working" means actually processing your query (vs "processing" which was ambiguous)
3920
3921
  # Meta requests expect a single response - sending a heartbeat first breaks the protocol
3921
3922
  # and causes clients to read the heartbeat as the actual response
3922
3923
  if not is_meta_request:
3923
3924
  text = request.get('text') or request.get('texts')
3924
3925
  text_length = len(text) if isinstance(text, str) else (len(text) if text else 0)
3925
3926
  heartbeat = {
3926
- 'status': 'processing',
3927
+ 'status': 'working',
3927
3928
  'text_length': text_length
3928
3929
  }
3929
3930
  if request_id:
3930
3931
  heartbeat['requestId'] = request_id
3931
3932
  hb_ok = self._safe_sendall(conn, json.dumps(heartbeat).encode('utf-8') + b'\n')
3932
- print(f"[WORKER {thread_name}] Heartbeat sent ok={hb_ok}", file=sys.stderr, flush=True)
3933
+ print(f"[WORKER {thread_name}] Working status sent ok={hb_ok}", file=sys.stderr, flush=True)
3933
3934
 
3934
3935
  # Process - each thread gets its own call stack
3935
3936
  print(f"[WORKER {thread_name}] Calling handle_request(type={req_type})...", file=sys.stderr, flush=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specmem-hardwicksoftware",
3
- "version": "3.7.36",
3
+ "version": "3.7.38",
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",