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,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 ``&quot;`` 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
+ }