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,970 @@
1
+ """Route group: admin.
2
+
3
+ Admin operations: storage, ingest, watcher, LLM, redact, auth token, rescore, gc, consolidate, compact, evolution, graph rebuild, etc.
4
+
5
+ All routes were extracted from ``serve/app.py`` as part of the O1
6
+ refactor to keep the central ``create_app`` small. Each block lives
7
+ inside ``register(app, store, scheduler=None)`` so closures over the
8
+ three captured variables work unchanged from the original layout.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ from fastapi import FastAPI, HTTPException
17
+ from fastapi.responses import JSONResponse
18
+ from ...serve.handlers import llm_test
19
+
20
+ from ...storage.sqlite_store import MemoryStore
21
+ from ._shared import _memory_to_dict, _export_safe_segment
22
+
23
+
24
+ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
25
+ """Mount every route in this bucket onto ``app``.
26
+
27
+ ``store`` and ``scheduler`` are captured in the route closures so
28
+ the function bodies stay byte-identical to the pre-split layout.
29
+ """
30
+ @app.post("/api/admin/bump-recall")
31
+ def bump_recall(ids: str):
32
+ """Manually increment recall_count on a set of memory ids.
33
+ Used by the dashboard's ↻ button when the user wants to mark
34
+ a memory as 'just consulted' so it ranks higher next time.
35
+ ``ids`` is a comma-separated list of memory ids (the dashboard
36
+ encodes the body this way so we stay on a query-only path,
37
+ which works cleanly under FastAPI 0.139's stricter validation)."""
38
+ ids_list = [s for s in (ids or "").split(",") if s]
39
+ if not ids_list:
40
+ raise HTTPException(400, "ids query param required (comma-separated)")
41
+ n = store.bump_recalls(ids_list)
42
+ if n:
43
+ try: store.rescore_all(half_life_days=30.0)
44
+ except Exception: pass
45
+ return {"bumped": n}
46
+
47
+
48
+ @app.post("/api/admin/evolution/run")
49
+ def run_evolution(batch_size: int = 300, dry_run: bool = False):
50
+ """Run the 5-stage evolution consolidator once. Requires an LLM
51
+ provider to be configured; falls back to rule-based if not."""
52
+ from loop_memory.jobs.evolution import EvolutionConsolidator
53
+ from loop_memory.llm.providers import build_provider, default_config
54
+ # The consolidator config is stored under "llm_consolidator" (see
55
+ # /api/admin/llm/config). Pull it from there so MiniMax / etc.
56
+ # providers are actually wired up — not silently downgraded.
57
+ cfg_dict = store.get_setting("llm_consolidator", default_config()) or {}
58
+ provider = build_provider(cfg_dict)
59
+ rid = store.start_consolidation_run("manual-evolution", model=getattr(provider, "model", None))
60
+ ec = EvolutionConsolidator(store, provider, {"dry_run": dry_run, "batch_size": batch_size})
61
+ ec.set_run_id(rid)
62
+ try:
63
+ stats = ec.run(limit=batch_size)
64
+ store.finish_consolidation_run(rid, status="done", stats=stats.to_dict())
65
+ return stats.to_dict()
66
+ except Exception as e:
67
+ store.finish_consolidation_run(rid, status="error", error=str(e))
68
+ raise HTTPException(500, str(e))
69
+
70
+
71
+ @app.post("/api/admin/rescore")
72
+ def rescore(half_life_days: float = 30.0):
73
+ return {"updated": store.rescore_all(half_life_days)}
74
+
75
+
76
+ @app.post("/api/admin/gc")
77
+ def gc():
78
+ return {"deleted": store.gc()}
79
+
80
+
81
+ @app.post("/api/admin/consolidate")
82
+ def consolidate():
83
+ from ...backends.embedding import HashingEmbedder
84
+ from ...jobs.consolidate import Consolidator
85
+ report = Consolidator(store, embedder=HashingEmbedder(dim=64)).run()
86
+ return {
87
+ "rescored": report.rescored,
88
+ "gc_removed": report.gc_removed,
89
+ "merged": report.merged,
90
+ "elapsed_ms": round(report.elapsed_ms, 1),
91
+ }
92
+
93
+
94
+ @app.post("/api/admin/compact")
95
+ def admin_compact(force: bool = False, mode: str = "heuristic"):
96
+ """Run a single compaction pass over the memory store.
97
+
98
+ ``force=True`` ignores the age filter — useful for a one-shot
99
+ "tidy up after a heavy ingest burst" trigger from the UI.
100
+ ``mode`` switches between ``heuristic`` (cheap, scheduled) and
101
+ ``llm`` (slower, higher-quality fusion). The LLM mode is a
102
+ no-op for now and reserved for the future fusion pass.
103
+ """
104
+ from ...jobs.compact import Compactor
105
+ from ...jobs.scheduler import ConsolidatorScheduler
106
+ # Make sure we run on a worker thread so the request returns
107
+ # fast even when there are tens of thousands of rows.
108
+ sched: ConsolidatorScheduler | None = getattr(app.state, "scheduler", None)
109
+
110
+ def _do() -> dict:
111
+ c = Compactor(store, mode=mode)
112
+ report = c.run(force=force)
113
+ return report.to_dict()
114
+
115
+ if sched is not None:
116
+ return sched.run_blocking("compact", _do)
117
+ return _do()
118
+
119
+
120
+ @app.get("/api/admin/storage")
121
+ def admin_storage():
122
+ """Storage usage + per-table breakdown + budget info.
123
+
124
+ Polled by the dashboard so the user can see when the store is
125
+ approaching its ceiling *before* it crosses it.
126
+ """
127
+ breakdown = store.storage_breakdown()
128
+ cfg = store.get_setting("storage_budget", {}) or {}
129
+ return {
130
+ "db_size_bytes": breakdown.get("db_size_bytes", 0),
131
+ "breakdown": breakdown,
132
+ "budget": {
133
+ "max_bytes": int(cfg.get("max_bytes") or 0),
134
+ "max_memories": int(cfg.get("max_memories") or 0),
135
+ "auto_compact": bool(cfg.get("auto_compact", False)),
136
+ "compact_interval_hours": int(cfg.get("compact_interval_hours") or 24),
137
+ },
138
+ "last_compact": store.get_setting("last_compact", {}) or {},
139
+ }
140
+
141
+
142
+ @app.post("/api/admin/storage/budget")
143
+ def admin_storage_budget(body: dict):
144
+ """Persist the storage budget config.
145
+
146
+ Body keys (all optional, merged into existing config):
147
+
148
+ * ``max_bytes`` int - hard ceiling; auto-prune when exceeded
149
+ * ``max_memories`` int - soft ceiling on row count
150
+ * ``auto_compact`` bool - run compact on the scheduler cadence
151
+ * ``compact_interval_hours`` int - cadence in hours (default 24)
152
+ """
153
+ current = store.get_setting("storage_budget", {}) or {}
154
+ allowed_keys = {"max_bytes", "max_memories", "auto_compact", "compact_interval_hours"}
155
+ for k in allowed_keys:
156
+ if k in body:
157
+ current[k] = body[k]
158
+ store.set_setting("storage_budget", current)
159
+ # Wake the scheduler so it picks up the new cadence.
160
+ sched = getattr(app.state, "scheduler", None)
161
+ if sched is not None and hasattr(sched, "reload_config"):
162
+ sched.reload_config()
163
+ return {"ok": True, "budget": current}
164
+
165
+
166
+ @app.post("/api/admin/consolidate-now")
167
+ def consolidate_now():
168
+ """Trigger the configured consolidator scheduler immediately.
169
+
170
+ Unlike ``/api/admin/llm/run`` (which runs once with the
171
+ current request's params) and ``/api/admin/evolution/run``
172
+ (which runs the 5-stage Evolution pipeline), this hits the
173
+ user's *configured* scheduler — using their chosen model,
174
+ provider, batch size, and schedule-derived behaviour — and
175
+ records progress in the dashboard.
176
+ """
177
+ sched = getattr(app.state, "scheduler", None)
178
+ if sched is None:
179
+ raise HTTPException(503, "scheduler not running")
180
+ result = sched.run_now(trigger="manual", block=False)
181
+ if result is None:
182
+ return {"queued": True}
183
+ return {"queued": False, "result": result}
184
+
185
+
186
+ @app.get("/api/admin/ingest/config")
187
+ def ingest_config_get():
188
+ """Return the watcher's ingest cadence settings.
189
+
190
+ These knobs drive the background file-system watcher that
191
+ auto-ingests finished transcripts from Codex / Claude / Hermes
192
+ / OpenClaw. Exposing them via the API lets the Settings
193
+ drawer tune the cadence without regenerating the launchd
194
+ plist — the watcher reads from the settings store every
195
+ few iterations and picks up changes automatically.
196
+
197
+ Defaults are 5 minutes idle (size-stable wait) and 5-second
198
+ poll. Users can dial up for less aggressive scanning (good
199
+ for SSDs and long-lived sessions) or down for snappier
200
+ recall.
201
+ """
202
+ from ...serve.watcher import DEFAULT_IDLE_SECONDS, DEFAULT_POLL_SECONDS
203
+ cfg = store.get_setting("ingest", {}) or {}
204
+ return {
205
+ "idle_seconds": float(cfg.get("idle_seconds", DEFAULT_IDLE_SECONDS)),
206
+ "poll_seconds": float(cfg.get("poll_seconds", DEFAULT_POLL_SECONDS)),
207
+ "defaults": {
208
+ "idle_seconds": DEFAULT_IDLE_SECONDS,
209
+ "poll_seconds": DEFAULT_POLL_SECONDS,
210
+ },
211
+ "notes": {
212
+ "idle_seconds": "size-stable wait before a transcript is "
213
+ "considered finished and ingested (seconds)",
214
+ "poll_seconds": "how often the watcher scans the directory "
215
+ "(seconds)",
216
+ "min_idle_seconds": 30,
217
+ "min_poll_seconds": 1,
218
+ "max_idle_seconds": 3600,
219
+ "max_poll_seconds": 60,
220
+ },
221
+ }
222
+
223
+
224
+ @app.get("/api/admin/wiki/scope")
225
+ @app.get("/api/admin/wiki-auto-scope")
226
+ def wiki_scope_config_get():
227
+ """Return automatic wiki scope-routing settings.
228
+
229
+ Pattern evaluation is local and deterministic. Disabling it keeps
230
+ new pages source-scoped but prevents automatic security promotion;
231
+ it never changes existing pages.
232
+ """
233
+ from ...wiki.scope import auto_scope_config
234
+ cfg = auto_scope_config(store)
235
+ return {
236
+ "wiki_auto_scope": cfg,
237
+ "enabled": cfg["enabled"],
238
+ "mode": cfg["mode"],
239
+ "defaults": {"enabled": True, "mode": "pattern"},
240
+ }
241
+
242
+
243
+ @app.put("/api/admin/wiki/scope")
244
+ @app.post("/api/admin/wiki/scope")
245
+ @app.put("/api/admin/wiki-auto-scope")
246
+ @app.post("/api/admin/wiki-auto-scope")
247
+ def wiki_scope_config_put(body: dict):
248
+ if not isinstance(body, dict):
249
+ raise HTTPException(400, "body must be an object")
250
+ current = wiki_scope_config_get()["wiki_auto_scope"]
251
+ if "enabled" in body:
252
+ value = body["enabled"]
253
+ if isinstance(value, str):
254
+ value = value.strip().lower() in {"1", "true", "yes", "on"}
255
+ current["enabled"] = bool(value)
256
+ if "mode" in body:
257
+ mode = str(body["mode"] or "").strip().lower()
258
+ if mode not in {"pattern", "llm", "off"}:
259
+ raise HTTPException(400, "mode must be one of: pattern, llm, off")
260
+ current["mode"] = mode
261
+ store.set_setting("wiki_auto_scope", current)
262
+ store.set_setting("wiki_auto_scope_enabled", current["enabled"])
263
+ store.set_setting("wiki_auto_scope_mode", current["mode"])
264
+ return {"ok": True, "wiki_auto_scope": current, "enabled": current["enabled"], "mode": current["mode"]}
265
+
266
+
267
+ @app.get("/api/admin/redact")
268
+ def redact_config_get():
269
+ """Return the current redaction settings.
270
+
271
+ ``enabled`` controls whether ``MemoryPipeline._write`` strips
272
+ secrets before they hit the long-term store. The setting
273
+ also disables the ``<private>...</private>`` span stripping
274
+ (a downstream consumer would still see the markers, but the
275
+ body inside isn't removed).
276
+
277
+ The ``counts`` field is empty here — the per-pipeline-run
278
+ counter lives on the live ingest pipeline. See the live
279
+ ``/api/admin/redact/live`` endpoint.
280
+ """
281
+ cfg = store.get_setting("redact", {}) or {}
282
+ return {
283
+ "enabled": bool(cfg.get("enabled", True)),
284
+ "kinds": cfg.get("kinds") or [
285
+ "private_key_block",
286
+ "openai_key", "openai_project_key", "openai_service_key",
287
+ "anthropic_key", "gemini_key", "github_pat", "slack_token",
288
+ "aws_access_key", "jwt", "bearer_token", "generic_high_entropy",
289
+ ],
290
+ "private_spans": bool(cfg.get("private_spans", True)),
291
+ }
292
+
293
+
294
+ @app.post("/api/admin/redact")
295
+ def redact_config_post(body: dict):
296
+ """Persist redaction settings.
297
+
298
+ Body fields (all optional):
299
+ * ``enabled`` (bool) — global on/off for secret redaction
300
+ * ``kinds`` (list[str])— disable specific pattern kinds
301
+ * ``private_spans`` (bool) — honour ``<private>...</private>``
302
+ """
303
+ if not isinstance(body, dict):
304
+ raise HTTPException(400, "body must be an object")
305
+ current = store.get_setting("redact", {}) or {}
306
+ new_cfg = dict(current)
307
+ if "enabled" in body:
308
+ new_cfg["enabled"] = bool(body["enabled"])
309
+ if "private_spans" in body:
310
+ new_cfg["private_spans"] = bool(body["private_spans"])
311
+ if "kinds" in body:
312
+ kinds = body["kinds"]
313
+ if not isinstance(kinds, list):
314
+ raise HTTPException(400, "kinds must be a list of strings")
315
+ new_cfg["kinds"] = [str(k) for k in kinds]
316
+ store.set_setting("redact", new_cfg)
317
+ return {"ok": True, "redact": new_cfg}
318
+
319
+
320
+ @app.post("/api/admin/redact/preview")
321
+ def redact_preview(body: dict):
322
+ """Show what the live redaction pipeline WOULD do on an
323
+ arbitrary text snippet.
324
+
325
+ Body: ``{"text": "..."}``
326
+ Returns: ``{"text": "...redacted...",
327
+ "counts": {"openai_key": 1, ...},
328
+ "total": 1,
329
+ "total_chars": 51}``
330
+
331
+ The intent is to let the UI show a side-by-side preview so
332
+ users can see (and tune) what their pipeline produces
333
+ without having to ingest a real transcript.
334
+ """
335
+ from ...privacy import redact_text, RedactionSummary
336
+ if not isinstance(body, dict) or "text" not in body:
337
+ raise HTTPException(400, "body must be {text: ...}")
338
+ text = str(body.get("text") or "")
339
+ summary = RedactionSummary()
340
+ out = redact_text(text, summary=summary)
341
+ return {
342
+ "text": out,
343
+ "counts": summary.counts,
344
+ "total": summary.total,
345
+ "total_chars": summary.total_chars,
346
+ }
347
+
348
+
349
+ @app.post("/api/admin/ingest/config")
350
+ def ingest_config_post(body: dict):
351
+ """Persist new ingest cadence settings.
352
+
353
+ Body fields (all optional):
354
+ * ``idle_seconds`` (int|float) — size-stable wait before ingest
355
+ * ``poll_seconds`` (int|float) — directory scan period
356
+
357
+ Validation enforces sane bounds (>= 30s for idle to avoid
358
+ fragmenting long sessions, <= 3600s so something eventually
359
+ ingests; >= 1s for poll, <= 60s to bound stat() churn).
360
+ Settings persist immediately and the running watcher picks
361
+ them up on the next reload tick (≤ 30s later).
362
+ """
363
+ if not isinstance(body, dict):
364
+ from fastapi import HTTPException
365
+ raise HTTPException(400, "body must be an object")
366
+ from ...serve.watcher import DEFAULT_IDLE_SECONDS, DEFAULT_POLL_SECONDS
367
+ current = store.get_setting("ingest", {}) or {}
368
+ idle = body.get("idle_seconds", current.get("idle_seconds", DEFAULT_IDLE_SECONDS))
369
+ poll = body.get("poll_seconds", current.get("poll_seconds", DEFAULT_POLL_SECONDS))
370
+ try:
371
+ idle = float(idle)
372
+ poll = float(poll)
373
+ except (TypeError, ValueError):
374
+ from fastapi import HTTPException
375
+ raise HTTPException(400, "idle_seconds and poll_seconds must be numeric")
376
+ # Bounds — see API docstring.
377
+ if idle < 30 or idle > 3600:
378
+ from fastapi import HTTPException
379
+ raise HTTPException(
380
+ 400,
381
+ f"idle_seconds must be in [30, 3600] (got {idle}); "
382
+ "use longer intervals to reduce disk wear and avoid "
383
+ "fragmenting long sessions",
384
+ )
385
+ if poll < 1 or poll > 60:
386
+ from fastapi import HTTPException
387
+ raise HTTPException(
388
+ 400,
389
+ f"poll_seconds must be in [1, 60] (got {poll}); "
390
+ "the directory scan itself is cheap (stat()) but it "
391
+ "still runs once per tick",
392
+ )
393
+ new_cfg = dict(current)
394
+ new_cfg["idle_seconds"] = idle
395
+ new_cfg["poll_seconds"] = poll
396
+ store.set_setting("ingest", new_cfg)
397
+ return {"ok": True, "ingest": new_cfg}
398
+
399
+
400
+ @app.post("/api/admin/ingest")
401
+ def ingest(source: str, path: str | None = None):
402
+ """One-shot ingest endpoint (manual trigger).
403
+
404
+ Was previously shadowed by an orphan @app.post decorator
405
+ sitting on top of ingest_config_get — see the audit
406
+ report (H1). Now correctly wired so a UI click actually
407
+ performs ingest.
408
+ """
409
+ from ...backends.embedding import HashingEmbedder
410
+ from ...ingest.loader import default_paths, get_loader
411
+ from ...ingest.pipeline import MemoryPipeline
412
+
413
+ loader = get_loader(source)
414
+ root = Path(path).expanduser() if path else default_paths()[source]
415
+ files = list(loader.discover(root))
416
+ if not files:
417
+ return {"files": 0, "note": f"no transcripts found under {root}"}
418
+ pipeline = MemoryPipeline(store, embedder=HashingEmbedder(dim=64))
419
+ ingested = 0
420
+ for fp in files:
421
+ session = loader.load_one(fp)
422
+ if session is None:
423
+ continue
424
+ pipeline.run(session)
425
+ ingested += 1
426
+ return {"files": ingested, "root": str(root)}
427
+
428
+
429
+ @app.post("/api/admin/watcher/force-ingest")
430
+ def watcher_force_ingest(
431
+ source: str | None = None,
432
+ path: str | None = None,
433
+ idle_seconds: float = 0.0,
434
+ active_only: bool = False,
435
+ ):
436
+ """Trigger a one-shot ingest pass for a watcher directory.
437
+
438
+ This is the manual escape hatch for cases where the persistent
439
+ launchd watcher is still waiting for the idle window (long
440
+ active sessions whose file mtime keeps refreshing).
441
+
442
+ Parameters:
443
+ source - one of "codex" | "claude" | "hermes" |
444
+ "openclaw". If omitted, we auto-detect from
445
+ the path (path's leaf must contain the
446
+ source name) or default to "codex".
447
+ path - explicit watch directory. If omitted, uses
448
+ the default watch dir for the given source.
449
+ idle_seconds - require files to have been size-stable for
450
+ at least this long before ingesting.
451
+ Default 0 = ingest any file that has new
452
+ content vs the last successful ingest.
453
+ active_only - if True, restrict to the most-recently
454
+ modified .jsonl file under the watch dir
455
+ (the "currently active" session). Useful
456
+ for the UI button: "force the active Codex
457
+ session to ingest now".
458
+
459
+ Returns a per-file breakdown so the UI can show what happened.
460
+ """
461
+ from ...backends.embedding import HashingEmbedder
462
+ from ...ingest.loader import default_paths, get_loader
463
+ from ...ingest.pipeline import MemoryPipeline
464
+ from .watcher import run_once
465
+
466
+ # Resolve source
467
+ src = (source or "").strip().lower() or None
468
+ if not src and path:
469
+ # Try to infer from path: ~/.codex/sessions → codex
470
+ p_lower = path.lower()
471
+ for candidate in ("codex", "claude", "hermes", "openclaw"):
472
+ if f"/{candidate}/" in p_lower or p_lower.endswith(f"/{candidate}"):
473
+ src = candidate
474
+ break
475
+ if not src:
476
+ src = "codex" # default fallback for the common case
477
+
478
+ # Resolve watch dir
479
+ if path:
480
+ root = Path(path).expanduser()
481
+ else:
482
+ defaults = default_paths()
483
+ if src not in defaults:
484
+ return {"error": f"unknown source {src!r}", "sources": list(defaults.keys())}
485
+ # default_paths may return a list for openclaw (sessions + memory)
486
+ default_val = defaults[src]
487
+ if isinstance(default_val, list):
488
+ # For multi-path sources, force-ingest against the
489
+ # first path; callers wanting a specific sub-dir
490
+ # should pass ``path`` explicitly.
491
+ root = Path(default_val[0]).expanduser()
492
+ else:
493
+ root = Path(default_val).expanduser()
494
+
495
+ loader = get_loader(src)
496
+ pipeline = MemoryPipeline(store, embedder=HashingEmbedder(dim=64))
497
+
498
+ # active_only: pick the most-recently-modified transcript file
499
+ # under root (or fall back to the dir itself).
500
+ if active_only:
501
+ try:
502
+ candidates = [
503
+ p for p in loader.discover(root)
504
+ if p.is_file() and p.name != ".loop_memory_seen.json"
505
+ ]
506
+ except FileNotFoundError:
507
+ candidates = []
508
+ if not candidates:
509
+ return {
510
+ "source": src, "root": str(root),
511
+ "active_only": True,
512
+ "scanned": 0, "ingested": 0, "skipped": 0, "errors": 0,
513
+ "files": [], "note": "no transcripts under watch dir",
514
+ }
515
+ candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
516
+ target = candidates[0]
517
+ # Build a tiny virtual dir containing only this file by
518
+ # running run_once against a temp dir is overkill — instead
519
+ # we manually load + ingest just this one file but still
520
+ # update the ledger.
521
+ from .watcher import _load_ledger, _ledger_path, _save_ledger
522
+ ledger_path = _ledger_path(root)
523
+ ledger = _load_ledger(ledger_path)
524
+ key = str(target)
525
+ prev = ledger.get(key)
526
+ try:
527
+ st = target.stat()
528
+ except FileNotFoundError:
529
+ return {"source": src, "path": key, "error": "file disappeared"}
530
+ file_result = {
531
+ "path": key, "size": st.st_size, "mtime": st.st_mtime,
532
+ "previous_ingested_at": (prev or {}).get("ingested_at"),
533
+ }
534
+ try:
535
+ session = loader.load_one(target)
536
+ except Exception as e:
537
+ file_result.update({"status": "error", "error": f"{type(e).__name__}: {e}"})
538
+ return {"source": src, "root": str(root), "active_only": True,
539
+ "scanned": 1, "ingested": 0, "skipped": 0, "errors": 1,
540
+ "files": [file_result]}
541
+ if session is None:
542
+ file_result["status"] = "skipped"
543
+ file_result["reason"] = "loader returned None"
544
+ return {"source": src, "root": str(root), "active_only": True,
545
+ "scanned": 1, "ingested": 0, "skipped": 1, "errors": 0,
546
+ "files": [file_result]}
547
+ try:
548
+ pipe_result = pipeline.run(session)
549
+ except Exception as e:
550
+ file_result.update({"status": "error", "error": f"{type(e).__name__}: {e}"})
551
+ return {"source": src, "root": str(root), "active_only": True,
552
+ "scanned": 1, "ingested": 0, "skipped": 0, "errors": 1,
553
+ "files": [file_result]}
554
+ n_items = len(pipe_result.summary_items)
555
+ now = time.time()
556
+ ledger[key] = {
557
+ "sig": [st.st_mtime, st.st_size],
558
+ "first_seen": (prev or {}).get("first_seen", now),
559
+ "last_mtime": st.st_mtime,
560
+ "size": st.st_size,
561
+ "last_size_change_at": now,
562
+ "ingested_at": now,
563
+ }
564
+ _save_ledger(ledger_path, ledger)
565
+ file_result.update({"status": "ingested", "summary_items": n_items})
566
+ return {
567
+ "source": src, "root": str(root), "active_only": True,
568
+ "scanned": 1, "ingested": 1, "skipped": 0, "errors": 0,
569
+ "files": [file_result],
570
+ }
571
+
572
+ result = run_once(loader, root, pipeline, idle_seconds=idle_seconds)
573
+ result["source"] = src
574
+ result["root"] = str(root)
575
+ result["active_only"] = False
576
+ return result
577
+
578
+
579
+ @app.get("/api/admin/watcher/active-session")
580
+ def watcher_active_session(source: str = "codex"):
581
+ """Return the most-recently-modified transcript file under the
582
+ source's default watch dir. Used by the UI button to show
583
+ "currently active session: <name>" next to the Force-ingest
584
+ action.
585
+ """
586
+ from ...ingest.loader import default_paths, get_loader
587
+ defaults = default_paths()
588
+ if source not in defaults:
589
+ return {"error": f"unknown source {source!r}", "sources": list(defaults.keys())}
590
+ root = defaults[source]
591
+ if isinstance(root, list):
592
+ root = root[0]
593
+ root = Path(root).expanduser()
594
+ loader = get_loader(source)
595
+ try:
596
+ candidates = [
597
+ p for p in loader.discover(root)
598
+ if p.is_file() and p.name != ".loop_memory_seen.json"
599
+ ]
600
+ except FileNotFoundError:
601
+ candidates = []
602
+ if not candidates:
603
+ return {"source": source, "root": str(root), "active": None}
604
+ candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
605
+ top = candidates[0]
606
+ st = top.stat()
607
+ return {
608
+ "source": source,
609
+ "root": str(root),
610
+ "active": {
611
+ "path": str(top),
612
+ "name": top.name,
613
+ "size": st.st_size,
614
+ "mtime": st.st_mtime,
615
+ "age_seconds": time.time() - st.st_mtime,
616
+ },
617
+ }
618
+
619
+
620
+ @app.post("/api/admin/graph/rebuild")
621
+ def graph_rebuild(clear: bool = True, limit: int = 0, mode: str = "wiki"):
622
+ """Rebuild the knowledge graph.
623
+
624
+ ``mode``:
625
+
626
+ * ``wiki`` (default) — build from distilled wiki pages.
627
+ Cleaner, denser, mirrors the user's curated knowledge.
628
+ * ``memory`` — build from raw memories (legacy behaviour).
629
+ Useful for "I want to see everything" exploration.
630
+ """
631
+ from ...graph.build import KnowledgeGraph
632
+ kg = KnowledgeGraph(store)
633
+ if mode == "memory":
634
+ report = kg.rebuild(clear=clear, limit=limit or None)
635
+ else:
636
+ report = kg.rebuild_from_wiki(clear=clear, limit=limit or None)
637
+ return {
638
+ "entities": report.entities,
639
+ "relations": report.relations,
640
+ "memories_scanned": report.memories_scanned,
641
+ "elapsed_ms": round(report.elapsed_ms, 1),
642
+ "mode": mode,
643
+ }
644
+
645
+ # =====================================================================
646
+ # LLM-driven consolidator: settings, status, runs, manual + dry-run
647
+ # =====================================================================
648
+
649
+
650
+ @app.get("/api/admin/llm/providers")
651
+ def llm_providers():
652
+ from ...llm.providers import PROVIDERS
653
+ return [
654
+ {
655
+ "id": spec.id,
656
+ "label": spec.label,
657
+ "default_model": spec.default_model,
658
+ "needs_api_key": spec.needs_api_key,
659
+ "needs_base_url": spec.needs_base_url,
660
+ "default_base_url": spec.default_base_url,
661
+ "description": spec.description,
662
+ }
663
+ for spec in PROVIDERS.values()
664
+ ]
665
+
666
+
667
+ @app.get("/api/admin/llm/config")
668
+ def llm_config_get():
669
+ import hashlib
670
+ import time
671
+ from ...llm.providers import default_config, validate_config
672
+ from ...security import (
673
+ account_for, backend_display_name, backend_name,
674
+ get_secret, has_secret,
675
+ )
676
+ cfg, warnings = validate_config(
677
+ store.get_setting("llm_consolidator", default_config())
678
+ )
679
+ provider = cfg.get("provider") or "echo"
680
+ account = cfg.get("api_key_account") or account_for(provider)
681
+ cfg["api_key_account"] = account
682
+ has = has_secret(account)
683
+ cfg["api_key_set"] = bool(has)
684
+ # If the secret exists but no fingerprint / saved_at are recorded
685
+ # in cfg (e.g. the user pasted the key directly into the JSON file
686
+ # or the secret backend was filled by an external tool), derive them on
687
+ # the fly so the UI can show a useful "ends with … · saved …"
688
+ # chip instead of placeholders.
689
+ if has and (not cfg.get("api_key_fingerprint") or not cfg.get("api_key_saved_at")):
690
+ try:
691
+ raw = get_secret(account) or ""
692
+ if raw:
693
+ tail = raw[-4:] if len(raw) >= 4 else raw
694
+ h = hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:6]
695
+ cfg["api_key_fingerprint"] = f"{tail}·{h}"
696
+ # Use the secret file mtime as a sane fallback so the chip
697
+ # shows a real timestamp instead of "—".
698
+ try:
699
+ import os as _os
700
+ from pathlib import Path as _Path
701
+ from ...security.secrets import _pick_backend
702
+ backend = _pick_backend()
703
+ p = getattr(backend, "path", None)
704
+ if p is None and hasattr(backend, "path"):
705
+ p = backend.path
706
+ if p:
707
+ cfg["api_key_saved_at"] = p.stat().st_mtime
708
+ except Exception:
709
+ cfg["api_key_saved_at"] = cfg.get("api_key_saved_at") or _time.time()
710
+ except Exception:
711
+ pass
712
+ return {
713
+ "config": cfg,
714
+ "warnings": warnings,
715
+ "secret_backend": backend_name(),
716
+ "secret_backend_display": backend_display_name(),
717
+ }
718
+
719
+
720
+ @app.put("/api/admin/llm/config")
721
+ async def llm_config_put(body: dict):
722
+ from ...llm.providers import default_config, validate_config
723
+ from ...security import account_for, delete_secret, has_secret, set_secret
724
+ body = body or {}
725
+ provider = (body.get("provider") or "echo").lower()
726
+ # The api_key field, if present, is *only* sent to the
727
+ # secret backend - never to the settings store. We pop it before
728
+ # validate_config so the cleaned config never carries the key.
729
+ raw_key = body.pop("api_key", None)
730
+ if raw_key is not None and raw_key != "":
731
+ account = account_for(provider)
732
+ if raw_key == "__clear__":
733
+ delete_secret(account)
734
+ body["api_key_set"] = False
735
+ body["api_key_fingerprint"] = ""
736
+ body["api_key_saved_at"] = 0
737
+ else:
738
+ set_secret(account, raw_key)
739
+ body["api_key_set"] = True
740
+ # Non-secret fingerprint: last 4 chars + short hash.
741
+ # Never store the key itself.
742
+ import hashlib
743
+ tail = raw_key[-4:] if len(raw_key) >= 4 else raw_key
744
+ h = hashlib.sha1(raw_key.encode("utf-8"), usedforsecurity=False).hexdigest()[:6]
745
+ body["api_key_fingerprint"] = f"{tail}·{h}"
746
+ body["api_key_saved_at"] = time.time()
747
+ body["api_key_account"] = account
748
+ elif "api_key" not in body:
749
+ # Preserve current key status if the user didn't touch it
750
+ existing = store.get_setting("llm_consolidator", default_config())
751
+ ex_provider = (existing.get("provider") or "echo").lower()
752
+ if ex_provider == provider:
753
+ ex_account = existing.get("api_key_account") or account_for(provider)
754
+ body["api_key_set"] = bool(existing.get("api_key_set")) and has_secret(ex_account)
755
+ body["api_key_account"] = ex_account
756
+ cfg, warnings = validate_config(body)
757
+ # Always read the canonical status from the secret backend so the
758
+ # response reflects reality.
759
+ cfg["api_key_set"] = has_secret(cfg.get("api_key_account") or account_for(provider))
760
+ cfg["api_key_account"] = cfg.get("api_key_account") or account_for(provider)
761
+ # Compute the response-only fingerprint / saved_at on the fly
762
+ # rather than persisting them. Audit M3: persisting a 6-char
763
+ # sha1+tail fingerprint into the settings table leaks enough
764
+ # info to brute-force candidate keys (36-bit entropy). The
765
+ # secret file's mtime already records "when" anyway.
766
+ try:
767
+ from ...security.secrets import _pick_backend as _pb
768
+ _be = _pb()
769
+ _path = getattr(_be, "path", None)
770
+ _saved_at = _path.stat().st_mtime if _path else time.time()
771
+ except Exception:
772
+ _saved_at = time.time()
773
+ # Only compute a fingerprint if we just wrote a fresh key
774
+ # (the prior branch set body["api_key_fingerprint"]); never
775
+ # read the secret back to display — that would force a key
776
+ # unlock prompt on every PUT for OS keychain users.
777
+ fingerprint = body.get("api_key_fingerprint", "")
778
+ # Strip response-only fields before persisting.
779
+ cfg.pop("api_key_fingerprint", None)
780
+ cfg.pop("api_key_saved_at", None)
781
+ store.set_setting("llm_consolidator", cfg)
782
+ scheduler = getattr(app.state, "scheduler", None)
783
+ if scheduler is not None:
784
+ scheduler.reload_config()
785
+ from ...security import backend_display_name, backend_name
786
+ return {
787
+ "ok": True,
788
+ "config": cfg,
789
+ "warnings": warnings,
790
+ "secret_backend": backend_name(),
791
+ "secret_backend_display": backend_display_name(),
792
+ "api_key_fingerprint": fingerprint,
793
+ "api_key_saved_at": _saved_at,
794
+ }
795
+
796
+
797
+ @app.get("/api/admin/auth/token")
798
+ def auth_token_get():
799
+ """Check if auth token is configured."""
800
+ token = store.get_setting("loop_memory_auth_token")
801
+ return {"enabled": bool(token)}
802
+
803
+
804
+ @app.post("/api/admin/auth/token")
805
+ async def auth_token_post():
806
+ """Generate or rotate the auth token.
807
+
808
+ Audit M4: previously this endpoint was a TOFU bootstrap
809
+ that left the server in ``no-token`` mode after a DELETE,
810
+ immediately making every protected endpoint unauthenticated
811
+ again. Now DELETE *also* rotates (returns a fresh token)
812
+ so the server can never be in a fully-unauthenticated state
813
+ unless the user has never visited the UI.
814
+
815
+ Auth-wise: middleware already requires ``Authorization:
816
+ Bearer`` on this route *if* a token is currently
817
+ configured, so a rotation always needs the old token.
818
+ """
819
+ import secrets as _secrets
820
+ new_token = _secrets.token_urlsafe(32)
821
+ store.set_setting("loop_memory_auth_token", new_token)
822
+ return {"token": new_token, "rotated": True}
823
+
824
+
825
+ @app.delete("/api/admin/auth/token")
826
+ def auth_token_rotate_via_delete():
827
+ """Rotate the auth token (audit M4).
828
+
829
+ Previous behaviour: fully removed the token, leaving the
830
+ server unauthenticated for every subsequent call. The
831
+ attacker-friendly use case is: a curious user creates a
832
+ token, then clicks "Disable", at which point anyone with
833
+ localhost access (e.g. a misconfigured proxy, a malicious
834
+ dashboard widget, a future feature that exposes the
835
+ endpoint without auth) gains admin.
836
+
837
+ New behaviour: this endpoint is a *rotate*, not a *disable*.
838
+ A fresh token is generated and returned; the old one is
839
+ invalidated. The server stays authenticated, so we can
840
+ never bottom out at ``auth-disabled`` after a first token
841
+ has been set.
842
+
843
+ If the user genuinely wants to disable auth (which we do
844
+ not recommend for any non-loopback bind), they should
845
+ delete the row directly via the ``settings`` store or
846
+ uninstall the server.
847
+ """
848
+ import secrets as _secrets
849
+ # Even if a token exists, this route is still gated by the
850
+ # middleware (so a DELETE without a Bearer header on a
851
+ # previously-configured server is 401). When no token
852
+ # existed, we act as a bootstrap (acts the same as POST).
853
+ new_token = _secrets.token_urlsafe(32)
854
+ store.set_setting("loop_memory_auth_token", new_token)
855
+ return {"token": new_token, "rotated": True, "ok": True}
856
+
857
+
858
+ @app.post("/api/admin/llm/test")
859
+ async def llm_test_route(body: dict | None = None):
860
+ """Smoke-test the LLM provider without writing to the store.
861
+ See handlers.llm_test for the body contract.
862
+
863
+ The result is also recorded on the scheduler so the top-bar
864
+ model chip can switch between green-pulsing (verified) and
865
+ amber (last test failed) without the user re-opening the
866
+ drawer.
867
+ """
868
+ sched = getattr(app.state, "scheduler", None)
869
+ return llm_test(store, body or {}, scheduler=sched)
870
+
871
+
872
+ @app.get("/api/admin/llm/status")
873
+ def llm_status():
874
+ scheduler = getattr(app.state, "scheduler", None)
875
+ if scheduler is None:
876
+ return {"running": False, "reason": "scheduler-not-started"}
877
+ return scheduler.status()
878
+
879
+
880
+ @app.delete("/api/admin/llm/key")
881
+ def llm_clear_key():
882
+ """Delete the API key for the currently-configured provider."""
883
+ from ...llm.providers import default_config, validate_config
884
+ from ...security import account_for, delete_secret, has_secret
885
+ cfg, _ = validate_config(store.get_setting("llm_consolidator", default_config()))
886
+ account = cfg.get("api_key_account") or account_for(cfg.get("provider") or "echo")
887
+ removed = delete_secret(account) if has_secret(account) else False
888
+ cfg["api_key_set"] = False
889
+ cfg["api_key_fingerprint"] = ""
890
+ cfg["api_key_saved_at"] = 0
891
+ store.set_setting("llm_consolidator", cfg)
892
+ return {"removed": removed, "account": account}
893
+
894
+
895
+ @app.get("/api/admin/llm/runs")
896
+ def llm_runs(limit: int = 20):
897
+ return store.list_consolidation_runs(limit=limit)
898
+
899
+
900
+ @app.post("/api/admin/llm/run")
901
+ def llm_run_now(dry_run: bool = False, limit: int = 0):
902
+ """Trigger a consolidation pass right now.
903
+
904
+ ``dry_run=true`` returns the actions the LLM *would* take on
905
+ the first ~50 memories without writing anything back.
906
+ """
907
+ from ...jobs.llm_consolidate import LLMConsolidator
908
+ from ...llm.providers import build_provider, default_config, validate_config
909
+ cfg, _ = validate_config(store.get_setting("llm_consolidator", default_config()))
910
+ provider = build_provider(cfg)
911
+ if dry_run:
912
+ cons = LLMConsolidator(store, provider, cfg.get("behaviour") or {})
913
+ preview = cons.preview(limit=limit or 20)
914
+ return {"dry_run": True, "preview": preview, "config": cfg}
915
+ scheduler = getattr(app.state, "scheduler", None)
916
+ if scheduler is not None:
917
+ scheduler.notify_ingest()
918
+ scheduler.run_now(trigger="manual", block=False)
919
+ return {"ok": True, "queued": True, "config": cfg}
920
+ # no scheduler (shouldn't happen in normal serve mode): run synchronously
921
+ cons = LLMConsolidator(store, provider, cfg.get("behaviour") or {})
922
+ stats = cons.run()
923
+ return {"ok": True, "queued": False, "stats": stats.to_dict(), "config": cfg}
924
+
925
+
926
+ @app.post("/api/admin/llm/schedule")
927
+ async def llm_schedule(body: dict):
928
+ """Quick-toggle the scheduler on/off without rewriting the rest of the config."""
929
+ from ...llm.providers import default_config, validate_config
930
+ cfg, warnings = validate_config(store.get_setting("llm_consolidator", default_config()))
931
+ sched = dict(cfg.get("schedule") or {})
932
+ for k, v in (body or {}).items():
933
+ sched[k] = v
934
+ cfg["schedule"] = sched
935
+ store.set_setting("llm_consolidator", cfg)
936
+ scheduler = getattr(app.state, "scheduler", None)
937
+ if scheduler is not None:
938
+ scheduler.reload_config()
939
+ return {"ok": True, "schedule": sched, "warnings": warnings}
940
+
941
+
942
+ @app.post("/api/admin/wiki/reclassify-legacy")
943
+ def wiki_reclassify_legacy_endpoint(body: dict | None = None):
944
+ """Re-classify legacy wiki pages and downgrade non-universal
945
+ security rows to per-source scope. Read-only with
946
+ body.dry_run=true; otherwise persists scope changes
947
+ in-place. Audit history is preserved on every touched row
948
+ so the front-end classification history view still works."""
949
+ body = body or {}
950
+ dry = bool(body.get("dry_run", False))
951
+ batch = int(body.get("batch", 500))
952
+ if dry:
953
+ pages = store.list_wiki_pages(limit=batch)
954
+ legacy = [
955
+ p for p in pages
956
+ if (p.get("scope") or "global") == "global"
957
+ and p.get("auto_classification") is None
958
+ ]
959
+ return {
960
+ "dry_run": True,
961
+ "scanned": len(pages),
962
+ "legacy_global": len(legacy),
963
+ "items": [{"slug": p["slug"], "title": p.get("title")}
964
+ for p in legacy],
965
+ }
966
+ from ...wiki import reclassify_legacy_pages
967
+ return reclassify_legacy_pages(store, batch=batch)
968
+
969
+
970
+ # ---- wiki pages -----------------------------------------------------