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,493 @@
|
|
|
1
|
+
"""Route group: system.
|
|
2
|
+
|
|
3
|
+
Read-only system diagnostics + bootstrap endpoints (index, stats, diag, install-hooks, source-health, llm-audit, write-guard, signals, pipeline*, recall).
|
|
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
|
+
from typing import Any, Optional
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from fastapi import FastAPI, HTTPException
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
from fastapi.responses import FileResponse
|
|
20
|
+
from ...serve.handlers import pipeline_stage_items
|
|
21
|
+
from ...serve.handlers import pipeline_dashboard
|
|
22
|
+
from ...cli._common import DEFAULT_DB
|
|
23
|
+
|
|
24
|
+
from ...storage.sqlite_store import MemoryStore
|
|
25
|
+
from ._shared import _memory_to_dict, _export_safe_segment
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
|
|
29
|
+
static_dir: Path | None = None) -> None:
|
|
30
|
+
"""Mount every route in this bucket onto ``app``.
|
|
31
|
+
|
|
32
|
+
``store`` and ``scheduler`` are captured in the route closures so
|
|
33
|
+
the function bodies stay byte-identical to the pre-split layout.
|
|
34
|
+
``static_dir`` is the directory containing index.html + i18n JSONs.
|
|
35
|
+
Falls back to ``../static`` next to ``routes/`` if not provided.
|
|
36
|
+
"""
|
|
37
|
+
if static_dir is None:
|
|
38
|
+
static_dir = Path(__file__).parent.parent / "static"
|
|
39
|
+
@app.get("/")
|
|
40
|
+
def index():
|
|
41
|
+
"""Serve the dashboard HTML with the i18n JSONs inlined.
|
|
42
|
+
|
|
43
|
+
The dashboard renders its UI from string keys like
|
|
44
|
+
``tab.wiki`` looked up via ``t()`` in ``store.js``. Until the
|
|
45
|
+
i18n JSON files finish loading, ``t()`` would return the raw
|
|
46
|
+
key (e.g. ``tab.wiki``) — which the user perceived as
|
|
47
|
+
English-looking code-style text on every hard refresh
|
|
48
|
+
("页面先变英文再转中文").
|
|
49
|
+
|
|
50
|
+
The fix is to inline both dictionaries as ``<script
|
|
51
|
+
type="application/json">`` tags in the HTML so ``store.js``
|
|
52
|
+
can read them synchronously at module init time, before Vue
|
|
53
|
+
even mounts. The total inline payload is ~60KB (the two
|
|
54
|
+
JSON files), which is acceptable for a single full-page
|
|
55
|
+
load — and crucially, it eliminates the hydration race.
|
|
56
|
+
|
|
57
|
+
Reads the JSONs from disk on every request so an i18n edit
|
|
58
|
+
is picked up on the next hard refresh without a server
|
|
59
|
+
restart. Cheap (<1ms on local SSD).
|
|
60
|
+
"""
|
|
61
|
+
index_path = static_dir / "index.html"
|
|
62
|
+
if not index_path.exists():
|
|
63
|
+
return JSONResponse({"error": "index.html missing"}, status_code=500)
|
|
64
|
+
try:
|
|
65
|
+
html = index_path.read_text(encoding="utf-8")
|
|
66
|
+
except Exception:
|
|
67
|
+
return FileResponse(str(index_path))
|
|
68
|
+
try:
|
|
69
|
+
en_json = (static_dir / "i18n" / "en.json").read_text(encoding="utf-8")
|
|
70
|
+
zh_json = (static_dir / "i18n" / "zh.json").read_text(encoding="utf-8")
|
|
71
|
+
# Inject the JSON verbatim into <script type="application/json">.
|
|
72
|
+
# The browser treats that script type as raw CDATA — every
|
|
73
|
+
# byte inside (including `"`, `<`, `>`, `&`) passes through
|
|
74
|
+
# to ``JSON.parse`` unchanged. HTML-escaping the JSON
|
|
75
|
+
# instead would turn `"` into ``"`` and silently break
|
|
76
|
+
# ``JSON.parse`` (which the user notices as "all types"
|
|
77
|
+
# first rendering English then snapping to Chinese on hard
|
|
78
|
+
# refresh). The only real risk is the closing </script>
|
|
79
|
+
# sequence appearing inside a JSON string, which we
|
|
80
|
+
# neutralise with a reverse-solidus escape — a comment-out
|
|
81
|
+
# JSON-safe way of breaking up the tag.
|
|
82
|
+
en_safe = en_json.replace("</script>", "<\\/script>")
|
|
83
|
+
zh_safe = zh_json.replace("</script>", "<\\/script>")
|
|
84
|
+
# Inject the two payloads right before main.js so they
|
|
85
|
+
# exist when store.js evaluates.
|
|
86
|
+
inject = (
|
|
87
|
+
'<script type="application/json" id="loop-i18n-en">'
|
|
88
|
+
+ en_safe + "</script>"
|
|
89
|
+
'<script type="application/json" id="loop-i18n-zh">'
|
|
90
|
+
+ zh_safe + "</script>"
|
|
91
|
+
)
|
|
92
|
+
marker = '<script type="module" src="static/js/main.js"></script>'
|
|
93
|
+
if marker in html:
|
|
94
|
+
html = html.replace(marker, inject + marker, 1)
|
|
95
|
+
else:
|
|
96
|
+
# Fallback: inject before </body>
|
|
97
|
+
html = html.replace("</body>", inject + "</body>", 1)
|
|
98
|
+
except Exception:
|
|
99
|
+
# If the i18n files are missing or unreadable, fall back
|
|
100
|
+
# to the bare HTML — ``store.js`` will then load them
|
|
101
|
+
# over HTTP (with the old flash behaviour, but at least
|
|
102
|
+
# the page still renders).
|
|
103
|
+
pass
|
|
104
|
+
from fastapi.responses import HTMLResponse # type: ignore
|
|
105
|
+
return HTMLResponse(html)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.get("/api/llm-audit")
|
|
109
|
+
def llm_audit(limit: int = 50, kind: str | None = None, since: float | None = None):
|
|
110
|
+
from ...storage.sqlite_store import LLMAuditStore
|
|
111
|
+
audit = LLMAuditStore(store)
|
|
112
|
+
recent = audit.recent(limit=limit, kind=kind)
|
|
113
|
+
s = audit.stats(since_ts=since)
|
|
114
|
+
return {"recent": recent, "stats": s}
|
|
115
|
+
|
|
116
|
+
# ====================================================================
|
|
117
|
+
# /api/write-guard — live drop counts + last-rejected timestamps
|
|
118
|
+
# ====================================================================
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.get("/api/write-guard")
|
|
122
|
+
def write_guard(window_hours: float = 24 * 7):
|
|
123
|
+
from ...storage.sqlite_store import WriteGuardDropStore
|
|
124
|
+
ds = WriteGuardDropStore(store)
|
|
125
|
+
return ds.summary(window_hours=window_hours)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.get("/api/source-health")
|
|
129
|
+
def source_health():
|
|
130
|
+
"""Detect per-source ingest staleness + launchd hook status.
|
|
131
|
+
|
|
132
|
+
Statuses:
|
|
133
|
+
- fresh: last ingest within 24h
|
|
134
|
+
- stale: 24h - 7d
|
|
135
|
+
- silent: > 7d
|
|
136
|
+
- never: no memories yet
|
|
137
|
+
"""
|
|
138
|
+
import subprocess as _sp
|
|
139
|
+
now = time.time()
|
|
140
|
+
with store._conn() as c:
|
|
141
|
+
# Normalize per-thread codex sources to a single bucket so the
|
|
142
|
+
# health view shows one row per real source.
|
|
143
|
+
rows = c.execute(
|
|
144
|
+
"""SELECT COALESCE(source, 'unknown') AS s,
|
|
145
|
+
COUNT(*) AS c,
|
|
146
|
+
MAX(created_at) AS last_ts
|
|
147
|
+
FROM memories GROUP BY s"""
|
|
148
|
+
).fetchall()
|
|
149
|
+
bucket = {}
|
|
150
|
+
for r in rows:
|
|
151
|
+
key = r['s'].split('/')[0] if '/' in r['s'] else r['s']
|
|
152
|
+
if key not in bucket or r['last_ts'] > bucket[key]['last_ts']:
|
|
153
|
+
bucket[key] = {'c': r['c'], 'last_ts': r['last_ts']}
|
|
154
|
+
rows = [
|
|
155
|
+
{'s': k, 'c': v['c'], 'last_ts': v['last_ts']}
|
|
156
|
+
for k, v in bucket.items()
|
|
157
|
+
]
|
|
158
|
+
rows.sort(key=lambda x: -x['c'])
|
|
159
|
+
sources = []
|
|
160
|
+
healthy_count = 0
|
|
161
|
+
for r in rows:
|
|
162
|
+
last_ts = r["last_ts"] or 0
|
|
163
|
+
age_h = (now - last_ts) / 3600 if last_ts else None
|
|
164
|
+
if age_h is None:
|
|
165
|
+
status = "never"
|
|
166
|
+
hint = "no memories yet -- check the watcher is running"
|
|
167
|
+
elif age_h <= 24:
|
|
168
|
+
status = "fresh"; healthy_count += 1
|
|
169
|
+
hint = "OK ingesting normally"
|
|
170
|
+
elif age_h <= 24 * 7:
|
|
171
|
+
status = "stale"
|
|
172
|
+
hint = "WARN no ingest in " + str(int(age_h // 24)) + "d -- check the hook"
|
|
173
|
+
else:
|
|
174
|
+
status = "silent"
|
|
175
|
+
hint = "DEAD silent for " + str(int(age_h // 24)) + "d -- hook likely dead"
|
|
176
|
+
sources.append({
|
|
177
|
+
"source": r["s"],
|
|
178
|
+
"count": r["c"],
|
|
179
|
+
"last_ts": last_ts,
|
|
180
|
+
"age_hours": round(age_h, 1) if age_h is not None else None,
|
|
181
|
+
"status": status,
|
|
182
|
+
"hint": hint,
|
|
183
|
+
})
|
|
184
|
+
# Detected launchd hooks
|
|
185
|
+
hooks = []
|
|
186
|
+
try:
|
|
187
|
+
out = _sp.run(
|
|
188
|
+
["launchctl", "list"],
|
|
189
|
+
capture_output=True, text=True, timeout=3,
|
|
190
|
+
)
|
|
191
|
+
for line in out.stdout.splitlines():
|
|
192
|
+
if "loopmemory" in line.lower():
|
|
193
|
+
hooks.append(line.strip())
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
# Overall
|
|
197
|
+
statuses = [s["status"] for s in sources]
|
|
198
|
+
if statuses and all(s in ("never", "silent") for s in statuses):
|
|
199
|
+
overall = "silent"
|
|
200
|
+
elif any(s in ("silent", "stale") for s in statuses):
|
|
201
|
+
overall = "degraded"
|
|
202
|
+
else:
|
|
203
|
+
overall = "healthy"
|
|
204
|
+
return {
|
|
205
|
+
"sources": sources,
|
|
206
|
+
"hooks": hooks,
|
|
207
|
+
"overall": overall,
|
|
208
|
+
"healthy_count": healthy_count,
|
|
209
|
+
"checked_at": now,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Client integration: ``install-hooks`` endpoint.
|
|
214
|
+
#
|
|
215
|
+
# Until 2026-07 the only way to wire Codex/Claude/Hermes up to the
|
|
216
|
+
# loop-memory MCP + SessionStart inject was the CLI command
|
|
217
|
+
# ``loop-memory install-hooks`` — invisible from the web UI. Users
|
|
218
|
+
# had to find the README, the doctor command, or the wiki page.
|
|
219
|
+
# This endpoint + Diagnostic modal give the user a single button
|
|
220
|
+
# that configures every detected CLI in one shot.
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.get("/api/install-hooks")
|
|
225
|
+
def install_hooks_status():
|
|
226
|
+
"""Inspect detected clients + their MCP/hook state (read-only).
|
|
227
|
+
|
|
228
|
+
Returns a per-client status map + a hint of what would happen
|
|
229
|
+
if the user pressed the 'configure all' button.
|
|
230
|
+
"""
|
|
231
|
+
from pathlib import Path as _P
|
|
232
|
+
try:
|
|
233
|
+
from ...cli.commands.diag import _detect_clients as _dc
|
|
234
|
+
clients = _dc()
|
|
235
|
+
except Exception as e:
|
|
236
|
+
return {"ok": False, "error": str(e), "clients": {}}
|
|
237
|
+
return {
|
|
238
|
+
"ok": True,
|
|
239
|
+
"clients": clients,
|
|
240
|
+
"configured_count": sum(1 for c in clients.values() if c.get("mcp_configured")),
|
|
241
|
+
"installed_count": sum(1 for c in clients.values() if c.get("installed")),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.post("/api/install-hooks")
|
|
246
|
+
def install_hooks_run():
|
|
247
|
+
"""Run ``loop-memory install-hooks`` programmatically.
|
|
248
|
+
|
|
249
|
+
Re-uses the same code path the CLI does so a UI click produces
|
|
250
|
+
an identical result to ``loop-memory install-hooks`` typed at
|
|
251
|
+
the shell. Returns the per-client actions so the UI can show
|
|
252
|
+
confirmation rows.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
from ...cli.commands.hooks import (
|
|
256
|
+
_install_codex, _install_claude, _install_hermes, _openclaw_hint,
|
|
257
|
+
)
|
|
258
|
+
from pathlib import Path as _P
|
|
259
|
+
home = _P.home()
|
|
260
|
+
actions = []
|
|
261
|
+
_install_codex(home, actions)
|
|
262
|
+
_install_claude(home, actions)
|
|
263
|
+
_install_hermes(home, actions)
|
|
264
|
+
_openclaw_hint(home, actions)
|
|
265
|
+
try:
|
|
266
|
+
from ...cli.commands.diag import _detect_clients as _dc
|
|
267
|
+
clients_after = _dc()
|
|
268
|
+
except Exception:
|
|
269
|
+
clients_after = {}
|
|
270
|
+
return {
|
|
271
|
+
"ok": True,
|
|
272
|
+
"actions": actions,
|
|
273
|
+
"clients": clients_after,
|
|
274
|
+
"note": "Restart your CLI to pick up the new MCP server + hooks.",
|
|
275
|
+
}
|
|
276
|
+
except Exception as e:
|
|
277
|
+
return {"ok": False, "error": str(e), "actions": []}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.get("/api/diag")
|
|
281
|
+
def diag():
|
|
282
|
+
"""JSON version of ``loop-memory doctor``.
|
|
283
|
+
|
|
284
|
+
Used by the web UI "Run doctor" modal so the user sees
|
|
285
|
+
what's installed / wired / broken without opening a shell.
|
|
286
|
+
"""
|
|
287
|
+
from pathlib import Path as _Path
|
|
288
|
+
import json as _json
|
|
289
|
+
import shutil as _shutil
|
|
290
|
+
import urllib.request as _ur
|
|
291
|
+
from ...cli.commands.diag import (
|
|
292
|
+
_detect_clients, _detect_watcher,
|
|
293
|
+
)
|
|
294
|
+
from ...llm.providers import default_config
|
|
295
|
+
from ...security import (
|
|
296
|
+
account_for, backend_display_name, has_secret,
|
|
297
|
+
)
|
|
298
|
+
cli_path = _shutil.which("loop-memory")
|
|
299
|
+
server_running = False
|
|
300
|
+
try:
|
|
301
|
+
with _ur.urlopen("http://127.0.0.1:7767/api/stats", timeout=1) as r:
|
|
302
|
+
server_running = r.status == 200
|
|
303
|
+
except Exception:
|
|
304
|
+
server_running = False
|
|
305
|
+
# Provider + key status
|
|
306
|
+
try:
|
|
307
|
+
cfg = store.get_setting("llm_consolidator", default_config())
|
|
308
|
+
provider = cfg.get("provider") or "echo"
|
|
309
|
+
if provider != "echo":
|
|
310
|
+
account = cfg.get("api_key_account") or account_for(provider)
|
|
311
|
+
api_key_set = bool(has_secret(account))
|
|
312
|
+
else:
|
|
313
|
+
api_key_set = None
|
|
314
|
+
except Exception:
|
|
315
|
+
cfg = {}
|
|
316
|
+
provider = "echo"
|
|
317
|
+
api_key_set = None
|
|
318
|
+
try:
|
|
319
|
+
n_mem = store.count_memories()
|
|
320
|
+
n_sess = store.count_sessions()
|
|
321
|
+
n_wiki = store.count_wiki_pages()
|
|
322
|
+
n_ent = store.count_entities()
|
|
323
|
+
except Exception:
|
|
324
|
+
n_mem = n_sess = n_wiki = n_ent = 0
|
|
325
|
+
clients = _detect_clients()
|
|
326
|
+
return {
|
|
327
|
+
"cli_on_path": cli_path is not None,
|
|
328
|
+
"cli_path": cli_path,
|
|
329
|
+
"db_path": str(_Path(DEFAULT_DB)),
|
|
330
|
+
"db_exists": _Path(DEFAULT_DB).exists(),
|
|
331
|
+
"server_running": server_running,
|
|
332
|
+
"clients": clients,
|
|
333
|
+
"watcher_running": _detect_watcher(),
|
|
334
|
+
"provider": provider,
|
|
335
|
+
"model": cfg.get("model") if cfg else None,
|
|
336
|
+
"secret_backend": backend_display_name(),
|
|
337
|
+
"api_key_set": api_key_set,
|
|
338
|
+
"counts": {
|
|
339
|
+
"memories": n_mem, "sessions": n_sess,
|
|
340
|
+
"wiki": n_wiki, "entities": n_ent,
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.get("/api/stats")
|
|
346
|
+
def stats():
|
|
347
|
+
return store.stats()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.get("/api/recall")
|
|
351
|
+
def recall(query: str, limit: int = 10, include: str = "memories,wiki,entities",
|
|
352
|
+
bump: int = 1, source: str | None = None,
|
|
353
|
+
mode: str = "hybrid", level: int = 1):
|
|
354
|
+
"""Unified recall — wiki + memories + entities in one ranked stream.
|
|
355
|
+
|
|
356
|
+
``mode`` is 'hybrid' (default) for BM25+semantic+entity RRF,
|
|
357
|
+
or 'legacy' for the old LIKE-based recall. Hybrid is what the
|
|
358
|
+
dashboard Timeline + MCP + CLI use; legacy is kept for
|
|
359
|
+
benchmarking and for skipping the FTS5 rebuild on huge DBs.
|
|
360
|
+
|
|
361
|
+
``source`` enables per-source knowledge scope: only wiki pages
|
|
362
|
+
whose scope is 'global' OR contains this source are returned,
|
|
363
|
+
and memories whose source matches this token are boosted. The
|
|
364
|
+
dashboard passes the currently-active client (codex/claude/
|
|
365
|
+
hermes/openclaw); the MCP/CLI tools pass the calling client.
|
|
366
|
+
"""
|
|
367
|
+
wanted = tuple(s.strip() for s in include.split(",") if s.strip())
|
|
368
|
+
if not wanted:
|
|
369
|
+
wanted = ("memories", "wiki", "entities")
|
|
370
|
+
if mode == "legacy" or not hasattr(store, "recall_hybrid"):
|
|
371
|
+
r = store.recall(
|
|
372
|
+
query,
|
|
373
|
+
limit=limit,
|
|
374
|
+
include=wanted,
|
|
375
|
+
bump_signals=bool(bump),
|
|
376
|
+
source=source,
|
|
377
|
+
)
|
|
378
|
+
else:
|
|
379
|
+
r = store.recall_hybrid(
|
|
380
|
+
query, limit=limit, include=wanted,
|
|
381
|
+
bump_signals=bool(bump), source=source, level=level,
|
|
382
|
+
)
|
|
383
|
+
return {
|
|
384
|
+
"query": query,
|
|
385
|
+
"tokens": r.get("tokens", []),
|
|
386
|
+
"memories": r.get("memories", []),
|
|
387
|
+
"wiki": r.get("wiki", []),
|
|
388
|
+
"entities": r.get("entities", []),
|
|
389
|
+
"mode": mode,
|
|
390
|
+
"source": source,
|
|
391
|
+
"level": level,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.get("/api/pipeline")
|
|
396
|
+
def pipeline_dashboard_route():
|
|
397
|
+
"""5-stage data flow dashboard. See handlers.pipeline_dashboard."""
|
|
398
|
+
return pipeline_dashboard(store)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.get("/api/pipeline/{stage}/items")
|
|
402
|
+
def pipeline_stage_items_route(stage: str, limit: int = 50):
|
|
403
|
+
"""Drill-down: see handlers.pipeline_stage_items."""
|
|
404
|
+
return pipeline_stage_items(store, stage, limit=limit)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.get("/api/pipeline/score-distribution")
|
|
408
|
+
def score_distribution(limit: int = 1000):
|
|
409
|
+
"""Bucket every memory's score into 10 bins (0.0–0.1, 0.1–0.2, ...)
|
|
410
|
+
for the dashboard's score-distribution chart."""
|
|
411
|
+
bins = [0] * 10
|
|
412
|
+
now = time.time()
|
|
413
|
+
rows = store.list_memories(limit=limit)
|
|
414
|
+
for m in rows:
|
|
415
|
+
sig = store.get_signal(m.id)
|
|
416
|
+
comps = MemoryStore.score_components(
|
|
417
|
+
importance=m.importance or 0.0,
|
|
418
|
+
created_at=m.created_at,
|
|
419
|
+
now=now,
|
|
420
|
+
recall_count=sig["recall_count"],
|
|
421
|
+
last_recalled_at=sig["last_recalled_at"],
|
|
422
|
+
positive=sig["positive"],
|
|
423
|
+
negative=sig["negative"],
|
|
424
|
+
half_life_days=30.0,
|
|
425
|
+
)
|
|
426
|
+
idx = min(9, int(comps["score"] * 10))
|
|
427
|
+
bins[idx] += 1
|
|
428
|
+
return {
|
|
429
|
+
"bins": [{"range": [i/10, (i+1)/10], "count": bins[i]} for i in range(10)],
|
|
430
|
+
"total": sum(bins),
|
|
431
|
+
"sampled": len(rows),
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@app.get("/api/pipeline/decay-stats")
|
|
436
|
+
def decay_stats():
|
|
437
|
+
"""How is the average score decaying over time? Bins memories by
|
|
438
|
+
age bucket so the dashboard can show 'fresh memories still rank
|
|
439
|
+
high, 60-day-old memories have decayed to X%'."""
|
|
440
|
+
now = time.time()
|
|
441
|
+
buckets = [
|
|
442
|
+
("<1d", 0, 86400),
|
|
443
|
+
("1-7d", 86400, 7*86400),
|
|
444
|
+
("7-30d", 7*86400, 30*86400),
|
|
445
|
+
("30-90d", 30*86400, 90*86400),
|
|
446
|
+
(">90d", 90*86400, 10**9),
|
|
447
|
+
]
|
|
448
|
+
out = []
|
|
449
|
+
for label, lo, hi in buckets:
|
|
450
|
+
mems = store.list_memories(since=now-hi, until=now-lo, limit=2000)
|
|
451
|
+
if not mems:
|
|
452
|
+
out.append({"label": label, "count": 0, "avg_score": 0,
|
|
453
|
+
"avg_recency": 0, "avg_usage": 0})
|
|
454
|
+
continue
|
|
455
|
+
rec_sum = use_sum = sc_sum = 0.0
|
|
456
|
+
for m in mems:
|
|
457
|
+
sig = store.get_signal(m.id)
|
|
458
|
+
c = MemoryStore.score_components(
|
|
459
|
+
importance=m.importance or 0.0, created_at=m.created_at,
|
|
460
|
+
now=now, recall_count=sig["recall_count"],
|
|
461
|
+
last_recalled_at=sig["last_recalled_at"],
|
|
462
|
+
positive=sig["positive"], negative=sig["negative"],
|
|
463
|
+
half_life_days=30.0,
|
|
464
|
+
)
|
|
465
|
+
sc_sum += c["score"]; rec_sum += c["recency"]; use_sum += c["usage"]
|
|
466
|
+
n = len(mems)
|
|
467
|
+
out.append({"label": label, "count": n,
|
|
468
|
+
"avg_score": round(sc_sum/n, 3),
|
|
469
|
+
"avg_recency": round(rec_sum/n, 3),
|
|
470
|
+
"avg_usage": round(use_sum/n, 3)})
|
|
471
|
+
return {"buckets": out}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@app.get("/api/signals")
|
|
475
|
+
def signals(kind: str = "recall_count", limit: int = 10):
|
|
476
|
+
"""Top-N memories by a usage signal column (recall / 👍 / 👎)."""
|
|
477
|
+
rows = store.top_signals(kind=kind, limit=limit)
|
|
478
|
+
return {
|
|
479
|
+
"kind": kind,
|
|
480
|
+
"items": [
|
|
481
|
+
{
|
|
482
|
+
"id": r["id"],
|
|
483
|
+
"kind": r.get("kind"),
|
|
484
|
+
"text": r.get("text") or "",
|
|
485
|
+
"importance": r.get("importance") or 0.0,
|
|
486
|
+
"score": r.get("score") or 0.0,
|
|
487
|
+
"recall_count": r.get("recall_count") or 0,
|
|
488
|
+
"positive": r.get("positive") or 0,
|
|
489
|
+
"negative": r.get("negative") or 0,
|
|
490
|
+
}
|
|
491
|
+
for r in rows
|
|
492
|
+
],
|
|
493
|
+
}
|