loop-memory 0.4.0__py3-none-any.whl

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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,804 @@
1
+ """Minimal MCP server over stdio (JSON-RPC 2.0).
2
+
3
+ Exposes loop-memory's distilled wiki and search to any MCP-compatible
4
+ client (Codex CLI, Claude Code, Hermes, …). Zero third-party deps —
5
+ the protocol is small enough to implement directly.
6
+
7
+ Wire format: one JSON message per line (newline-delimited JSON).
8
+ We deliberately use line-delimited instead of the standard LSP-style
9
+ Content-Length headers because every MCP client we care about
10
+ (Codex CLI, Claude Code, Hermes) accepts newline-delimited JSON.
11
+
12
+ Tools exposed:
13
+
14
+ * ``recall(query, limit=8)`` - full-text + entity search over memories
15
+ * ``list_wiki(limit=20)`` - list distilled wiki pages
16
+ * ``get_wiki(slug)`` - full body of one wiki page
17
+ * ``recent_memories(limit=20)`` - newest memories (for warm-start)
18
+ * ``wiki_summary()`` - one-paragraph "what we know about you"
19
+
20
+ All tools return a single text block. The clients surface that text
21
+ directly to the model.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import sys
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from ..storage.sqlite_store import MemoryStore
32
+
33
+ log = logging.getLogger("loop_memory.mcp")
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Tool implementations
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _store() -> MemoryStore:
42
+ """Lazy import to avoid file-touching on every request."""
43
+ import os
44
+ from pathlib import Path
45
+ db = os.environ.get("LOOP_MEMORY_DB") or str(
46
+ Path.home() / ".loop_memory" / "loop_memory.db"
47
+ )
48
+ return MemoryStore(db)
49
+
50
+
51
+ def _agent_context() -> tuple[str | None, str | None]:
52
+ """Return the (agent_id, user_id) tuple for this MCP session.
53
+
54
+ The defaults come from environment variables so a process started
55
+ per-client (``loop-memory mcp`` launched by Codex, Claude Code,
56
+ Hermes, …) can stamp every write with its own identity without
57
+ the LLM having to remember to set it on every call.
58
+ """
59
+ import os
60
+ return (
61
+ os.environ.get("LOOP_MEMORY_AGENT_ID") or None,
62
+ os.environ.get("LOOP_MEMORY_USER_ID") or None,
63
+ )
64
+
65
+
66
+ def _clip(text: str, n: int) -> str:
67
+ t = (text or "").strip()
68
+ if len(t) <= n:
69
+ return t
70
+ return t[: n - 1] + "…"
71
+
72
+
73
+ def tool_recall(arguments: dict[str, Any]) -> list[dict[str, Any]]:
74
+ query = (arguments.get("query") or "").strip()
75
+ limit = int(arguments.get("limit") or 8)
76
+ limit = max(1, min(limit, 50))
77
+ if not query:
78
+ return [_err("missing 'query' argument")]
79
+ store = _store()
80
+ source = arguments.get("source") or _agent_context()[0]
81
+ r = store.recall(query, limit=limit, source=source)
82
+ n_mem = len(r["memories"])
83
+ n_wiki = len(r["wiki"])
84
+ n_ent = len(r["entities"])
85
+ if not (n_mem or n_wiki or n_ent):
86
+ return [_text(
87
+ f"No memories or wiki pages match {query!r} yet. "
88
+ "If you just finished a conversation, wait ~60s for the "
89
+ "watcher to ingest it, then try again."
90
+ )]
91
+ lines = [
92
+ f"# Recall: {query!r}",
93
+ f"_({n_mem} memories · {n_wiki} wiki pages · {n_ent} entities)_",
94
+ "",
95
+ ]
96
+ if r["wiki"]:
97
+ lines.append("## Distilled knowledge (wiki)")
98
+ for w in r["wiki"][:max(2, limit // 2)]:
99
+ tag_s = " [" + ", ".join(w.get("tags") or []) + "]" if w.get("tags") else ""
100
+ lines.append(f"- **{w.get('title')}** (`{w.get('slug')}`){tag_s} — imp {w.get('importance', 0):.2f}")
101
+ if w.get("summary"):
102
+ lines.append(f" > {_clip(w['summary'], 200)}")
103
+ elif w.get("body"):
104
+ lines.append(f" {_clip(w['body'], 200)}")
105
+ lines.append("")
106
+ if r["memories"]:
107
+ lines.append("## Raw memories")
108
+ for m in r["memories"][:limit]:
109
+ meta = []
110
+ if m.get("kind"):
111
+ meta.append(m["kind"])
112
+ if m.get("tags"):
113
+ meta.append("tags=" + ",".join(m["tags"][:3]))
114
+ if m.get("importance"):
115
+ meta.append(f"imp={m['importance']:.2f}")
116
+ when = ""
117
+ try:
118
+ from datetime import datetime
119
+ when = " · " + datetime.fromtimestamp(float(m["created_at"])).strftime("%Y-%m-%d")
120
+ except Exception:
121
+ pass
122
+ lines.append(f"- [{' · '.join(meta)}{when}]")
123
+ lines.append(f" {_clip(m['text'], 240)}")
124
+ lines.append("")
125
+ if r["entities"]:
126
+ lines.append("## Entities")
127
+ for e in r["entities"][:limit]:
128
+ lines.append(
129
+ f"- {e['name']} _({e['entity_kind']}, w={e['weight']:.2f}, "
130
+ f"mentions={e['mention_count']})_"
131
+ )
132
+ return [_text("\n".join(lines))]
133
+
134
+
135
+ def tool_list_wiki(arguments: dict[str, Any]) -> list[dict[str, Any]]:
136
+ limit = int(arguments.get("limit") or 20)
137
+ limit = max(1, min(limit, 100))
138
+ store = _store()
139
+ pages = store.list_wiki_pages(limit=limit)
140
+ if not pages:
141
+ return [_text("No distilled wiki pages yet. Run an AI consolidation pass first.")]
142
+ lines = [f"# Distilled wiki ({len(pages)} page{'s' if len(pages)!=1 else ''})", ""]
143
+ for p in pages:
144
+ tags = ", ".join(p.get("tags") or [])
145
+ meta = f"v{p.get('version',1)} · imp={p.get('importance',0):.2f}"
146
+ if tags:
147
+ meta += f" · [{tags}]"
148
+ lines.append(f"- **{p.get('title', p.get('slug','?'))}** (`{p.get('slug')}`) — {meta}")
149
+ if p.get("summary"):
150
+ lines.append(f" > {_clip(p['summary'], 200)}")
151
+ return [_text("\n".join(lines))]
152
+
153
+
154
+ def tool_get_wiki(arguments: dict[str, Any]) -> list[dict[str, Any]]:
155
+ slug = (arguments.get("slug") or "").strip()
156
+ if not slug:
157
+ return [_err("missing 'slug' argument")]
158
+ store = _store()
159
+ page = store.get_wiki_page_by_slug(slug) or store.get_wiki_page(slug)
160
+ if not page:
161
+ return [_text(f"No wiki page found for slug {slug!r}.")]
162
+ tags = ", ".join(page.get("tags") or [])
163
+ header = (
164
+ f"# {page.get('title', page.get('slug'))}\n"
165
+ f"slug: {page.get('slug')} · version {page.get('version',1)} · "
166
+ f"importance {page.get('importance',0):.2f}"
167
+ )
168
+ if tags:
169
+ header += f"\ntags: {tags}"
170
+ body = (page.get("body") or "").strip() or "(empty)"
171
+ summary = page.get("summary") or ""
172
+ parts = [header]
173
+ if summary:
174
+ parts.append("")
175
+ parts.append(f"## Summary\n{summary}")
176
+ parts.append("")
177
+ parts.append(f"## Body\n{body}")
178
+ return [_text("\n".join(parts))]
179
+
180
+
181
+ def tool_recent_memories(arguments: dict[str, Any]) -> list[dict[str, Any]]:
182
+ limit = int(arguments.get("limit") or 20)
183
+ limit = max(1, min(limit, 100))
184
+ store = _store()
185
+ rows = store.list_memories(limit=limit)
186
+ if not rows:
187
+ return [_text("No memories stored yet.")]
188
+ lines = [f"# Recent memories ({len(rows)})", ""]
189
+ for r in rows:
190
+ try:
191
+ from datetime import datetime
192
+ when = datetime.fromtimestamp(float(r.created_at)).strftime("%Y-%m-%d %H:%M")
193
+ except Exception:
194
+ when = "?"
195
+ lines.append(
196
+ f"- **{r.kind or 'memory'}** ({when}, imp={r.importance or 0:.2f}): "
197
+ f"{_clip(r.text, 220)}"
198
+ )
199
+ return [_text("\n".join(lines))]
200
+
201
+
202
+ def tool_wiki_summary(arguments: dict[str, Any]) -> list[dict[str, Any]]:
203
+ store = _store()
204
+ pages = store.list_wiki_pages(limit=200, min_importance=0.4)
205
+ if not pages:
206
+ return [_text(
207
+ "No high-importance wiki pages yet. The distillation pipeline "
208
+ "needs to run at least once (open the web UI → AI Consolidate)."
209
+ )]
210
+ # Group by leading tag if possible
211
+ lines = [
212
+ f"# What we know about you (top {len(pages)} distilled pages)",
213
+ "",
214
+ "This is a digest of the user's long-term memory: their preferences, "
215
+ "decisions, ongoing projects, and the facts they have validated. "
216
+ "Treat this as ground truth unless the user contradicts it.",
217
+ "",
218
+ ]
219
+ for p in pages[:12]:
220
+ tags = p.get("tags") or []
221
+ tag_s = f" [{', '.join(tags[:4])}]" if tags else ""
222
+ lines.append(f"## {p.get('title', p.get('slug'))}{tag_s}")
223
+ if p.get("summary"):
224
+ lines.append(_clip(p["summary"], 320))
225
+ else:
226
+ lines.append(_clip(p.get("body", "") or "", 320))
227
+ lines.append("")
228
+ return [_text("\n".join(lines))]
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # JSON-RPC plumbing
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ def _text(s: str) -> dict[str, Any]:
237
+ return {"type": "text", "text": s}
238
+
239
+
240
+ def _err(s: str) -> dict[str, Any]:
241
+ return {"type": "text", "text": f"⚠ {s}"}
242
+
243
+
244
+
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Write surface — the universal Agent Memory contract
249
+ # ---------------------------------------------------------------------------
250
+ # Until now the MCP server was strictly read-only. Any Agent that
251
+ # wanted to push a fact into long-term memory had to shell out to
252
+ # ``loop-memory write`` or hit ``/api/v1/memories`` over HTTP. These
253
+ # three tools close that gap and make the MCP server self-sufficient
254
+ # for write→read round-trips from any MCP-aware client.
255
+
256
+
257
+ def tool_remember(arguments: dict[str, Any]) -> list[dict[str, Any]]:
258
+ """Push a memory into long-term storage.
259
+
260
+ Args:
261
+ text: required memory body.
262
+ kind: ``fact`` (default) / ``preference`` / ``decision`` /
263
+ ``reflection`` / ``plan`` / ``episode``.
264
+ importance: 0..1; default 0.5.
265
+ tags: list of strings.
266
+ source: free-form source pointer (e.g. tool name, URL).
267
+ session_id: optional session id this memory belongs to.
268
+ external_id: optional stable id; re-calling with the same
269
+ ``(agent_id, user_id, external_id)`` updates
270
+ the row in place. ``agent_id`` / ``user_id``
271
+ come from ``LOOP_MEMORY_AGENT_ID`` /
272
+ ``LOOP_MEMORY_USER_ID`` env when not given.
273
+ """
274
+ text = (arguments.get("text") or "").strip()
275
+ if not text:
276
+ return [_err("missing 'text' argument")]
277
+ kind = (arguments.get("kind") or "fact").strip()
278
+ importance = arguments.get("importance", 0.5)
279
+ try:
280
+ importance_f = float(importance)
281
+ except (TypeError, ValueError):
282
+ return [_err(f"importance must be a number, got {importance!r}")]
283
+ importance_f = max(0.0, min(1.0, importance_f))
284
+ tags = arguments.get("tags") or []
285
+ if not isinstance(tags, list):
286
+ return [_err("tags must be a list of strings")]
287
+ ext = arguments.get("external_id")
288
+ if ext is not None:
289
+ ext = str(ext).strip() or None
290
+ agent_id, user_id = _agent_context()
291
+ agent_id = arguments.get("agent_id") or agent_id
292
+ user_id = arguments.get("user_id") or user_id
293
+ session_id = arguments.get("session_id")
294
+ source = arguments.get("source")
295
+ try:
296
+ row = _store().upsert_memory(
297
+ kind=kind,
298
+ text=text,
299
+ importance=importance_f,
300
+ tags=[str(t) for t in tags],
301
+ source=source,
302
+ session_id=session_id,
303
+ agent_id=agent_id,
304
+ user_id=user_id,
305
+ external_id=ext,
306
+ )
307
+ except Exception as e:
308
+ return [_err(f"remember() failed: {e}")]
309
+ out = (
310
+ f"✅ remembered ({row.id})\n"
311
+ f" text: {_clip(text, 160)}\n"
312
+ f" kind={row.kind} importance={row.importance:.2f} "
313
+ f"agent_id={agent_id or '-'} external_id={row.external_id or '-'}"
314
+ )
315
+ return [_text(out)]
316
+
317
+
318
+ def tool_forget(arguments: dict[str, Any]) -> list[dict[str, Any]]:
319
+ """Delete a memory by id or by ``(agent_id, user_id, external_id)``.
320
+
321
+ Returns the number of rows removed (0 or 1).
322
+ """
323
+ mid = (arguments.get("id") or "").strip() or None
324
+ ext = arguments.get("external_id")
325
+ if ext is not None:
326
+ ext = str(ext).strip() or None
327
+ if not mid and not ext:
328
+ return [_err("forget() needs 'id' or 'external_id'")]
329
+ agent_id, user_id = _agent_context()
330
+ agent_id = arguments.get("agent_id") or agent_id
331
+ user_id = arguments.get("user_id") or user_id
332
+ store = _store()
333
+ if not mid and ext:
334
+ row = store.find_memory_by_external_id(
335
+ agent_id or "", ext, user_id=user_id,
336
+ )
337
+ if row is None:
338
+ return [_text(f"No memory matches external_id={ext!r} for this agent.")]
339
+ mid = row.id
340
+ n = store.delete_memory(mid)
341
+ return [_text(f"forget() → deleted={n} (id={mid})")]
342
+
343
+
344
+ def tool_feedback(arguments: dict[str, Any]) -> list[dict[str, Any]]:
345
+ """Record 👍/👎 on a memory by id or by external tuple.
346
+
347
+ ``value`` is 'up' / 'down' / 'ignore'. Returns whether the
348
+ signal was recorded.
349
+ """
350
+ value = (arguments.get("value") or "up").strip().lower()
351
+ if value not in ("up", "down", "ignore"):
352
+ return [_err("value must be up|down|ignore")]
353
+ mid = (arguments.get("id") or "").strip() or None
354
+ ext = arguments.get("external_id")
355
+ if ext is not None:
356
+ ext = str(ext).strip() or None
357
+ if not mid and not ext:
358
+ return [_err("feedback() needs 'id' or 'external_id'")]
359
+ agent_id, user_id = _agent_context()
360
+ agent_id = arguments.get("agent_id") or agent_id
361
+ user_id = arguments.get("user_id") or user_id
362
+ store = _store()
363
+ if not mid and ext:
364
+ row = store.find_memory_by_external_id(
365
+ agent_id or "", ext, user_id=user_id,
366
+ )
367
+ if row is None:
368
+ return [_text(f"No memory matches external_id={ext!r} for this agent.")]
369
+ mid = row.id
370
+ try:
371
+ store.record_signal(mid, positive=(value == "up"))
372
+ except Exception as e:
373
+ return [_err(f"feedback() failed: {e}")]
374
+ deleted = 0
375
+ if value == "ignore":
376
+ deleted = store.delete_memory(mid)
377
+ return [_text(f"feedback({value}) → memory_id={mid} deleted={deleted}")]
378
+
379
+
380
+
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Universal Agent Memory v7 — graph + cognitive tools
385
+ # ---------------------------------------------------------------------------
386
+
387
+
388
+ def tool_remember_edge(arguments: dict[str, Any]) -> list[dict[str, Any]]:
389
+ """Push a high-signal semantic relation between two entities.
390
+
391
+ Mirrors ``MemoryClient.remember_edge``. ``src`` and ``dst``
392
+ are entity names; ``kind`` defaults to ``relates_to`` and
393
+ ``weight`` to 0.5. The ``LOOP_MEMORY_AGENT_ID`` env is *not*
394
+ auto-applied to graph edges because they're a public schema,
395
+ not private memory.
396
+ """
397
+ src = (arguments.get("src") or "").strip()
398
+ dst = (arguments.get("dst") or "").strip()
399
+ if not src or not dst or src == dst:
400
+ return [_err("'src' and 'dst' must be distinct non-empty names")]
401
+ kind = (arguments.get("kind") or "relates_to").strip() or "relates_to"
402
+ try:
403
+ weight = float(arguments.get("weight", 0.5))
404
+ except (TypeError, ValueError):
405
+ return [_err(f"weight must be a number, got {arguments.get('weight')!r}")]
406
+ try:
407
+ from ..jobs.graph import upsert_semantic_edge
408
+ upsert_semantic_edge(
409
+ _store(), src, dst, kind=kind,
410
+ weight=max(0.0, min(1.5, weight)),
411
+ evidence_id=arguments.get("evidence_id"),
412
+ )
413
+ except Exception as e:
414
+ return [_err(f"remember_edge failed: {e}")]
415
+ return [_text(
416
+ f"edge({src} -[{kind}, w={weight:.2f}]-> {dst}) ✓"
417
+ )]
418
+
419
+
420
+ def tool_subgraph(arguments: dict[str, Any]) -> list[dict[str, Any]]:
421
+ """Return a small subgraph relevant to a free-text query."""
422
+ query = (arguments.get("query") or "").strip()
423
+ if not query:
424
+ return [_err("'query' is required")]
425
+ try:
426
+ from ..jobs.graph import subgraph_for
427
+ sg = subgraph_for(_store(), query)
428
+ except Exception as e:
429
+ return [_err(f"subgraph failed: {e}")]
430
+ nodes = ", ".join(n.get("name", "") for n in sg.nodes)
431
+ edges = ", ".join(f"{e['src']}→{e['dst']}" for e in sg.edges[:10])
432
+ return [_text(
433
+ f"# Subgraph for {query!r}\n"
434
+ f"nodes ({len(sg.nodes)}): {nodes or '(none)'}\n"
435
+ f"edges ({len(sg.edges)}): {edges or '(none)'}\n"
436
+ f"backing memories: {len(sg.memory_ids)}"
437
+ )]
438
+
439
+
440
+ def tool_cognitive_sleep(arguments: dict[str, Any]) -> list[dict[str, Any]]:
441
+ """Run the cognitive sleep sweep. ``apply=true`` actually
442
+ deletes the suggested memories; default is dry-run.
443
+
444
+ Returns a count summary plus a short list of the top actions
445
+ so the LLM can decide whether to call apply=true.
446
+ """
447
+ apply = bool(arguments.get("apply", False))
448
+ try:
449
+ from ..jobs.cognitive import cognitive_sleep
450
+ rpt = cognitive_sleep(
451
+ _store(), apply=apply,
452
+ stale_days=int(arguments.get("stale_days", 90)),
453
+ min_score=float(arguments.get("min_score", 0.2)),
454
+ min_importance=float(arguments.get("min_importance", 0.3)),
455
+ low_value=float(arguments.get("low_value", 0.3)),
456
+ )
457
+ except Exception as e:
458
+ return [_err(f"cognitive_sleep failed: {e}")]
459
+ top = rpt.actions[:5]
460
+ out = [
461
+ f"# Cognitive sleep ({'applied' if apply else 'dry-run'})",
462
+ f"counts: {rpt.counts}",
463
+ f"total: {len(rpt.actions)} actions in {rpt.elapsed_ms:.1f}ms",
464
+ "",
465
+ ]
466
+ for a in top:
467
+ snippet = (a.target_text or "").replace("\n", " ")[:80]
468
+ out.append(f"- [{a.kind}] {snippet} ({a.reason})")
469
+ return [_text("\n".join(out))]
470
+
471
+
472
+ def tool_audit(arguments: dict[str, Any]) -> list[dict[str, Any]]:
473
+ """Read the cognitive audit trail. Filters: ``kind``, ``action``,
474
+ ``limit`` (default 50)."""
475
+ try:
476
+ rows = _store().list_audit(
477
+ kind=arguments.get("kind") or None,
478
+ action=arguments.get("action") or None,
479
+ limit=int(arguments.get("limit", 50)),
480
+ )
481
+ except Exception as e:
482
+ return [_err(f"audit failed: {e}")]
483
+ if not rows:
484
+ return [_text("No audit rows yet — run `cognitive_sleep` first.")]
485
+ lines = [f"# Audit ({len(rows)} rows)"]
486
+ for r in rows[:20]:
487
+ snippet = (r.get("target_text") or "").replace("\n", " ")[:80]
488
+ lines.append(
489
+ f"- [{r.get('action','?')}/{r.get('kind','?')}] {snippet}"
490
+ )
491
+ return [_text("\n".join(lines))]
492
+
493
+
494
+ TOOLS = [
495
+ {
496
+ "name": "recall",
497
+ "description": (
498
+ "Unified search across the user's loop-memory store: returns "
499
+ "ranked wiki pages (curated knowledge), raw memories (with "
500
+ "importance + tags + a short preview), and matching entities "
501
+ "(people, projects, concepts) for any free-text query. Use this "
502
+ "when the user references something specific and you need to "
503
+ "recall context, prior decisions, or earlier conversations. "
504
+ "Handles English + Chinese tokenisation automatically."
505
+ ),
506
+ "inputSchema": {
507
+ "type": "object",
508
+ "properties": {
509
+ "query": {"type": "string", "description": "Free-text query"},
510
+ "limit": {"type": "integer", "description": "Max results (default 8, max 50)"},
511
+ },
512
+ "required": ["query"],
513
+ },
514
+ },
515
+ {
516
+ "name": "list_wiki",
517
+ "description": (
518
+ "List all distilled wiki pages with their slugs, summaries and "
519
+ "importance. Use this to discover what topics the user has "
520
+ "already validated through consolidation."
521
+ ),
522
+ "inputSchema": {
523
+ "type": "object",
524
+ "properties": {
525
+ "limit": {"type": "integer", "description": "Max pages (default 20, max 100)"},
526
+ },
527
+ },
528
+ },
529
+ {
530
+ "name": "get_wiki",
531
+ "description": (
532
+ "Fetch the full body of one distilled wiki page by its slug. "
533
+ "Use this after list_wiki to drill into the topic you need."
534
+ ),
535
+ "inputSchema": {
536
+ "type": "object",
537
+ "properties": {
538
+ "slug": {"type": "string", "description": "Wiki page slug"},
539
+ },
540
+ "required": ["slug"],
541
+ },
542
+ },
543
+ {
544
+ "name": "recent_memories",
545
+ "description": (
546
+ "Return the most recent N memories verbatim. Useful when you "
547
+ "need raw context for a continuation of an earlier session."
548
+ ),
549
+ "inputSchema": {
550
+ "type": "object",
551
+ "properties": {
552
+ "limit": {"type": "integer", "description": "Max memories (default 20, max 100)"},
553
+ },
554
+ },
555
+ },
556
+ {
557
+ "name": "wiki_summary",
558
+ "description": (
559
+ "Return a structured digest of the highest-importance wiki pages. "
560
+ "Use this as a warm-start background block when the user opens "
561
+ "a new conversation, so you immediately know their preferences, "
562
+ "ongoing projects, and validated decisions."
563
+ ),
564
+ "inputSchema": {
565
+ "type": "object",
566
+ "properties": {},
567
+ },
568
+ },
569
+ {
570
+ "name": "remember",
571
+ "description": (
572
+ "Push a single memory into the user's long-term store. Use this "
573
+ "when the user shares a stable preference, decision, fact, or "
574
+ "reflection that should survive across sessions. ``external_id`` "
575
+ "makes the call idempotent: re-pushing the same external_id "
576
+ "updates the row in place. ``importance`` is 0..1 (default 0.5). "
577
+ "If ``LOOP_MEMORY_AGENT_ID`` is set in the environment, every "
578
+ "call is auto-stamped with that agent id so different Agents "
579
+ "don't overwrite each other."
580
+ ),
581
+ "inputSchema": {
582
+ "type": "object",
583
+ "properties": {
584
+ "text": {"type": "string", "description": "Memory body (required)"},
585
+ "kind": {"type": "string", "description": "fact|preference|decision|reflection|plan|episode (default fact)"},
586
+ "importance": {"type": "number", "description": "0..1 (default 0.5)"},
587
+ "tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tags"},
588
+ "source": {"type": "string", "description": "Free-form source pointer"},
589
+ "session_id": {"type": "string", "description": "Session this memory belongs to"},
590
+ "external_id": {"type": "string", "description": "Stable id for idempotent re-pushes"},
591
+ "agent_id": {"type": "string", "description": "Override LOOP_MEMORY_AGENT_ID for this call"},
592
+ "user_id": {"type": "string", "description": "Override LOOP_MEMORY_USER_ID for this call"},
593
+ },
594
+ "required": ["text"],
595
+ },
596
+ },
597
+ {
598
+ "name": "forget",
599
+ "description": (
600
+ "Delete a memory by id or by its ``(agent_id, user_id, "
601
+ "external_id)`` triple. Returns the number of rows removed."
602
+ ),
603
+ "inputSchema": {
604
+ "type": "object",
605
+ "properties": {
606
+ "id": {"type": "string", "description": "Internal memory id"},
607
+ "external_id": {"type": "string", "description": "Stable external id"},
608
+ "agent_id": {"type": "string", "description": "Agent namespace (overrides env)"},
609
+ "user_id": {"type": "string", "description": "User namespace (overrides env)"},
610
+ },
611
+ },
612
+ },
613
+ {
614
+ "name": "feedback",
615
+ "description": (
616
+ "Record 👍/👎 on a memory. ``value`` is 'up' (boost), "
617
+ "'down' (demote), or 'ignore' (demote + soft-delete). "
618
+ "Address by id or by ``(agent_id, user_id, external_id)``."
619
+ ),
620
+ "inputSchema": {
621
+ "type": "object",
622
+ "properties": {
623
+ "value": {"type": "string", "description": "up|down|ignore (default up)"},
624
+ "id": {"type": "string", "description": "Internal memory id"},
625
+ "external_id": {"type": "string", "description": "Stable external id"},
626
+ "agent_id": {"type": "string"},
627
+ "user_id": {"type": "string"},
628
+ },
629
+ "required": ["value"],
630
+ },
631
+ },
632
+ {
633
+ "name": "remember_edge",
634
+ "description": (
635
+ "Push a high-signal semantic relation between two entities "
636
+ "(Mem0's graph-memory differentiator). E.g. "
637
+ "``{src: User, dst: Hangzhou, kind: lives_in, weight: 0.9}``."
638
+ ),
639
+ "inputSchema": {
640
+ "type": "object",
641
+ "properties": {
642
+ "src": {"type": "string", "description": "Source entity name (required)"},
643
+ "dst": {"type": "string", "description": "Destination entity name (required)"},
644
+ "kind": {"type": "string", "description": "lives_in|works_on|uses|prefers|decided|...|relates_to (default)"},
645
+ "weight": {"type": "number", "description": "0..1.5 (default 0.5)"},
646
+ "evidence_id": {"type": "string", "description": "Optional memory id backing this edge"},
647
+ },
648
+ "required": ["src", "dst"],
649
+ },
650
+ },
651
+ {
652
+ "name": "subgraph",
653
+ "description": (
654
+ "Return a small subgraph (entities + edges + backing "
655
+ "memory ids) relevant to a free-text query. Use to ground "
656
+ "a prompt on the user\'s knowledge graph before answering."
657
+ ),
658
+ "inputSchema": {
659
+ "type": "object",
660
+ "properties": {
661
+ "query": {"type": "string", "description": "Free-text query (required)"},
662
+ },
663
+ "required": ["query"],
664
+ },
665
+ },
666
+ {
667
+ "name": "cognitive_sleep",
668
+ "description": (
669
+ "Run the cognitive sweep: identify stale, low-value, "
670
+ "near-duplicate, and contradicted memories. ``apply=true`` "
671
+ "actually deletes the suggestions; the default dry-run "
672
+ "just lists them so the model can decide. Every action "
673
+ "is recorded in the audit trail."
674
+ ),
675
+ "inputSchema": {
676
+ "type": "object",
677
+ "properties": {
678
+ "apply": {"type": "boolean", "description": "Actually delete (default false / dry-run)"},
679
+ "stale_days": {"type": "integer", "description": "Stale cutoff in days (default 90)"},
680
+ "min_score": {"type": "number", "description": "Score below which a memory is stale (default 0.2)"},
681
+ "min_importance": {"type": "number", "description": "Importance below which a memory is stale (default 0.3)"},
682
+ "low_value": {"type": "number", "description": "Score+0.5*importance below which a memory is low-value (default 0.3)"},
683
+ },
684
+ },
685
+ },
686
+ {
687
+ "name": "audit",
688
+ "description": (
689
+ "Read the cognitive audit trail. ``kind`` and ``action`` "
690
+ "filter; ``limit`` caps the rows."
691
+ ),
692
+ "inputSchema": {
693
+ "type": "object",
694
+ "properties": {
695
+ "kind": {"type": "string", "description": "stale|low_value|merge|contradict|forget|revert"},
696
+ "action": {"type": "string", "description": "suggest|applied|reverted"},
697
+ "limit": {"type": "integer", "description": "Max rows (default 50)"},
698
+ },
699
+ },
700
+ },
701
+ ]
702
+
703
+
704
+ TOOL_DISPATCH = {
705
+ "recall": tool_recall,
706
+ "list_wiki": tool_list_wiki,
707
+ "get_wiki": tool_get_wiki,
708
+ "recent_memories": tool_recent_memories,
709
+ "wiki_summary": tool_wiki_summary,
710
+ "remember": tool_remember,
711
+ "forget": tool_forget,
712
+ "feedback": tool_feedback,
713
+ "remember_edge": tool_remember_edge,
714
+ "subgraph": tool_subgraph,
715
+ "cognitive_sleep": tool_cognitive_sleep,
716
+ "audit": tool_audit,
717
+ }
718
+
719
+
720
+ SERVER_INFO = {
721
+ "name": "loop-memory",
722
+ "version": "0.3.0",
723
+ }
724
+
725
+
726
+ CAPABILITIES = {
727
+ "tools": {"listChanged": False},
728
+ }
729
+
730
+
731
+ def _ok(req_id: Any, result: Any) -> dict[str, Any]:
732
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
733
+
734
+
735
+ def _err_resp(req_id: Any, code: int, message: str) -> dict[str, Any]:
736
+ return {
737
+ "jsonrpc": "2.0",
738
+ "id": req_id,
739
+ "error": {"code": code, "message": message},
740
+ }
741
+
742
+
743
+ def _handle(req: dict[str, Any]) -> dict[str, Any] | None:
744
+ """Return a JSON-RPC response or None for notifications."""
745
+ method = req.get("method")
746
+ params = req.get("params") or {}
747
+ rid = req.get("id")
748
+ if method == "initialize":
749
+ return _ok(rid, {
750
+ "protocolVersion": "2024-11-05",
751
+ "serverInfo": SERVER_INFO,
752
+ "capabilities": CAPABILITIES,
753
+ })
754
+ if method == "ping":
755
+ return _ok(rid, {})
756
+ if method == "notifications/initialized":
757
+ return None # no response for notifications
758
+ if method == "tools/list":
759
+ return _ok(rid, {"tools": TOOLS})
760
+ if method == "tools/call":
761
+ name = params.get("name")
762
+ args = params.get("arguments") or {}
763
+ fn = TOOL_DISPATCH.get(name)
764
+ if not fn:
765
+ return _err_resp(rid, -32601, f"unknown tool {name!r}")
766
+ try:
767
+ content = fn(args)
768
+ except Exception as e: # noqa: BLE001
769
+ log.exception("tool %s failed", name)
770
+ content = [_err(f"tool {name} failed: {type(e).__name__}: {e}")]
771
+ return _ok(rid, {"content": content, "isError": False})
772
+ if method == "resources/list":
773
+ return _ok(rid, {"resources": []})
774
+ if method == "prompts/list":
775
+ return _ok(rid, {"prompts": []})
776
+ # Unknown — be lenient and ack with empty result.
777
+ if rid is None:
778
+ return None
779
+ return _ok(rid, {})
780
+
781
+
782
+ def serve_stdio() -> None:
783
+ """Read newline-delimited JSON-RPC from stdin, write to stdout."""
784
+ log.info("loop-memory MCP server starting")
785
+ for raw in sys.stdin:
786
+ line = raw.strip()
787
+ if not line:
788
+ continue
789
+ try:
790
+ req = json.loads(line)
791
+ except Exception as e: # noqa: BLE001
792
+ sys.stdout.write(json.dumps(_err_resp(
793
+ None, -32700, f"parse error: {e}"
794
+ )) + "\n")
795
+ sys.stdout.flush()
796
+ continue
797
+ resp = _handle(req)
798
+ if resp is None:
799
+ continue
800
+ sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
801
+ sys.stdout.flush()
802
+
803
+
804
+ __all__ = ["serve_stdio", "TOOLS", "TOOL_DISPATCH", "SERVER_INFO"]