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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Cognitive sleep — the "主动学习" / "主动修正" layer the
|
|
2
|
+
Universal Agent Memory contract needs.
|
|
3
|
+
|
|
4
|
+
Background: the article that prompted this work argues that real
|
|
5
|
+
memory systems are not databases but *cognitive processes*. They
|
|
6
|
+
actively:
|
|
7
|
+
|
|
8
|
+
* filter low-value noise (memories that are stale, low-importance,
|
|
9
|
+
and never recalled);
|
|
10
|
+
* detect contradictions (two memories / wiki pages that disagree);
|
|
11
|
+
* propose merges (near-duplicates that should be one);
|
|
12
|
+
* suggest forgets (memories the user almost certainly doesn't need
|
|
13
|
+
anymore).
|
|
14
|
+
|
|
15
|
+
This module implements that pipeline as a single ``cognitive_sleep``
|
|
16
|
+
call. The output is an audit report plus, when ``apply=True``, a
|
|
17
|
+
set of mutations on the store. Every decision is recorded in
|
|
18
|
+
``cognitive_audit`` so the user / SDK can review, revert, or simply
|
|
19
|
+
log the result.
|
|
20
|
+
|
|
21
|
+
The job is zero-dep: it uses only the existing store + a tiny set
|
|
22
|
+
of heuristics. The LLM-driven distillate step in the evolution
|
|
23
|
+
consolidator stays the source of truth for high-level
|
|
24
|
+
"is this a contradiction?" questions; cognitive_sleep is the cheap,
|
|
25
|
+
deterministic nightly sweep that catches the obvious cases without
|
|
26
|
+
needing a model call.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import math
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..storage.sqlite_store import MemoryStore
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Heuristic thresholds
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# A memory is "stale" if it has not been recalled in this many days
|
|
50
|
+
# AND its score is below this threshold AND its importance is below
|
|
51
|
+
# the threshold. These defaults are conservative — the user can
|
|
52
|
+
# tighten them via the ``stale_days`` / ``min_score`` / ``min_importance``
|
|
53
|
+
# parameters to ``cognitive_sleep``.
|
|
54
|
+
DEFAULT_STALE_DAYS = 90
|
|
55
|
+
DEFAULT_MIN_SCORE = 0.2
|
|
56
|
+
DEFAULT_MIN_IMPORTANCE = 0.3
|
|
57
|
+
|
|
58
|
+
# A memory is "low value" if its score + 0.5 * importance is below
|
|
59
|
+
# this AND it has never been recalled. This catches the
|
|
60
|
+
# "auto-generated noise from a long transcript" case the LLM
|
|
61
|
+
# consolidator sometimes leaves behind.
|
|
62
|
+
DEFAULT_LOW_VALUE = 0.3
|
|
63
|
+
|
|
64
|
+
# A merge is suggested when two memories have cosine similarity above
|
|
65
|
+
# this threshold AND the same (agent_id, user_id) namespace.
|
|
66
|
+
DEFAULT_MERGE_THRESHOLD = 0.92
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Result dataclass
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CognitiveAction:
|
|
76
|
+
"""One proposed (or applied) action from the cognitive sleep sweep."""
|
|
77
|
+
|
|
78
|
+
kind: str # forget / merge / contradict / stale / low_value
|
|
79
|
+
target_kind: str # memory / wiki_page
|
|
80
|
+
target_id: str
|
|
81
|
+
target_text: str
|
|
82
|
+
reason: str
|
|
83
|
+
score: float = 0.0
|
|
84
|
+
payload: dict = field(default_factory=dict)
|
|
85
|
+
action: str = "suggest" # suggest / applied / reverted
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"kind": self.kind,
|
|
90
|
+
"target_kind": self.target_kind,
|
|
91
|
+
"target_id": self.target_id,
|
|
92
|
+
"target_text": self.target_text,
|
|
93
|
+
"reason": self.reason,
|
|
94
|
+
"score": self.score,
|
|
95
|
+
"payload": self.payload,
|
|
96
|
+
"action": self.action,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class CognitiveReport:
|
|
102
|
+
"""The full result of one ``cognitive_sleep`` call."""
|
|
103
|
+
|
|
104
|
+
actions: list[CognitiveAction] = field(default_factory=list)
|
|
105
|
+
elapsed_ms: float = 0.0
|
|
106
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
107
|
+
applied: bool = False
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, Any]:
|
|
110
|
+
return {
|
|
111
|
+
"actions": [a.to_dict() for a in self.actions],
|
|
112
|
+
"elapsed_ms": self.elapsed_ms,
|
|
113
|
+
"counts": self.counts,
|
|
114
|
+
"applied": self.applied,
|
|
115
|
+
"total": len(self.actions),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Sweep
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cognitive_sleep(
|
|
125
|
+
store: MemoryStore,
|
|
126
|
+
*,
|
|
127
|
+
apply: bool = False,
|
|
128
|
+
stale_days: int = DEFAULT_STALE_DAYS,
|
|
129
|
+
min_score: float = DEFAULT_MIN_SCORE,
|
|
130
|
+
min_importance: float = DEFAULT_MIN_IMPORTANCE,
|
|
131
|
+
low_value: float = DEFAULT_LOW_VALUE,
|
|
132
|
+
merge_threshold: float = DEFAULT_MERGE_THRESHOLD,
|
|
133
|
+
limit: int = 1000,
|
|
134
|
+
record_audit: bool = True,
|
|
135
|
+
) -> CognitiveReport:
|
|
136
|
+
"""Run a single cognitive sweep.
|
|
137
|
+
|
|
138
|
+
* ``apply=False`` (default): only suggest actions, do not
|
|
139
|
+
mutate the store. Used by the UI's "preview" panel.
|
|
140
|
+
* ``apply=True``: actually delete the ``forget`` actions and
|
|
141
|
+
merge the ``merge`` actions, then record each applied action
|
|
142
|
+
in ``cognitive_audit`` with ``action='applied'``.
|
|
143
|
+
|
|
144
|
+
Every suggestion is recorded in ``cognitive_audit`` with
|
|
145
|
+
``action='suggest'`` regardless of whether the user applies it,
|
|
146
|
+
so the trail is complete.
|
|
147
|
+
|
|
148
|
+
The sweep is bounded to ``limit`` memories per pass to keep it
|
|
149
|
+
cheap; for very large stores the user can call it multiple
|
|
150
|
+
times or wire it into a cron.
|
|
151
|
+
"""
|
|
152
|
+
t0 = time.time()
|
|
153
|
+
actions: list[CognitiveAction] = []
|
|
154
|
+
counts: dict[str, int] = {
|
|
155
|
+
"stale": 0, "low_value": 0, "merge": 0, "contradict": 0, "forget": 0,
|
|
156
|
+
}
|
|
157
|
+
now = time.time()
|
|
158
|
+
stale_cutoff = now - stale_days * 86400.0
|
|
159
|
+
|
|
160
|
+
# ----- 1. Stale memories --------------------------------------
|
|
161
|
+
# Pull every memory below the score + importance gates. We do a
|
|
162
|
+
# single SQL scan to keep the pass fast.
|
|
163
|
+
rows = store.list_memories(limit=limit)
|
|
164
|
+
for r in rows:
|
|
165
|
+
score = float(r.score or 0)
|
|
166
|
+
importance = float(r.importance or 0)
|
|
167
|
+
created = float(r.created_at or 0)
|
|
168
|
+
# Stale: old + low score + low importance, regardless of recall.
|
|
169
|
+
if created < stale_cutoff and score < min_score and importance < min_importance:
|
|
170
|
+
counts["stale"] += 1
|
|
171
|
+
actions.append(CognitiveAction(
|
|
172
|
+
kind="stale", target_kind="memory", target_id=r.id,
|
|
173
|
+
target_text=(r.text or "")[:200],
|
|
174
|
+
reason=f"age>{stale_days}d & score<{min_score} & importance<{min_importance}",
|
|
175
|
+
score=score, payload={"importance": importance,
|
|
176
|
+
"age_days": int((now - created) / 86400)},
|
|
177
|
+
))
|
|
178
|
+
continue
|
|
179
|
+
# Low value: never recalled, score + importance * 0.5 below
|
|
180
|
+
# ``low_value`` (this is the cheap "noise from a long
|
|
181
|
+
# transcript" filter).
|
|
182
|
+
# ``list_memories`` doesn't include recall_count in the
|
|
183
|
+
# dataclass; re-fetch via the signals table.
|
|
184
|
+
signals = _signals_for(store, r.id)
|
|
185
|
+
if signals["recall_count"] == 0 and score + 0.5 * importance < low_value:
|
|
186
|
+
counts["low_value"] += 1
|
|
187
|
+
actions.append(CognitiveAction(
|
|
188
|
+
kind="low_value", target_kind="memory", target_id=r.id,
|
|
189
|
+
target_text=(r.text or "")[:200],
|
|
190
|
+
reason=f"never recalled & score+0.5*importance<{low_value}",
|
|
191
|
+
score=score, payload={"importance": importance},
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
# ----- 2. Near-duplicate merges ------------------------------
|
|
195
|
+
# Cheap O(n^2) on the first ``limit`` memories; good enough for
|
|
196
|
+
# nightly sweeps on a store of a few thousand rows. We use the
|
|
197
|
+
# ``text`` Jaccard over a small token set so the comparison
|
|
198
|
+
# doesn't need embeddings.
|
|
199
|
+
text_index = [(r.id, _token_set(r.text or "")) for r in rows]
|
|
200
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
201
|
+
for i in range(len(text_index)):
|
|
202
|
+
for j in range(i + 1, len(text_index)):
|
|
203
|
+
mid_i, ti = text_index[i]
|
|
204
|
+
mid_j, tj = text_index[j]
|
|
205
|
+
if not ti or not tj:
|
|
206
|
+
continue
|
|
207
|
+
j_sim = _jaccard(ti, tj)
|
|
208
|
+
# Short-text containment: when both memories are < 30
|
|
209
|
+
# tokens, the Jaccard threshold is too strict because one
|
|
210
|
+
# extra word in a paraphrase drags the score way down.
|
|
211
|
+
# Use a containment fallback: 80 % of A's tokens in B
|
|
212
|
+
# (or vice versa) counts as a merge candidate.
|
|
213
|
+
contain_a_in_b = (len(ti & tj) / max(1, len(ti))) >= 0.8
|
|
214
|
+
contain_b_in_a = (len(ti & tj) / max(1, len(tj))) >= 0.8
|
|
215
|
+
short_text = len(ti) <= 30 and len(tj) <= 30
|
|
216
|
+
if j_sim >= merge_threshold or (short_text and (contain_a_in_b or contain_b_in_a)):
|
|
217
|
+
pair = tuple(sorted([mid_i, mid_j]))
|
|
218
|
+
if pair in seen_pairs:
|
|
219
|
+
continue
|
|
220
|
+
seen_pairs.add(pair)
|
|
221
|
+
counts["merge"] += 1
|
|
222
|
+
actions.append(CognitiveAction(
|
|
223
|
+
kind="merge", target_kind="memory", target_id=mid_i,
|
|
224
|
+
target_text="(merge with " + mid_j + ")",
|
|
225
|
+
reason=f"Jaccard={j_sim:.3f} ≥ {merge_threshold}",
|
|
226
|
+
score=j_sim, payload={"other_id": mid_j, "jaccard": j_sim},
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
# ----- 3. Contradictions -------------------------------------
|
|
230
|
+
# Reuse the existing wiki-page contradiction detector. It's
|
|
231
|
+
# cheap (key_facts Jaccard, no LLM) and already returns the
|
|
232
|
+
# matches we need.
|
|
233
|
+
from .contradiction import list_contradictions
|
|
234
|
+
try:
|
|
235
|
+
contradictions = list_contradictions(store)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
log.warning("contradiction scan failed: %s", e)
|
|
238
|
+
contradictions = []
|
|
239
|
+
for c in contradictions:
|
|
240
|
+
# ``c`` is a dict; the page id is the *winner* and the
|
|
241
|
+
# partner ids are in a list.
|
|
242
|
+
for partner in c.get("partners", []):
|
|
243
|
+
counts["contradict"] += 1
|
|
244
|
+
actions.append(CognitiveAction(
|
|
245
|
+
kind="contradict", target_kind="wiki_page",
|
|
246
|
+
target_id=c.get("id") or "",
|
|
247
|
+
target_text=c.get("title", "")[:160],
|
|
248
|
+
reason="wiki_page contradict detected by key_facts Jaccard",
|
|
249
|
+
score=float(partner.get("score", 0) or 0),
|
|
250
|
+
payload={
|
|
251
|
+
"partner_id": partner.get("id"),
|
|
252
|
+
"partner_title": partner.get("title"),
|
|
253
|
+
},
|
|
254
|
+
))
|
|
255
|
+
|
|
256
|
+
# ----- 4. Apply (optional) -----------------------------------
|
|
257
|
+
if apply:
|
|
258
|
+
applied_actions: list[CognitiveAction] = []
|
|
259
|
+
for a in actions:
|
|
260
|
+
if a.kind in ("stale", "low_value"):
|
|
261
|
+
n = store.delete_memory(a.target_id)
|
|
262
|
+
if n:
|
|
263
|
+
counts["forget"] += 1
|
|
264
|
+
a.action = "applied"
|
|
265
|
+
applied_actions.append(a)
|
|
266
|
+
elif a.kind == "merge":
|
|
267
|
+
other = a.payload.get("other_id")
|
|
268
|
+
if not other:
|
|
269
|
+
continue
|
|
270
|
+
result = store.merge_memories(a.target_id, other)
|
|
271
|
+
if result.get("merged"):
|
|
272
|
+
a.action = "applied"
|
|
273
|
+
applied_actions.append(a)
|
|
274
|
+
elif a.kind == "contradict":
|
|
275
|
+
# We don't auto-resolve contradictions — the UI
|
|
276
|
+
# shows them and the user clicks "merge" / "keep
|
|
277
|
+
# both". But we still mark the action as suggested
|
|
278
|
+
# so the audit trail is complete.
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
# ----- 5. Persist to cognitive_audit -------------------------
|
|
282
|
+
if record_audit:
|
|
283
|
+
for a in actions:
|
|
284
|
+
store.record_audit(
|
|
285
|
+
kind=a.kind,
|
|
286
|
+
action=a.action,
|
|
287
|
+
target_kind=a.target_kind,
|
|
288
|
+
target_id=a.target_id,
|
|
289
|
+
target_text=a.target_text,
|
|
290
|
+
reason=a.reason,
|
|
291
|
+
score=a.score,
|
|
292
|
+
payload=a.payload,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
elapsed_ms = (time.time() - t0) * 1000
|
|
296
|
+
return CognitiveReport(
|
|
297
|
+
actions=actions,
|
|
298
|
+
elapsed_ms=round(elapsed_ms, 1),
|
|
299
|
+
counts=counts,
|
|
300
|
+
applied=bool(apply),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Internal helpers
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _signals_for(store: MemoryStore, memory_id: str) -> dict[str, Any]:
|
|
310
|
+
"""Return the signal row for a memory, or zeros if missing."""
|
|
311
|
+
with store._conn() as c: # type: ignore[attr-defined]
|
|
312
|
+
row = c.execute(
|
|
313
|
+
"SELECT recall_count, positive, negative, last_recalled_at "
|
|
314
|
+
"FROM memory_signals WHERE memory_id=?",
|
|
315
|
+
(memory_id,),
|
|
316
|
+
).fetchone()
|
|
317
|
+
if not row:
|
|
318
|
+
return {"recall_count": 0, "positive": 0, "negative": 0,
|
|
319
|
+
"last_recalled_at": None}
|
|
320
|
+
return dict(row)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _token_set(text: str) -> set[str]:
|
|
324
|
+
"""Cheap token set: lowercase + split on whitespace + punctuation.
|
|
325
|
+
|
|
326
|
+
CJK characters are kept as 1-grams (no bigrams) to keep the
|
|
327
|
+
similarity symmetric. The result is a set, not a multiset.
|
|
328
|
+
"""
|
|
329
|
+
import re
|
|
330
|
+
if not text:
|
|
331
|
+
return set()
|
|
332
|
+
toks = re.findall(r"[A-Za-z0-9]+|[\u4e00-\u9fff]", text.lower())
|
|
333
|
+
return set(toks)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
337
|
+
if not a or not b:
|
|
338
|
+
return 0.0
|
|
339
|
+
inter = a & b
|
|
340
|
+
union = a | b
|
|
341
|
+
return len(inter) / max(1, len(union))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
__all__ = [
|
|
345
|
+
"CognitiveAction",
|
|
346
|
+
"CognitiveReport",
|
|
347
|
+
"DEFAULT_STALE_DAYS",
|
|
348
|
+
"DEFAULT_MIN_SCORE",
|
|
349
|
+
"DEFAULT_MIN_IMPORTANCE",
|
|
350
|
+
"DEFAULT_LOW_VALUE",
|
|
351
|
+
"DEFAULT_MERGE_THRESHOLD",
|
|
352
|
+
"cognitive_sleep",
|
|
353
|
+
]
|