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,702 @@
1
+ """Route group: insights.
2
+
3
+ Heavy computed views (insights + weekly report).
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
+
14
+ import re
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ import time
18
+ import json
19
+ from fastapi import FastAPI, HTTPException
20
+ from fastapi.responses import JSONResponse
21
+
22
+ from ...storage.sqlite_store import MemoryStore
23
+ from ._shared import _memory_to_dict, _split_think, _export_safe_segment
24
+
25
+
26
+ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
27
+ """Mount every route in this bucket onto ``app``.
28
+
29
+ ``store`` and ``scheduler`` are captured in the route closures so
30
+ the function bodies stay byte-identical to the pre-split layout.
31
+ """
32
+ @app.get("/api/insights")
33
+ def insights():
34
+ """All the data the Insights dashboard needs in one shot.
35
+
36
+ Powers the Stats overview + 生命周期 + Self-improvement Pulse
37
+ + 记忆压缩 + 记忆粒度 + 数据分布 widgets. Cheap enough to
38
+ poll every 5-10s.
39
+ """
40
+ import time as _time
41
+ now = _time.time()
42
+ with store._conn() as c:
43
+ # --- 1) Stats overview ---
44
+ n_total = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
45
+ n_today = c.execute(
46
+ "SELECT COUNT(*) c FROM memories "
47
+ "WHERE created_at >= strftime('%s','now','start of day')"
48
+ ).fetchone()["c"]
49
+ n_active = c.execute(
50
+ "SELECT COUNT(*) c FROM memories "
51
+ "WHERE score >= 0.3 AND created_at >= ?",
52
+ (now - 7 * 86400,),
53
+ ).fetchone()["c"]
54
+ n_links = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"]
55
+ # Merge group count: tag-cluster clusters from evolution runs
56
+ n_clusters = c.execute(
57
+ "SELECT COUNT(*) c FROM (SELECT DISTINCT slug FROM wiki_pages)"
58
+ ).fetchone()["c"]
59
+ avg_score = c.execute(
60
+ "SELECT AVG(score) FROM memories"
61
+ ).fetchone()[0] or 0.0
62
+ n_decayed = c.execute(
63
+ "SELECT COUNT(*) c FROM memories WHERE score < 0.3"
64
+ ).fetchone()["c"]
65
+ # entities are our "向量数"
66
+ n_entities = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
67
+ # Occupation = % of memories that are High+Mid importance
68
+ n_high = c.execute(
69
+ "SELECT COUNT(*) c FROM memories WHERE importance >= 0.7"
70
+ ).fetchone()["c"]
71
+ n_mid = c.execute(
72
+ "SELECT COUNT(*) c FROM memories WHERE importance >= 0.4 AND importance < 0.7"
73
+ ).fetchone()["c"]
74
+ occupation = ((n_high + n_mid) / max(1, n_total)) * 100.0
75
+ # Citation rate = share of memories that were recalled >= 1 time
76
+ n_recalled = c.execute(
77
+ "SELECT COUNT(DISTINCT s.memory_id) c FROM memory_signals s "
78
+ "WHERE s.recall_count > 0"
79
+ ).fetchone()["c"]
80
+ citation = (n_recalled / max(1, n_total)) * 100.0
81
+ # Decay rate = share of memories with score < 0.3
82
+ decay = (n_decayed / max(1, n_total)) * 100.0
83
+
84
+ # --- 2) Lifecycle stage counts ---
85
+ # Six stages: 已提取 / 活跃 / 已衰减 / 已合并 / 已归档 / 已遗忘
86
+ stages = {
87
+ "extracted": c.execute(
88
+ "SELECT COUNT(*) c FROM memories WHERE created_at >= ?",
89
+ (now - 86400,),
90
+ ).fetchone()["c"],
91
+ "active": n_active,
92
+ "decayed": n_decayed,
93
+ "merged": c.execute(
94
+ "SELECT COUNT(*) c FROM wiki_pages"
95
+ ).fetchone()["c"],
96
+ "archived": c.execute(
97
+ "SELECT COUNT(*) c FROM memories "
98
+ "WHERE score < 0.15 AND score > 0"
99
+ ).fetchone()["c"],
100
+ "forgotten": c.execute(
101
+ "SELECT COUNT(*) c FROM memories WHERE score = 0"
102
+ ).fetchone()["c"],
103
+ }
104
+
105
+ # --- 3) Compression candidates ---
106
+ # A memory is "compressible" if its text is long AND it has
107
+ # been distilled into a wiki page.
108
+ wiki_ids = {r[0] for r in c.execute(
109
+ "SELECT id FROM memories WHERE id IN "
110
+ "(SELECT DISTINCT json_each.value FROM wiki_pages, json_each(evidence_ids))"
111
+ ).fetchall()}
112
+ compressible = c.execute(
113
+ "SELECT id, kind, text, length(text) AS L, importance, score "
114
+ "FROM memories WHERE length(text) > 280 "
115
+ "ORDER BY length(text) DESC LIMIT 30"
116
+ ).fetchall()
117
+ compression = {
118
+ "compressible_count": len(compressible),
119
+ "avg_length": int(c.execute(
120
+ "SELECT AVG(length(text)) FROM memories"
121
+ ).fetchone()[0] or 0),
122
+ "compression_progress": 0,
123
+ "items": [
124
+ {
125
+ "id": r["id"],
126
+ "kind": r["kind"],
127
+ "preview": (r["text"] or "")[:80],
128
+ "length": r["L"],
129
+ "importance": r["importance"] or 0.0,
130
+ "score": r["score"] or 0.0,
131
+ "in_wiki": r["id"] in wiki_ids,
132
+ }
133
+ for r in compressible
134
+ ],
135
+ }
136
+ # progress = wiki_count / compressible_count
137
+ if compression["compressible_count"] > 0:
138
+ compression["compression_progress"] = round(
139
+ min(100.0, (len(wiki_ids) / compression["compressible_count"]) * 100.0),
140
+ 1,
141
+ )
142
+
143
+ # --- 4) Memory granularity buckets ---
144
+ # Core: kind=fact + importance >= 0.7 + score >= 0.5
145
+ # Working: anything else not in Core/Scratch
146
+ # Scratch: low score AND low importance OR has been merged
147
+ core = c.execute(
148
+ "SELECT id, text, importance, score FROM memories "
149
+ "WHERE kind='fact' AND importance >= 0.7 AND score >= 0.5 "
150
+ "ORDER BY score DESC LIMIT 24"
151
+ ).fetchall()
152
+ scratch = c.execute(
153
+ "SELECT id, text, importance, score FROM memories "
154
+ "WHERE (importance < 0.4 AND score < 0.4) OR score < 0.15 "
155
+ "ORDER BY score ASC LIMIT 24"
156
+ ).fetchall()
157
+ working = c.execute(
158
+ "SELECT id, text, importance, score FROM memories "
159
+ "WHERE id NOT IN (SELECT id FROM memories WHERE kind='fact' "
160
+ " AND importance >= 0.7 AND score >= 0.5) "
161
+ " AND id NOT IN (SELECT id FROM memories WHERE "
162
+ " (importance < 0.4 AND score < 0.4) OR score < 0.15) "
163
+ "ORDER BY score DESC LIMIT 24"
164
+ ).fetchall()
165
+
166
+ def _row(r):
167
+ return {
168
+ "id": r["id"],
169
+ "text": (r["text"] or "")[:60],
170
+ "importance": r["importance"] or 0.0,
171
+ "score": r["score"] or 0.0,
172
+ }
173
+ granularity = {
174
+ "core_count": c.execute(
175
+ "SELECT COUNT(*) c FROM memories "
176
+ "WHERE kind='fact' AND importance >= 0.7 AND score >= 0.5"
177
+ ).fetchone()["c"],
178
+ "working_count": c.execute(
179
+ "SELECT COUNT(*) c FROM memories "
180
+ "WHERE NOT (kind='fact' AND importance >= 0.7 AND score >= 0.5) "
181
+ "AND NOT ((importance < 0.4 AND score < 0.4) OR score < 0.15)"
182
+ ).fetchone()["c"],
183
+ "scratch_count": c.execute(
184
+ "SELECT COUNT(*) c FROM memories "
185
+ "WHERE (importance < 0.4 AND score < 0.4) OR score < 0.15"
186
+ ).fetchone()["c"],
187
+ "core": [_row(r) for r in core],
188
+ "working": [_row(r) for r in working],
189
+ "scratch": [_row(r) for r in scratch],
190
+ }
191
+
192
+ # --- 5) Data distribution: type / status / 7-day trend ---
193
+ type_rows = c.execute(
194
+ "SELECT kind, COUNT(*) c FROM memories GROUP BY kind ORDER BY c DESC"
195
+ ).fetchall()
196
+ status_rows = c.execute(
197
+ "SELECT CASE "
198
+ " WHEN score >= 0.5 THEN 'active' "
199
+ " WHEN score >= 0.15 THEN 'decayed' "
200
+ " WHEN score = 0 THEN 'forgotten' "
201
+ " ELSE 'archived' "
202
+ "END AS s, COUNT(*) c FROM memories GROUP BY s"
203
+ ).fetchall()
204
+ trend_rows = c.execute(
205
+ "SELECT date(created_at, 'unixepoch', 'localtime') AS d, COUNT(*) c "
206
+ "FROM memories WHERE created_at >= strftime('%s','now','-7 days') "
207
+ "GROUP BY d ORDER BY d"
208
+ ).fetchall()
209
+ # Build a dense 7-day series (filling gaps with 0)
210
+ import datetime as _dt
211
+ today = _dt.date.today()
212
+ trend_map = {r["d"]: r["c"] for r in trend_rows}
213
+ trend_series = []
214
+ for i in range(6, -1, -1):
215
+ d = (today - _dt.timedelta(days=i)).isoformat()
216
+ trend_series.append({"date": d, "count": trend_map.get(d, 0)})
217
+
218
+ distribution = {
219
+ "types": [{"kind": r["kind"], "count": r["c"]} for r in type_rows],
220
+ "status": [{"status": r["s"], "count": r["c"]} for r in status_rows],
221
+ "trend": trend_series,
222
+ }
223
+
224
+ # --- 6) Self-improvement Pulse: contradiction pairs ---
225
+ # Cheap heuristic: 2 memories share >= 4 distinct tags and
226
+ # have contradicting importance deltas (one very high, one
227
+ # mid/low), suggesting they should be merged.
228
+ # --- 5b) Sources breakdown (real, not synthetic) ---
229
+ # Normalize per-thread codex sources to a single bucket.
230
+ src_rows = c.execute(
231
+ "SELECT source, COUNT(*) c FROM memories "
232
+ "WHERE source IS NOT NULL AND source != '' "
233
+ "GROUP BY source ORDER BY c DESC"
234
+ ).fetchall()
235
+ bucket = {}
236
+ for r in src_rows:
237
+ s = r["source"] or "unknown"
238
+ # Collapse long thread ids into their parent prefix
239
+ key = s.split("/")[0] if "/" in s else s
240
+ bucket[key] = bucket.get(key, 0) + r["c"]
241
+ sources = [
242
+ {"source": k, "count": v}
243
+ for k, v in sorted(bucket.items(), key=lambda x: -x[1])
244
+ ]
245
+
246
+ # --- 5c) Wiki health ---
247
+ wiki_count = c.execute(
248
+ "SELECT COUNT(*) c FROM wiki_pages"
249
+ ).fetchone()["c"]
250
+ wiki_avg_imp = c.execute(
251
+ "SELECT AVG(importance) FROM wiki_pages"
252
+ ).fetchone()[0] or 0.0
253
+ wiki_total_chars = c.execute(
254
+ "SELECT COALESCE(SUM(length(body)), 0) FROM wiki_pages"
255
+ ).fetchone()[0] or 0
256
+ # how many memories are referenced by at least one wiki page
257
+ wiki_ref_count = c.execute(
258
+ "SELECT COUNT(DISTINCT json_each.value) c "
259
+ "FROM wiki_pages, json_each(wiki_pages.evidence_ids) "
260
+ "WHERE evidence_ids IS NOT NULL"
261
+ ).fetchone()["c"]
262
+
263
+ # --- 5d) Ingest rate (24 hourly buckets) ---
264
+ hourly_rows = c.execute(
265
+ "SELECT strftime('%H', created_at, 'unixepoch', 'localtime') AS h, "
266
+ "COUNT(*) c FROM memories "
267
+ "WHERE created_at >= strftime('%s','now','-24 hours') "
268
+ "GROUP BY h ORDER BY h"
269
+ ).fetchall()
270
+ hourly_map = {r["h"]: r["c"] for r in hourly_rows}
271
+ hourly = [{"hour": f"{int(h):02d}:00", "count": hourly_map.get(f"{int(h):02d}", 0)}
272
+ for h in range(24)]
273
+
274
+ # --- 5e) Recall throughput (last 24h) ---
275
+ recall_rows = c.execute(
276
+ "SELECT COALESCE(SUM(recall_count), 0) AS c, "
277
+ "COUNT(DISTINCT memory_id) AS uniq "
278
+ "FROM memory_signals "
279
+ "WHERE last_recalled_at >= strftime('%s','now','-24 hours')"
280
+ ).fetchone()
281
+ recall_total_24h = recall_rows["c"] or 0
282
+ recall_uniq_24h = recall_rows["uniq"] or 0
283
+
284
+ pulse = {"contradictions": [], "score_distribution": []}
285
+ c.execute(
286
+ "SELECT tags FROM memories WHERE tags IS NOT NULL AND tags != '' "
287
+ "AND length(tags) > 5"
288
+ ).fetchall()
289
+ # (Real contradiction detection needs an LLM; we surface
290
+ # near-duplicates so the user can pick which to merge.)
291
+ sample = c.execute(
292
+ "SELECT id, text, importance, score, tags FROM memories "
293
+ "WHERE length(text) > 120 ORDER BY created_at DESC LIMIT 80"
294
+ ).fetchall()
295
+ from collections import defaultdict
296
+ tag_buckets = defaultdict(list)
297
+ for r in sample:
298
+ try:
299
+ import json as _json
300
+ tags = _json.loads(r["tags"]) if r["tags"] else []
301
+ except Exception:
302
+ tags = []
303
+ for t in tags[:3]:
304
+ tag_buckets[t].append(r)
305
+ # Filter out pairs the user already resolved.
306
+ ignored = store.list_ignored_pairs()
307
+ for tag, rows in list(tag_buckets.items())[:6]:
308
+ if len(rows) >= 2:
309
+ a, b = rows[0], rows[1]
310
+ if store.pair_key(a["id"], b["id"]) in ignored:
311
+ continue
312
+ pulse["contradictions"].append({
313
+ "tag": tag,
314
+ "a": {"id": a["id"], "text": (a["text"] or "")[:120],
315
+ "importance": a["importance"] or 0,
316
+ "score": a["score"] or 0},
317
+ "b": {"id": b["id"], "text": (b["text"] or "")[:120],
318
+ "importance": b["importance"] or 0,
319
+ "score": b["score"] or 0},
320
+ "similarity": 0.78, # placeholder
321
+ })
322
+
323
+ # Score distribution histogram (0.0 .. 1.0 in 0.1 bins)
324
+ for i in range(10):
325
+ lo, hi = i * 0.1, (i + 1) * 0.1
326
+ cnt = c.execute(
327
+ "SELECT COUNT(*) c FROM memories WHERE score >= ? AND score < ?",
328
+ (lo, hi if hi < 1 else 2.0),
329
+ ).fetchone()["c"]
330
+ pulse["score_distribution"].append({
331
+ "range": [round(lo, 1), round(hi, 1)],
332
+ "count": cnt,
333
+ })
334
+
335
+ # --- 7) Pipeline latency / skill pipeline ---
336
+ # Average ms per stage from pipeline_runs.
337
+ stages_pipeline = []
338
+ for stage in ("score", "cluster", "distill", "wiki", "graph"):
339
+ row = c.execute(
340
+ "SELECT AVG((finished_at - started_at) * 1000) AS ms, COUNT(*) c "
341
+ "FROM pipeline_runs WHERE stage=? AND finished_at IS NOT NULL",
342
+ (stage,),
343
+ ).fetchone()
344
+ stages_pipeline.append({
345
+ "stage": stage,
346
+ "avg_ms": int(row["ms"] or 0),
347
+ "count": row["c"] or 0,
348
+ })
349
+
350
+ return {
351
+ "now": now,
352
+ "overview": {
353
+ "total": n_total,
354
+ "today": n_today,
355
+ "active": n_active,
356
+ "links": n_links,
357
+ "clusters": n_clusters,
358
+ "avg_score": round(avg_score, 3),
359
+ "decay_pct": round(decay, 1),
360
+ "entities": n_entities,
361
+ "occupation": round(occupation, 1),
362
+ "citation": round(citation, 1),
363
+ "decay": round(decay, 1),
364
+ },
365
+ "stages": stages,
366
+ "compression": compression,
367
+ "granularity": granularity,
368
+ "distribution": distribution,
369
+ "sources": sources,
370
+ "wiki_health": {
371
+ "pages": wiki_count,
372
+ "avg_importance": round(wiki_avg_imp, 3),
373
+ "total_chars": int(wiki_total_chars),
374
+ "referenced_memories": wiki_ref_count,
375
+ },
376
+ "ingest_rate": hourly,
377
+ "recall_24h": {
378
+ "total": recall_total_24h,
379
+ "unique_memories": recall_uniq_24h,
380
+ },
381
+ "pulse": pulse,
382
+ "pipeline": stages_pipeline,
383
+ }
384
+
385
+ # ====================================================================
386
+ # /api/weekly-report — natural-language weekly digest of what was learned
387
+ # ====================================================================
388
+
389
+
390
+ @app.get("/api/weekly-report")
391
+ def weekly_report(
392
+ days: int = 7,
393
+ max_facts: int = 60,
394
+ use_llm: bool = True,
395
+ lang: str = "zh",
396
+ force: bool = False,
397
+ ):
398
+ """Build a Markdown weekly report covering the last ``days`` days.
399
+
400
+ The full LLM-backed report is heavy (several seconds + 600+ tokens
401
+ of output) and the user only needs a new digest when the week
402
+ rolls over. We therefore cache the rendered response to disk
403
+ under ``~/.loop_memory/cache/weekly/{lang}-{days}-{week_start}.json``
404
+ and serve it as-is on subsequent calls within the same ISO week.
405
+
406
+ Pass ``?force=true`` to bypass the cache and regenerate now
407
+ (this is what the manual refresh button wires up).
408
+ """
409
+ import datetime as _dt
410
+ from ...llm.providers import build_provider, default_config
411
+ from ...llm.base import ChatHistory, Message
412
+ from ...storage.sqlite_store import LLMAuditStore
413
+
414
+ lang = "en" if str(lang).lower().startswith("en") else "zh"
415
+
416
+ # ---- weekly report cache ----------------------------------------
417
+ # The cache key is intentionally simple: same language + same
418
+ # window + same ISO week (Monday 00:00 in the user's local
419
+ # timezone) = same report. When Monday rolls around the key
420
+ # changes and a fresh report is generated.
421
+ cache_dir = Path.home() / ".loop_memory" / "cache" / "weekly"
422
+ _now_local = _dt.datetime.now().astimezone()
423
+ _iso_week = _now_local.isocalendar() # (year, week, weekday)
424
+ _week_start = (_now_local - _dt.timedelta(days=_iso_week.weekday - 1)
425
+ ).replace(hour=0, minute=0, second=0, microsecond=0)
426
+ _cache_key = f"{lang}-{days}-{_iso_week.year}-W{_iso_week.week:02d}"
427
+ _cache_path = cache_dir / f"{_cache_key}.json"
428
+ if not force and use_llm:
429
+ try:
430
+ if _cache_path.exists():
431
+ cached = json.loads(_cache_path.read_text(encoding="utf-8"))
432
+ cached["from_cache"] = True
433
+ cached["cache_key"] = _cache_key
434
+ return cached
435
+ except Exception:
436
+ # Corrupt cache file: fall through and regenerate.
437
+ pass
438
+
439
+ now = time.time()
440
+ since = now - days * 86400
441
+ with store._conn() as c:
442
+ rows = c.execute(
443
+ """SELECT id, kind, text, importance, source, tags, score,
444
+ datetime(created_at, 'unixepoch') AS ts
445
+ FROM memories
446
+ WHERE created_at >= ?
447
+ ORDER BY importance DESC, score DESC, created_at DESC
448
+ LIMIT ?""",
449
+ (since, max_facts),
450
+ ).fetchall()
451
+ source_rows = c.execute(
452
+ """SELECT COALESCE(source, 'unknown') AS s, COUNT(*) c
453
+ FROM memories WHERE created_at >= ? GROUP BY s ORDER BY c DESC""",
454
+ (since,),
455
+ ).fetchall()
456
+ total_window = c.execute(
457
+ "SELECT COUNT(*) c FROM memories WHERE created_at >= ?", (since,)
458
+ ).fetchone()["c"]
459
+
460
+ # Normalize sources: collapse per-thread codex IDs into the parent
461
+ # "codex" bucket so the UI doesn't show one noisy row per thread.
462
+ norm: dict[str, int] = {}
463
+ for r in source_rows:
464
+ key = (r["s"] or "unknown").split("/")[0]
465
+ norm[key] = norm.get(key, 0) + int(r["c"])
466
+ normalized_sources = [
467
+ {"source": k, "count": v} for k, v in sorted(norm.items(), key=lambda x: -x[1])
468
+ ]
469
+
470
+ items = [dict(r) for r in rows]
471
+ sources = [{"source": r["s"], "count": r["c"]} for r in source_rows]
472
+ highlights = items[: max(3, max_facts // 4)]
473
+ lowlights = sorted(items, key=lambda x: (x.get("importance") or 0, x.get("score") or 0))[: max(3, max_facts // 6)]
474
+
475
+ stats = {
476
+ "window_days": days,
477
+ "since": since,
478
+ "now": now,
479
+ "total_in_window": total_window,
480
+ "sources": sources,
481
+ "sources_normalized": normalized_sources,
482
+ "highlight_count": len(highlights),
483
+ "lowlight_count": len(lowlights),
484
+ }
485
+
486
+ llm_used = False
487
+ summary_md = ""
488
+ thinking_md = ""
489
+ prov_name = "none"
490
+ provider = None
491
+ llm_error = None
492
+ llm_error_kind = None # "no_provider" | "no_key" | "auth_failed" | "network" | "other"
493
+ llm_hint = None # user-facing hint for the banner
494
+ try:
495
+ cfg = store.get_setting("llm_consolidator", default_config()) or {}
496
+ provider = build_provider(cfg)
497
+ prov_name = type(provider).__name__
498
+ except Exception as e:
499
+ llm_error = f"{type(e).__name__}: {e}"
500
+ llm_error_kind = "no_provider"
501
+ llm_hint = (
502
+ "LLM provider not configured. Open Settings → Models to pick one."
503
+ if lang == "en"
504
+ else "尚未配置大模型提供方,请前往“设置 → 模型”选择一个提供方。"
505
+ )
506
+ provider = None
507
+
508
+ # Detect placeholder / empty API keys (the most common cause of "auth
509
+ # failed" on a fresh install). The shipped secret store contains
510
+ # a recognizable placeholder by default — anyone who never opened
511
+ # the settings page is hitting that.
512
+ raw_key = getattr(provider, "api_key", None) or ""
513
+ # Only flag *clearly fake* placeholders, not real keys that happen to
514
+ # contain \'x\'. Common patterns: "sk-xxxx...", "your-api-key",
515
+ # "REPLACE_ME", "<API_KEY>", or fewer than 8 non-whitespace chars.
516
+ if (not raw_key
517
+ or len(raw_key.strip()) < 8
518
+ or re.search(r"x{4,}", raw_key)
519
+ or re.search(r"(your[-_ ]?(api[-_ ]?)?key|replace[-_ ]?me|<api[-_ ]?key>|placeholder)", raw_key, re.I)):
520
+ llm_error_kind = "no_key"
521
+ llm_error = llm_error or "API key not configured"
522
+ llm_hint = (
523
+ f"No real API key for {prov_name}. Open Settings → Models, paste your key, "
524
+ "and Save. Stored locally in ~/.loop_memory/secrets.json — never sent anywhere else."
525
+ if lang == "en"
526
+ else f"{prov_name} 尚未配置有效 API Key。请前往“设置 → 模型”粘贴并保存。"
527
+ "密钥仅保存在本机 ~/.loop_memory/secrets.json,不会发送到其他位置。"
528
+ )
529
+ provider = None # skip the LLM call entirely
530
+
531
+ if use_llm and provider is not None:
532
+ t0 = time.time()
533
+ try:
534
+ if lang == "en":
535
+ sys_prompt = (
536
+ "You are a memory-system reporter. Given a JSON snapshot of a user's "
537
+ "recent memories, write a concise weekly report in English Markdown. "
538
+ "Output exactly 3 sections: ## Highlights (3-5 bullets), "
539
+ "## Lowlights (2-3 bullets, things to review / prune / forget), "
540
+ "## Next focus (1-2 bullets suggesting what to remember better next week). "
541
+ "Keep the total under 250 words."
542
+ )
543
+ else:
544
+ sys_prompt = (
545
+ "你是记忆系统周报助手。根据用户近期记忆的 JSON 快照,使用中文 Markdown "
546
+ "生成简洁周报。必须只输出 3 个章节:## 本周亮点(3-5 条)、"
547
+ "## 待整理内容(2-3 条需要复查、裁剪或遗忘的内容)、"
548
+ "## 下周重点(1-2 条下周应重点积累的知识)。总字数不超过 250 字。"
549
+ )
550
+ user_payload = {
551
+ "window": f"{days} days",
552
+ "total_new_memories": total_window,
553
+ "sources": normalized_sources,
554
+ "top_memories": [
555
+ {
556
+ "kind": i["kind"], "imp": round(i["importance"] or 0, 2),
557
+ "score": round(i["score"] or 0, 2),
558
+ "source": i["source"],
559
+ "text": (i["text"] or "")[:160],
560
+ }
561
+ for i in items[:20]
562
+ ],
563
+ }
564
+ user_prompt = json.dumps(user_payload, ensure_ascii=False)
565
+ history = ChatHistory(
566
+ system=sys_prompt,
567
+ messages=[Message(role="user", content=user_prompt)],
568
+ )
569
+ try:
570
+ reply = provider.complete(history, temperature=0.3, max_tokens=1200) or ""
571
+ except Exception as e:
572
+ reply = ""
573
+ raw = f"{type(e).__name__}: {e}"
574
+ llm_error = raw
575
+ # Pull structured fields from LLMHttpError when available.
576
+ status = getattr(e, "status", None)
577
+ pcode = getattr(e, "provider_code", None) or ""
578
+ pmsg = getattr(e, "provider_message", None) or ""
579
+ sl = str(e).lower()
580
+ if status is None:
581
+ m = re.search(r"HTTP\s+(\d+)", str(e))
582
+ status = int(m.group(1)) if m else 0
583
+ if (status in (401, 403)
584
+ or "2049" in pcode or "1004" in pcode
585
+ or "authorized" in sl or "invalid api key" in sl):
586
+ llm_error_kind = "auth_failed"
587
+ elif status == 429 or "rate" in sl:
588
+ llm_error_kind = "rate_limited"
589
+ elif status >= 500 or status == 0 or "timeout" in sl or "connection" in sl:
590
+ llm_error_kind = "network"
591
+ else:
592
+ llm_error_kind = "other"
593
+ llm_hint = _hint_for_llm_error(prov_name, status or 0, pcode, pmsg)
594
+ # Keep the bare kind; provider code goes in its own field.
595
+ llm_provider_code_resp = pcode
596
+ llm_provider_message_resp = pmsg
597
+ log.warning("weekly-report LLM call failed [kind=%s, status=%s, code=%s]: %s",
598
+ llm_error_kind, status, pcode, raw)
599
+ llm_used = bool(reply.strip())
600
+ summary_md, thinking_md = _split_think(reply.strip())
601
+ # Audit
602
+ try:
603
+ LLMAuditStore(store).record(
604
+ provider=type(provider).__name__,
605
+ model=getattr(provider, "model", "?") or "?",
606
+ kind="weekly_report",
607
+ prompt=sys_prompt + "\n" + user_prompt,
608
+ response=reply,
609
+ prompt_tokens=max(1, len(sys_prompt) // 4) + max(1, len(user_prompt) // 4),
610
+ completion_tokens=max(1, len(reply) // 4) if reply else 0,
611
+ cost_usd=0.0,
612
+ latency_ms=int((time.time() - t0) * 1000),
613
+ ok=llm_used,
614
+ )
615
+ except Exception:
616
+ pass
617
+ except Exception as e:
618
+ llm_error = f"{type(e).__name__}: {e}"
619
+
620
+ if not summary_md:
621
+ # Templated fallback
622
+ if lang == "en":
623
+ lines = [f"# Memory weekly · {_dt.date.today().isoformat()}", ""]
624
+ lines.append(f"- Window: {days} days")
625
+ lines.append(f"- New memories: {total_window}")
626
+ else:
627
+ lines = [f"# 记忆周报 · {_dt.date.today().isoformat()}", ""]
628
+ lines.append(f"- 时间窗口:{days} 天")
629
+ lines.append(f"- 新增记忆:{total_window} 条")
630
+ if sources:
631
+ src_str = ", ".join(f"{s['source']}={s['count']}" for s in sources[:5])
632
+ separator = ": " if lang == "en" else ":"
633
+ lines.append(f"- {'Source distribution' if lang == 'en' else '来源分布'}{separator}{src_str}")
634
+ lines.append("")
635
+ lines.append("## Highlights" if lang == "en" else "## 本周亮点")
636
+ for h in highlights[:5]:
637
+ lines.append(f"- **[{h['source']}]** {h['text'][:100]}")
638
+ lines.append("")
639
+ lines.append("## Lowlights" if lang == "en" else "## 待整理内容")
640
+ for lowlight in lowlights[:3]:
641
+ lines.append(f"- *[{lowlight['source']}]* {lowlight['text'][:100]}")
642
+ lines.append("")
643
+ lines.append("## Next focus" if lang == "en" else "## 下周重点")
644
+ lines.append(
645
+ "- Keep capturing, distill regularly, and retain high-quality memories"
646
+ if lang == "en"
647
+ else "- 持续记录、定期蒸馏,并保留高质量记忆"
648
+ )
649
+ summary_md = "\n".join(lines)
650
+
651
+ # Surface enough provider / key info that the UI can render a
652
+ # specific "what went wrong" hint (key prefix, provider code, etc.)
653
+ # without making the user re-test from the settings page.
654
+ prov_code = locals().get("llm_provider_code_resp") or None
655
+ prov_msg = locals().get("llm_provider_message_resp") or None
656
+ if provider is not None:
657
+ rk = getattr(provider, "api_key", None) or ""
658
+ kp = (rk[:10] + "...") if len(rk) > 10 else rk
659
+ klen = len(rk)
660
+ else:
661
+ kp = ""
662
+ klen = 0
663
+ result = {
664
+ "markdown": summary_md,
665
+ "stats": stats,
666
+ "highlights": highlights[:5],
667
+ "lowlights": lowlights[:3],
668
+ "llm_used": llm_used,
669
+ "llm_provider": prov_name,
670
+ "llm_error": llm_error,
671
+ "llm_error_kind": llm_error_kind,
672
+ "llm_provider_code": prov_code,
673
+ "llm_provider_message": prov_msg,
674
+ "llm_hint": llm_hint,
675
+ "llm_key_prefix": kp,
676
+ "llm_key_len": klen,
677
+ "thinking": thinking_md,
678
+ "generated_at": now,
679
+ "cache_key": _cache_key,
680
+ "week_start": _week_start.isoformat(),
681
+ "from_cache": False,
682
+ }
683
+ # Persist for next call within the same ISO week. Only cache
684
+ # the LLM-backed reports — the templated fallback is cheap to
685
+ # recompute and changes on every memory write, so caching it
686
+ # would actually make the report go stale.
687
+ if use_llm and llm_used:
688
+ try:
689
+ cache_dir.mkdir(parents=True, exist_ok=True)
690
+ _cache_path.write_text(
691
+ json.dumps(result, ensure_ascii=False, default=str),
692
+ encoding="utf-8",
693
+ )
694
+ except Exception:
695
+ pass
696
+ return result
697
+
698
+ # ====================================================================
699
+ # /api/llm-audit — surface the LLM audit log
700
+ # ====================================================================
701
+
702
+