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,495 @@
|
|
|
1
|
+
"""Background scheduler for LLM-driven consolidation.
|
|
2
|
+
|
|
3
|
+
A single daemon thread (started lazily on the first call) keeps a
|
|
4
|
+
cheap clock and triggers an ``LLMConsolidator.run`` when the user's
|
|
5
|
+
configured schedule says so. Modes supported:
|
|
6
|
+
|
|
7
|
+
* ``off`` - never run
|
|
8
|
+
* ``realtime`` - run after ingest has been idle for N seconds
|
|
9
|
+
* ``hourly`` - run every hour on the hour
|
|
10
|
+
* ``daily`` - run once a day at ``schedule.hour:schedule.minute``
|
|
11
|
+
* ``weekly`` - run once a week on ``schedule.weekday`` at the same
|
|
12
|
+
hour/minute
|
|
13
|
+
* ``interval`` - run every ``schedule.interval_minutes`` minutes
|
|
14
|
+
|
|
15
|
+
The scheduler is *co-operative*: it can be stopped and reconfigured
|
|
16
|
+
without restarting the server. ``tick(now)`` is idempotent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..llm.providers import build_provider, default_config, validate_config
|
|
29
|
+
from ..storage.sqlite_store import MemoryStore
|
|
30
|
+
from .evolution import EvolutionConsolidator
|
|
31
|
+
from .llm_consolidate import ConsolidateStats, LLMConsolidator # noqa: F401 (kept for backward-compat)
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _next_daily(now: float, hour: int, minute: int) -> float:
|
|
37
|
+
import datetime as _dt
|
|
38
|
+
t = _dt.datetime.fromtimestamp(now)
|
|
39
|
+
nxt = t.replace(hour=int(hour) % 24, minute=int(minute) % 60, second=0, microsecond=0)
|
|
40
|
+
if nxt.timestamp() <= now:
|
|
41
|
+
nxt = nxt + _dt.timedelta(days=1)
|
|
42
|
+
return nxt.timestamp()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _next_weekly(now: float, weekday: int, hour: int, minute: int) -> float:
|
|
46
|
+
import datetime as _dt
|
|
47
|
+
t = _dt.datetime.fromtimestamp(now)
|
|
48
|
+
nxt = t.replace(hour=int(hour) % 24, minute=int(minute) % 60, second=0, microsecond=0)
|
|
49
|
+
days_ahead = (int(weekday) - t.weekday()) % 7
|
|
50
|
+
if days_ahead == 0 and nxt.timestamp() <= now:
|
|
51
|
+
days_ahead = 7
|
|
52
|
+
nxt = nxt + _dt.timedelta(days=days_ahead)
|
|
53
|
+
return nxt.timestamp()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class _State:
|
|
58
|
+
next_run: float = 0.0
|
|
59
|
+
last_run: float = 0.0
|
|
60
|
+
last_stats: dict[str, Any] | None = None
|
|
61
|
+
last_error: str | None = None
|
|
62
|
+
last_run_id: str | None = None
|
|
63
|
+
is_running: bool = False
|
|
64
|
+
last_ingest_at: float = 0.0 # updated by watcher / ingest hooks
|
|
65
|
+
# Live progress for the currently active run.
|
|
66
|
+
progress_current: int = 0
|
|
67
|
+
progress_total: int = 0
|
|
68
|
+
progress_started_at: float = 0.0
|
|
69
|
+
progress_run_id: str | None = None
|
|
70
|
+
progress_message: str = ""
|
|
71
|
+
# Connectivity probe state — updated by /api/admin/llm/test and by
|
|
72
|
+
# every successful real LLM call. Drives the top-bar model chip
|
|
73
|
+
# dot color (green pulsing vs static vs amber vs red).
|
|
74
|
+
last_test_ok: bool | None = None # None = never tested
|
|
75
|
+
last_test_at: float = 0.0 # epoch seconds
|
|
76
|
+
last_test_message: str = ""
|
|
77
|
+
# Compaction: small enough to share the same wake loop as the
|
|
78
|
+
# LLM consolidator, but never blocks it.
|
|
79
|
+
compact_running: bool = False
|
|
80
|
+
last_compact_at: float = 0.0
|
|
81
|
+
compact_started_at: float = 0.0
|
|
82
|
+
compact_message: str = ""
|
|
83
|
+
last_compact_report: dict[str, Any] | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ConsolidatorScheduler:
|
|
87
|
+
"""A small in-process scheduler.
|
|
88
|
+
|
|
89
|
+
It is *not* a replacement for cron/launchd. It's good enough to
|
|
90
|
+
satisfy "the page should auto-refresh on a schedule even when the
|
|
91
|
+
server is just running in the background" - the typical developer
|
|
92
|
+
loop.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, store: MemoryStore) -> None:
|
|
96
|
+
self.store = store
|
|
97
|
+
self._lock = threading.RLock()
|
|
98
|
+
self._state = _State()
|
|
99
|
+
self._thread: threading.Thread | None = None
|
|
100
|
+
self._run_threads: set[threading.Thread] = set()
|
|
101
|
+
self._stop = threading.Event()
|
|
102
|
+
self._wake = threading.Event() # set by config changes to recompute next_run
|
|
103
|
+
self._cfg: dict[str, Any] = default_config()
|
|
104
|
+
self._load_config()
|
|
105
|
+
|
|
106
|
+
# --- public API -------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def reload_config(self) -> dict[str, Any]:
|
|
109
|
+
"""Read latest config from the store; return the effective config."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._load_config()
|
|
112
|
+
self._recompute_next_run(time.time())
|
|
113
|
+
self._wake.set()
|
|
114
|
+
return self._cfg
|
|
115
|
+
|
|
116
|
+
def status(self) -> dict[str, Any]:
|
|
117
|
+
with self._lock:
|
|
118
|
+
s = self._state
|
|
119
|
+
cfg = self._cfg or {}
|
|
120
|
+
# Check the secret backend for a stored API key. We do
|
|
121
|
+
# this here so the top-bar chip can show the right state
|
|
122
|
+
# without the frontend having to round-trip the keychain.
|
|
123
|
+
try:
|
|
124
|
+
from ..security import account_for, has_secret
|
|
125
|
+
account = cfg.get("api_key_account") or account_for(cfg.get("provider") or "echo")
|
|
126
|
+
key_set = has_secret(account)
|
|
127
|
+
except Exception:
|
|
128
|
+
key_set = False
|
|
129
|
+
return {
|
|
130
|
+
"is_running": s.is_running,
|
|
131
|
+
"next_run": s.next_run if s.next_run > 0 else None,
|
|
132
|
+
"last_run": s.last_run if s.last_run > 0 else None,
|
|
133
|
+
"last_stats": s.last_stats,
|
|
134
|
+
"last_error": s.last_error,
|
|
135
|
+
"last_run_id": s.last_run_id,
|
|
136
|
+
"schedule": (cfg.get("schedule") or {}),
|
|
137
|
+
"behaviour": (cfg.get("behaviour") or {}),
|
|
138
|
+
"provider": cfg.get("provider"),
|
|
139
|
+
"model": cfg.get("model"),
|
|
140
|
+
"api_key_set": bool(key_set),
|
|
141
|
+
"api_key_fingerprint": cfg.get("api_key_fingerprint", "") or "",
|
|
142
|
+
"last_test_ok": s.last_test_ok,
|
|
143
|
+
"last_test_at": s.last_test_at if s.last_test_at > 0 else None,
|
|
144
|
+
"last_test_message": s.last_test_message,
|
|
145
|
+
"progress": {
|
|
146
|
+
"current": s.progress_current,
|
|
147
|
+
"total": s.progress_total,
|
|
148
|
+
"started_at": s.progress_started_at if s.progress_started_at > 0 else None,
|
|
149
|
+
"run_id": s.progress_run_id,
|
|
150
|
+
"message": s.progress_message,
|
|
151
|
+
},
|
|
152
|
+
"compact_running": s.compact_running,
|
|
153
|
+
"last_compact_at": s.last_compact_at if s.last_compact_at > 0 else None,
|
|
154
|
+
"compact_started_at": s.compact_started_at if s.compact_started_at > 0 else None,
|
|
155
|
+
"compact_message": s.compact_message,
|
|
156
|
+
"last_compact_report": s.last_compact_report,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def record_test_result(self, ok: bool, message: str = "") -> None:
|
|
160
|
+
"""Stash the result of a connectivity probe.
|
|
161
|
+
|
|
162
|
+
Called by /api/admin/llm/test and any successful real LLM
|
|
163
|
+
call (so the top-bar dot stays in sync without forcing the
|
|
164
|
+
user to open the Settings drawer and click Test).
|
|
165
|
+
"""
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._state.last_test_ok = bool(ok)
|
|
168
|
+
self._state.last_test_at = time.time()
|
|
169
|
+
self._state.last_test_message = (message or "")[:200]
|
|
170
|
+
|
|
171
|
+
def notify_ingest(self) -> None:
|
|
172
|
+
"""Mark 'something was just ingested' so realtime mode can fire."""
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._state.last_ingest_at = time.time()
|
|
175
|
+
sched = self._cfg.get("schedule") or {}
|
|
176
|
+
if sched.get("mode") == "realtime":
|
|
177
|
+
self._recompute_next_run(time.time())
|
|
178
|
+
self._wake.set()
|
|
179
|
+
|
|
180
|
+
def run_now(self, trigger: str = "manual", block: bool = False) -> dict[str, Any] | None:
|
|
181
|
+
"""Run a consolidation pass synchronously (or on a background
|
|
182
|
+
thread if ``block`` is False). Returns the run id when async."""
|
|
183
|
+
if block:
|
|
184
|
+
return self._do_run(trigger)
|
|
185
|
+
self._start_run_thread(trigger)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def run_blocking(self, label: str, fn: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
|
189
|
+
"""Run an arbitrary callable off the request thread, returning
|
|
190
|
+
the dict it produced. Used by maintenance endpoints
|
|
191
|
+
(compaction, reindex, …) so the HTTP request returns quickly
|
|
192
|
+
and the user can poll a status endpoint while the work
|
|
193
|
+
completes.
|
|
194
|
+
|
|
195
|
+
Returns ``{"status": "busy", ...}`` when a consolidation is
|
|
196
|
+
already running, ``{"status": "error", ...}`` on failure,
|
|
197
|
+
or ``{"status": "done", "label": label, "result": result}``
|
|
198
|
+
on success. We delegate to a one-shot ``ThreadPoolExecutor``
|
|
199
|
+
so the call returns quickly to FastAPI while the heavy
|
|
200
|
+
work runs in a worker thread.
|
|
201
|
+
"""
|
|
202
|
+
import concurrent.futures as _cf
|
|
203
|
+
with self._lock:
|
|
204
|
+
running = bool(self._state.is_running)
|
|
205
|
+
if running:
|
|
206
|
+
return {"status": "busy", "label": label, "detail": "another job is in progress"}
|
|
207
|
+
with _cf.ThreadPoolExecutor(max_workers=1, thread_name_prefix=f"lm-{label}") as ex:
|
|
208
|
+
fut = ex.submit(fn)
|
|
209
|
+
try:
|
|
210
|
+
result = fut.result(timeout=300)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
log.exception("%s job failed", label)
|
|
213
|
+
return {"status": "error", "label": label, "error": str(e)}
|
|
214
|
+
return {"status": "done", "label": label, "result": result}
|
|
215
|
+
|
|
216
|
+
def start(self) -> None:
|
|
217
|
+
if self._thread and self._thread.is_alive():
|
|
218
|
+
return
|
|
219
|
+
self._stop.clear()
|
|
220
|
+
self._thread = threading.Thread(target=self._loop, name="consolidator", daemon=True)
|
|
221
|
+
self._thread.start()
|
|
222
|
+
|
|
223
|
+
def stop(self) -> None:
|
|
224
|
+
self._stop.set()
|
|
225
|
+
self._wake.set()
|
|
226
|
+
if self._thread:
|
|
227
|
+
self._thread.join(timeout=2.0)
|
|
228
|
+
self._thread = None
|
|
229
|
+
with self._lock:
|
|
230
|
+
run_threads = list(self._run_threads)
|
|
231
|
+
for thread in run_threads:
|
|
232
|
+
thread.join(timeout=2.0)
|
|
233
|
+
|
|
234
|
+
def _start_run_thread(self, trigger: str) -> None:
|
|
235
|
+
def run() -> None:
|
|
236
|
+
try:
|
|
237
|
+
self._do_run_safe(trigger)
|
|
238
|
+
finally:
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._run_threads.discard(thread)
|
|
241
|
+
|
|
242
|
+
thread = threading.Thread(target=run, daemon=True)
|
|
243
|
+
with self._lock:
|
|
244
|
+
self._run_threads.add(thread)
|
|
245
|
+
thread.start()
|
|
246
|
+
|
|
247
|
+
# --- internals --------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _load_config(self) -> None:
|
|
250
|
+
cfg = self.store.get_setting("llm_consolidator", default_config())
|
|
251
|
+
if not isinstance(cfg, dict):
|
|
252
|
+
cfg = default_config()
|
|
253
|
+
cfg, _ = validate_config(cfg)
|
|
254
|
+
self._cfg = cfg
|
|
255
|
+
|
|
256
|
+
def _recompute_next_run(self, now: float) -> None:
|
|
257
|
+
sched = self._cfg.get("schedule") or {}
|
|
258
|
+
mode = sched.get("mode", "off")
|
|
259
|
+
s = self._state
|
|
260
|
+
if not sched.get("enabled", False) or mode == "off":
|
|
261
|
+
s.next_run = 0.0
|
|
262
|
+
return
|
|
263
|
+
if mode == "hourly":
|
|
264
|
+
# next top of hour
|
|
265
|
+
import datetime as _dt
|
|
266
|
+
t = _dt.datetime.fromtimestamp(now)
|
|
267
|
+
nxt = t.replace(minute=0, second=0, microsecond=0) + _dt.timedelta(hours=1)
|
|
268
|
+
s.next_run = nxt.timestamp()
|
|
269
|
+
return
|
|
270
|
+
if mode == "daily":
|
|
271
|
+
s.next_run = _next_daily(now, sched.get("hour", 3), sched.get("minute", 0))
|
|
272
|
+
return
|
|
273
|
+
if mode == "weekly":
|
|
274
|
+
s.next_run = _next_weekly(
|
|
275
|
+
now, sched.get("weekday", 0), sched.get("hour", 3), sched.get("minute", 0)
|
|
276
|
+
)
|
|
277
|
+
return
|
|
278
|
+
if mode == "interval":
|
|
279
|
+
minutes = max(1, int(sched.get("interval_minutes") or 60))
|
|
280
|
+
base = s.last_run or now
|
|
281
|
+
s.next_run = base + minutes * 60
|
|
282
|
+
if s.next_run < now:
|
|
283
|
+
s.next_run = now + 1
|
|
284
|
+
return
|
|
285
|
+
if mode == "realtime":
|
|
286
|
+
idle = max(0, int(sched.get("after_ingest_idle_sec") or 30))
|
|
287
|
+
base = max(s.last_ingest_at, now)
|
|
288
|
+
s.next_run = base + idle
|
|
289
|
+
return
|
|
290
|
+
s.next_run = 0.0
|
|
291
|
+
|
|
292
|
+
def _loop(self) -> None:
|
|
293
|
+
log.info("consolidator scheduler started")
|
|
294
|
+
while not self._stop.is_set():
|
|
295
|
+
now = time.time()
|
|
296
|
+
with self._lock:
|
|
297
|
+
s = self._state
|
|
298
|
+
sched = self._cfg.get("schedule") or {}
|
|
299
|
+
enabled = bool(sched.get("enabled", False)) and (sched.get("mode", "off") != "off")
|
|
300
|
+
if not enabled:
|
|
301
|
+
s.next_run = 0.0
|
|
302
|
+
else:
|
|
303
|
+
if s.next_run <= 0:
|
|
304
|
+
self._recompute_next_run(now)
|
|
305
|
+
if now >= s.next_run and not s.is_running:
|
|
306
|
+
s.is_running = True
|
|
307
|
+
trigger = "schedule" if sched.get("mode") != "realtime" else "realtime"
|
|
308
|
+
self._start_run_thread(trigger)
|
|
309
|
+
# Compaction runs on its own cadence so the user can
|
|
310
|
+
# opt into background tidying without enabling the
|
|
311
|
+
# expensive LLM consolidator. We piggy-back on the
|
|
312
|
+
# same wake loop to avoid a second thread.
|
|
313
|
+
self._maybe_schedule_compact(now)
|
|
314
|
+
wait = max(1.0, min(60.0, (s.next_run - now) if s.next_run > 0 else 30.0))
|
|
315
|
+
self._wake.wait(timeout=wait)
|
|
316
|
+
self._wake.clear()
|
|
317
|
+
log.info("consolidator scheduler stopped")
|
|
318
|
+
|
|
319
|
+
# ---- compact cadence -------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def _maybe_schedule_compact(self, now: float) -> None:
|
|
322
|
+
"""Decide whether to kick off a compaction pass on the
|
|
323
|
+
background thread. Cheap to call once per scheduler tick.
|
|
324
|
+
|
|
325
|
+
Triggers:
|
|
326
|
+
|
|
327
|
+
* ``auto_compact`` is on AND the cadence has elapsed
|
|
328
|
+
* the store has crossed its byte budget
|
|
329
|
+
"""
|
|
330
|
+
with self._lock:
|
|
331
|
+
if self._state.is_running or self._state.compact_running:
|
|
332
|
+
return
|
|
333
|
+
cfg = self.store.get_setting("storage_budget", {}) or {}
|
|
334
|
+
if not cfg:
|
|
335
|
+
return
|
|
336
|
+
auto = bool(cfg.get("auto_compact", False))
|
|
337
|
+
interval_h = max(1, int(cfg.get("compact_interval_hours") or 24))
|
|
338
|
+
last = (self.store.get_setting("last_compact", {}) or {}).get("finished_at", 0.0)
|
|
339
|
+
max_bytes = int(cfg.get("max_bytes") or 0)
|
|
340
|
+
size_now = self.store.db_size_bytes()
|
|
341
|
+
budget_breached = max_bytes > 0 and size_now > max_bytes
|
|
342
|
+
cadence_elapsed = (now - float(last)) >= interval_h * 3600
|
|
343
|
+
if not (auto and cadence_elapsed) and not budget_breached:
|
|
344
|
+
return
|
|
345
|
+
reason = "budget" if budget_breached else "cadence"
|
|
346
|
+
self._start_compact_thread(reason)
|
|
347
|
+
|
|
348
|
+
def _start_compact_thread(self, reason: str) -> None:
|
|
349
|
+
def run() -> None:
|
|
350
|
+
try:
|
|
351
|
+
self._do_compact(reason)
|
|
352
|
+
finally:
|
|
353
|
+
with self._lock:
|
|
354
|
+
self._state.compact_running = False
|
|
355
|
+
self._wake.set()
|
|
356
|
+
|
|
357
|
+
with self._lock:
|
|
358
|
+
if self._state.compact_running:
|
|
359
|
+
return
|
|
360
|
+
self._state.compact_running = True
|
|
361
|
+
thread = threading.Thread(target=run, daemon=True, name=f"compactor-{reason}")
|
|
362
|
+
thread.start()
|
|
363
|
+
|
|
364
|
+
def _do_compact(self, reason: str) -> dict[str, Any]:
|
|
365
|
+
from .compact import Compactor
|
|
366
|
+
log.info("compaction start (reason=%s)", reason)
|
|
367
|
+
with self._lock:
|
|
368
|
+
self._state.compact_started_at = time.time()
|
|
369
|
+
self._state.compact_message = "starting"
|
|
370
|
+
|
|
371
|
+
def _progress(cur: int, total: int, msg: str) -> None:
|
|
372
|
+
with self._lock:
|
|
373
|
+
self._state.progress_current = cur
|
|
374
|
+
self._state.progress_total = total
|
|
375
|
+
self._state.compact_message = msg
|
|
376
|
+
|
|
377
|
+
comp = Compactor(self.store, mode="heuristic")
|
|
378
|
+
report = comp.run(progress=_progress, force=False)
|
|
379
|
+
d = report.to_dict()
|
|
380
|
+
d["reason"] = reason
|
|
381
|
+
d["finished_at"] = time.time()
|
|
382
|
+
self.store.set_setting("last_compact", d)
|
|
383
|
+
with self._lock:
|
|
384
|
+
self._state.last_compact_at = time.time()
|
|
385
|
+
self._state.last_compact_report = d
|
|
386
|
+
self._state.compact_message = ""
|
|
387
|
+
log.info("compaction done: %s", d)
|
|
388
|
+
return d
|
|
389
|
+
|
|
390
|
+
def _do_run_safe(self, trigger: str) -> None:
|
|
391
|
+
try:
|
|
392
|
+
self._do_run(trigger)
|
|
393
|
+
except Exception as e:
|
|
394
|
+
log.exception("consolidator run failed: %s", e)
|
|
395
|
+
finally:
|
|
396
|
+
with self._lock:
|
|
397
|
+
self._state.is_running = False
|
|
398
|
+
self._recompute_next_run(time.time())
|
|
399
|
+
self._wake.set()
|
|
400
|
+
|
|
401
|
+
def _do_run(self, trigger: str) -> dict[str, Any]:
|
|
402
|
+
# Reload config in case the user updated it between ticks.
|
|
403
|
+
with self._lock:
|
|
404
|
+
self._load_config()
|
|
405
|
+
cfg = self._cfg
|
|
406
|
+
model_name = cfg.get("model") or "?"
|
|
407
|
+
self._state.is_running = True
|
|
408
|
+
self._state.progress_current = 0
|
|
409
|
+
self._state.progress_total = 0
|
|
410
|
+
self._state.progress_started_at = time.time()
|
|
411
|
+
self._state.progress_message = ""
|
|
412
|
+
provider = build_provider(cfg)
|
|
413
|
+
run_id = self.store.start_consolidation_run(trigger=trigger, model=model_name)
|
|
414
|
+
log.info("consolidation run %s start (trigger=%s, model=%s)", run_id, trigger, model_name)
|
|
415
|
+
with self._lock:
|
|
416
|
+
self._state.progress_run_id = run_id
|
|
417
|
+
|
|
418
|
+
def _progress(current: int, total: int) -> None:
|
|
419
|
+
with self._lock:
|
|
420
|
+
self._state.progress_current = current
|
|
421
|
+
self._state.progress_total = total
|
|
422
|
+
self._state.progress_message = f"{current}/{total} memories"
|
|
423
|
+
|
|
424
|
+
stats: ConsolidateStats
|
|
425
|
+
try:
|
|
426
|
+
# Prefer the new EvolutionConsolidator: it adds memory dedup,
|
|
427
|
+
# noisy wiki cleanup, and bullet-style wiki synthesis. Fall back
|
|
428
|
+
# to the legacy single-pass LLMConsolidator if anything goes
|
|
429
|
+
# wrong during construction (e.g. provider mismatch).
|
|
430
|
+
try:
|
|
431
|
+
cons = EvolutionConsolidator(self.store, provider, cfg.get("behaviour") or {})
|
|
432
|
+
cons.set_run_id(run_id)
|
|
433
|
+
stats = cons.run(progress=_progress)
|
|
434
|
+
except Exception:
|
|
435
|
+
log.warning("EvolutionConsolidator unavailable; falling back to LLMConsolidator", exc_info=True)
|
|
436
|
+
cons = LLMConsolidator(self.store, provider, cfg.get("behaviour") or {})
|
|
437
|
+
cons.set_run_id(run_id)
|
|
438
|
+
stats = cons.run(progress=_progress)
|
|
439
|
+
except Exception as e:
|
|
440
|
+
log.exception("consolidator failed: %s", e)
|
|
441
|
+
self.store.finish_consolidation_run(run_id, "error", stats=None, error=str(e))
|
|
442
|
+
with self._lock:
|
|
443
|
+
self._state.is_running = False
|
|
444
|
+
self._state.progress_current = 0
|
|
445
|
+
self._state.progress_total = 0
|
|
446
|
+
self._state.progress_started_at = 0.0
|
|
447
|
+
self._state.progress_message = ""
|
|
448
|
+
self._state.last_error = str(e)
|
|
449
|
+
self._state.last_run_id = run_id
|
|
450
|
+
self._state.last_run = time.time()
|
|
451
|
+
# If the run blew up while talking to the provider,
|
|
452
|
+
# remember it so the top-bar dot goes amber on the
|
|
453
|
+
# next page load.
|
|
454
|
+
if any(s in str(e).lower() for s in ("llm", "provider", "api", "http")):
|
|
455
|
+
self._state.last_test_ok = False
|
|
456
|
+
self._state.last_test_at = time.time()
|
|
457
|
+
self._state.last_test_message = str(e)[:200]
|
|
458
|
+
return {"run_id": run_id, "status": "error", "error": str(e)}
|
|
459
|
+
d = stats.to_dict()
|
|
460
|
+
self.store.finish_consolidation_run(run_id, "done", stats=d)
|
|
461
|
+
# The run successfully talked to the provider — promote the
|
|
462
|
+
# top-bar dot from "set but stale" to "verified reachable".
|
|
463
|
+
with self._lock:
|
|
464
|
+
self._state.last_test_ok = True
|
|
465
|
+
self._state.last_test_at = time.time()
|
|
466
|
+
self._state.last_test_message = "live run succeeded"
|
|
467
|
+
# If the run produced or updated any wiki pages, refresh the
|
|
468
|
+
# knowledge graph from the new distilled knowledge so the
|
|
469
|
+
# graph tab stays in sync with the wiki.
|
|
470
|
+
try:
|
|
471
|
+
# Tolerate both EvolutionStats (wiki_created / wiki_updated)
|
|
472
|
+
# and legacy LLMConsolidator.stats (wiki_pages_created / wiki_pages_updated).
|
|
473
|
+
wpc = (d.get("wiki_pages_created") or 0) + (d.get("wiki_created") or 0)
|
|
474
|
+
wpu = (d.get("wiki_pages_updated") or 0) + (d.get("wiki_updated") or 0)
|
|
475
|
+
if wpc + wpu > 0:
|
|
476
|
+
from ..graph.build import KnowledgeGraph
|
|
477
|
+
report = KnowledgeGraph(self.store).rebuild_from_wiki(clear=True)
|
|
478
|
+
log.info(
|
|
479
|
+
"graph auto-rebuilt after run %s: %d entities, %d relations",
|
|
480
|
+
run_id, report.entities, report.relations,
|
|
481
|
+
)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
log.warning("post-run graph rebuild failed: %s", e)
|
|
484
|
+
with self._lock:
|
|
485
|
+
self._state.is_running = False
|
|
486
|
+
self._state.progress_current = 0
|
|
487
|
+
self._state.progress_total = 0
|
|
488
|
+
self._state.progress_started_at = 0.0
|
|
489
|
+
self._state.progress_message = ""
|
|
490
|
+
self._state.last_run = time.time()
|
|
491
|
+
self._state.last_stats = d
|
|
492
|
+
self._state.last_error = None
|
|
493
|
+
self._state.last_run_id = run_id
|
|
494
|
+
log.info("consolidation run %s done: %s", run_id, d)
|
|
495
|
+
return {"run_id": run_id, "status": "done", "stats": d}
|
|
File without changes
|
loop_memory/llm/base.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""LLM client interface.
|
|
2
|
+
|
|
3
|
+
The Loop Engine never instantiates an LLM directly — it always talks
|
|
4
|
+
to one through ``LLMClient``. Bundle ``EchoLLM`` for offline runs and
|
|
5
|
+
quick tests, and provide ``ChatHistory`` to standardize the prompt
|
|
6
|
+
shape passed to the LLM.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Message:
|
|
17
|
+
role: str # "system" | "user" | "assistant"
|
|
18
|
+
content: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ChatHistory:
|
|
23
|
+
system: str | None = None
|
|
24
|
+
messages: list[Message] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def to_prompt(self) -> str:
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
if self.system:
|
|
29
|
+
parts.append(f"[SYSTEM]\n{self.system}\n")
|
|
30
|
+
for m in self.messages:
|
|
31
|
+
parts.append(f"[{m.role.upper()}]\n{m.content}\n")
|
|
32
|
+
return "\n".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LLMClient(Protocol):
|
|
36
|
+
"""A minimal protocol for chat-style LLMs."""
|
|
37
|
+
|
|
38
|
+
model: str
|
|
39
|
+
|
|
40
|
+
def complete(self, history: ChatHistory, **kwargs) -> str: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EchoLLM:
|
|
44
|
+
"""A no-API-key fallback.
|
|
45
|
+
|
|
46
|
+
Returns a short acknowledgement plus the *last user message*.
|
|
47
|
+
Useful for unit-testing the loop wiring without burning tokens.
|
|
48
|
+
The engine still runs end-to-end against this client.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
model: str = "echo"
|
|
52
|
+
|
|
53
|
+
def __init__(self, prefix: str = "(ok) ") -> None:
|
|
54
|
+
self.prefix = prefix
|
|
55
|
+
|
|
56
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
57
|
+
for m in reversed(history.messages):
|
|
58
|
+
if m.role == "user":
|
|
59
|
+
# Pull the trailing "USER: ..." line so the echo is short
|
|
60
|
+
# and stable rather than dumping the full reconstructed prompt.
|
|
61
|
+
tail = m.content.rsplit("USER:", 1)[-1].strip()
|
|
62
|
+
tail = tail.split("\n", 1)[0]
|
|
63
|
+
return f"{self.prefix}heard: {tail[:160]}"
|
|
64
|
+
return f"{self.prefix}(empty)"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SimpleCompletionLLM:
|
|
68
|
+
"""Reference implementation that talks to a ``completion`` callable.
|
|
69
|
+
|
|
70
|
+
Useful as a template for building real adapters:
|
|
71
|
+
|
|
72
|
+
>>> llm = SimpleCompletionLLM(lambda prompt: openai_chat(prompt))
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, completion, model: str = "simple") -> None:
|
|
76
|
+
self._completion = completion
|
|
77
|
+
self.model = model
|
|
78
|
+
|
|
79
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
80
|
+
return self._completion(history.to_prompt(), **kwargs)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Optional OpenAI adapter.
|
|
2
|
+
|
|
3
|
+
Only imported when the user has the ``openai`` package installed
|
|
4
|
+
(``pip install loop-memory[openai]``). Keeps the core library
|
|
5
|
+
zero-dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..llm.base import ChatHistory, LLMClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenAIClient(LLMClient):
|
|
14
|
+
def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None) -> None:
|
|
15
|
+
try:
|
|
16
|
+
from openai import OpenAI # type: ignore
|
|
17
|
+
except ImportError as e:
|
|
18
|
+
raise RuntimeError("openai is not installed; pip install loop-memory[openai]") from e
|
|
19
|
+
self.model = model
|
|
20
|
+
self._client = OpenAI(api_key=api_key) # type: ignore[arg-type]
|
|
21
|
+
|
|
22
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
23
|
+
msgs = [{"role": "system", "content": history.system}] if history.system else []
|
|
24
|
+
msgs += [{"role": m.role, "content": m.content} for m in history.messages]
|
|
25
|
+
resp = self._client.chat.completions.create(
|
|
26
|
+
model=self.model,
|
|
27
|
+
messages=msgs,
|
|
28
|
+
temperature=kwargs.get("temperature", 0.4),
|
|
29
|
+
max_tokens=kwargs.get("max_tokens", 600),
|
|
30
|
+
)
|
|
31
|
+
return resp.choices[0].message.content or ""
|