universal-memory-mcp 0.2.3 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-memory-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "MCP Server for persistent AI memory across sessions",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.0.0",
28
- "universal-memory-core": "^0.1.3"
28
+ "universal-memory-core": "^0.1.4"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.11.0",
@@ -12,8 +12,10 @@ import fs from 'fs';
12
12
  import path from 'path';
13
13
  import os from 'os';
14
14
 
15
- const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
16
- const CLAUDE_SKILLS_PATH = path.join(os.homedir(), '.claude', 'skills');
15
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
16
+ const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
17
+ const CLAUDE_SKILLS_PATH = path.join(CLAUDE_DIR, 'skills');
18
+ const CLAUDE_HOOKS_PATH = path.join(CLAUDE_DIR, 'hooks');
17
19
 
18
20
  // MCP server configuration
19
21
  const MCP_CONFIG = {
@@ -155,6 +157,21 @@ memory_update_long_term({
155
157
  - Use project name when in project context
156
158
  `;
157
159
 
160
+ /**
161
+ * Check if Claude Code is installed
162
+ */
163
+ function checkClaudeCodeInstalled() {
164
+ if (!fs.existsSync(CLAUDE_DIR)) {
165
+ console.log('\n⚠️ Claude Code not detected!\n');
166
+ console.log('Please install Claude Code first:');
167
+ console.log(' https://code.claude.com/\n');
168
+ console.log('After installing Claude Code, run:');
169
+ console.log(' npm install -g universal-memory-mcp\n');
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+
158
175
  /**
159
176
  * Read JSON file safely
160
177
  */
@@ -253,6 +270,92 @@ function installSkill() {
253
270
  return true;
254
271
  }
255
272
 
273
+ /**
274
+ * Install Stop hook script
275
+ */
276
+ function installStopHook() {
277
+ console.log('\n🪝 Installing Stop hook...');
278
+
279
+ const hookScriptPath = path.join(CLAUDE_HOOKS_PATH, 'universal-memory-stop-hook.mjs');
280
+
281
+ // Create hooks directory if not exists
282
+ if (!fs.existsSync(CLAUDE_HOOKS_PATH)) {
283
+ fs.mkdirSync(CLAUDE_HOOKS_PATH, { recursive: true });
284
+ console.log(' Created hooks directory');
285
+ }
286
+
287
+ // Get source script path (in the same directory as this postinstall script)
288
+ const sourceScript = new URL('universal-memory-stop-hook.mjs', import.meta.url).pathname;
289
+
290
+ // Check if hook already exists
291
+ if (fs.existsSync(hookScriptPath)) {
292
+ const existingContent = fs.readFileSync(hookScriptPath, 'utf-8');
293
+ const newContent = fs.readFileSync(sourceScript, 'utf-8');
294
+
295
+ if (existingContent === newContent) {
296
+ console.log(' Stop hook already installed (same version)');
297
+ return false;
298
+ }
299
+
300
+ // Backup existing hook
301
+ const backupPath = `${hookScriptPath}.backup.${Date.now()}`;
302
+ fs.copyFileSync(hookScriptPath, backupPath);
303
+ console.log(` Backed up existing hook to: ${backupPath}`);
304
+ }
305
+
306
+ fs.copyFileSync(sourceScript, hookScriptPath);
307
+ fs.chmodSync(hookScriptPath, 0o755); // Make executable
308
+ console.log(' Stop hook installed successfully');
309
+ return true;
310
+ }
311
+
312
+ /**
313
+ * Configure Stop hook in Claude settings
314
+ */
315
+ function configureStopHook() {
316
+ console.log('\n⚙️ Configuring Stop hook...');
317
+
318
+ let settings = readJsonFile(CLAUDE_SETTINGS_PATH) || {};
319
+
320
+ // Initialize hooks if not exists
321
+ if (!settings.hooks) {
322
+ settings.hooks = {};
323
+ }
324
+
325
+ // Initialize Stop hook array if not exists
326
+ if (!settings.hooks.Stop) {
327
+ settings.hooks.Stop = [];
328
+ }
329
+
330
+ // Check if our hook is already configured
331
+ const hookScriptPath = path.join(os.homedir(), '.claude', 'hooks', 'universal-memory-stop-hook.mjs');
332
+ const hookCommand = `node ${hookScriptPath}`;
333
+ const alreadyConfigured = settings.hooks.Stop.some(entry =>
334
+ entry.hooks?.some(hook =>
335
+ hook.type === 'command' && hook.command.includes('universal-memory-stop-hook')
336
+ )
337
+ );
338
+
339
+ if (alreadyConfigured) {
340
+ console.log(' Stop hook already configured');
341
+ return false;
342
+ }
343
+
344
+ // Add Stop hook configuration
345
+ settings.hooks.Stop.push({
346
+ hooks: [
347
+ {
348
+ type: 'command',
349
+ command: hookCommand
350
+ }
351
+ ]
352
+ });
353
+
354
+ writeJsonFile(CLAUDE_SETTINGS_PATH, settings);
355
+ console.log(' Stop hook configured successfully');
356
+ return true;
357
+ }
358
+
256
359
  /**
257
360
  * Main installation
258
361
  */
@@ -261,26 +364,39 @@ function main() {
261
364
  console.log('║ Universal Memory MCP - Setup ║');
262
365
  console.log('╚════════════════════════════════════════════════════════════╝');
263
366
 
367
+ // Check Claude Code installation
368
+ if (!checkClaudeCodeInstalled()) {
369
+ process.exit(0);
370
+ }
371
+
264
372
  let needsRestart = false;
265
373
 
266
374
  try {
267
- // Configure MCP server
375
+ // 1. Configure MCP server
268
376
  const mcpConfigured = configureMcpServer();
269
377
  if (mcpConfigured) needsRestart = true;
270
378
 
271
- // Install skill
379
+ // 2. Install skill
272
380
  const skillInstalled = installSkill();
273
381
  if (skillInstalled) needsRestart = true;
274
382
 
383
+ // 3. Install Stop hook script
384
+ const hookInstalled = installStopHook();
385
+ if (hookInstalled) needsRestart = true;
386
+
387
+ // 4. Configure Stop hook
388
+ const hookConfigured = configureStopHook();
389
+ if (hookConfigured) needsRestart = true;
390
+
275
391
  // Summary
276
392
  console.log('\n' + '═'.repeat(60));
277
393
 
278
394
  if (needsRestart) {
279
395
  console.log('\n✅ Setup complete!\n');
280
- console.log('⚠️ IMPORTANT: Please restart Claude Code to enable the MCP server.\n');
396
+ console.log('⚠️ IMPORTANT: Please restart Claude Code to enable all features.\n');
281
397
  console.log('After restart, Claude will automatically:');
282
398
  console.log(' • Search past conversations when you reference them');
283
- console.log(' • Record important conversations for future recall');
399
+ console.log(' • Record EVERY conversation automatically (via Stop hook)');
284
400
  console.log(' • Remember your preferences and decisions\n');
285
401
  } else {
286
402
  console.log('\n✅ Already configured! No changes needed.\n');
@@ -289,6 +405,7 @@ function main() {
289
405
  console.log('📁 Configuration locations:');
290
406
  console.log(` MCP config: ${CLAUDE_SETTINGS_PATH}`);
291
407
  console.log(` Skill: ${path.join(CLAUDE_SKILLS_PATH, 'memory-assistant', 'SKILL.md')}`);
408
+ console.log(` Stop hook: ${path.join(CLAUDE_HOOKS_PATH, 'universal-memory-stop-hook.mjs')}`);
292
409
  console.log(` Memory storage: ${path.join(os.homedir(), '.ai_memory')}\n`);
293
410
 
294
411
  } catch (error) {
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ // Enable debug logging via environment variable
8
+ const DEBUG = process.env.UNIVERSAL_MEMORY_DEBUG === '1';
9
+
10
+ function debugLog(message) {
11
+ if (DEBUG) {
12
+ fs.appendFileSync('/tmp/universal-memory-stop-hook.log', `[${new Date().toISOString()}] ${message}\n`);
13
+ }
14
+ }
15
+
16
+ function readStdinSync() {
17
+ return fs.readFileSync(0, 'utf8');
18
+ }
19
+
20
+ function isNonEmptyString(value) {
21
+ return typeof value === 'string' && value.trim().length > 0;
22
+ }
23
+
24
+ function contentToText(content) {
25
+ if (typeof content === 'string') return content;
26
+ if (Array.isArray(content)) {
27
+ return content
28
+ .map((item) => {
29
+ if (typeof item === 'string') return item;
30
+ if (item && typeof item === 'object' && typeof item.text === 'string') return item.text;
31
+ if (item && typeof item === 'object' && typeof item.content === 'string') return item.content;
32
+ return '';
33
+ })
34
+ .filter(Boolean)
35
+ .join('\n');
36
+ }
37
+ if (content && typeof content === 'object') {
38
+ if (typeof content.text === 'string') return content.text;
39
+ if (typeof content.content === 'string') return content.content;
40
+ }
41
+ return '';
42
+ }
43
+
44
+ function extractMessages(transcriptJson) {
45
+ if (!transcriptJson || typeof transcriptJson !== 'object') return [];
46
+
47
+ if (Array.isArray(transcriptJson.messages)) return transcriptJson.messages;
48
+ if (Array.isArray(transcriptJson.turns)) return transcriptJson.turns;
49
+ if (Array.isArray(transcriptJson.events)) return transcriptJson.events;
50
+ if (Array.isArray(transcriptJson.transcript)) return transcriptJson.transcript;
51
+
52
+ if (transcriptJson.conversation && Array.isArray(transcriptJson.conversation.messages)) {
53
+ return transcriptJson.conversation.messages;
54
+ }
55
+
56
+ return [];
57
+ }
58
+
59
+ function extractLastExchange(messages) {
60
+ const normalized = messages
61
+ .map((m) => {
62
+ if (!m || typeof m !== 'object') return null;
63
+ const role = m.role || m.author || m.type;
64
+ const content = m.content ?? m.text ?? m.message ?? m.data?.content;
65
+ return {
66
+ role: typeof role === 'string' ? role : '',
67
+ text: contentToText(content),
68
+ };
69
+ })
70
+ .filter(Boolean)
71
+ .filter((m) => isNonEmptyString(m.role) && isNonEmptyString(m.text));
72
+
73
+ const roleNorm = (r) => String(r).toLowerCase();
74
+ const lastUserIdx = (() => {
75
+ for (let i = normalized.length - 1; i >= 0; i--) {
76
+ if (roleNorm(normalized[i].role) === 'user') return i;
77
+ }
78
+ return -1;
79
+ })();
80
+
81
+ const lastAssistantIdxAfterUser = (() => {
82
+ if (lastUserIdx === -1) return -1;
83
+ for (let i = lastUserIdx + 1; i < normalized.length; i++) {
84
+ if (roleNorm(normalized[i].role) === 'assistant') return i;
85
+ }
86
+ return -1;
87
+ })();
88
+
89
+ const lastAssistantIdx = (() => {
90
+ for (let i = normalized.length - 1; i >= 0; i--) {
91
+ if (roleNorm(normalized[i].role) === 'assistant') return i;
92
+ }
93
+ return -1;
94
+ })();
95
+
96
+ const userText = lastUserIdx !== -1 ? normalized[lastUserIdx].text : '';
97
+ const aiText =
98
+ lastAssistantIdxAfterUser !== -1
99
+ ? normalized[lastAssistantIdxAfterUser].text
100
+ : lastAssistantIdx !== -1
101
+ ? normalized[lastAssistantIdx].text
102
+ : '';
103
+
104
+ return { userText, aiText };
105
+ }
106
+
107
+ function truncate(text, maxChars) {
108
+ if (!isNonEmptyString(text)) return '';
109
+ if (text.length <= maxChars) return text;
110
+ return text.slice(0, maxChars) + `\n\n[truncated to ${maxChars} chars]`;
111
+ }
112
+
113
+ function findUp(startDir, relativeTarget) {
114
+ let dir = startDir;
115
+ for (;;) {
116
+ const candidate = path.join(dir, relativeTarget);
117
+ if (fs.existsSync(candidate)) return candidate;
118
+ const parent = path.dirname(dir);
119
+ if (parent === dir) return null;
120
+ dir = parent;
121
+ }
122
+ }
123
+
124
+ function runRecordCommand(payload, cwd) {
125
+ const input = JSON.stringify(payload);
126
+
127
+ const direct = spawnSync('universal-memory-record', ['--json'], {
128
+ input,
129
+ encoding: 'utf8',
130
+ cwd,
131
+ stdio: ['pipe', 'pipe', 'pipe'],
132
+ });
133
+ if (direct.status === 0) return { ok: true, id: direct.stdout.trim() };
134
+
135
+ const npx = spawnSync(
136
+ 'npx',
137
+ ['-y', '--package', 'universal-memory-mcp', 'universal-memory-record', '--json'],
138
+ {
139
+ input,
140
+ encoding: 'utf8',
141
+ cwd,
142
+ stdio: ['pipe', 'pipe', 'pipe'],
143
+ }
144
+ );
145
+ if (npx.status === 0) return { ok: true, id: npx.stdout.trim() };
146
+
147
+ const distPath = findUp(cwd, path.join('packages', 'mcp-server', 'dist', 'record.js'));
148
+ if (!distPath) {
149
+ return {
150
+ ok: false,
151
+ error: 'universal-memory-record not found (PATH/npx) and dist/record.js not found',
152
+ };
153
+ }
154
+
155
+ const fallback = spawnSync('node', [distPath, '--json'], {
156
+ input,
157
+ encoding: 'utf8',
158
+ cwd,
159
+ stdio: ['pipe', 'pipe', 'pipe'],
160
+ });
161
+ if (fallback.status === 0) return { ok: true, id: fallback.stdout.trim() };
162
+ return { ok: false, error: fallback.stderr || fallback.stdout || 'record command failed' };
163
+ }
164
+
165
+ function detectProjectName(cwd) {
166
+ try {
167
+ const gitDir = findUp(cwd, '.git');
168
+ if (gitDir) return path.basename(path.dirname(gitDir));
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ function main() {
176
+ debugLog('Stop hook triggered');
177
+
178
+ const raw = readStdinSync();
179
+ if (!raw.trim()) {
180
+ debugLog('No stdin input, exiting');
181
+ process.exit(0);
182
+ }
183
+
184
+ debugLog(`Received input: ${raw.substring(0, 200)}...`);
185
+
186
+ let hookInput;
187
+ try {
188
+ hookInput = JSON.parse(raw);
189
+ } catch (err) {
190
+ debugLog(`Failed to parse input JSON: ${err.message}`);
191
+ process.exit(0);
192
+ }
193
+
194
+ const cwd = hookInput.cwd || process.cwd();
195
+ const transcriptPath = hookInput.transcript_path || hookInput.transcriptPath;
196
+ const sessionId = hookInput.session_id || hookInput.sessionId;
197
+
198
+ debugLog(`transcript_path: ${transcriptPath}`);
199
+
200
+ if (!isNonEmptyString(transcriptPath)) {
201
+ debugLog('No transcript_path, exiting');
202
+ process.exit(0);
203
+ }
204
+
205
+ let messages;
206
+ try {
207
+ const transcriptContent = fs.readFileSync(transcriptPath, 'utf8');
208
+
209
+ // Parse JSONL format (one JSON object per line)
210
+ const lines = transcriptContent.trim().split('\n').filter(line => line.trim());
211
+ const entries = lines.map(line => JSON.parse(line));
212
+
213
+ debugLog(`Successfully read ${entries.length} transcript entries`);
214
+
215
+ // Extract messages from JSONL entries
216
+ messages = entries
217
+ .filter(entry => entry.type === 'user' || entry.type === 'assistant')
218
+ .map(entry => ({
219
+ role: entry.type === 'user' ? 'user' : 'assistant',
220
+ content: entry.message?.content || entry.content
221
+ }));
222
+
223
+ debugLog(`Extracted ${messages.length} messages from JSONL`);
224
+ } catch (err) {
225
+ debugLog(`Failed to read transcript: ${err.message}`);
226
+ process.exit(0);
227
+ }
228
+
229
+ const { userText, aiText } = extractLastExchange(messages);
230
+ debugLog(`userText length: ${userText.length}, aiText length: ${aiText.length}`);
231
+
232
+ if (!isNonEmptyString(userText) || !isNonEmptyString(aiText)) {
233
+ debugLog('Empty user or AI text, exiting');
234
+ process.exit(0);
235
+ }
236
+
237
+ // Determine client - default to 'claude-code' if not provided
238
+ const client = isNonEmptyString(hookInput.client) ? hookInput.client : 'claude-code';
239
+
240
+ const payload = {
241
+ user_message: truncate(userText, 8000),
242
+ ai_response: truncate(aiText, 20000),
243
+ project: detectProjectName(cwd),
244
+ client: client,
245
+ session_id: isNonEmptyString(sessionId) ? sessionId : undefined,
246
+ working_directory: cwd,
247
+ };
248
+
249
+ debugLog(`Payload: client=${client}, project=${payload.project}, session_id=${payload.session_id}`);
250
+
251
+ const res = runRecordCommand(payload, cwd);
252
+ if (!res.ok) {
253
+ const errorMsg = `universal-memory stop hook warning: ${res.error}\n`;
254
+ process.stderr.write(errorMsg);
255
+ debugLog(`Error: ${res.error}`);
256
+ } else {
257
+ debugLog(`Successfully saved memory: ${res.id}`);
258
+ }
259
+ }
260
+
261
+ main();