specmem-hardwicksoftware 3.7.17 → 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
+ }
67
70
 
68
- const contentLength = parseInt(match[1], 10);
69
- const bodyStart = headerEnd + 4;
70
- const bodyEnd = bodyStart + contentLength;
71
+ const contentLength = parseInt(match[1], 10);
72
+ const bodyStart = headerEnd + 4;
73
+ const bodyEnd = bodyStart + contentLength;
71
74
 
72
- if (remaining.length < bodyEnd) {
73
- break; // Incomplete message, wait for more data
75
+ if (remaining.length < bodyEnd) break;
76
+
77
+ const body = remaining.substring(bodyStart, bodyEnd);
78
+ remaining = remaining.substring(bodyEnd);
79
+
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
  }
@@ -130,7 +164,7 @@ function sendToServer(msg) {
130
164
  }
131
165
 
132
166
  try {
133
- child.stdin.write(frameMessage(msg));
167
+ child.stdin.write(serializeMessage(msg));
134
168
  } catch (e) {
135
169
  log(`child stdin write error: ${e.message}`);
136
170
  pendingQueue.push(msg);
@@ -170,8 +204,8 @@ function spawnServer() {
170
204
  // CRITICAL: Do NOT hardcode --max-old-space-size here
171
205
  // The proxy's own heap limit is set by Claude config args (e.g. --max-old-space-size=250)
172
206
  // but the child bootstrap needs MORE memory for all its initialization
173
- const heapLimit = process.env.SPECMEM_MAX_HEAP_MB || '512';
174
- 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], {
175
209
  env,
176
210
  stdio: ['pipe', 'pipe', 'pipe'],
177
211
  cwd: process.env.SPECMEM_PROJECT_PATH || process.cwd()
@@ -183,9 +217,12 @@ function spawnServer() {
183
217
  });
184
218
 
185
219
  child.stdout.on('data', (data) => {
186
- childStdoutBuffer += data.toString();
220
+ const raw = data.toString();
221
+ log(`CHILD STDOUT (${raw.length} bytes): ${raw.substring(0, 200)}`);
222
+ childStdoutBuffer += raw;
187
223
 
188
224
  const { messages, remaining } = parseMessages(childStdoutBuffer);
225
+ log(`CHILD PARSED: ${messages.length} messages, ${remaining.length} bytes remaining`);
189
226
  childStdoutBuffer = remaining;
190
227
 
191
228
  for (const msg of messages) {
@@ -234,12 +271,12 @@ function spawnServer() {
234
271
  setTimeout(() => {
235
272
  if (child && !child.killed) {
236
273
  try {
237
- child.stdin.write(frameMessage(lastInitializeRequest));
274
+ child.stdin.write(serializeMessage(lastInitializeRequest));
238
275
  // Also send initialized notification
239
276
  setTimeout(() => {
240
277
  if (child && !child.killed) {
241
278
  try {
242
- child.stdin.write(frameMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }));
279
+ child.stdin.write(serializeMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }));
243
280
  } catch {}
244
281
  }
245
282
  }, 100);
@@ -292,9 +329,12 @@ function stopHeartbeat() {
292
329
  // Handle stdin from Claude
293
330
  // ============================================================================
294
331
  process.stdin.on('data', (data) => {
295
- stdinBuffer += data.toString();
332
+ const raw = data.toString();
333
+ log(`STDIN RAW (${raw.length} bytes): ${raw.substring(0, 200)}`);
334
+ stdinBuffer += raw;
296
335
 
297
336
  const { messages, remaining } = parseMessages(stdinBuffer);
337
+ log(`STDIN PARSED: ${messages.length} messages, ${remaining.length} bytes remaining`);
298
338
  stdinBuffer = remaining;
299
339
 
300
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.17",
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;