memento-mcp 0.3.13 → 0.3.14

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": "memento-mcp",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "mcpName": "io.github.myrakrusemark/memento-protocol",
5
5
  "description": "The Memento Protocol — persistent memory for AI agents",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  "dependencies": {
23
23
  "@modelcontextprotocol/sdk": "^1.26.0",
24
24
  "dotenv": "^16.6.1",
25
+ "sharp": "^0.34.5",
25
26
  "zod": "^3.24.2"
26
27
  },
27
28
  "files": [
@@ -3,6 +3,8 @@
3
3
  # JSON output: systemMessage (user sees count) + additionalContext (model sees details).
4
4
  #
5
5
  # Calls /v1/context endpoint for memories + skip list matches.
6
+ # Supports image search: detects pasted images (Claude Code image cache) and
7
+ # file paths in the message, downscales to 224x224, sends to /v1/context.
6
8
 
7
9
  set -o pipefail
8
10
 
@@ -60,6 +62,7 @@ MEMENTO_WS="${MEMENTO_WORKSPACE:-default}"
60
62
 
61
63
  INPUT=$(cat)
62
64
  USER_MESSAGE=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null)
65
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
63
66
 
64
67
  if [ -z "$USER_MESSAGE" ] || [ ${#USER_MESSAGE} -lt 10 ]; then
65
68
  exit 0
@@ -67,16 +70,97 @@ fi
67
70
 
68
71
  QUERY="${USER_MESSAGE:0:500}"
69
72
 
73
+ # --- Image detection ---
74
+ # Collect up to 3 images from two sources:
75
+ # 1. Claude Code image cache (pasted/dropped images, recent only)
76
+ # 2. File paths mentioned in the message text
77
+ IMAGE_PATHS=()
78
+
79
+ # Prong 1: Claude Code image cache — pasted images cached in ~/.claude/image-cache/{session_id}/
80
+ if [ -n "$SESSION_ID" ]; then
81
+ IMAGE_CACHE="$HOME/.claude/image-cache/$SESSION_ID"
82
+ if [ -d "$IMAGE_CACHE" ]; then
83
+ while IFS= read -r img; do
84
+ IMAGE_PATHS+=("$img")
85
+ done < <(find "$IMAGE_CACHE" -maxdepth 1 -name "*.png" -newermt '5 seconds ago' 2>/dev/null | head -3)
86
+ fi
87
+ fi
88
+
89
+ # Prong 2: File paths in message text (explicit image references)
90
+ if [ ${#IMAGE_PATHS[@]} -lt 3 ]; then
91
+ REMAINING_SLOTS=$(( 3 - ${#IMAGE_PATHS[@]} ))
92
+ while IFS= read -r img; do
93
+ if [ -f "$img" ]; then
94
+ IMAGE_PATHS+=("$img")
95
+ fi
96
+ done < <(echo "$USER_MESSAGE" | grep -oE '(/[^ ]+\.(jpg|jpeg|png|gif|webp))' | head -"$REMAINING_SLOTS")
97
+ fi
98
+
99
+ # Build images JSON array (base64-encoded, downscaled to 224x224)
100
+ IMAGES_JSON=""
101
+ if [ ${#IMAGE_PATHS[@]} -gt 0 ] && command -v convert &>/dev/null; then
102
+ IMAGES_JSON=$(python3 -c "
103
+ import subprocess, base64, json, sys, os
104
+
105
+ paths = sys.argv[1:]
106
+ images = []
107
+ for p in paths:
108
+ if not os.path.isfile(p):
109
+ continue
110
+ ext = os.path.splitext(p)[1].lower()
111
+ mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp'}
112
+ mimetype = mime_map.get(ext, 'image/jpeg')
113
+ try:
114
+ result = subprocess.run(
115
+ ['convert', p, '-resize', '224x224^', '-gravity', 'center', '-extent', '224x224', '-quality', '80', 'jpeg:-'],
116
+ capture_output=True, timeout=5
117
+ )
118
+ if result.returncode == 0 and len(result.stdout) > 0:
119
+ b64 = base64.b64encode(result.stdout).decode()
120
+ images.append({'data': b64, 'mimetype': 'image/jpeg'})
121
+ except Exception:
122
+ pass
123
+
124
+ if images:
125
+ print(json.dumps(images))
126
+ else:
127
+ print('')
128
+ " "${IMAGE_PATHS[@]}" 2>/dev/null)
129
+ fi
130
+
70
131
  # Toast: start retrieving
71
132
  "$TOAST" memento "⏳ Retrieving memories..." &>/dev/null
72
133
 
134
+ # Build request body with optional images
135
+ REQUEST_BODY=$(python3 -c "
136
+ import json, sys
137
+
138
+ message = sys.argv[1]
139
+ images_json = sys.argv[2] if len(sys.argv) > 2 else ''
140
+
141
+ body = {
142
+ 'message': message,
143
+ 'include': ['memories', 'skip_list']
144
+ }
145
+
146
+ if images_json:
147
+ try:
148
+ images = json.loads(images_json)
149
+ if images:
150
+ body['images'] = images
151
+ except Exception:
152
+ pass
153
+
154
+ print(json.dumps(body))
155
+ " "$QUERY" "$IMAGES_JSON")
156
+
73
157
  # Call Memento SaaS /v1/context
74
158
  SAAS_OUTPUT=$(curl -s --max-time 8 \
75
159
  -X POST \
76
160
  -H "Authorization: Bearer $MEMENTO_KEY" \
77
161
  -H "X-Memento-Workspace: $MEMENTO_WS" \
78
162
  -H "Content-Type: application/json" \
79
- -d "{\"message\": $(echo "$QUERY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'), \"include\": [\"memories\", \"skip_list\"]}" \
163
+ -d "$REQUEST_BODY" \
80
164
  "$MEMENTO_API/v1/context" 2>/dev/null \
81
165
  | python3 -c "
82
166
  import json, sys
package/src/index.js CHANGED
@@ -257,11 +257,13 @@ Use tags generously — they power recall. Set expiration for time-sensitive fac
257
257
 
258
258
  server.tool(
259
259
  "memento_recall",
260
- `Search stored memories by keyword, tag, or type. Use this before starting work on any topic — someone may have already figured it out.
260
+ `Search stored memories by keyword, tag, type, or image similarity. Use this before starting work on any topic — someone may have already figured it out.
261
261
 
262
- Results are ranked by relevance (keyword match + recency + access frequency). Each recall increments the memory's access count, reinforcing important memories and letting unused ones decay naturally.`,
262
+ Results are ranked by relevance (keyword match + semantic similarity + recency + access frequency). Each recall increments the memory's access count, reinforcing important memories and letting unused ones decay naturally.
263
+
264
+ Supports image search: provide image_path to find visually similar memories. Can combine text query + image for multi-modal search.`,
263
265
  {
264
- query: z.string().describe("Search query (matched against memory content)"),
266
+ query: z.string().optional().describe("Search query (matched against memory content). Required unless image_path is provided."),
265
267
  tags: z.array(z.string()).optional().describe("Filter by tags (matches any)"),
266
268
  type: z
267
269
  .string()
@@ -269,9 +271,57 @@ Results are ranked by relevance (keyword match + recency + access frequency). Ea
269
271
  .describe("Filter by type: fact, decision, observation, instruction"),
270
272
  limit: z.number().optional().describe("Max results (default: 10)"),
271
273
  workspace: z.string().optional().describe('Omit to search your own workspace. Set to a workspace name (e.g. "fathom") to search that workspace instead.'),
274
+ image_path: z.string().optional().describe(
275
+ "Path to an image file to search by visual similarity. Can combine with query for multi-modal search."
276
+ ),
272
277
  },
273
- async ({ query, tags, type, limit, workspace }) => {
274
- const result = await storage.recallMemories(null, { query, tags, type, limit, workspace });
278
+ async ({ query, tags, type, limit, workspace, image_path }) => {
279
+ if (!query && !image_path) {
280
+ return {
281
+ content: [{ type: "text", text: 'At least one of "query" or "image_path" is required.' }],
282
+ isError: true,
283
+ };
284
+ }
285
+
286
+ // Process image if provided
287
+ let images;
288
+ if (image_path) {
289
+ const MIME_MAP = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp" };
290
+ const ext = path.extname(image_path).toLowerCase();
291
+ const mimetype = MIME_MAP[ext];
292
+ if (!mimetype) {
293
+ return {
294
+ content: [{ type: "text", text: `Unsupported image format: ${ext}. Allowed: .jpg, .jpeg, .png, .gif, .webp` }],
295
+ isError: true,
296
+ };
297
+ }
298
+
299
+ let buffer;
300
+ try {
301
+ buffer = fs.readFileSync(image_path);
302
+ } catch (err) {
303
+ return {
304
+ content: [{ type: "text", text: `Cannot read image: ${err.message}` }],
305
+ isError: true,
306
+ };
307
+ }
308
+
309
+ // Downscale to 224x224 via sharp for efficient embedding
310
+ try {
311
+ const sharp = (await import("sharp")).default;
312
+ buffer = await sharp(buffer)
313
+ .resize(224, 224, { fit: "cover" })
314
+ .jpeg({ quality: 80 })
315
+ .toBuffer();
316
+ } catch {
317
+ // If sharp fails, send the original — Nomic resizes internally anyway
318
+ }
319
+
320
+ const data = buffer.toString("base64");
321
+ images = [{ data, mimetype: "image/jpeg" }];
322
+ }
323
+
324
+ const result = await storage.recallMemories(null, { query, tags, type, limit, workspace, images });
275
325
 
276
326
  if (result._raw) {
277
327
  return {
@@ -108,7 +108,22 @@ export class HostedStorageAdapter extends StorageInterface {
108
108
  return { _raw: true, text, isError: false };
109
109
  }
110
110
 
111
- async recallMemories(_wsPath, { query, tags, type, limit, workspace }) {
111
+ async recallMemories(_wsPath, { query, tags, type, limit, workspace, images }) {
112
+ // Use POST when images are present (GET can't carry binary data)
113
+ if (images?.length > 0) {
114
+ const body = { images };
115
+ if (query) body.query = query;
116
+ if (tags?.length) body.tags = tags;
117
+ if (type) body.type = type;
118
+ if (limit) body.limit = limit;
119
+ // Note: cross-workspace peek not supported for image search
120
+
121
+ const json = await this._fetchJson("POST", "/v1/memories/recall", body);
122
+ if (json.error) return { error: json.error };
123
+ return { _raw: true, text: json.text, memories: json.memories || [], isError: false };
124
+ }
125
+
126
+ // Existing GET path for text-only recall
112
127
  const params = new URLSearchParams({ query, format: "json" });
113
128
  if (tags?.length) params.set("tags", tags.join(","));
114
129
  if (type) params.set("type", type);