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,115 @@
1
+ """CLI for Loop Memory.
2
+
3
+ Usage:
4
+
5
+ loop-memory chat # REPL with echo LLM
6
+ loop-memory stats # counters
7
+ loop-memory recall <text> # show top memories
8
+ loop-memory ingest codex # ingest local Codex transcripts
9
+ loop-memory ingest claude # ingest local Claude Code transcripts
10
+ loop-memory ingest hermes # ingest Hermes transcripts
11
+ loop-memory consolidate # rescore + GC + dedupe
12
+ loop-memory rescore [--half-life 30]
13
+ loop-memory serve [--port 7767] # start the local web UI
14
+ loop-memory mcp # stdio MCP server (for codex/claude/hermes)
15
+ loop-memory inject [query] # dump long-term context block (for SessionStart hooks)
16
+ loop-memory install-hooks # auto-write MCP + SessionStart hooks for known clients
17
+ loop-memory consolidate-now # ask the running server to trigger a pass right now
18
+ loop-memory export # legacy markdown export (no positional path)
19
+ loop-memory digest [--out PATH] # compact knowledge digest for AGENTS.md (≤ max-chars bytes)
20
+ loop-memory ask "what about…" # print a paste-ready context block for any LLM client
21
+ loop-memory cognitive-sleep [--apply] # dry-run / apply cognitive sweep (v7)
22
+ loop-memory audit [--kind X] [--action Y] # read the cognitive audit trail
23
+ loop-memory export <out_dir> # write a MEMORY.md bundle (v7)
24
+ loop-memory export-bundle <out_dir> # explicit v7 bundle alias
25
+ loop-memory import <in_dir> # re-hydrate a bundle
26
+ loop-memory fork [--branch-tag T] # snapshot every wiki page
27
+ loop-memory graph-edge <src> <dst> [--kind K] [--weight W] # push a relation
28
+ loop-memory subgraph <query> # print a small subgraph
29
+ loop-memory graph-rebuild # rebuild entities + entity_mentions
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import sys
35
+
36
+ from .commands import cognitive as cognitive_cmd
37
+ from .commands import diag as diag_cmd
38
+ from .commands import graph as graph_cmd
39
+ from .commands import hooks as hooks_cmd
40
+ from .commands import read as read_cmd
41
+ from .commands import serve as serve_cmd
42
+ from .commands import write as write_cmd
43
+
44
+ # Backwards-compatible re-exports. Earlier tests imported these
45
+ # private symbols from this module; keep the names available so
46
+ # external code doesn't break after the 0.3.0 split.
47
+ _upsert_block = hooks_cmd._upsert_block
48
+ cmd_inject = read_cmd.run_inject
49
+
50
+
51
+ def _run_export(args: list[str]) -> int:
52
+ """Keep the legacy markdown export and expose the v7 bundle export.
53
+
54
+ A positional output directory selects the v7 bundle. The historical
55
+ ``--out`` / ``--q`` form remains available so existing scripts keep
56
+ producing a single markdown file.
57
+ """
58
+ if not args or any(flag in args for flag in ("--out", "--q")):
59
+ return read_cmd.run_export(args)
60
+ return cognitive_cmd.run_export(args)
61
+
62
+
63
+ # Dispatch table: command name -> (callable, optional module doc).
64
+ # Each module's ``run_<name>`` function takes ``args: list[str]`` and
65
+ # returns an integer exit code. Keep the keys matching the docstring
66
+ # at the top of this file.
67
+ COMMANDS = {
68
+ "chat": read_cmd.run_chat,
69
+ "stats": read_cmd.run_stats,
70
+ "recall": read_cmd.run_recall,
71
+ "ingest": write_cmd.run_ingest,
72
+ "consolidate": write_cmd.run_consolidate,
73
+ "consolidate-now": write_cmd.run_consolidate_now,
74
+ "export": _run_export,
75
+ "digest": read_cmd.run_digest,
76
+ "ask": read_cmd.run_ask,
77
+ "rescore": write_cmd.run_rescore,
78
+ "serve": serve_cmd.run_serve,
79
+ "hook": serve_cmd.run_hook,
80
+ "mcp": serve_cmd.run_mcp,
81
+ "inject": read_cmd.run_inject,
82
+ "install-hooks": hooks_cmd.run_install_hooks,
83
+ "flush": write_cmd.run_flush,
84
+ "graph": graph_cmd.run_graph,
85
+ "doctor": diag_cmd.run_doctor,
86
+ "status": diag_cmd.run_status,
87
+ "openclaw-setup": diag_cmd.run_openclaw_setup,
88
+ # Universal Agent Memory v7 — graph, cognitive, export, fork
89
+ "cognitive-sleep": cognitive_cmd.run_cognitive_sleep,
90
+ "audit": cognitive_cmd.run_audit,
91
+ "export-bundle": cognitive_cmd.run_export,
92
+ "import": cognitive_cmd.run_import,
93
+ "fork": cognitive_cmd.run_fork,
94
+ "graph-edge": cognitive_cmd.run_graph_edge,
95
+ "subgraph": cognitive_cmd.run_subgraph,
96
+ "graph-rebuild": cognitive_cmd.run_graph_rebuild,
97
+ "wiki-reclassify-legacy": cognitive_cmd.run_wiki_reclassify_legacy,
98
+ }
99
+
100
+
101
+ def main(argv: list | None = None) -> int:
102
+ args = list(sys.argv[1:] if argv is None else argv)
103
+ if not args or args[0] in {"-h", "--help"}:
104
+ print(__doc__)
105
+ return 0
106
+ cmd, rest = args[0], args[1:]
107
+ fn = COMMANDS.get(cmd)
108
+ if fn is None:
109
+ print(f"unknown command: {cmd}", file=sys.stderr)
110
+ return 2
111
+ return fn(rest)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
File without changes
@@ -0,0 +1,247 @@
1
+ """The Loop Engine.
2
+
3
+ Each call to ``engine.turn(user_msg)`` runs the canonical four-stage loop:
4
+
5
+ 1. RETRIEVE — pull relevant items from long-term memory, recent
6
+ episodes, and any open task plan.
7
+ 2. GENERATE — build an augmented prompt and ask the LLM to
8
+ produce an answer.
9
+ 3. REFLECT — turn the new exchange into durable facts.
10
+ 4. STORE — persist facts, record an episode, push to short-term
11
+ scratchpad with periodic compaction + GC.
12
+
13
+ The engine is intentionally small and synchronous. It is the *contract*
14
+ the rest of the project depends on. Replace the LLM, reflector, embedder,
15
+ or vector store to specialize the loop without touching this file.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass, field
24
+
25
+ from ..backends.embedding import BaseEmbedder, IdentityEmbedder
26
+ from ..backends.vector_store import InMemoryVectorStore, VectorStore
27
+ from ..llm.base import ChatHistory, LLMClient, Message
28
+ from ..memory.types import (
29
+ EpisodicMemory,
30
+ LongTermMemory,
31
+ MemoryItem,
32
+ ProceduralMemory,
33
+ ShortTermMemory,
34
+ )
35
+ from .reflect import Reflector, summarize_window
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+
40
+ SYSTEM_PROMPT = (
41
+ "You are a thoughtful assistant with persistent memory.\n"
42
+ "You will be given RELEVANT MEMORIES recalled from long-term storage, "
43
+ "a brief EPISODE LOG of recent events, and any open TASK PLANS. "
44
+ "Use them naturally when they help. If the user is mid-task, continue "
45
+ "the plan instead of starting a new one. Never claim to remember "
46
+ "something that isn't in the memory section."
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class LoopResult:
52
+ reply: str
53
+ retrieved: list[MemoryItem] = field(default_factory=list)
54
+ stored: list[MemoryItem] = field(default_factory=list)
55
+ reflection: list[MemoryItem] = field(default_factory=list)
56
+ diagnostics: dict = field(default_factory=dict)
57
+
58
+
59
+ class LoopEngine:
60
+ def __init__(
61
+ self,
62
+ llm: LLMClient,
63
+ embedder: BaseEmbedder | None = None,
64
+ longterm: LongTermMemory | None = None,
65
+ episodic: EpisodicMemory | None = None,
66
+ procedural: ProceduralMemory | None = None,
67
+ vector_store: VectorStore | None = None,
68
+ short_capacity: int = 16,
69
+ compact_at: int | None = None,
70
+ ) -> None:
71
+ if llm is None:
72
+ raise ValueError("llm is required")
73
+ self.llm = llm
74
+ self.embedder = embedder or IdentityEmbedder()
75
+ self.short = ShortTermMemory(capacity=short_capacity)
76
+ self.long = longterm or LongTermMemory()
77
+ self.episodic = episodic or EpisodicMemory()
78
+ self.procedural = procedural or ProceduralMemory()
79
+ self.vector_store: VectorStore = vector_store or InMemoryVectorStore()
80
+ self.compact_at = compact_at if compact_at is not None else short_capacity
81
+ self.reflector: Callable[[str], list[MemoryItem]] = Reflector(
82
+ llm=self.llm, use_llm=False
83
+ ).extract
84
+
85
+ # --- public hooks ------------------------------------------------------
86
+
87
+ def set_reflector(self, fn: Callable[[str], list[MemoryItem]]) -> None:
88
+ """Replace the reflection step. ``fn`` receives raw user text."""
89
+ self.reflector = fn
90
+
91
+ def push_plan(self, goal_text: str) -> MemoryItem:
92
+ goal = MemoryItem(text=goal_text, importance=0.6, kind="plan", tags=["goal"])
93
+ self.procedural.push(goal)
94
+ return goal
95
+
96
+ def complete_current_plan(self) -> MemoryItem | None:
97
+ return self.procedural.complete_top()
98
+
99
+ def recall(self, query: str, k: int = 5) -> list[MemoryItem]:
100
+ q_emb = self.embedder.embed_query(query) if self.embedder.dim else None
101
+ return self.long.search(query_embedding=q_emb, top_k=k)
102
+
103
+ # --- the canonical loop ------------------------------------------------
104
+
105
+ def turn(
106
+ self,
107
+ user_msg: str,
108
+ *,
109
+ k: int = 5,
110
+ max_chars: int = 6000,
111
+ ) -> LoopResult:
112
+ if not isinstance(user_msg, str) or not user_msg.strip():
113
+ raise ValueError("user_msg must be a non-empty string")
114
+
115
+ # --- 1. RETRIEVE ---
116
+ q_emb = self.embedder.embed_query(user_msg) if self.embedder.dim else None
117
+ retrieved = self.long.search(query_embedding=q_emb, top_k=k)
118
+ recent_episodes = self.episodic.recent(3)
119
+ current_plan = self.procedural.current()
120
+
121
+ memory_block = self._format_memory_block(retrieved, recent_episodes, current_plan)
122
+ short_window = self.short.items()
123
+ short_text = "\n".join(f"- [{m.kind}] {m.text}" for m in short_window)
124
+
125
+ prompt = (
126
+ f"{memory_block}\n\n"
127
+ f"RECENT SCRATCHPAD:\n{short_text or '(empty)'}\n\n"
128
+ f"USER: {user_msg}\n"
129
+ f"ASSISTANT:"
130
+ )
131
+ # Hard cap so a runaway prompt never explodes the LLM context.
132
+ if len(prompt) > max_chars:
133
+ prompt = prompt[: max(0, max_chars - 1)].rstrip() + "…"
134
+
135
+ # --- 2. GENERATE ---
136
+ history = ChatHistory(
137
+ system=SYSTEM_PROMPT,
138
+ messages=[Message("user", prompt)],
139
+ )
140
+ t0 = time.time()
141
+ try:
142
+ reply = self.llm.complete(history)
143
+ except Exception: # pragma: no cover — exercised only with bad adapters
144
+ log.exception("LLM call failed during turn()")
145
+ raise
146
+ gen_ms = (time.time() - t0) * 1000
147
+
148
+ # --- 3. REFLECT ---
149
+ try:
150
+ reflection = self.reflector(user_msg) or []
151
+ except Exception:
152
+ log.exception("Reflector failed; continuing without storing new facts")
153
+ reflection = []
154
+
155
+ if reflection and self.embedder.dim:
156
+ vecs = self.embedder.embed(reflection)
157
+ for item, vec in zip(reflection, vecs, strict=False):
158
+ item.embedding = vec
159
+ added_ltm = self.long.extend(reflection)
160
+ if reflection and self.embedder.dim:
161
+ try:
162
+ self.vector_store.add([it for it in added_ltm if it.embedding is not None])
163
+ except Exception:
164
+ log.exception("Vector store add failed; continuing with in-LTM list")
165
+
166
+ # Record an episode for the exchange.
167
+ episode = MemoryItem(
168
+ text=f"user: {user_msg}\nassistant: {reply}",
169
+ importance=0.4,
170
+ kind="episode",
171
+ )
172
+ self.episodic.record(episode)
173
+
174
+ # --- 4. STORE ---
175
+ self.short.push(MemoryItem(text=f"user: {user_msg}", importance=0.4, kind="turn"))
176
+ self.short.push(MemoryItem(text=f"assistant: {reply}", importance=0.4, kind="turn"))
177
+
178
+ # Periodic compaction: roll the scratchpad into a single summary item.
179
+ if len(self.short._items) >= self.compact_at:
180
+ summary = MemoryItem(
181
+ text=f"summary: {summarize_window(short_window)}",
182
+ importance=0.55,
183
+ kind="reflection",
184
+ )
185
+ if self.embedder.dim:
186
+ summary.embedding = self.embedder.embed_text(summary.text)
187
+ self.long.add(summary)
188
+ self.short.clear()
189
+
190
+ # Background GC: drop expired long-term items on every turn.
191
+ gc_removed = self.long.gc()
192
+
193
+ diagnostics = {
194
+ "retrieved": len(retrieved),
195
+ "stored": len(added_ltm),
196
+ "reflection_candidates": len(reflection),
197
+ "scratchpad_size": len(self.short._items),
198
+ "long_term_size": len(self.long),
199
+ "episodes": len(self.episodic._events),
200
+ "open_plans": len(self.procedural.goals),
201
+ "expired_dropped": gc_removed,
202
+ "gen_ms": round(gen_ms, 1),
203
+ "prompt_chars": min(len(prompt), max_chars),
204
+ }
205
+ return LoopResult(
206
+ reply=reply,
207
+ retrieved=retrieved,
208
+ stored=added_ltm,
209
+ reflection=added_ltm,
210
+ diagnostics=diagnostics,
211
+ )
212
+
213
+ # --- helpers ------------------------------------------------------------
214
+
215
+ def _format_memory_block(
216
+ self,
217
+ retrieved,
218
+ recent_episodes,
219
+ current_plan: MemoryItem | None,
220
+ ) -> str:
221
+ parts = ["RELEVANT MEMORIES:"]
222
+ retrieved_list = list(retrieved)
223
+ if retrieved_list:
224
+ for i, m in enumerate(retrieved_list, 1):
225
+ parts.append(f" {i}. ({m.kind}, importance={m.importance:.2f}) {m.text}")
226
+ else:
227
+ parts.append(" (none)")
228
+
229
+ parts.append("\nEPISODE LOG (most recent first):")
230
+ eps = list(recent_episodes)
231
+ if eps:
232
+ for m in reversed(eps):
233
+ parts.append(f" - {m.text}")
234
+ else:
235
+ parts.append(" (none)")
236
+
237
+ if current_plan is not None:
238
+ parts.append(f"\nOPEN TASK PLAN: {current_plan.text}")
239
+ return "\n".join(parts)
240
+
241
+ def __repr__(self) -> str:
242
+ return (
243
+ f"LoopEngine(llm={self.llm.model}, "
244
+ f"short={len(self.short._items)}/{self.short.capacity}, "
245
+ f"long={len(self.long)}, episodes={len(self.episodic._events)}, "
246
+ f"plans={len(self.procedural.goals)})"
247
+ )
@@ -0,0 +1,89 @@
1
+ """Reflection and summarization passes.
2
+
3
+ The reflection step is what turns a sequence of raw messages into
4
+ structured long-term memories. In production it's an LLM call; here
5
+ we use a deterministic heuristic extractor so the framework remains
6
+ usable without an API key. Plug in a smarter pass via
7
+ ``engine.set_reflector(...)``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Iterable
14
+
15
+ from ..llm.base import LLMClient
16
+ from ..memory.types import MemoryItem
17
+
18
+ _NAME_RE = re.compile(r"\b(?:my name is|i am|i'm|call me)\s+([A-Z][a-zA-Z'-]{1,30})")
19
+ _LIKE_RE = re.compile(r"\b(?:i (?:really )?(?:like|love|enjoy|prefer))\s+([^.!?\n]+)", re.IGNORECASE)
20
+ _DISLIKE_RE = re.compile(r"\b(?:i (?:really )?(?:dislike|hate|avoid))\s+([^.!?\n]+)", re.IGNORECASE)
21
+ _FACT_HINT = ("my ", "i ", "we ", "always", "never", "remember", "important")
22
+
23
+
24
+ def heuristic_extract(text: str) -> list[MemoryItem]:
25
+ """Cheap, deterministic fact extractor. Used when no LLM reflector is set."""
26
+ found: list[MemoryItem] = []
27
+ name = _NAME_RE.search(text)
28
+ if name:
29
+ found.append(MemoryItem(text=f"User's name is {name.group(1)}.", importance=0.95, kind="fact", tags=["identity"]))
30
+ for m in _LIKE_RE.finditer(text):
31
+ found.append(MemoryItem(text=f"User likes: {m.group(1).strip()}.", importance=0.7, kind="fact", tags=["preference"]))
32
+ for m in _DISLIKE_RE.finditer(text):
33
+ found.append(MemoryItem(text=f"User dislikes: {m.group(1).strip()}.", importance=0.7, kind="fact", tags=["preference"]))
34
+ if not found:
35
+ lower = text.lower()
36
+ if any(h in lower for h in _FACT_HINT) and len(text) < 240:
37
+ found.append(MemoryItem(text=text.strip(), importance=0.5, kind="fact"))
38
+ return found
39
+
40
+
41
+ def summarize_window(messages: Iterable[MemoryItem], max_chars: int = 240) -> str:
42
+ """Compress a short-term window into a single roll-up line."""
43
+ pieces = [m.text for m in messages]
44
+ joined = " | ".join(pieces)
45
+ if len(joined) <= max_chars:
46
+ return joined
47
+ return joined[: max_chars - 1].rstrip() + "…"
48
+
49
+
50
+ class Reflector:
51
+ """Pluggable reflection pass.
52
+
53
+ Default uses ``heuristic_extract``. Pass an LLM-backed reflector
54
+ for higher-quality, fewer-false-positive fact extraction:
55
+
56
+ def llm_reflect(text, llm):
57
+ prompt = f"Extract durable user facts from: {text}\nReturn JSON list."
58
+ return [MemoryItem(text=s, importance=0.8) for s in parse(llm.complete(...))]
59
+ engine.set_reflector(llm_reflect)
60
+ """
61
+
62
+ def __init__(self, llm: LLMClient | None = None, use_llm: bool = False) -> None:
63
+ self.llm = llm
64
+ self.use_llm = use_llm
65
+
66
+ def extract(self, text: str) -> list[MemoryItem]:
67
+ if self.use_llm and self.llm is not None:
68
+ return self._llm_extract(text)
69
+ return heuristic_extract(text)
70
+
71
+ def _llm_extract(self, text: str) -> list[MemoryItem]:
72
+ # Contract: the LLM is asked to return one fact per line, no commentary.
73
+ from ..llm.base import ChatHistory
74
+
75
+ history = ChatHistory(
76
+ system="Extract durable user facts. One per line. No numbering. No preamble.",
77
+ messages=[{"role": "user", "content": text}], # type: ignore[list-item]
78
+ ) if False else ChatHistory(
79
+ system="Extract durable user facts. One per line. No numbering. No preamble.",
80
+ messages=[],
81
+ )
82
+ history.messages.append(__import__("loop_memory.llm.base", fromlist=["Message"]).Message("user", text))
83
+ out = self.llm.complete(history)
84
+ facts: list[MemoryItem] = []
85
+ for line in out.splitlines():
86
+ line = line.strip(" -•\t")
87
+ if 2 <= len(line) <= 200:
88
+ facts.append(MemoryItem(text=line, importance=0.75, kind="fact"))
89
+ return facts or heuristic_extract(text)
File without changes
@@ -0,0 +1,39 @@
1
+ """Run a short multi-turn conversation through the loop engine.
2
+
3
+ No API keys required — uses the bundled EchoLLM and a HashingEmbedder,
4
+ so this example is fully self-contained.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from loop_memory import EchoLLM, HashingEmbedder, LoopEngine
10
+
11
+
12
+ def main() -> None:
13
+ engine = LoopEngine(llm=EchoLLM(), embedder=HashingEmbedder(dim=128))
14
+
15
+ plan = engine.push_plan("Help Mia plan a weekend trip to Hangzhou.")
16
+ print(f"[plan] {plan.text}\n")
17
+
18
+ turns = [
19
+ "Hi, my name is Mia. I really love matcha and I dislike spicy food.",
20
+ "I'm travelling this weekend — somewhere quiet, ideally with tea and a lake.",
21
+ "I'll be coming back next week. Can you summarise what we decided?",
22
+ ]
23
+ for msg in turns:
24
+ result = engine.turn(msg)
25
+ print(f"you> {msg}")
26
+ print(f"bot> {result.reply}")
27
+ print(f" diag={result.diagnostics}")
28
+ print()
29
+
30
+ print("[recall: 'tea']")
31
+ for item in engine.recall("tea"):
32
+ print(f" - ({item.kind}) {item.text}")
33
+
34
+ print("\n[final state]")
35
+ print(engine)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,39 @@
1
+ """White-box export for the Universal Agent Memory contract.
2
+
3
+ The article the user pointed us at proposes a local-first,
4
+ Git-trackable, human-readable ``MEMORY.md`` as the storage format
5
+ for the long-term layer. This package brings that idea to
6
+ loop-memory without breaking the SQLite store — ``export_bundle``
7
+ turns the live state into a directory of Markdown + JSON, and
8
+ ``import_bundle`` rehydrates it.
9
+
10
+ The bundle layout (under the chosen ``out_dir``) is:
11
+
12
+ out_dir/
13
+ MEMORY.md # top-level "what we know about you"
14
+ INDEX.md # file map
15
+ pages/ # one .md per wiki page (slug-named)
16
+ memories.jsonl # raw memories (one JSON per line)
17
+ graph.json # entities + relations
18
+ meta.json # schema version, export time, agent_id, user_id
19
+ sessions.json # sessions index (titles + timestamps)
20
+
21
+ Git-friendly by design: every file is small, text-only, and
22
+ deterministically sorted so a diff shows exactly what the agent
23
+ learned. Re-importing is idempotent (upsert by slug for pages,
24
+ ``(agent_id, user_id, external_id)`` for memories).
25
+ """
26
+
27
+ from .memory_md import (
28
+ export_bundle,
29
+ import_bundle,
30
+ fork_snapshot,
31
+ write_memory_md,
32
+ )
33
+
34
+ __all__ = [
35
+ "export_bundle",
36
+ "import_bundle",
37
+ "fork_snapshot",
38
+ "write_memory_md",
39
+ ]