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