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 +2 -1
- package/scripts/memento-userprompt-recall.sh +85 -1
- package/src/index.js +55 -5
- package/src/storage/hosted.js +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memento-mcp",
|
|
3
|
-
"version": "0.3.
|
|
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 "
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/storage/hosted.js
CHANGED
|
@@ -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);
|