memento-mcp 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+ - `precompact-distill` hook supports `model` config option in `.memento.json`: `"llama"` (default, free via Cloudflare Workers AI) or `"claude-code"` (runs `claude -p` locally, better extraction quality, uses API credits).
13
+ - `/v1/context` memory matches now include `created_at` timestamp — enables contradiction resolution and temporal reasoning when comparing recalled memories.
14
+
15
+ ### Changed
16
+ - `source:distill` tag renamed to `source:distill:llama-3.1-8b` to encode model provenance. The `claude-code` path tags memories `source:distill:claude-code`.
17
+
11
18
  ---
12
19
 
13
20
  ## [0.2.0] - 2026-02-20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memento-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "mcpName": "io.github.myrakrusemark/memento-protocol",
5
5
  "description": "The Memento Protocol — persistent memory for AI agents",
6
6
  "type": "module",
package/scripts/README.md CHANGED
@@ -107,12 +107,29 @@ Fires after every assistant response. Uses the assistant's own output as the rec
107
107
 
108
108
  ### `memento-precompact-distill.sh` — PreCompact
109
109
 
110
- Fires before Claude Code compresses the conversation. Parses the full JSONL transcript and sends it to `/v1/distill`, which extracts novel facts, decisions, and observations as stored memories.
110
+ Fires before Claude Code compresses the conversation. Parses the full JSONL transcript and extracts novel facts, decisions, and observations as stored memories. Supports two extraction backends:
111
111
 
112
- - **Timeout:** 30 seconds
112
+ - **`"llama"` (default)** sends transcript to `/v1/distill`, which runs Llama 3.1 8B via Cloudflare Workers AI. Free.
113
+ - **`"claude-code"`** — runs `claude -p` locally for better extraction quality, then pushes to `/v1/memories/ingest`. Uses API credits.
114
+
115
+ Configure the model in `.memento.json`:
116
+
117
+ ```json
118
+ {
119
+ "hooks": {
120
+ "precompact-distill": {
121
+ "enabled": true,
122
+ "model": "claude-code"
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ - **Timeout:** 30s (llama) / 60s (claude-code)
113
129
  - **User sees:** "Memento Distill: extracted N memories"
114
130
  - **Minimum threshold:** Transcripts under 200 characters are skipped
115
131
  - **Transcript parsing:** Extracts user and assistant text from the JSONL format
132
+ - **Source tag:** `source:distill:llama-3.1-8b` or `source:distill:claude-code` — identifies which model extracted each memory
116
133
 
117
134
  **Output format:** JSON with `systemMessage` only (informational).
118
135
 
@@ -54,6 +54,7 @@ print('true' if hook.get('enabled', True) else 'false')
54
54
  MEMENTO_API_KEY="${MEMENTO_API_KEY:-$(echo "$CONFIG_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('apiKey',''))" 2>/dev/null)}"
55
55
  MEMENTO_API_URL="${MEMENTO_API_URL:-$(echo "$CONFIG_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('apiUrl',''))" 2>/dev/null)}"
56
56
  MEMENTO_WORKSPACE="${MEMENTO_WORKSPACE:-$(echo "$CONFIG_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('workspace',''))" 2>/dev/null)}"
57
+ DISTILL_MODEL="${DISTILL_MODEL:-$(echo "$CONFIG_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('hooks',{}).get('precompact-distill',{}).get('model','llama'))" 2>/dev/null)}"
57
58
  fi
58
59
  # --- End config block ---
59
60
 
@@ -81,6 +82,99 @@ if [ ${#TRANSCRIPT_TEXT} -lt 200 ]; then
81
82
  exit 0 # Too short to distill anything useful
82
83
  fi
83
84
 
85
+ DISTILL_MODEL="${DISTILL_MODEL:-llama}"
86
+
87
+ # claude-code path: run extraction locally via claude -p, push to /v1/memories/ingest
88
+ if [ "$DISTILL_MODEL" = "claude-code" ]; then
89
+ PROMPT_FILE=$(mktemp)
90
+ cat > "$PROMPT_FILE" << 'DISTILL_PROMPT'
91
+ You are a memory extraction system. Read the conversation transcript below and extract discrete memories worth remembering long-term.
92
+
93
+ Rules:
94
+ - Extract ONLY genuinely new information — facts, decisions, preferences, instructions, observations, or insights.
95
+ - Each memory should be a single, self-contained statement.
96
+ - Each memory needs a type: "fact", "decision", "instruction", "observation", or "preference".
97
+ - Each memory needs tags (max 6, lowercase, hyphenated). Do NOT include a source tag.
98
+ - If the conversation is trivial, return an empty array.
99
+ - Return ONLY valid JSON — no markdown, no commentary, no code fences.
100
+
101
+ Memory writing style:
102
+ - Lead with the most searchable term: entity names, project names, specific identifiers.
103
+ - Preserve exact values verbatim: IDs, amounts, measurements, dates.
104
+ - Use direct active phrasing.
105
+
106
+ Output format:
107
+ [{"content": "...", "type": "fact", "tags": ["tag1", "tag2"]}]
108
+
109
+ If nothing novel to extract, return: []
110
+
111
+ ---
112
+ TRANSCRIPT:
113
+ DISTILL_PROMPT
114
+ echo "$TRANSCRIPT_TEXT" >> "$PROMPT_FILE"
115
+
116
+ RAW_OUTPUT=$(claude -p "$(cat "$PROMPT_FILE")" 2>/dev/null)
117
+ rm -f "$PROMPT_FILE"
118
+
119
+ CC_SUMMARY=$(echo "$RAW_OUTPUT" | python3 -c "
120
+ import json, sys, re
121
+ from collections import Counter
122
+ raw = sys.stdin.read()
123
+ cleaned = re.sub(r'^\x60{3}(?:json)?\s*', '', raw.strip(), flags=re.IGNORECASE)
124
+ cleaned = re.sub(r'\s*\x60{3}$', '', cleaned.strip())
125
+ try:
126
+ parsed = json.loads(cleaned.strip())
127
+ except Exception:
128
+ match = re.search(r'\[[\s\S]*\]', raw)
129
+ try:
130
+ parsed = json.loads(match.group(0)) if match else []
131
+ except Exception:
132
+ parsed = []
133
+ if not isinstance(parsed, list):
134
+ parsed = []
135
+ count = len(parsed)
136
+ if count == 0:
137
+ print('0|')
138
+ else:
139
+ type_counts = Counter(m.get('type', 'unknown') for m in parsed)
140
+ breakdown = ', '.join(f\"{v} {k}{'s' if v != 1 else ''}\" for k, v in sorted(type_counts.items()))
141
+ print(f'{count}|{breakdown}')
142
+ " 2>/dev/null)
143
+
144
+ MEMORY_COUNT=$(echo "$CC_SUMMARY" | cut -d'|' -f1)
145
+ TYPE_BREAKDOWN=$(echo "$CC_SUMMARY" | cut -d'|' -f2)
146
+
147
+ if [ "${MEMORY_COUNT:-0}" -gt 0 ] 2>/dev/null; then
148
+ INGEST_PAYLOAD=$(echo "$RAW_OUTPUT" | python3 -c "
149
+ import json, sys, re
150
+ raw = sys.stdin.read()
151
+ cleaned = re.sub(r'^\x60{3}(?:json)?\s*', '', raw.strip(), flags=re.IGNORECASE)
152
+ cleaned = re.sub(r'\s*\x60{3}$', '', cleaned.strip())
153
+ try:
154
+ parsed = json.loads(cleaned.strip())
155
+ except Exception:
156
+ match = re.search(r'\[[\s\S]*\]', raw)
157
+ parsed = json.loads(match.group(0)) if match else []
158
+ print(json.dumps({'memories': parsed, 'source': 'distill:claude-code'}))
159
+ " 2>/dev/null)
160
+
161
+ curl -s --max-time 60 \
162
+ -X POST \
163
+ -H "Authorization: Bearer $MEMENTO_KEY" \
164
+ -H "X-Memento-Workspace: $MEMENTO_WS" \
165
+ -H "Content-Type: application/json" \
166
+ -d "$INGEST_PAYLOAD" \
167
+ "$MEMENTO_API/v1/memories/ingest" > /dev/null 2>&1
168
+
169
+ python3 -c "import json,sys; print(json.dumps({'systemMessage': sys.argv[1]}))" \
170
+ "Memento Distill (claude-code): ${MEMORY_COUNT} memories — ${TYPE_BREAKDOWN}"
171
+ else
172
+ python3 -c "import json,sys; print(json.dumps({'systemMessage': sys.argv[1]}))" \
173
+ "Memento Distill (claude-code): no memories extracted"
174
+ fi
175
+ exit 0
176
+ fi
177
+
84
178
  # Send to Memento SaaS /v1/distill
85
179
  RESPONSE=$(curl -s --max-time 30 \
86
180
  -X POST \
@@ -91,27 +185,32 @@ RESPONSE=$(curl -s --max-time 30 \
91
185
  "$MEMENTO_API/v1/distill" 2>/dev/null)
92
186
 
93
187
  # Report results
94
- MEMORY_COUNT=$(echo "$RESPONSE" | python3 -c "
188
+ DISTILL_SUMMARY=$(echo "$RESPONSE" | python3 -c "
95
189
  import json, sys
190
+ from collections import Counter
96
191
  try:
97
192
  data = json.load(sys.stdin)
98
- print(len(data.get('memories', [])))
193
+ memories = data.get('memories', [])
194
+ count = len(memories)
195
+ if count == 0:
196
+ print('0|')
197
+ else:
198
+ type_counts = Counter(m.get('type', 'unknown') for m in memories)
199
+ breakdown = ', '.join(f\"{v} {k}{'s' if v != 1 else ''}\" for k, v in sorted(type_counts.items()))
200
+ print(f'{count}|{breakdown}')
99
201
  except Exception:
100
- print('0')
202
+ print('0|')
101
203
  " 2>/dev/null)
102
204
 
103
- if [ "$MEMORY_COUNT" -gt 0 ] 2>/dev/null; then
104
- python3 -c "
105
- import json, sys
106
- msg = sys.argv[1]
107
- print(json.dumps({'systemMessage': msg}))
108
- " "Memento Distill: extracted ${MEMORY_COUNT} memories"
205
+ MEMORY_COUNT=$(echo "$DISTILL_SUMMARY" | cut -d'|' -f1)
206
+ TYPE_BREAKDOWN=$(echo "$DISTILL_SUMMARY" | cut -d'|' -f2)
207
+
208
+ if [ "${MEMORY_COUNT:-0}" -gt 0 ] 2>/dev/null; then
209
+ python3 -c "import json,sys; print(json.dumps({'systemMessage': sys.argv[1]}))" \
210
+ "Memento Distill: ${MEMORY_COUNT} memories — ${TYPE_BREAKDOWN}"
109
211
  else
110
- python3 -c "
111
- import json, sys
112
- msg = sys.argv[1]
113
- print(json.dumps({'systemMessage': msg}))
114
- " "Memento Distill: no memories extracted"
212
+ python3 -c "import json,sys; print(json.dumps({'systemMessage': sys.argv[1]}))" \
213
+ "Memento Distill: no memories extracted"
115
214
  fi
116
215
 
117
216
  exit 0
@@ -96,7 +96,8 @@ try:
96
96
  tag_str = f' [{\", \".join(tags)}]' if tags else ''
97
97
  content = m['content'][:${RECALL_MAX_LENGTH:-200}]
98
98
  score = m.get('score', '?')
99
- lines.append(f' {m[\"id\"]} ({m[\"type\"]}, {score}){tag_str} {content}')
99
+ date_str = f' {m["created_at"][:10]}' if m.get('created_at') else ''
100
+ lines.append(f' {m[\"id\"]} ({m[\"type\"]}, {score}{date_str}){tag_str} — {content}')
100
101
  count += 1
101
102
 
102
103
  skip_matches = data.get('skip_matches', [])
@@ -89,7 +89,8 @@ try:
89
89
  tag_str = f' [{\", \".join(tags)}]' if tags else ''
90
90
  content = m['content'][:${RECALL_MAX_LENGTH:-200}]
91
91
  score = m.get('score', '?')
92
- lines.append(f' {m[\"id\"]} ({m[\"type\"]}, {score}){tag_str} {content}')
92
+ date_str = f' {m["created_at"][:10]}' if m.get('created_at') else ''
93
+ lines.append(f' {m[\"id\"]} ({m[\"type\"]}, {score}{date_str}){tag_str} — {content}')
93
94
  count += 1
94
95
 
95
96
  # Skip matches (WARNING format)
package/src/config.js CHANGED
@@ -68,5 +68,8 @@ export function resolveConfig(startDir = process.cwd()) {
68
68
  hooks[key] = { ...defaults, ...(fileConfig.hooks?.[key] || {}) };
69
69
  }
70
70
 
71
- return { apiKey, apiUrl, workspace, features, hooks };
71
+ // peek_workspaces: array of workspace names to include in recall/list queries
72
+ const peek_workspaces = fileConfig.peek_workspaces || [];
73
+
74
+ return { apiKey, apiUrl, workspace, features, hooks, peek_workspaces };
72
75
  }
package/src/index.js CHANGED
@@ -39,6 +39,7 @@ const storage = new HostedStorageAdapter({
39
39
  apiKey: config.apiKey,
40
40
  apiUrl: config.apiUrl,
41
41
  workspace: config.workspace,
42
+ peekWorkspaces: config.peek_workspaces,
42
43
  });
43
44
 
44
45
  // ---------------------------------------------------------------------------
@@ -268,9 +269,10 @@ Results are ranked by relevance (keyword match + recency + access frequency). Ea
268
269
  .optional()
269
270
  .describe("Filter by type: fact, decision, observation, instruction"),
270
271
  limit: z.number().optional().describe("Max results (default: 10)"),
272
+ peek_workspaces: z.array(z.string()).optional().describe("Peek into other workspaces (read-only). Returns their memories tagged with workspace name. Max 5."),
271
273
  },
272
- async ({ query, tags, type, limit }) => {
273
- const result = await storage.recallMemories(null, { query, tags, type, limit });
274
+ async ({ query, tags, type, limit, peek_workspaces }) => {
275
+ const result = await storage.recallMemories(null, { query, tags, type, limit, peek_workspaces });
274
276
 
275
277
  if (result._raw) {
276
278
  return {
@@ -522,6 +524,108 @@ Use this before routine checks (news, weather, HN stories) to avoid re-reading t
522
524
  }
523
525
  );
524
526
 
527
+ // ---------------------------------------------------------------------------
528
+ // Tool: memento_skip_list
529
+ // ---------------------------------------------------------------------------
530
+
531
+ server.tool(
532
+ "memento_skip_list",
533
+ `List all skip list entries with their IDs. Use this to find entry IDs for removal with memento_skip_remove.
534
+
535
+ Auto-purges expired entries before returning results.`,
536
+ {},
537
+ async () => {
538
+ const result = await storage.listSkips(null);
539
+
540
+ if (result.error) {
541
+ return { content: [{ type: "text", text: result.error }], isError: true };
542
+ }
543
+
544
+ if (!result.entries || result.entries.length === 0) {
545
+ return {
546
+ content: [{ type: "text", text: "Skip list is empty." }],
547
+ };
548
+ }
549
+
550
+ const formatted = result.entries
551
+ .map((e) => `**${e.id}** "${e.item}"\n Reason: ${e.reason}\n Expires: ${e.expires_at}`)
552
+ .join("\n\n");
553
+
554
+ return {
555
+ content: [
556
+ {
557
+ type: "text",
558
+ text: `${result.total} skip list entr${result.total === 1 ? "y" : "ies"}:\n\n${formatted}`,
559
+ },
560
+ ],
561
+ };
562
+ }
563
+ );
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Tool: memento_skip_remove
567
+ // ---------------------------------------------------------------------------
568
+
569
+ server.tool(
570
+ "memento_skip_remove",
571
+ `Remove a skip list entry by ID. Use memento_skip_list first to find the entry ID.
572
+
573
+ Use this when a skip condition has been resolved early or was added in error.`,
574
+ {
575
+ id: z.string().describe("Skip entry ID to remove"),
576
+ },
577
+ async ({ id }) => {
578
+ const result = await storage.deleteSkip(null, id);
579
+
580
+ if (result._raw) {
581
+ return {
582
+ content: [{ type: "text", text: result.text }],
583
+ ...(result.isError ? { isError: true } : {}),
584
+ };
585
+ }
586
+
587
+ if (result.error) {
588
+ return { content: [{ type: "text", text: result.error }], isError: true };
589
+ }
590
+
591
+ return {
592
+ content: [{ type: "text", text: `Skip entry ${id} removed.` }],
593
+ };
594
+ }
595
+ );
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Tool: memento_memory_delete
599
+ // ---------------------------------------------------------------------------
600
+
601
+ server.tool(
602
+ "memento_memory_delete",
603
+ `Permanently delete a memory by ID. Prefer memento_consolidate over deletion for most cases — consolidation preserves history while sharpening recall.
604
+
605
+ Use deletion only for memories that are incorrect, contain errors, or should never have been stored.`,
606
+ {
607
+ id: z.string().describe("Memory ID to delete"),
608
+ },
609
+ async ({ id }) => {
610
+ const result = await storage.deleteMemory(null, id);
611
+
612
+ if (result._raw) {
613
+ return {
614
+ content: [{ type: "text", text: result.text }],
615
+ ...(result.isError ? { isError: true } : {}),
616
+ };
617
+ }
618
+
619
+ if (result.error) {
620
+ return { content: [{ type: "text", text: result.error }], isError: true };
621
+ }
622
+
623
+ return {
624
+ content: [{ type: "text", text: `Memory ${id} deleted.` }],
625
+ };
626
+ }
627
+ );
628
+
525
629
  // ---------------------------------------------------------------------------
526
630
  // Tool: memento_health
527
631
  // ---------------------------------------------------------------------------
@@ -710,9 +814,10 @@ server.tool(
710
814
  .optional()
711
815
  .describe("Filter by status"),
712
816
  query: z.string().optional().describe("Search title and content"),
817
+ peek_workspaces: z.array(z.string()).optional().describe("Peek into other workspaces (read-only). Returns their items tagged with workspace name. Max 5."),
713
818
  },
714
- async ({ category, status, query }) => {
715
- const result = await storage.listItems(null, { category, status, query });
819
+ async ({ category, status, query, peek_workspaces }) => {
820
+ const result = await storage.listItems(null, { category, status, query, peek_workspaces });
716
821
 
717
822
  if (result.error) {
718
823
  return { content: [{ type: "text", text: result.error }], isError: true };
@@ -17,11 +17,12 @@
17
17
  import { StorageInterface } from "./interface.js";
18
18
 
19
19
  export class HostedStorageAdapter extends StorageInterface {
20
- constructor({ apiKey, apiUrl, workspace }) {
20
+ constructor({ apiKey, apiUrl, workspace, peekWorkspaces }) {
21
21
  super();
22
22
  this.apiKey = apiKey;
23
23
  this.apiUrl = apiUrl.replace(/\/$/, ""); // strip trailing slash
24
24
  this.workspace = workspace || "default";
25
+ this.peekWorkspaces = peekWorkspaces || [];
25
26
  }
26
27
 
27
28
  /**
@@ -108,11 +109,13 @@ export class HostedStorageAdapter extends StorageInterface {
108
109
  return { _raw: true, text, isError: false };
109
110
  }
110
111
 
111
- async recallMemories(_wsPath, { query, tags, type, limit }) {
112
+ async recallMemories(_wsPath, { query, tags, type, limit, peek_workspaces }) {
112
113
  const params = new URLSearchParams({ query, format: "json" });
113
114
  if (tags?.length) params.set("tags", tags.join(","));
114
115
  if (type) params.set("type", type);
115
116
  if (limit) params.set("limit", String(limit));
117
+ const peek = peek_workspaces?.length ? peek_workspaces : this.peekWorkspaces;
118
+ if (peek?.length) params.set("peek_workspaces", peek.join(","));
116
119
 
117
120
  const json = await this._fetchJson(
118
121
  "GET",
@@ -149,6 +152,24 @@ export class HostedStorageAdapter extends StorageInterface {
149
152
  return { _raw: true, text, isError: false };
150
153
  }
151
154
 
155
+ async listSkips(_wsPath) {
156
+ const json = await this._fetchJson("GET", "/v1/skip-list");
157
+ if (json.error) return { error: json.error };
158
+ return json;
159
+ }
160
+
161
+ async deleteSkip(_wsPath, id) {
162
+ const { text, isError } = await this._fetch("DELETE", `/v1/skip-list/${id}`);
163
+ if (isError) return { error: text };
164
+ return { _raw: true, text, isError: false };
165
+ }
166
+
167
+ async deleteMemory(_wsPath, id) {
168
+ const { text, isError } = await this._fetch("DELETE", `/v1/memories/${id}`);
169
+ if (isError) return { error: text };
170
+ return { _raw: true, text, isError: false };
171
+ }
172
+
152
173
  async checkSkip(_wsPath, query) {
153
174
  const params = new URLSearchParams({ query });
154
175
  const { text, isError } = await this._fetch(
@@ -188,6 +209,8 @@ export class HostedStorageAdapter extends StorageInterface {
188
209
  if (filters.category) params.set("category", filters.category);
189
210
  if (filters.status) params.set("status", filters.status);
190
211
  if (filters.query) params.set("q", filters.query);
212
+ const peek = filters.peek_workspaces?.length ? filters.peek_workspaces : this.peekWorkspaces;
213
+ if (peek?.length) params.set("peek_workspaces", peek.join(","));
191
214
  const qs = params.toString();
192
215
  const path = `/v1/working-memory/items${qs ? `?${qs}` : ""}`;
193
216
  const res = await this._fetchJson("GET", path);
@@ -79,6 +79,35 @@ export class StorageInterface {
79
79
  throw new Error("Not implemented");
80
80
  }
81
81
 
82
+ /**
83
+ * List all skip list entries with IDs. Auto-purges expired entries.
84
+ * @param {string} wsPath - Resolved workspace path
85
+ * @returns {Promise<{ entries?: Array, total?: number, error?: string }>}
86
+ */
87
+ async listSkips(wsPath) {
88
+ throw new Error("Not implemented");
89
+ }
90
+
91
+ /**
92
+ * Remove a skip list entry by ID.
93
+ * @param {string} wsPath - Resolved workspace path
94
+ * @param {string} id - Skip entry ID
95
+ * @returns {Promise<{ _raw?: boolean, text?: string, error?: string }>}
96
+ */
97
+ async deleteSkip(wsPath, id) {
98
+ throw new Error("Not implemented");
99
+ }
100
+
101
+ /**
102
+ * Permanently delete a memory by ID.
103
+ * @param {string} wsPath - Resolved workspace path
104
+ * @param {string} id - Memory ID
105
+ * @returns {Promise<{ _raw?: boolean, text?: string, error?: string }>}
106
+ */
107
+ async deleteMemory(wsPath, id) {
108
+ throw new Error("Not implemented");
109
+ }
110
+
82
111
  /**
83
112
  * Report memory system health and stats.
84
113
  * @param {string} wsPath - Resolved workspace path