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
loop_memory/sdk.py ADDED
@@ -0,0 +1,875 @@
1
+ """Universal Agent Memory SDK.
2
+
3
+ This is the protocol-agnostic surface every Agent (Codex, Claude,
4
+ Hermes, OpenClaw, LangChain, AutoGPT, a custom internal bot, …) uses
5
+ to remember facts, recall context, give feedback, and forget.
6
+
7
+ The SDK deliberately has *zero* third-party dependencies so it
8
+ ships with ``loop-memory`` itself. Two backends are supported:
9
+
10
+ * **In-process** — wraps a :class:`MemoryStore` directly. Use this
11
+ when your agent runs in the same Python process as loop-memory
12
+ (long-running daemon, embedded tool, tests).
13
+ * **HTTP** — talks to a running ``loop-memory serve`` instance over
14
+ ``http://127.0.0.1:7767``. Use this from any other language that
15
+ can speak JSON over HTTP, or from another process. Zero deps:
16
+ uses :mod:`urllib` from the stdlib.
17
+
18
+ The public surface is intentionally small and stable:
19
+
20
+ >>> client = MemoryClient.memory(store)
21
+ >>> client.remember("user prefers dark mode", kind="preference",
22
+ ... tags=["ui"], external_id="pref-dark")
23
+ >>> client.recall("dark mode", limit=5)
24
+ >>> client.feedback(external_id="pref-dark", value="up")
25
+ >>> client.forget(external_id="pref-dark")
26
+ >>> client.close()
27
+
28
+ Every write supports ``external_id`` for idempotency: re-pushing the
29
+ same ``(agent_id, user_id, external_id)`` tuple updates the row in
30
+ place instead of creating a duplicate.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import time
38
+ import urllib.error
39
+ import urllib.parse
40
+ import urllib.request
41
+ from contextlib import AbstractContextManager
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+ from typing import Any, Iterable, Iterator
45
+
46
+ from .sdk_extensions import (
47
+ CognitiveActionView, CognitiveReportView, ExportView, GraphHit,
48
+ ImportView, MemoryClientExt, MemoryClientError, MemoryNamespace,
49
+ SubgraphView, http_delete_json, http_get_json, http_post_json,
50
+ )
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Public dataclasses — the Agent-facing vocabulary
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ @dataclass
59
+ class Memory:
60
+ """Agent-facing view of one memory row.
61
+
62
+ Fields mirror the storage layer but stay JSON-serialisable so
63
+ callers can pass them straight to any LLM prompt.
64
+ """
65
+
66
+ id: str
67
+ text: str
68
+ kind: str
69
+ importance: float
70
+ score: float
71
+ source: str | None
72
+ session_id: str | None
73
+ agent_id: str | None
74
+ user_id: str | None
75
+ external_id: str | None
76
+ tags: list[str] = field(default_factory=list)
77
+ created_at: float = 0.0
78
+ updated_at: float = 0.0
79
+
80
+ @classmethod
81
+ def from_dict(cls, d: dict[str, Any]) -> Memory:
82
+ return cls(
83
+ id=d.get("id", ""),
84
+ text=d.get("text", ""),
85
+ kind=d.get("kind", "fact"),
86
+ importance=float(d.get("importance", 0.5) or 0.5),
87
+ score=float(d.get("score", 0.5) or 0.5),
88
+ source=d.get("source"),
89
+ session_id=d.get("session_id"),
90
+ agent_id=d.get("agent_id"),
91
+ user_id=d.get("user_id"),
92
+ external_id=d.get("external_id"),
93
+ tags=list(d.get("tags") or []),
94
+ created_at=float(d.get("created_at") or 0.0),
95
+ updated_at=float(d.get("updated_at") or 0.0),
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class RecallHit:
101
+ """One ranked item in a recall response.
102
+
103
+ ``kind`` is one of "memory" | "wiki" | "entity" so a single
104
+ Agent prompt can render any of them in a unified way. Memory
105
+ hits carry ``agent_id`` / ``user_id`` / ``external_id`` so the
106
+ caller can route feedback / forget calls back to the store
107
+ without a second lookup.
108
+ """
109
+
110
+ kind: str
111
+ id: str
112
+ text: str
113
+ score: float
114
+ title: str | None = None
115
+ source: str | None = None
116
+ tags: list[str] = field(default_factory=list)
117
+ snippet: str | None = None
118
+ agent_id: str | None = None
119
+ user_id: str | None = None
120
+ external_id: str | None = None
121
+
122
+ @classmethod
123
+ def from_dict(cls, d: dict[str, Any]) -> RecallHit:
124
+ return cls(
125
+ kind=d.get("kind", "memory"),
126
+ id=d.get("id", ""),
127
+ text=d.get("text", ""),
128
+ score=float(d.get("score", 0.0) or 0.0),
129
+ title=d.get("title"),
130
+ source=d.get("source"),
131
+ tags=list(d.get("tags") or []),
132
+ snippet=d.get("preview") or d.get("snippet"),
133
+ agent_id=d.get("agent_id"),
134
+ user_id=d.get("user_id"),
135
+ external_id=d.get("external_id"),
136
+ )
137
+
138
+
139
+ @dataclass
140
+ class RecallResult:
141
+ """The full payload returned by :meth:`MemoryClient.recall`."""
142
+
143
+ query: str
144
+ memories: list[RecallHit] = field(default_factory=list)
145
+ wiki: list[RecallHit] = field(default_factory=list)
146
+ entities: list[RecallHit] = field(default_factory=list)
147
+ temporal_intent: str = "any"
148
+ temporal_confidence: float = 0.0
149
+ source: str | None = None
150
+
151
+ def all(self) -> list[RecallHit]:
152
+ """Return a single sorted stream across all three channels."""
153
+ merged = self.memories + self.wiki + self.entities
154
+ merged.sort(key=lambda h: -h.score)
155
+ return merged
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # SDK base class
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ class MemoryClient(MemoryClientExt, AbstractContextManager):
164
+ """Protocol-agnostic Agent Memory client.
165
+
166
+ Use the two factory constructors to pick a backend:
167
+
168
+ * :meth:`memory` — direct in-process access to a ``MemoryStore``
169
+ * :meth:`http` — talks to a running ``loop-memory serve`` over
170
+ HTTP (zero third-party deps, stdlib only)
171
+ """
172
+
173
+ def __init__(self) -> None:
174
+ self._owns_backend = False
175
+
176
+ # ---- factories -------------------------------------------------------
177
+
178
+ @classmethod
179
+ def memory(cls, store, *, agent_id: str | None = None,
180
+ user_id: str | None = None) -> MemoryClient:
181
+ """Build an SDK bound to an in-process :class:`MemoryStore`."""
182
+ c = _InProcessClient(store)
183
+ if agent_id is not None:
184
+ c._default_agent_id = agent_id
185
+ if user_id is not None:
186
+ c._default_user_id = user_id
187
+ return c
188
+
189
+ @classmethod
190
+ def http(cls, base_url: str = "http://127.0.0.1:7767",
191
+ *, agent_id: str | None = None,
192
+ user_id: str | None = None,
193
+ timeout: float = 10.0) -> MemoryClient:
194
+ """Build an SDK that talks to a running ``loop-memory serve``."""
195
+ c = _HttpClient(base_url=base_url, timeout=timeout)
196
+ if agent_id is not None:
197
+ c._default_agent_id = agent_id
198
+ if user_id is not None:
199
+ c._default_user_id = user_id
200
+ return c
201
+
202
+ # ---- shared state for defaults -------------------------------------
203
+
204
+ _default_agent_id: str | None = None
205
+ _default_user_id: str | None = None
206
+
207
+ # ---- Agent-facing API -----------------------------------------------
208
+
209
+ def remember(
210
+ self,
211
+ text: str,
212
+ *,
213
+ kind: str = "fact",
214
+ importance: float = 0.5,
215
+ tags: list[str] | None = None,
216
+ source: str | None = None,
217
+ session_id: str | None = None,
218
+ external_id: str | None = None,
219
+ agent_id: str | None = None,
220
+ user_id: str | None = None,
221
+ ttl: float | None = None,
222
+ created_at: float | None = None,
223
+ ) -> Memory:
224
+ """Push one memory into long-term storage.
225
+
226
+ ``external_id`` makes the write idempotent: re-pushing the
227
+ same ``(agent_id, user_id, external_id)`` tuple updates the
228
+ row in place. Use a stable per-agent id (e.g. the tool name
229
+ + call args hash) so retries from your Agent don't duplicate.
230
+ """
231
+ raise NotImplementedError
232
+
233
+ def remember_batch(self, items: Iterable[dict[str, Any]]) -> list[Memory]:
234
+ """Push many memories in one call. Same shape as ``remember``."""
235
+ raise NotImplementedError
236
+
237
+ def recall(
238
+ self,
239
+ query: str,
240
+ *,
241
+ limit: int = 8,
242
+ source: str | None = None,
243
+ agent_id: str | None = None,
244
+ user_id: str | None = None,
245
+ include: str = "memories,wiki,entities",
246
+ ) -> RecallResult:
247
+ """Unified search across the user's memory store."""
248
+ raise NotImplementedError
249
+
250
+ def forget(
251
+ self,
252
+ *,
253
+ external_id: str | None = None,
254
+ memory_id: str | None = None,
255
+ agent_id: str | None = None,
256
+ user_id: str | None = None,
257
+ ) -> int:
258
+ """Delete a memory. Returns the number of rows removed (0 or 1)."""
259
+ raise NotImplementedError
260
+
261
+ def feedback(
262
+ self,
263
+ *,
264
+ memory_id: str | None = None,
265
+ external_id: str | None = None,
266
+ value: str = "up",
267
+ reason: str | None = None,
268
+ agent_id: str | None = None,
269
+ user_id: str | None = None,
270
+ ) -> bool:
271
+ """Send 👍/👎 on a memory. ``value`` is 'up' / 'down' / 'ignore'.
272
+
273
+ Returns True if the signal was recorded, False if no matching
274
+ memory was found.
275
+ """
276
+ raise NotImplementedError
277
+
278
+ def list(
279
+ self,
280
+ *,
281
+ agent_id: str | None = None,
282
+ user_id: str | None = None,
283
+ session_id: str | None = None,
284
+ kind: str | None = None,
285
+ limit: int = 50,
286
+ ) -> list[Memory]:
287
+ """List recent memories with simple filters."""
288
+ raise NotImplementedError
289
+
290
+ def close(self) -> None:
291
+ """Release any resources. Default is a no-op."""
292
+ return None
293
+
294
+ # ---- context manager -------------------------------------------------
295
+
296
+ def __exit__(self, exc_type, exc, tb) -> None:
297
+ self.close()
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # In-process backend
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ class _InProcessClient(MemoryClient):
306
+ def __init__(self, store) -> None:
307
+ super().__init__()
308
+ self._store = store
309
+
310
+ def remember(
311
+ self,
312
+ text: str,
313
+ *,
314
+ kind: str = "fact",
315
+ importance: float = 0.5,
316
+ tags: list[str] | None = None,
317
+ source: str | None = None,
318
+ session_id: str | None = None,
319
+ external_id: str | None = None,
320
+ agent_id: str | None = None,
321
+ user_id: str | None = None,
322
+ ttl: float | None = None,
323
+ created_at: float | None = None,
324
+ ) -> Memory:
325
+ if not text or not text.strip():
326
+ raise ValueError("text is required")
327
+ row = self._store.upsert_memory(
328
+ kind=kind,
329
+ text=text.strip(),
330
+ importance=float(importance),
331
+ source=source,
332
+ session_id=session_id,
333
+ tags=tags or [],
334
+ agent_id=agent_id if agent_id is not None else self._default_agent_id,
335
+ user_id=user_id if user_id is not None else self._default_user_id,
336
+ external_id=external_id,
337
+ ttl=ttl,
338
+ created_at=created_at,
339
+ )
340
+ return _memory_from_stored(row)
341
+
342
+ def remember_batch(self, items: Iterable[dict[str, Any]]) -> list[Memory]:
343
+ return [self.remember(**item) for item in items]
344
+
345
+ def recall(
346
+ self,
347
+ query: str,
348
+ *,
349
+ limit: int = 8,
350
+ source: str | None = None,
351
+ agent_id: str | None = None,
352
+ user_id: str | None = None,
353
+ include: str = "memories,wiki,entities",
354
+ ) -> RecallResult:
355
+ # ``recall_hybrid`` is the production code path; fall back to
356
+ # the legacy LIKE-based ``recall`` if a stale DB doesn't have
357
+ # it (older installs, tests).
358
+ wanted = tuple(s.strip() for s in include.split(",") if s.strip())
359
+ if hasattr(self._store, "recall_hybrid"):
360
+ r = self._store.recall_hybrid(
361
+ query, limit=limit, include=wanted,
362
+ bump_signals=True, source=source, level=1,
363
+ )
364
+ else:
365
+ r = self._store.recall(
366
+ query,
367
+ limit=limit,
368
+ include=wanted,
369
+ source=source,
370
+ )
371
+ # Optional post-filter: even if the store returned hits, the
372
+ # caller may want only their agent's namespace.
373
+ def _own(h: dict[str, Any]) -> bool:
374
+ ag = h.get("agent_id")
375
+ ur = h.get("user_id")
376
+ # Global memories (no agent_id, no user_id) are visible
377
+ # to every caller. If either namespace is set, both must
378
+ # match the caller's filter or be unset on the memory.
379
+ if ag is None and ur is None:
380
+ return True
381
+ if agent_id is not None and ag not in (None, agent_id):
382
+ return False
383
+ if user_id is not None and ur not in (None, user_id):
384
+ return False
385
+ return True
386
+ return RecallResult(
387
+ query=query,
388
+ memories=[RecallHit.from_dict(m) for m in r.get("memories", []) if _own(m)],
389
+ wiki=[RecallHit.from_dict(w) for w in r.get("wiki", [])],
390
+ entities=[RecallHit.from_dict(e) for e in r.get("entities", [])],
391
+ temporal_intent=r.get("temporal_intent", "any"),
392
+ temporal_confidence=float(r.get("temporal_confidence", 0.0) or 0.0),
393
+ source=source,
394
+ )
395
+
396
+ def forget(
397
+ self,
398
+ *,
399
+ external_id: str | None = None,
400
+ memory_id: str | None = None,
401
+ agent_id: str | None = None,
402
+ user_id: str | None = None,
403
+ ) -> int:
404
+ if not memory_id and not external_id:
405
+ raise ValueError("forget() needs memory_id or external_id")
406
+ mid = memory_id
407
+ if not mid and external_id:
408
+ target_agent = agent_id if agent_id is not None else self._default_agent_id
409
+ target_user = user_id if user_id is not None else self._default_user_id
410
+ row = self._store.find_memory_by_external_id(
411
+ target_agent or "", external_id, user_id=target_user,
412
+ )
413
+ if row is None:
414
+ return 0
415
+ mid = row.id
416
+ return self._store.delete_memory(mid)
417
+
418
+ def feedback(
419
+ self,
420
+ *,
421
+ memory_id: str | None = None,
422
+ external_id: str | None = None,
423
+ value: str = "up",
424
+ reason: str | None = None,
425
+ agent_id: str | None = None,
426
+ user_id: str | None = None,
427
+ ) -> bool:
428
+ if not memory_id and not external_id:
429
+ raise ValueError("feedback() needs memory_id or external_id")
430
+ if not memory_id:
431
+ target_agent = agent_id if agent_id is not None else self._default_agent_id
432
+ target_user = user_id if user_id is not None else self._default_user_id
433
+ row = self._store.find_memory_by_external_id(
434
+ target_agent or "", external_id, user_id=target_user,
435
+ )
436
+ if row is None:
437
+ return False
438
+ memory_id = row.id
439
+ v = (value or "up").strip().lower()
440
+ if v not in ("up", "down", "ignore"):
441
+ raise ValueError("value must be up|down|ignore")
442
+ # record_signal is the store primitive; mirrors the HTTP
443
+ # feedback endpoint's behaviour. "ignore" is a soft-delete:
444
+ # record the negative signal then remove the row so future
445
+ # recalls stop surfacing it.
446
+ self._store.record_signal(memory_id, positive=(v == "up"))
447
+ if v == "ignore":
448
+ self._store.delete_memory(memory_id)
449
+ return True
450
+
451
+ def list(
452
+ self,
453
+ *,
454
+ agent_id: str | None = None,
455
+ user_id: str | None = None,
456
+ session_id: str | None = None,
457
+ kind: str | None = None,
458
+ limit: int = 50,
459
+ ) -> list[Memory]:
460
+ rows = self._store.list_memories(
461
+ agent_id=agent_id if agent_id is not None else self._default_agent_id,
462
+ user_id=user_id if user_id is not None else self._default_user_id,
463
+ session_id=session_id,
464
+ kind=kind,
465
+ limit=limit,
466
+ )
467
+ return [_memory_from_stored(r) for r in rows]
468
+
469
+ # ---- graph ----------------------------------------------------
470
+
471
+ def remember_edge(self, src: str, dst: str, *,
472
+ kind: str = "relates_to", weight: float = 0.5,
473
+ evidence_id: str | None = None) -> GraphHit:
474
+ from .jobs.graph import upsert_semantic_edge
475
+ info = upsert_semantic_edge(
476
+ self._store, src, dst, kind=kind, weight=weight,
477
+ evidence_id=evidence_id,
478
+ )
479
+ return GraphHit(kind=info["kind"], src=info["src"],
480
+ dst=info["dst"], weight=info["weight"],
481
+ evidence_id=info.get("evidence_id"))
482
+
483
+ def subgraph(self, query: str, *, max_nodes: int = 32,
484
+ max_edges: int = 64) -> SubgraphView:
485
+ from .jobs.graph import subgraph_for
486
+ sg = subgraph_for(self._store, query,
487
+ max_nodes=max_nodes, max_edges=max_edges)
488
+ return SubgraphView.from_dict(sg.to_dict())
489
+
490
+ def rebuild_graph(self) -> int:
491
+ from .graph.build import KnowledgeGraph
492
+ KnowledgeGraph(self._store).rebuild(clear=True)
493
+ return self._store.rebuild_entity_mentions()
494
+
495
+ def recall_adaptive(self, query: str, *, limit: int = 8, **kwargs) -> Any:
496
+ if not hasattr(self._store, "recall_hybrid"):
497
+ return self.recall(query, limit=limit, **kwargs)
498
+ include = ("memories", "wiki", "entities")
499
+ if "include" in kwargs:
500
+ raw = kwargs.pop("include")
501
+ include = tuple(s.strip() for s in raw.split(",") if s.strip()) or include
502
+ r = self._store.recall_hybrid(
503
+ query, limit=limit, include=include, bump_signals=True,
504
+ level=1, adaptive=True,
505
+ )
506
+ return RecallResult(
507
+ query=query,
508
+ memories=[RecallHit.from_dict(m) for m in r.get("memories", [])],
509
+ wiki=[RecallHit.from_dict(w) for w in r.get("wiki", [])],
510
+ entities=[RecallHit.from_dict(e) for e in r.get("entities", [])],
511
+ temporal_intent=r.get("temporal_intent", "any"),
512
+ temporal_confidence=float(r.get("temporal_confidence", 0.0) or 0.0),
513
+ )
514
+
515
+ # ---- cognitive ------------------------------------------------
516
+
517
+ def cognitive_sleep(self, *, apply: bool = False, **kwargs) -> CognitiveReportView:
518
+ from .jobs.cognitive import cognitive_sleep
519
+ rpt = cognitive_sleep(self._store, apply=apply, **kwargs)
520
+ return CognitiveReportView.from_dict(rpt.to_dict())
521
+
522
+ def audit(self, *, kind: str | None = None, action: str | None = None,
523
+ limit: int = 200) -> list[CognitiveActionView]:
524
+ rows = self._store.list_audit(kind=kind, action=action, limit=limit)
525
+ return [CognitiveActionView.from_dict(r) for r in rows]
526
+
527
+ def revert_audit(self, audit_id: str) -> bool:
528
+ self._store.record_audit(
529
+ kind="revert", action="reverted",
530
+ target_kind="memory", target_id=audit_id,
531
+ reason="user marked audit row as reverted",
532
+ )
533
+ return True
534
+
535
+ # ---- export / import / fork -----------------------------------
536
+
537
+ def export(self, out_dir: str, *, agent_id: str | None = None,
538
+ user_id: str | None = None, scope: str = "global",
539
+ min_importance: float = 0.0) -> ExportView:
540
+ from .export import export_bundle
541
+ a = agent_id if agent_id is not None else self._default_agent_id
542
+ u = user_id if user_id is not None else self._default_user_id
543
+ r = export_bundle(self._store, out_dir, agent_id=a, user_id=u,
544
+ scope=scope, min_importance=min_importance)
545
+ return ExportView.from_dict(r.to_dict())
546
+
547
+ def import_bundle(self, in_dir: str, *, agent_id: str | None = None,
548
+ user_id: str | None = None, dry_run: bool = False) -> ImportView:
549
+ from .export import import_bundle
550
+ a = agent_id if agent_id is not None else self._default_agent_id
551
+ u = user_id if user_id is not None else self._default_user_id
552
+ r = import_bundle(self._store, in_dir, agent_id=a, user_id=u,
553
+ dry_run=dry_run)
554
+ return ImportView.from_dict(r.to_dict())
555
+
556
+ def fork(self, branch_tag: str | None = None) -> dict[str, Any]:
557
+ from .export import fork_snapshot
558
+ return fork_snapshot(self._store, branch_tag=branch_tag)
559
+
560
+
561
+
562
+ def _memory_from_stored(row) -> Memory:
563
+ return Memory(
564
+ id=row.id,
565
+ text=row.text,
566
+ kind=row.kind,
567
+ importance=float(row.importance or 0.0),
568
+ score=float(row.score or 0.0),
569
+ source=row.source,
570
+ session_id=row.session_id,
571
+ agent_id=getattr(row, "agent_id", None),
572
+ user_id=getattr(row, "user_id", None),
573
+ external_id=getattr(row, "external_id", None),
574
+ tags=list(row.tags or []),
575
+ created_at=float(row.created_at or 0.0),
576
+ updated_at=float(getattr(row, "updated_at", 0.0) or 0.0),
577
+ )
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # HTTP backend (zero-dep, stdlib only)
582
+ # ---------------------------------------------------------------------------
583
+
584
+
585
+ class _HttpClient(MemoryClient):
586
+ def __init__(self, base_url: str = "http://127.0.0.1:7767", timeout: float = 10.0):
587
+ super().__init__()
588
+ self._base = base_url.rstrip("/")
589
+ self._timeout = timeout
590
+
591
+ def _request(self, method: str, path: str, body: dict | None = None) -> dict:
592
+ url = f"{self._base}{path}"
593
+ data = None
594
+ headers = {"Accept": "application/json"}
595
+ if body is not None:
596
+ data = json.dumps(body).encode("utf-8")
597
+ headers["Content-Type"] = "application/json"
598
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
599
+ try:
600
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
601
+ raw = resp.read().decode("utf-8")
602
+ except urllib.error.HTTPError as e:
603
+ raw = e.read().decode("utf-8", errors="ignore")
604
+ raise MemoryClientError(
605
+ f"{method} {path} → {e.code}: {raw[:400]}"
606
+ ) from e
607
+ except urllib.error.URLError as e:
608
+ raise MemoryClientError(f"cannot reach {self._base}: {e}") from e
609
+ if not raw.strip():
610
+ return {}
611
+ try:
612
+ return json.loads(raw)
613
+ except json.JSONDecodeError as e:
614
+ raise MemoryClientError(f"invalid JSON from {path}: {e}") from e
615
+
616
+ def remember(
617
+ self,
618
+ text: str,
619
+ *,
620
+ kind: str = "fact",
621
+ importance: float = 0.5,
622
+ tags: list[str] | None = None,
623
+ source: str | None = None,
624
+ session_id: str | None = None,
625
+ external_id: str | None = None,
626
+ agent_id: str | None = None,
627
+ user_id: str | None = None,
628
+ ttl: float | None = None,
629
+ created_at: float | None = None,
630
+ ) -> Memory:
631
+ if not text or not text.strip():
632
+ raise ValueError("text is required")
633
+ body = {
634
+ "text": text.strip(),
635
+ "kind": kind,
636
+ "importance": float(importance),
637
+ "tags": list(tags or []),
638
+ "source": source,
639
+ "session_id": session_id,
640
+ "external_id": external_id,
641
+ "agent_id": agent_id if agent_id is not None else self._default_agent_id,
642
+ "user_id": user_id if user_id is not None else self._default_user_id,
643
+ }
644
+ if ttl is not None:
645
+ body["ttl"] = float(ttl)
646
+ if created_at is not None:
647
+ body["created_at"] = float(created_at)
648
+ resp = self._request("POST", "/api/v1/memories", body)
649
+ return Memory.from_dict(resp)
650
+
651
+ def remember_batch(self, items: Iterable[dict[str, Any]]) -> list[Memory]:
652
+ resp = self._request("POST", "/api/v1/memories:batch", {"items": list(items)})
653
+ return [Memory.from_dict(m) for m in resp.get("items", [])]
654
+
655
+ def recall(
656
+ self,
657
+ query: str,
658
+ *,
659
+ limit: int = 8,
660
+ source: str | None = None,
661
+ agent_id: str | None = None,
662
+ user_id: str | None = None,
663
+ include: str = "memories,wiki,entities",
664
+ ) -> RecallResult:
665
+ params = {
666
+ "q": query,
667
+ "limit": int(limit),
668
+ "include": include,
669
+ "source": source or "",
670
+ }
671
+ if agent_id is not None:
672
+ params["agent_id"] = agent_id
673
+ if user_id is not None:
674
+ params["user_id"] = user_id
675
+ qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""})
676
+ resp = self._request("GET", f"/api/v1/recall?{qs}")
677
+ return RecallResult(
678
+ query=query,
679
+ memories=[RecallHit.from_dict(m) for m in resp.get("memories", [])],
680
+ wiki=[RecallHit.from_dict(w) for w in resp.get("wiki", [])],
681
+ entities=[RecallHit.from_dict(e) for e in resp.get("entities", [])],
682
+ temporal_intent=resp.get("temporal_intent", "any"),
683
+ temporal_confidence=float(resp.get("temporal_confidence", 0.0) or 0.0),
684
+ source=source,
685
+ )
686
+
687
+ def forget(
688
+ self,
689
+ *,
690
+ external_id: str | None = None,
691
+ memory_id: str | None = None,
692
+ agent_id: str | None = None,
693
+ user_id: str | None = None,
694
+ ) -> int:
695
+ if memory_id:
696
+ resp = self._request("DELETE", f"/api/v1/memories/{memory_id}")
697
+ return int(resp.get("deleted", 0) or 0)
698
+ if external_id:
699
+ params = {"external_id": external_id}
700
+ if agent_id is not None:
701
+ params["agent_id"] = agent_id
702
+ elif self._default_agent_id is not None:
703
+ params["agent_id"] = self._default_agent_id
704
+ if user_id is not None:
705
+ params["user_id"] = user_id
706
+ elif self._default_user_id is not None:
707
+ params["user_id"] = self._default_user_id
708
+ qs = urllib.parse.urlencode(params)
709
+ resp = self._request("DELETE", f"/api/v1/memories?{qs}")
710
+ return int(resp.get("deleted", 0) or 0)
711
+ raise ValueError("forget() needs memory_id or external_id")
712
+
713
+ def feedback(
714
+ self,
715
+ *,
716
+ memory_id: str | None = None,
717
+ external_id: str | None = None,
718
+ value: str = "up",
719
+ reason: str | None = None,
720
+ agent_id: str | None = None,
721
+ user_id: str | None = None,
722
+ ) -> bool:
723
+ if memory_id:
724
+ body = {"value": value, "reason": reason}
725
+ self._request("POST", f"/api/v1/memories/{memory_id}/feedback", body)
726
+ return True
727
+ if external_id:
728
+ body = {
729
+ "value": value,
730
+ "reason": reason,
731
+ "external_id": external_id,
732
+ "agent_id": agent_id if agent_id is not None else self._default_agent_id,
733
+ "user_id": user_id if user_id is not None else self._default_user_id,
734
+ }
735
+ resp = self._request("POST", "/api/v1/memories/feedback", body)
736
+ return bool(resp.get("ok", False))
737
+ raise ValueError("feedback() needs memory_id or external_id")
738
+
739
+ def list(
740
+ self,
741
+ *,
742
+ agent_id: str | None = None,
743
+ user_id: str | None = None,
744
+ session_id: str | None = None,
745
+ kind: str | None = None,
746
+ limit: int = 50,
747
+ ) -> list[Memory]:
748
+ params: dict[str, Any] = {"limit": int(limit)}
749
+ if agent_id is not None:
750
+ params["agent_id"] = agent_id
751
+ elif self._default_agent_id is not None:
752
+ params["agent_id"] = self._default_agent_id
753
+ if user_id is not None:
754
+ params["user_id"] = user_id
755
+ elif self._default_user_id is not None:
756
+ params["user_id"] = self._default_user_id
757
+ if session_id is not None:
758
+ params["session_id"] = session_id
759
+ if kind is not None:
760
+ params["kind"] = kind
761
+ qs = urllib.parse.urlencode(params)
762
+ resp = self._request("GET", f"/api/v1/memories?{qs}")
763
+ return [Memory.from_dict(m) for m in resp.get("memories", [])]
764
+
765
+ # ---- graph ----------------------------------------------------
766
+
767
+ def remember_edge(self, src: str, dst: str, *,
768
+ kind: str = "relates_to", weight: float = 0.5,
769
+ evidence_id: str | None = None) -> GraphHit:
770
+ body = {"src": src, "dst": dst, "kind": kind, "weight": weight}
771
+ if evidence_id is not None:
772
+ body["evidence_id"] = evidence_id
773
+ resp = self._request("POST", "/api/v1/graph/edges", body)
774
+ return GraphHit.from_dict(resp)
775
+
776
+ def subgraph(self, query: str, *, max_nodes: int = 32,
777
+ max_edges: int = 64) -> SubgraphView:
778
+ qs = urllib.parse.urlencode({
779
+ "q": query, "max_nodes": max_nodes, "max_edges": max_edges,
780
+ })
781
+ resp = self._request("GET", f"/api/v1/graph/subgraph?{qs}")
782
+ return SubgraphView.from_dict(resp)
783
+
784
+ def rebuild_graph(self) -> int:
785
+ resp = self._request("POST", "/api/v1/graph/rebuild", {})
786
+ return int(resp.get("entity_mentions", 0) or 0)
787
+
788
+ def recall_adaptive(self, query: str, *, limit: int = 8, **kwargs) -> Any:
789
+ params = {
790
+ "q": query, "limit": int(limit),
791
+ "include": kwargs.pop("include", "memories,wiki,entities"),
792
+ "adaptive": 1,
793
+ }
794
+ for k, v in kwargs.items():
795
+ if v is not None:
796
+ params[k] = v
797
+ qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""})
798
+ r = self._request("GET", f"/api/v1/recall?{qs}")
799
+ return RecallResult(
800
+ query=query,
801
+ memories=[RecallHit.from_dict(m) for m in r.get("memories", [])],
802
+ wiki=[RecallHit.from_dict(w) for w in r.get("wiki", [])],
803
+ entities=[RecallHit.from_dict(e) for e in r.get("entities", [])],
804
+ temporal_intent=r.get("temporal_intent", "any"),
805
+ temporal_confidence=float(r.get("temporal_confidence", 0.0) or 0.0),
806
+ )
807
+
808
+ # ---- cognitive ------------------------------------------------
809
+
810
+ def cognitive_sleep(self, *, apply: bool = False, **kwargs) -> CognitiveReportView:
811
+ body = {"apply": bool(apply), **{k: v for k, v in kwargs.items() if v is not None}}
812
+ resp = self._request("POST", "/api/v1/cognitive/sleep", body)
813
+ return CognitiveReportView.from_dict(resp)
814
+
815
+ def audit(self, *, kind: str | None = None, action: str | None = None,
816
+ limit: int = 200) -> list[CognitiveActionView]:
817
+ params: dict[str, Any] = {"limit": int(limit)}
818
+ if kind is not None:
819
+ params["kind"] = kind
820
+ if action is not None:
821
+ params["action"] = action
822
+ qs = urllib.parse.urlencode(params)
823
+ resp = self._request("GET", f"/api/v1/cognitive/audit?{qs}")
824
+ return [CognitiveActionView.from_dict(r) for r in resp.get("rows", [])]
825
+
826
+ def revert_audit(self, audit_id: str) -> bool:
827
+ resp = self._request("POST", "/api/v1/cognitive/audit/revert",
828
+ {"id": audit_id})
829
+ return bool(resp.get("ok", False))
830
+
831
+ # ---- export / import / fork -----------------------------------
832
+
833
+ def export(self, out_dir: str, *, agent_id: str | None = None,
834
+ user_id: str | None = None, scope: str = "global",
835
+ min_importance: float = 0.0) -> ExportView:
836
+ body = {
837
+ "out_dir": out_dir,
838
+ "agent_id": agent_id,
839
+ "user_id": user_id,
840
+ "scope": scope,
841
+ "min_importance": float(min_importance),
842
+ }
843
+ body = {k: v for k, v in body.items() if v is not None and v != ""}
844
+ resp = self._request("POST", "/api/v1/export", body)
845
+ return ExportView.from_dict(resp)
846
+
847
+ def import_bundle(self, in_dir: str, *, agent_id: str | None = None,
848
+ user_id: str | None = None, dry_run: bool = False) -> ImportView:
849
+ body = {
850
+ "in_dir": in_dir,
851
+ "agent_id": agent_id,
852
+ "user_id": user_id,
853
+ "dry_run": bool(dry_run),
854
+ }
855
+ body = {k: v for k, v in body.items() if v is not None and v != ""}
856
+ resp = self._request("POST", "/api/v1/import", body)
857
+ return ImportView.from_dict(resp)
858
+
859
+ def fork(self, branch_tag: str | None = None) -> dict[str, Any]:
860
+ body = {"branch_tag": branch_tag} if branch_tag else {}
861
+ return self._request("POST", "/api/v1/fork", body)
862
+
863
+
864
+ # ---------------------------------------------------------------------------
865
+ # Module exports
866
+ # ---------------------------------------------------------------------------
867
+
868
+
869
+ __all__ = [
870
+ "Memory",
871
+ "RecallHit",
872
+ "RecallResult",
873
+ "MemoryClient",
874
+ "MemoryClientError",
875
+ ]