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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
loop_memory/cli/main.py
ADDED
|
@@ -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
|
+
]
|