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,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
@@ -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 ""