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.
- package/README.md +1 -1
- package/bin/specmem-autoclaude.cjs +12 -1
- package/bin/specmem-cli.cjs +1077 -11
- package/bin/specmem-console.cjs +51 -12
- package/bootstrap.cjs +10 -2
- package/claude-hooks/agent-loading-hook.js +10 -3
- package/claude-hooks/refusal-detector-hook.cjs +53 -0
- package/claude-hooks/settings.json +37 -1
- package/claude-hooks/smart-search-interceptor.js +1 -1
- package/claude-hooks/team-comms-enforcer.cjs +64 -0
- package/claude-hooks/use-code-pointers.cjs +1 -1
- package/dist/cli/deploy-to-claude.js +9 -2
- package/dist/index.js +49 -12
- package/dist/init/claudeConfigInjector.js +25 -6
- package/dist/installer/autoInstall.js +7 -1
- package/dist/mcp/compactionProxy.js +218 -6
- package/dist/mcp/embeddingServerManager.js +90 -16
- package/dist/tools/goofy/findCodePointers.js +17 -0
- package/dist/tools/goofy/findWhatISaid.js +19 -0
- package/embedding-sandbox/frankenstein-embeddings.py +4 -3
- package/package.json +1 -1
- package/scripts/deploy-hooks.cjs +10 -2
- package/scripts/fast-batch-embedder.cjs +2 -2
- package/scripts/force-retry.cjs +34 -0
- package/scripts/global-postinstall.cjs +95 -2
- package/scripts/poetic-abliteration.cjs +379 -0
- package/scripts/refusal-enforcer.cjs +88 -0
- package/scripts/specmem-init.cjs +99 -12
- package/specmem/supervisord.conf +1 -1
- package/claude-hooks/agent-chooser-hook.js +0 -179
|
@@ -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
|
|
265
|
-
SPECMEM_DB_HOST:
|
|
266
|
-
SPECMEM_DB_PORT:
|
|
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:
|
|
371
|
-
SPECMEM_DB_PORT:
|
|
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
|
-
|
|
44
|
-
|
|
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'] =
|
|
2099
|
+
upstreamHeaders['content-length'] = modifiedBody.length;
|
|
1896
2100
|
delete upstreamHeaders['proxy-connection'];
|
|
1897
2101
|
delete upstreamHeaders['proxy-authorization'];
|
|
1898
2102
|
|
|
1899
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
160
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1928
|
-
|
|
1929
|
-
if (v.startsWith('
|
|
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 "
|
|
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': '
|
|
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}]
|
|
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.
|
|
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",
|