specmem-hardwicksoftware 3.7.16 → 3.7.19

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/dist/database.js CHANGED
@@ -724,8 +724,16 @@ export class DatabaseManager {
724
724
  let success = true;
725
725
  let errorMsg;
726
726
  let rowsAffected;
727
+ // CRITICAL FIX: Use dedicated client with ensureSearchPath to prevent
728
+ // schema cross-contamination. pool.query() can race with the fire-and-forget
729
+ // search_path set in pool.on('connect'), causing writes to wrong schema.
730
+ const client = await this.pool.connect();
727
731
  try {
728
- const result = await this.pool.query(text, params);
732
+ if (this.currentSchema) {
733
+ const safeSchema = '"' + this.currentSchema.replace(/"/g, '""') + '"';
734
+ await client.query('SET search_path TO ' + safeSchema + ', public');
735
+ }
736
+ const result = await client.query(text, params);
729
737
  rowsAffected = result.rowCount ?? undefined;
730
738
  const duration = Date.now() - start;
731
739
  // Emit db:query:complete event via LWJEB
@@ -747,6 +755,9 @@ export class DatabaseManager {
747
755
  coordinator?.emitDBQueryComplete(queryId, queryType, duration, false, undefined, errorMsg);
748
756
  throw error;
749
757
  }
758
+ finally {
759
+ client.release();
760
+ }
750
761
  }
751
762
  /**
752
763
  * Execute query with GUARANTEED schema isolation.
@@ -54,8 +54,9 @@ import { SmartSearch } from '../tools/goofy/smartSearch.js';
54
54
  // Import memory drilldown tools - gallery view + full drill-down
55
55
  import { FindMemoryGallery } from '../tools/goofy/findMemoryGallery.js';
56
56
  import { GetMemoryFull } from '../tools/goofy/getMemoryFull.js';
57
- // Import project memory import tool - carry context across projects
57
+ // Import project memory import/export tools - carry context across projects
58
58
  import { ImportProjectMemories } from '../tools/goofy/importProjectMemories.js';
59
+ import { ExportProjectMemories } from '../tools/goofy/exportProjectMemories.js';
59
60
  // Import MCP-based team communication tools (NEW - replaces HTTP team member comms)
60
61
  import { createTeamCommTools } from './tools/teamComms.js';
61
62
  // Import embedding server control tools (Phase 4 - user start/stop/status)
@@ -502,8 +503,9 @@ export function createToolRegistry(db, embeddingProvider) {
502
503
  // Camera roll drilldown tools - zoom in/out on memories and code
503
504
  registry.register(new DrillDown(db));
504
505
  registry.register(new GetMemoryByDrilldownID(db));
505
- // Project memory import tool - import memories from other projects
506
+ // Project memory import/export tools - carry context across projects
506
507
  registry.register(new ImportProjectMemories(db, cachingProvider));
508
+ registry.register(new ExportProjectMemories(db));
507
509
  // Team communication tools - multi-team member coordination
508
510
  const teamCommTools = createTeamCommTools();
509
511
  for (const tool of teamCommTools) {
@@ -0,0 +1,243 @@
1
+ /**
2
+ * exportProjectMemories - export memories from current project to JSON
3
+ *
4
+ * dumps memories from the current project schema so you can
5
+ * back them up, share them, or import them elsewhere
6
+ */
7
+ import { logger } from '../../utils/logger.js';
8
+ import { getProjectPathForInsert } from '../../services/ProjectContext.js';
9
+
10
+ export class ExportProjectMemories {
11
+ db;
12
+ name = 'export_project_memories';
13
+ description = 'Export memories from the current project to JSON. Use for backups, sharing, or transferring memories between machines. Returns JSON array of memories.';
14
+ inputSchema = {
15
+ type: 'object',
16
+ properties: {
17
+ query: {
18
+ type: 'string',
19
+ description: 'Optional semantic search query to filter which memories to export. If omitted, exports all (up to limit).'
20
+ },
21
+ tags: {
22
+ type: 'array',
23
+ items: { type: 'string' },
24
+ description: 'Optional tag filter - only export memories with these tags'
25
+ },
26
+ memoryTypes: {
27
+ type: 'array',
28
+ items: {
29
+ type: 'string',
30
+ enum: ['episodic', 'semantic', 'procedural', 'working', 'consolidated']
31
+ },
32
+ description: 'Optional memory type filter'
33
+ },
34
+ importance: {
35
+ type: 'array',
36
+ items: {
37
+ type: 'string',
38
+ enum: ['critical', 'high', 'medium', 'low', 'trivial']
39
+ },
40
+ description: 'Optional importance filter'
41
+ },
42
+ limit: {
43
+ type: 'number',
44
+ default: 500,
45
+ minimum: 1,
46
+ maximum: 50000,
47
+ description: 'Max number of memories to export (default: 500)'
48
+ },
49
+ outputPath: {
50
+ type: 'string',
51
+ description: 'Optional file path to write JSON output. If omitted, returns in response.'
52
+ },
53
+ includeEmbeddings: {
54
+ type: 'boolean',
55
+ default: false,
56
+ description: 'Include embedding vectors in export (large, usually not needed)'
57
+ }
58
+ },
59
+ required: []
60
+ };
61
+
62
+ constructor(db) {
63
+ this.db = db;
64
+ }
65
+
66
+ async execute(params) {
67
+ const { query, tags, memoryTypes, importance, outputPath, includeEmbeddings = false } = params;
68
+ const limit = Math.min(params.limit || 500, 50000);
69
+ const startTime = Date.now();
70
+
71
+ try {
72
+ const currentSchema = this.db.getProjectSchemaName();
73
+ const currentProjectPath = getProjectPathForInsert();
74
+
75
+ logger.info({
76
+ currentSchema,
77
+ currentProjectPath,
78
+ limit,
79
+ query: query?.slice(0, 50),
80
+ tags,
81
+ outputPath
82
+ }, 'Starting memory export');
83
+
84
+ // Build query
85
+ const conditions = [];
86
+ const queryParams = [];
87
+ let paramIndex = 1;
88
+
89
+ if (tags && tags.length > 0) {
90
+ conditions.push(`tags && $${paramIndex}::text[]`);
91
+ queryParams.push(tags);
92
+ paramIndex++;
93
+ }
94
+
95
+ if (memoryTypes && memoryTypes.length > 0) {
96
+ conditions.push(`memory_type = ANY($${paramIndex}::text[])`);
97
+ queryParams.push(memoryTypes);
98
+ paramIndex++;
99
+ }
100
+
101
+ if (importance && importance.length > 0) {
102
+ conditions.push(`importance = ANY($${paramIndex}::text[])`);
103
+ queryParams.push(importance);
104
+ paramIndex++;
105
+ }
106
+
107
+ const embedSelect = includeEmbeddings ? ', embedding' : '';
108
+ let selectQuery;
109
+
110
+ if (query) {
111
+ // Semantic search - need embedding
112
+ let embedding;
113
+ try {
114
+ const embProvider = this.db._embeddingProvider || null;
115
+ if (embProvider) {
116
+ embedding = await embProvider.generateEmbedding(query);
117
+ }
118
+ } catch (e) {
119
+ logger.warn({ error: e?.message }, 'Could not generate embedding for query filter');
120
+ }
121
+
122
+ if (embedding) {
123
+ conditions.push(`embedding IS NOT NULL`);
124
+ const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
125
+ selectQuery = `
126
+ SELECT id, content, memory_type, importance, tags, metadata,
127
+ project_path, created_at, updated_at, expires_at,
128
+ 1 - (embedding <=> $${paramIndex}::vector) as similarity
129
+ ${embedSelect}
130
+ FROM memories
131
+ ${whereClause}
132
+ ORDER BY embedding <=> $${paramIndex}::vector
133
+ LIMIT $${paramIndex + 1}
134
+ `;
135
+ queryParams.push(`[${embedding.join(',')}]`);
136
+ queryParams.push(limit);
137
+ } else {
138
+ // Fallback to text search
139
+ conditions.push(`content ILIKE $${paramIndex}`);
140
+ queryParams.push(`%${query}%`);
141
+ paramIndex++;
142
+ const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
143
+ selectQuery = `
144
+ SELECT id, content, memory_type, importance, tags, metadata,
145
+ project_path, created_at, updated_at, expires_at
146
+ ${embedSelect}
147
+ FROM memories
148
+ ${whereClause}
149
+ ORDER BY created_at DESC
150
+ LIMIT $${paramIndex}
151
+ `;
152
+ queryParams.push(limit);
153
+ }
154
+ } else {
155
+ const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
156
+ selectQuery = `
157
+ SELECT id, content, memory_type, importance, tags, metadata,
158
+ project_path, created_at, updated_at, expires_at
159
+ ${embedSelect}
160
+ FROM memories
161
+ ${whereClause}
162
+ ORDER BY created_at DESC
163
+ LIMIT $${paramIndex}
164
+ `;
165
+ queryParams.push(limit);
166
+ }
167
+
168
+ const result = await this.db.query(selectQuery, queryParams);
169
+ const memories = result.rows.map(row => ({
170
+ id: row.id,
171
+ content: row.content,
172
+ memory_type: row.memory_type,
173
+ importance: row.importance,
174
+ tags: row.tags,
175
+ metadata: row.metadata,
176
+ project_path: row.project_path,
177
+ created_at: row.created_at,
178
+ updated_at: row.updated_at,
179
+ expires_at: row.expires_at,
180
+ ...(row.similarity !== undefined ? { similarity: Math.round(row.similarity * 1000) / 1000 } : {}),
181
+ ...(includeEmbeddings && row.embedding ? { embedding: row.embedding } : {})
182
+ }));
183
+
184
+ const duration = Date.now() - startTime;
185
+
186
+ // Write to file if outputPath specified
187
+ if (outputPath) {
188
+ const fs = await import('fs');
189
+ const exportData = {
190
+ exportedAt: new Date().toISOString(),
191
+ sourceProject: currentProjectPath,
192
+ sourceSchema: currentSchema,
193
+ totalExported: memories.length,
194
+ filters: { query, tags, memoryTypes, importance, limit },
195
+ memories
196
+ };
197
+ fs.writeFileSync(outputPath, JSON.stringify(exportData, null, 2));
198
+
199
+ return {
200
+ content: [{
201
+ type: 'text',
202
+ text: JSON.stringify({
203
+ success: true,
204
+ exported: memories.length,
205
+ outputPath,
206
+ sourceSchema: currentSchema,
207
+ duration: `${duration}ms`,
208
+ fileSizeKB: Math.round(fs.statSync(outputPath).size / 1024)
209
+ }, null, 2)
210
+ }]
211
+ };
212
+ }
213
+
214
+ // Return inline
215
+ return {
216
+ content: [{
217
+ type: 'text',
218
+ text: JSON.stringify({
219
+ success: true,
220
+ exported: memories.length,
221
+ sourceProject: currentProjectPath,
222
+ sourceSchema: currentSchema,
223
+ duration: `${duration}ms`,
224
+ memories
225
+ }, null, 2)
226
+ }]
227
+ };
228
+
229
+ } catch (err) {
230
+ const errMsg = err instanceof Error ? err.message : String(err);
231
+ logger.error({ error: errMsg }, 'Memory export failed');
232
+ return {
233
+ content: [{
234
+ type: 'text',
235
+ text: JSON.stringify({
236
+ error: 'Memory export failed',
237
+ details: errMsg
238
+ }, null, 2)
239
+ }]
240
+ };
241
+ }
242
+ }
243
+ }
@@ -104,12 +104,12 @@ export class ImportProjectMemories {
104
104
  return {
105
105
  content: [{
106
106
  type: 'text',
107
- text: formatHumanReadable({
107
+ text: JSON.stringify({
108
108
  error: `Source schema '${sourceSchema}' not found`,
109
109
  sourceProject,
110
110
  availableSchemas: schemaList || 'none',
111
111
  hint: 'Make sure the source project path is correct and has been used with SpecMem before'
112
- })
112
+ }, null, 2)
113
113
  }]
114
114
  };
115
115
  }
@@ -176,12 +176,12 @@ export class ImportProjectMemories {
176
176
  return {
177
177
  content: [{
178
178
  type: 'text',
179
- text: formatHumanReadable({
179
+ text: JSON.stringify({
180
180
  result: 'No memories found matching criteria in source project',
181
181
  sourceProject,
182
182
  sourceSchema,
183
183
  filters: { tags, memoryTypes, importance, query: query?.slice(0, 50) }
184
- })
184
+ }, null, 2)
185
185
  }]
186
186
  };
187
187
  }
@@ -200,7 +200,7 @@ export class ImportProjectMemories {
200
200
  return {
201
201
  content: [{
202
202
  type: 'text',
203
- text: formatHumanReadable({
203
+ text: JSON.stringify({
204
204
  dryRun: true,
205
205
  wouldImport: sourceMemories.rows.length,
206
206
  sourceProject,
@@ -210,7 +210,7 @@ export class ImportProjectMemories {
210
210
  previewNote: sourceMemories.rows.length > 10
211
211
  ? `Showing 10 of ${sourceMemories.rows.length} memories`
212
212
  : undefined
213
- })
213
+ }, null, 2)
214
214
  }]
215
215
  };
216
216
  }
@@ -315,7 +315,7 @@ export class ImportProjectMemories {
315
315
  return {
316
316
  content: [{
317
317
  type: 'text',
318
- text: formatHumanReadable(result)
318
+ text: JSON.stringify(result, null, 2)
319
319
  }]
320
320
  };
321
321
 
@@ -325,11 +325,11 @@ export class ImportProjectMemories {
325
325
  return {
326
326
  content: [{
327
327
  type: 'text',
328
- text: formatHumanReadable({
328
+ text: JSON.stringify({
329
329
  error: 'Memory import failed',
330
330
  details: errMsg,
331
331
  sourceProject
332
- })
332
+ }, null, 2)
333
333
  }]
334
334
  };
335
335
  }
package/mcp-proxy.cjs CHANGED
@@ -53,50 +53,84 @@ function parseMessages(buffer) {
53
53
  let remaining = buffer;
54
54
 
55
55
  while (remaining.length > 0) {
56
- // Look for Content-Length header
57
- const headerEnd = remaining.indexOf('\r\n\r\n');
58
- if (headerEnd === -1) break;
59
-
60
- const header = remaining.substring(0, headerEnd);
61
- const match = header.match(/Content-Length:\s*(\d+)/i);
62
- if (!match) {
63
- // Skip malformed data
64
- remaining = remaining.substring(headerEnd + 4);
65
- continue;
66
- }
56
+ remaining = remaining.trimStart();
57
+ if (remaining.length === 0) break;
58
+
59
+ // Mode 1: Content-Length framed (MCP spec)
60
+ if (remaining.startsWith('Content-Length:')) {
61
+ const headerEnd = remaining.indexOf('\r\n\r\n');
62
+ if (headerEnd === -1) break;
63
+
64
+ const header = remaining.substring(0, headerEnd);
65
+ const match = header.match(/Content-Length:\s*(\d+)/i);
66
+ if (!match) {
67
+ remaining = remaining.substring(headerEnd + 4);
68
+ continue;
69
+ }
70
+
71
+ const contentLength = parseInt(match[1], 10);
72
+ const bodyStart = headerEnd + 4;
73
+ const bodyEnd = bodyStart + contentLength;
74
+
75
+ if (remaining.length < bodyEnd) break;
67
76
 
68
- const contentLength = parseInt(match[1], 10);
69
- const bodyStart = headerEnd + 4;
70
- const bodyEnd = bodyStart + contentLength;
77
+ const body = remaining.substring(bodyStart, bodyEnd);
78
+ remaining = remaining.substring(bodyEnd);
71
79
 
72
- if (remaining.length < bodyEnd) {
73
- break; // Incomplete message, wait for more data
80
+ try {
81
+ messages.push(JSON.parse(body));
82
+ } catch (e) {
83
+ log(`Parse error (framed): ${e.message}`);
84
+ }
85
+ continue;
74
86
  }
75
87
 
76
- const body = remaining.substring(bodyStart, bodyEnd);
77
- remaining = remaining.substring(bodyEnd);
88
+ // Mode 2: Raw JSON (newline-delimited) — Claude Code sends this
89
+ if (remaining[0] === '{') {
90
+ // Find the end of this JSON object by tracking braces
91
+ let depth = 0;
92
+ let inString = false;
93
+ let escape = false;
94
+ let end = -1;
95
+ for (let i = 0; i < remaining.length; i++) {
96
+ const ch = remaining[i];
97
+ if (escape) { escape = false; continue; }
98
+ if (ch === '\\' && inString) { escape = true; continue; }
99
+ if (ch === '"') { inString = !inString; continue; }
100
+ if (inString) continue;
101
+ if (ch === '{') depth++;
102
+ if (ch === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
103
+ }
104
+ if (end === -1) break; // Incomplete JSON
78
105
 
79
- try {
80
- messages.push(JSON.parse(body));
81
- } catch (e) {
82
- log(`Parse error: ${e.message} body=${body.substring(0, 100)}`);
106
+ const jsonStr = remaining.substring(0, end);
107
+ remaining = remaining.substring(end);
108
+
109
+ try {
110
+ messages.push(JSON.parse(jsonStr));
111
+ } catch (e) {
112
+ log(`Parse error (raw): ${e.message}`);
113
+ }
114
+ continue;
83
115
  }
116
+
117
+ // Skip unknown byte
118
+ remaining = remaining.substring(1);
84
119
  }
85
120
 
86
121
  return { messages, remaining };
87
122
  }
88
123
 
89
- function frameMessage(obj) {
90
- const body = JSON.stringify(obj);
91
- return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
124
+ function serializeMessage(obj) {
125
+ return JSON.stringify(obj) + '\n';
92
126
  }
93
127
 
94
128
  // ============================================================================
95
- // Send message to Claude (stdout)
129
+ // Send message to Claude (stdout) — newline-delimited JSON per MCP SDK
96
130
  // ============================================================================
97
131
  function sendToClient(msg) {
98
132
  try {
99
- process.stdout.write(frameMessage(msg));
133
+ process.stdout.write(serializeMessage(msg));
100
134
  } catch (e) {
101
135
  log(`stdout write error: ${e.message}`);
102
136
  }
@@ -106,8 +140,20 @@ function sendToClient(msg) {
106
140
  // Send message to MCP server (child stdin)
107
141
  // ============================================================================
108
142
  function sendToServer(msg) {
109
- if (!child || !childReady || child.killed) {
110
- // Queue it
143
+ // CRITICAL: Always forward initialize and notifications/initialized immediately
144
+ // even before childReady — these are what MAKE the child ready.
145
+ // Without this, deadlock: proxy waits for init response, bootstrap waits for init request.
146
+ const isInitFlow = msg.method === 'initialize' || msg.method === 'notifications/initialized';
147
+
148
+ if (!child || child.killed) {
149
+ if (pendingQueue.length < MAX_QUEUE_SIZE) {
150
+ pendingQueue.push(msg);
151
+ log(`Queued message (no child) (${pendingQueue.length} pending): ${msg.method || msg.id || '?'}`);
152
+ }
153
+ return;
154
+ }
155
+
156
+ if (!childReady && !isInitFlow) {
111
157
  if (pendingQueue.length < MAX_QUEUE_SIZE) {
112
158
  pendingQueue.push(msg);
113
159
  log(`Queued message (${pendingQueue.length} pending): ${msg.method || msg.id || '?'}`);
@@ -118,7 +164,7 @@ function sendToServer(msg) {
118
164
  }
119
165
 
120
166
  try {
121
- child.stdin.write(frameMessage(msg));
167
+ child.stdin.write(serializeMessage(msg));
122
168
  } catch (e) {
123
169
  log(`child stdin write error: ${e.message}`);
124
170
  pendingQueue.push(msg);
@@ -158,8 +204,8 @@ function spawnServer() {
158
204
  // CRITICAL: Do NOT hardcode --max-old-space-size here
159
205
  // The proxy's own heap limit is set by Claude config args (e.g. --max-old-space-size=250)
160
206
  // but the child bootstrap needs MORE memory for all its initialization
161
- const heapLimit = process.env.SPECMEM_MAX_HEAP_MB || '512';
162
- child = spawn('node', [`--max-old-space-size=${heapLimit}`, BOOTSTRAP_PATH, ...args], {
207
+ const heapLimit = process.env.SPECMEM_MAX_HEAP_MB || '1024';
208
+ child = spawn('node', ['--expose-gc', `--max-old-space-size=${heapLimit}`, BOOTSTRAP_PATH, ...args], {
163
209
  env,
164
210
  stdio: ['pipe', 'pipe', 'pipe'],
165
211
  cwd: process.env.SPECMEM_PROJECT_PATH || process.cwd()
@@ -171,9 +217,12 @@ function spawnServer() {
171
217
  });
172
218
 
173
219
  child.stdout.on('data', (data) => {
174
- childStdoutBuffer += data.toString();
220
+ const raw = data.toString();
221
+ log(`CHILD STDOUT (${raw.length} bytes): ${raw.substring(0, 200)}`);
222
+ childStdoutBuffer += raw;
175
223
 
176
224
  const { messages, remaining } = parseMessages(childStdoutBuffer);
225
+ log(`CHILD PARSED: ${messages.length} messages, ${remaining.length} bytes remaining`);
177
226
  childStdoutBuffer = remaining;
178
227
 
179
228
  for (const msg of messages) {
@@ -222,12 +271,12 @@ function spawnServer() {
222
271
  setTimeout(() => {
223
272
  if (child && !child.killed) {
224
273
  try {
225
- child.stdin.write(frameMessage(lastInitializeRequest));
274
+ child.stdin.write(serializeMessage(lastInitializeRequest));
226
275
  // Also send initialized notification
227
276
  setTimeout(() => {
228
277
  if (child && !child.killed) {
229
278
  try {
230
- child.stdin.write(frameMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }));
279
+ child.stdin.write(serializeMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }));
231
280
  } catch {}
232
281
  }
233
282
  }, 100);
@@ -280,9 +329,12 @@ function stopHeartbeat() {
280
329
  // Handle stdin from Claude
281
330
  // ============================================================================
282
331
  process.stdin.on('data', (data) => {
283
- stdinBuffer += data.toString();
332
+ const raw = data.toString();
333
+ log(`STDIN RAW (${raw.length} bytes): ${raw.substring(0, 200)}`);
334
+ stdinBuffer += raw;
284
335
 
285
336
  const { messages, remaining } = parseMessages(stdinBuffer);
337
+ log(`STDIN PARSED: ${messages.length} messages, ${remaining.length} bytes remaining`);
286
338
  stdinBuffer = remaining;
287
339
 
288
340
  for (const msg of messages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specmem-hardwicksoftware",
3
- "version": "3.7.16",
3
+ "version": "3.7.19",
4
4
  "type": "module",
5
5
  "description": "Persistent memory system for coding sessions - semantic search with pgvector, token compression, team coordination, file watching. Needs root: installs system-wide hooks, manages docker/PostgreSQL, writes global configs, handles screen sessions. justcalljon.pro",
6
6
  "main": "dist/index.js",
@@ -6531,7 +6531,7 @@ async function runAutoSetup(projectPath) {
6531
6531
  SPECMEM_DB_NAME: "specmem_westayunprofessional",
6532
6532
  SPECMEM_DB_USER: "specmem_westayunprofessional",
6533
6533
  SPECMEM_DB_PASSWORD: "specmem_westayunprofessional",
6534
- SPECMEM_MAX_HEAP_MB: "512"
6534
+ SPECMEM_MAX_HEAP_MB: "1024"
6535
6535
  }
6536
6536
  };
6537
6537
  claudeJson.projects[projectPath].hasTrustDialogAccepted = true;