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,812 @@
1
+ """Route group: wiki.
2
+
3
+ Wiki CRUD + export/import/ask/contradictions + /api/v1/wiki/versions.
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
+ from fastapi import FastAPI, HTTPException
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from ...storage.sqlite_store import MemoryStore
18
+ from ._shared import _memory_to_dict, _export_safe_segment
19
+
20
+
21
+ def _wiki_scope_config(store: MemoryStore) -> dict:
22
+ from ...wiki.scope import auto_scope_config
23
+ return auto_scope_config(store)
24
+
25
+
26
+ def _wiki_source_hint(payload: dict) -> str | None:
27
+ for key in ("source", "client", "agent", "agent_id"):
28
+ value = payload.get(key)
29
+ if value is not None and str(value).strip():
30
+ return str(value).strip()
31
+ return None
32
+
33
+
34
+ def _wiki_evidence_sources(store: MemoryStore, evidence_ids: list) -> list[str]:
35
+ if not evidence_ids:
36
+ return []
37
+ try:
38
+ rows = store.list_memories(ids=[str(x) for x in evidence_ids], limit=max(1, len(evidence_ids)))
39
+ except Exception:
40
+ rows = []
41
+ return [str(getattr(row, "source", "") or "").strip() for row in rows if getattr(row, "source", None)]
42
+
43
+
44
+ def _resolve_wiki_scope(
45
+ store: MemoryStore,
46
+ *,
47
+ title: str,
48
+ body: str,
49
+ summary: str,
50
+ tags: list,
51
+ evidence_ids: list,
52
+ requested_scope: object,
53
+ source_hint: str | None = None,
54
+ existing: dict | None = None,
55
+ preserve_existing: bool = False,
56
+ ) -> tuple[str, dict]:
57
+ """Classify a page and return ``(scope, audit_json)``.
58
+
59
+ An explicit scope is authoritative. Missing/``auto`` scope is routed by
60
+ the deterministic classifier; non-security pages go to the evidence or
61
+ client source and never default to global. Existing rows are preserved
62
+ when an update omits scope, which protects legacy/manual overrides.
63
+ """
64
+ from ...wiki.classifier import classify_page
65
+ from ...wiki.scope import (
66
+ build_scope_audit,
67
+ derive_default_scope,
68
+ normalise_scope,
69
+ )
70
+
71
+ cfg = _wiki_scope_config(store)
72
+ classification = classify_page(
73
+ title=title,
74
+ body=body,
75
+ summary=summary,
76
+ tags=tags,
77
+ evidence_sources=_wiki_evidence_sources(store, evidence_ids),
78
+ mode=cfg["mode"] if cfg["enabled"] else "off",
79
+ )
80
+ raw_requested = "" if requested_scope is None else str(requested_scope).strip().lower()
81
+ explicit = bool(raw_requested and raw_requested != "auto")
82
+ if explicit:
83
+ try:
84
+ scope = normalise_scope(requested_scope)
85
+ except ValueError as exc:
86
+ raise HTTPException(400, str(exc)) from exc
87
+ decision = "explicit"
88
+ elif preserve_existing and existing and existing.get("scope"):
89
+ scope = str(existing["scope"]).strip().lower() or derive_default_scope(
90
+ source_hint, evidence_ids, store
91
+ )
92
+ decision = "preserved-existing"
93
+ elif cfg["enabled"] and classification.auto_global:
94
+ scope = "global"
95
+ decision = "auto-global"
96
+ else:
97
+ scope = derive_default_scope(source_hint, evidence_ids, store)
98
+ decision = "default-source"
99
+ audit = build_scope_audit(
100
+ classification,
101
+ scope=scope,
102
+ decision=decision,
103
+ source_hint=source_hint,
104
+ enabled=cfg["enabled"],
105
+ mode=cfg["mode"],
106
+ existing=existing,
107
+ )
108
+ return scope, audit
109
+
110
+
111
+ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
112
+ """Mount every route in this bucket onto ``app``.
113
+
114
+ ``store`` and ``scheduler`` are captured in the route closures so
115
+ the function bodies stay byte-identical to the pre-split layout.
116
+ """
117
+ @app.post("/api/contradictions/resolve")
118
+ def resolve_contradiction(a: str, b: str, action: str = "ignore",
119
+ keep: str | None = None):
120
+ """Resolve a contradiction pair surfaced on the dashboard.
121
+
122
+ ``action``:
123
+ - ``ignore`` — hide this pair from future pulses (default)
124
+ - ``merge`` — fuse the two into one memory (winner keeps its
125
+ row, loser's text is appended, the loser is deleted)
126
+ - ``keepA`` — explicitly delete side B
127
+ - ``keepB`` — explicitly delete side A
128
+ """
129
+ a_id = str(a or "").strip()
130
+ b_id = str(b or "").strip()
131
+ if not a_id or not b_id or a_id == b_id:
132
+ raise HTTPException(400, "need two distinct memory ids")
133
+ action_raw = action or "ignore"
134
+ action = action_raw.lower()
135
+ result = {"ok": True, "action": action_raw, "deleted": []}
136
+ # Always remember the ignore so the pair doesn't reappear next refresh
137
+ store.ignore_contradiction(a_id, b_id)
138
+ if action == "ignore":
139
+ # Record a soft "down" on both so the consolidator weighs them down.
140
+ try:
141
+ store.record_signal(a_id, positive=False)
142
+ store.record_signal(b_id, positive=False)
143
+ except Exception:
144
+ pass
145
+ elif action == "merge":
146
+ # True fusion: keep the higher-scored side as the row that
147
+ # survives, append the loser's text to it (de-duplicated),
148
+ # bump importance/score to the max of the two, then delete
149
+ # the loser in the same transaction. ``merge_memories``
150
+ # also writes the pair into ``contradiction_ignored`` so
151
+ # the pulse does not surface it again.
152
+ try:
153
+ merge_info = store.merge_memories(a_id, b_id)
154
+ except ValueError as e:
155
+ raise HTTPException(400, str(e))
156
+ if not merge_info.get("merged"):
157
+ # One of the sides was already gone; nothing to merge.
158
+ result["merged"] = False
159
+ result["loser"] = merge_info.get("lost")
160
+ if merge_info.get("reason") == "neither_exists":
161
+ raise HTTPException(404, "neither memory exists")
162
+ else:
163
+ result["merged"] = True
164
+ result["winner"] = merge_info["kept"]
165
+ result["loser"] = merge_info["lost"]
166
+ result["appended"] = merge_info.get("appended", False)
167
+ result["new_length"] = merge_info.get("new_length", 0)
168
+ try:
169
+ store.record_signal(merge_info["kept"], positive=True)
170
+ except Exception:
171
+ pass
172
+ elif action in ("keepa", "keepb"):
173
+ loser = b_id if action == "keepa" else a_id
174
+ store.delete_memory(loser)
175
+ result["deleted"].append({"id": loser, "kept": (a_id if loser == b_id else b_id)})
176
+ else:
177
+ raise HTTPException(400, f"unknown action {action!r}")
178
+ return result
179
+
180
+ # ------------------------------------------------------------------
181
+ # /api/v1/memories — Universal Agent Memory API
182
+ # ------------------------------------------------------------------
183
+ # Designed for any Agent (Codex / Claude / Hermes / OpenClaw /
184
+ # LangChain / AutoGPT / a custom internal bot) to remember facts,
185
+ # recall context, give feedback, and forget — without coupling to
186
+ # the in-process store or the existing /api/* surface. Idempotent
187
+ # writes are keyed on (agent_id, user_id, external_id) so a retry
188
+ # of the same tool call updates the row in place.
189
+ #
190
+ # This block is intentionally placed alongside the legacy
191
+ # ``/api/memories`` routes for discoverability — the OpenAPI doc
192
+ # at ``/openapi.json`` lists every route side-by-side.
193
+ # ------------------------------------------------------------------
194
+
195
+
196
+ @app.get("/api/v1/wiki/versions")
197
+ def v1_wiki_versions(page_id: str | None = None,
198
+ branch_tag: str | None = None, limit: int = 200):
199
+ rows = store.list_wiki_versions(
200
+ page_id=page_id, branch_tag=branch_tag, limit=limit,
201
+ )
202
+ return {"rows": rows, "count": len(rows)}
203
+
204
+
205
+ @app.get("/api/wiki/contradictions")
206
+ def wiki_contradictions():
207
+ """Return every wiki page that has at least one partner in
208
+ its ``contradicting_ids`` list, with the partner summaries
209
+ inline. The UI uses this to render the "needs review" list
210
+ on the Wiki page.
211
+ """
212
+ from ...jobs.contradiction import list_contradictions
213
+ rows = list_contradictions(store)
214
+ return {"count": len(rows), "items": rows}
215
+
216
+
217
+ @app.post("/api/wiki/contradictions/scan")
218
+ def wiki_contradictions_scan(threshold: float = 0.45):
219
+ """Re-scan every wiki page for contradictions. Cheap for
220
+ <1000 pages; intended for manual triggers from the UI after
221
+ the user adds or edits pages."""
222
+ from ...jobs.contradiction import scan_all
223
+ return scan_all(store, threshold=threshold)
224
+
225
+
226
+ @app.post("/api/wiki/{page_id}/merge")
227
+ def wiki_merge(page_id: str, body: dict):
228
+ """Merge ``page_id`` with the page in ``body.loser_id``.
229
+
230
+ Body keys:
231
+
232
+ * ``loser_id`` (str) — the page to dissolve into ``page_id``
233
+ * ``merged_body`` (str, optional)
234
+ * ``merged_summary`` (str, optional)
235
+ * ``merged_key_facts`` (list[str], optional)
236
+ * ``merged_importance`` (float, optional)
237
+ * ``merged_tags`` (list[str], optional)
238
+
239
+ The winner keeps its id; the loser is deleted. The winner's
240
+ ``contradicting_ids`` column is cleared because the conflict
241
+ is resolved.
242
+ """
243
+ loser_id = body.get("loser_id") or body.get("loserId")
244
+ if not loser_id:
245
+ raise HTTPException(400, "loser_id is required")
246
+ return store.merge_wiki_pages(
247
+ winner_id=page_id,
248
+ loser_id=loser_id,
249
+ merged_body=body.get("merged_body") or body.get("mergedBody"),
250
+ merged_summary=body.get("merged_summary") or body.get("mergedSummary"),
251
+ merged_key_facts=body.get("merged_key_facts") or body.get("mergedKeyFacts"),
252
+ merged_importance=body.get("merged_importance") or body.get("mergedImportance"),
253
+ merged_tags=body.get("merged_tags") or body.get("mergedTags"),
254
+ )
255
+
256
+
257
+ @app.post("/api/wiki/{page_id}/resolve")
258
+ def wiki_resolve(page_id: str):
259
+ """Clear a page's ``contradicting_ids`` so it disappears
260
+ from the contradiction list. Use when the user inspects and
261
+ decides the two pages are not actually in conflict.
262
+ """
263
+ ok = store.resolve_contradiction(page_id)
264
+ return {"ok": bool(ok)}
265
+
266
+
267
+ @app.get("/api/wiki/export")
268
+ def wiki_export(format: str = "markdown", limit: int = 500,
269
+ q: str | None = None):
270
+ """Dump all (or matched) wiki pages for backup / sharing.
271
+
272
+ ``format``:
273
+ - ``markdown`` (default) — single markdown document.
274
+ - ``json`` — round-trippable JSON object
275
+ (``{format, count, pages}``)
276
+ that the import endpoint can
277
+ re-ingest without loss.
278
+ ``q`` (optional) filters by substring match against title/body.
279
+ """
280
+ try:
281
+ pages = store.list_wiki_pages(limit=limit, query=q)
282
+ except Exception:
283
+ pages = []
284
+ fmt = (format or "markdown").lower()
285
+ if fmt == "json":
286
+ return {
287
+ "format": "json",
288
+ "count": len(pages),
289
+ "pages": [dict(p) for p in pages],
290
+ }
291
+ out_lines = ["# Loop Memory — Distilled Knowledge", ""]
292
+ out_lines.append(f"_Exported {len(pages)} wiki pages._")
293
+ out_lines.append("")
294
+ for p in pages:
295
+ title = _export_safe_segment(p.get("title"), fallback="untitled", kind="title")
296
+ body = _export_safe_segment(p.get("body"), fallback="", kind="body")
297
+ summary = _export_safe_segment(p.get("summary"), fallback="", kind="summary")
298
+ # Anchor the body on its own paragraph so a malicious body
299
+ # starting with "# " or "## " can't merge with the heading
300
+ # line and steal the title's section.
301
+ out_lines.append(f"## {title}")
302
+ out_lines.append("")
303
+ if summary and summary != title:
304
+ out_lines.append(f"> {summary}")
305
+ out_lines.append("")
306
+ out_lines.append(body)
307
+ if body and not body.endswith("\n"):
308
+ out_lines.append("")
309
+ out_lines.append("")
310
+ evidence = p.get("evidence_ids") or []
311
+ if evidence:
312
+ ev = ", ".join(str(x) for x in evidence[:6])
313
+ out_lines.append(f"<sub>evidence: {ev}… ({len(evidence)} sources)</sub>")
314
+ out_lines.append("")
315
+ return {"format": "markdown", "count": len(pages), "markdown": "\n".join(out_lines)}
316
+
317
+
318
+ @app.post("/api/wiki/import")
319
+ def wiki_import(body: dict):
320
+ """Bulk-upsert wiki pages from JSON or Markdown.
321
+
322
+ Body shape (one of):
323
+ - ``{"format": "json", "pages": [...]}`` — round-trip from
324
+ ``/api/wiki/export?format=json``. Each entry needs at
325
+ minimum ``slug`` (or ``title``), ``title`` and ``body``.
326
+ - ``{"format": "markdown", "markdown": "..."}`` — text dump
327
+ with ``## title`` sections. The slug is derived from the
328
+ title (lowercase, spaces → dashes, ascii-safe).
329
+
330
+ Returns ``{created, updated, skipped, errors}``. Pages whose
331
+ slug matches an existing one are updated; new slugs are
332
+ created. Malformed entries are skipped (counted in
333
+ ``skipped``) and the first error is reported.
334
+ """
335
+ if not isinstance(body, dict):
336
+ raise HTTPException(400, "body must be an object")
337
+ fmt = (body.get("format") or "json").lower()
338
+ entries: list[dict] = []
339
+ errors: list[str] = []
340
+
341
+ def _slugify(title: str, fallback: str = "") -> str:
342
+ import re as _re
343
+ s = (title or fallback or "").strip().lower()
344
+ s = _re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", s)
345
+ s = s.strip("-")[:80]
346
+ return s or "untitled"
347
+
348
+ if fmt == "json":
349
+ raw = body.get("pages")
350
+ if not isinstance(raw, list):
351
+ raise HTTPException(400, "pages must be a list")
352
+ # Audit O7: cap payload size so a single import can't trigger
353
+ # an unbounded FTS5 rebuild + graph build.
354
+ if len(raw) > 5000:
355
+ raise HTTPException(
356
+ 413,
357
+ f"too many pages in one request ({len(raw)} > 5000); "
358
+ "split into chunks or stream via /api/admin/ingest",
359
+ )
360
+ for i, item in enumerate(raw):
361
+ if not isinstance(item, dict):
362
+ errors.append(f"#{i}: not an object"); continue
363
+ title = (item.get("title") or "").lstrip("\ufeff").strip()
364
+ body_text = (item.get("body") or "").lstrip("\ufeff").strip()
365
+ slug = (item.get("slug") or "").strip()
366
+ if not title or not body_text:
367
+ errors.append(f"#{i}: missing title/body"); continue
368
+ if not slug:
369
+ slug = _slugify(title)
370
+ entries.append({
371
+ "slug": slug.lower().replace(" ", "-")[:80],
372
+ "title": title,
373
+ "body": body_text,
374
+ "summary": (item.get("summary") or "").strip(),
375
+ "tags": item.get("tags") or [],
376
+ "importance": float(item.get("importance") or 0.5),
377
+ "evidence_ids": item.get("evidence_ids") or [],
378
+ "requested_scope": item.get("scope"),
379
+ "source_hint": item.get("source") or item.get("client") or item.get("agent"),
380
+ })
381
+ elif fmt == "markdown":
382
+ md = (body.get("markdown") or "")
383
+ # Audit O13: strip UTF-8 BOM and normalise CRLF before
384
+ # splitting on ``\n(?=##\s)`` so a Windows-pasted doc
385
+ # round-trips the same as a Unix-pasted one.
386
+ md = md.lstrip("\ufeff").replace("\r\n", "\n")
387
+ # Cap raw markdown length -- 5 MB is the practical upper
388
+ # bound before the regex split hits pathological O(n^2)
389
+ # behaviour on inputs with millions of ``\n`` chars.
390
+ if len(md) > 5 * 1024 * 1024:
391
+ raise HTTPException(
392
+ 413,
393
+ f"markdown body too large ({len(md)} > 5MB); "
394
+ "split into chunks or use JSON format",
395
+ )
396
+ md = md.strip()
397
+ if not md:
398
+ raise HTTPException(400, "markdown is empty")
399
+ # Split on "## " headings. Each chunk becomes a page.
400
+ import re as _re
401
+ chunks = _re.split(r"\n(?=##\s)", md)
402
+ for chunk in chunks:
403
+ chunk = chunk.strip()
404
+ if not chunk.startswith("## "):
405
+ continue
406
+ # Drop leading "## "
407
+ lines = chunk.splitlines()
408
+ title = lines[0][3:].strip()
409
+ rest = "\n".join(lines[1:]).strip()
410
+ # Optional ">" summary at the start
411
+ summary = ""
412
+ if rest.startswith(">"):
413
+ maybe = rest.split("\n", 1)
414
+ summary = maybe[0].lstrip("> ").strip()
415
+ rest = maybe[1].strip() if len(maybe) > 1 else ""
416
+ if not title or not rest:
417
+ continue
418
+ entries.append({
419
+ "slug": _slugify(title),
420
+ "title": title,
421
+ "body": rest,
422
+ "summary": summary,
423
+ "tags": [],
424
+ "importance": 0.5,
425
+ "evidence_ids": [],
426
+ "requested_scope": body.get("scope"),
427
+ "source_hint": body.get("source") or body.get("client"),
428
+ })
429
+ else:
430
+ raise HTTPException(400, f"unknown format {fmt!r}; use 'json' or 'markdown'")
431
+
432
+ created = 0
433
+ updated = 0
434
+ for e in entries:
435
+ try:
436
+ existing = store.get_wiki_page_by_slug(e["slug"])
437
+ tags = e.get("tags") or []
438
+ if not isinstance(tags, list):
439
+ tags = [tags]
440
+ evidence_ids = e.get("evidence_ids") or []
441
+ if not isinstance(evidence_ids, list):
442
+ evidence_ids = [evidence_ids]
443
+ scope, auto_classification = _resolve_wiki_scope(
444
+ store,
445
+ title=e["title"],
446
+ body=e["body"],
447
+ summary=e.get("summary") or "",
448
+ tags=tags,
449
+ evidence_ids=evidence_ids,
450
+ requested_scope=e.get("requested_scope"),
451
+ source_hint=e.get("source_hint"),
452
+ existing=existing,
453
+ preserve_existing=existing is not None and e.get("requested_scope") is None,
454
+ )
455
+ store.upsert_wiki_page(
456
+ slug=e["slug"],
457
+ title=e["title"],
458
+ body=e["body"],
459
+ summary=e.get("summary") or "",
460
+ tags=tags,
461
+ importance=e.get("importance") or 0.5,
462
+ evidence_ids=evidence_ids,
463
+ scope=scope,
464
+ auto_classification=auto_classification,
465
+ )
466
+ if existing: updated += 1
467
+ else: created += 1
468
+ except Exception as ex:
469
+ errors.append(f"{e['slug']}: {ex}")
470
+ return {
471
+ "created": created,
472
+ "updated": updated,
473
+ "skipped": len(errors),
474
+ "errors": errors[:10],
475
+ "total": len(entries),
476
+ }
477
+
478
+
479
+ @app.get("/api/wiki/{page_id}/export")
480
+ def wiki_page_export(page_id: str, format: str = "markdown"):
481
+ """Export a single wiki page as a context-block ready to paste
482
+ into another LLM client as a system prompt."""
483
+ page = store.get_wiki_page(page_id)
484
+ if page is None:
485
+ raise HTTPException(404, "wiki page not found")
486
+ title = _export_safe_segment(page.get("title"), fallback="untitled", kind="title")
487
+ body = _export_safe_segment(page.get("body"), fallback="", kind="body")
488
+ summary = _export_safe_segment(page.get("summary"), fallback=title, kind="summary")
489
+ md = f"# {title}\n\n{body}"
490
+ ctx = (
491
+ "[Distilled knowledge — use as background context]\n"
492
+ f"Title: {title}\n"
493
+ f"Summary: {summary}\n\n"
494
+ f"{body}\n"
495
+ )
496
+ return {"format": format, "markdown": md, "context": ctx}
497
+
498
+
499
+ @app.post("/api/wiki/ask")
500
+ def wiki_ask(q: str, limit: int = 5):
501
+ """Recall top wiki pages by keyword match and return a ready-to-paste
502
+ context block plus the matching memory ids."""
503
+ ql = (q or "").lower().strip()
504
+ if not ql:
505
+ raise HTTPException(400, "q query param required")
506
+ try:
507
+ pages = store.list_wiki_pages(limit=200)
508
+ except Exception:
509
+ pages = []
510
+ scored = []
511
+ for p in pages:
512
+ t = (p.get("title") or "").lower()
513
+ b = (p.get("body") or "").lower()
514
+ sm = (p.get("summary") or "").lower()
515
+ score = 0
516
+ for token in ql.split():
517
+ score += t.count(token) * 3 + sm.count(token) * 2 + b.count(token) * 1
518
+ if score > 0:
519
+ scored.append((score, p))
520
+ scored.sort(key=lambda x: -x[0])
521
+ top = scored[:limit]
522
+ if not top:
523
+ return {"q": q, "matches": [], "context": f"(no wiki pages matched {q!r})"}
524
+ ctx_lines = [f"[Distilled knowledge for: {q}]"]
525
+ for _s, p in top:
526
+ _t = _export_safe_segment(p.get("title"), fallback="untitled", kind="title")
527
+ _s = _export_safe_segment(p.get("summary"), fallback="", kind="summary")
528
+ _b = _export_safe_segment(p.get("body"), fallback="", kind="body")
529
+ ctx_lines.append(
530
+ f"\n## {_t}\n"
531
+ f"{_s}\n"
532
+ f"{_b[:800]}"
533
+ )
534
+ return {
535
+ "q": q,
536
+ "matches": [{"id": p["id"], "title": p.get("title"), "score": s} for s, p in top],
537
+ "context": "\n".join(ctx_lines),
538
+ }
539
+
540
+
541
+ @app.get("/api/wiki")
542
+ def wiki_list(limit: int = 200, min_importance: float = 0.0,
543
+ q: str | None = None):
544
+ return store.list_wiki_pages(
545
+ limit=limit,
546
+ min_importance=min_importance if min_importance > 0 else None,
547
+ query=q,
548
+ )
549
+
550
+
551
+ @app.get("/api/wiki/{page_id}")
552
+ def wiki_get(page_id: str):
553
+ page = store.get_wiki_page(page_id)
554
+ if not page:
555
+ raise HTTPException(404, "wiki page not found")
556
+ return page
557
+
558
+
559
+ @app.get("/api/wiki/{page_id}/classification-history")
560
+ def wiki_classification_history(page_id: str):
561
+ page = store.get_wiki_page(page_id)
562
+ if not page:
563
+ raise HTTPException(404, "wiki page not found")
564
+ current = page.get("auto_classification")
565
+ history = []
566
+ if isinstance(current, dict):
567
+ raw_history = current.get("history")
568
+ if isinstance(raw_history, list):
569
+ history.extend(item for item in raw_history if isinstance(item, dict))
570
+ history.append({key: value for key, value in current.items() if key != "history"})
571
+ return {
572
+ "page_id": page_id,
573
+ "scope": page.get("scope") or "global",
574
+ "history": history,
575
+ "current": current,
576
+ }
577
+
578
+
579
+ @app.post("/api/wiki/classify")
580
+ def wiki_classify(body: dict):
581
+ if not isinstance(body, dict):
582
+ raise HTTPException(400, "body must be an object")
583
+ title = str(body.get("title") or "").strip()
584
+ body_text = str(body.get("body") or "").strip()
585
+ summary = str(body.get("summary") or "").strip()
586
+ tags = body.get("tags") or []
587
+ if not isinstance(tags, list):
588
+ tags = [tags]
589
+ evidence_ids = body.get("evidence_ids") or []
590
+ if not isinstance(evidence_ids, list):
591
+ evidence_ids = [evidence_ids]
592
+ scope, audit = _resolve_wiki_scope(
593
+ store,
594
+ title=title,
595
+ body=body_text,
596
+ summary=summary,
597
+ tags=tags,
598
+ evidence_ids=evidence_ids,
599
+ requested_scope=body.get("scope"),
600
+ source_hint=_wiki_source_hint(body),
601
+ )
602
+ return {
603
+ "classification": audit,
604
+ "recommended_scope": scope,
605
+ "scope": scope,
606
+ "config": _wiki_scope_config(store),
607
+ }
608
+
609
+
610
+ @app.post("/api/wiki")
611
+ def wiki_create(body: dict):
612
+ if not isinstance(body, dict):
613
+ raise HTTPException(400, "body must be an object")
614
+ slug = (body.get("slug") or "").strip()
615
+ title = (body.get("title") or "").strip()
616
+ body_text = (body.get("body") or "").strip()
617
+ if not slug or not title or not body_text:
618
+ raise HTTPException(400, "slug, title and body are required")
619
+ # Don't let manual creates clobber a consolidated page for the
620
+ # same slug; if it already exists, route them to PUT instead.
621
+ if store.get_wiki_page_by_slug(slug):
622
+ raise HTTPException(409, "wiki page with this slug already exists")
623
+ tags = body.get("tags") or []
624
+ if not isinstance(tags, list):
625
+ tags = [tags]
626
+ evidence_ids = body.get("evidence_ids") or []
627
+ if not isinstance(evidence_ids, list):
628
+ evidence_ids = [evidence_ids]
629
+ normalised, auto_classification = _resolve_wiki_scope(
630
+ store,
631
+ title=title,
632
+ body=body_text,
633
+ summary=str(body.get("summary") or "").strip(),
634
+ tags=tags,
635
+ evidence_ids=evidence_ids,
636
+ requested_scope=body.get("scope"),
637
+ source_hint=_wiki_source_hint(body),
638
+ )
639
+ return store.upsert_wiki_page(
640
+ slug=slug.lower().replace(" ", "-")[:80],
641
+ title=title,
642
+ body=body_text,
643
+ summary=(body.get("summary") or "").strip(),
644
+ tags=tags,
645
+ importance=float(body.get("importance") or 0.5),
646
+ evidence_ids=evidence_ids,
647
+ scope=normalised,
648
+ auto_classification=auto_classification,
649
+ )
650
+
651
+
652
+ @app.put("/api/wiki/{page_id}")
653
+ def wiki_update(page_id: str, body: dict):
654
+ existing = store.get_wiki_page(page_id)
655
+ if not existing:
656
+ raise HTTPException(404, "wiki page not found")
657
+ if not isinstance(body, dict):
658
+ raise HTTPException(400, "body must be an object")
659
+ slug = (body.get("slug") or existing["slug"]).strip()
660
+ title = (body.get("title") or existing["title"]).strip()
661
+ body_text = (body.get("body") or existing["body"]).strip()
662
+ summary = (body.get("summary") or existing.get("summary") or "").strip()
663
+ tags = body.get("tags") if "tags" in body else existing.get("tags") or []
664
+ if not isinstance(tags, list):
665
+ tags = [tags]
666
+ evidence_ids = body.get("evidence_ids") if "evidence_ids" in body else existing.get("evidence_ids") or []
667
+ if not isinstance(evidence_ids, list):
668
+ evidence_ids = [evidence_ids]
669
+ scope_val, auto_classification = _resolve_wiki_scope(
670
+ store,
671
+ title=title,
672
+ body=body_text,
673
+ summary=summary,
674
+ tags=tags,
675
+ evidence_ids=evidence_ids,
676
+ requested_scope=body.get("scope") if "scope" in body else None,
677
+ source_hint=_wiki_source_hint(body),
678
+ existing=existing,
679
+ preserve_existing="scope" not in body,
680
+ )
681
+ return store.upsert_wiki_page(
682
+ slug=slug.lower().replace(" ", "-")[:80],
683
+ title=title,
684
+ body=body_text,
685
+ summary=summary,
686
+ tags=tags,
687
+ importance=float(body.get("importance")
688
+ if "importance" in body else existing.get("importance") or 0.5),
689
+ evidence_ids=evidence_ids,
690
+ scope=scope_val,
691
+ auto_classification=auto_classification,
692
+ )
693
+
694
+
695
+ @app.delete("/api/wiki/{page_id}")
696
+ def wiki_delete(page_id: str):
697
+ ok = store.delete_wiki_page(page_id)
698
+ if not ok:
699
+ raise HTTPException(404, "wiki page not found")
700
+ return {"ok": True}
701
+
702
+
703
+ @app.post("/api/wiki/bulk-scope")
704
+ def wiki_bulk_scope(body: dict):
705
+ """Set the ``scope`` field on multiple wiki pages in one shot.
706
+
707
+ Drives the master 全局 toggle in the Wiki tab. When the user
708
+ flips the toolbar toggle ON, we bulk-update every page to
709
+ ``scope="global"``. When they flip a single page OFF while the
710
+ master is ON, the master auto-flips OFF — but this endpoint
711
+ is also exposed so the front-end can keep the bulk fast-path
712
+ (single round-trip) without iterating over PUTs.
713
+
714
+ Body:
715
+ ``scope`` (str, required) — the new scope value. Allowed
716
+ tokens: ``global``, ``codex``, ``claude``, ``hermes``,
717
+ ``openclaw``. Comma-separated for per-client lists.
718
+ ``page_ids`` (list[str], optional) — when provided, only
719
+ these pages are updated. When omitted, ALL pages are
720
+ updated. Use the explicit list for the "user
721
+ manually toggled one off" path, omit for the master
722
+ toggle's bulk-ON case.
723
+ """
724
+ if not isinstance(body, dict):
725
+ raise HTTPException(400, "body must be an object")
726
+ raw_scope = (body.get("scope") or "").strip().lower()
727
+ if not raw_scope:
728
+ raise HTTPException(400, "scope is required")
729
+ from ...wiki.scope import normalise_scope
730
+ try:
731
+ normalised = normalise_scope(raw_scope)
732
+ except ValueError as exc:
733
+ raise HTTPException(400, str(exc)) from exc
734
+ page_ids = body.get("page_ids")
735
+ if page_ids is not None:
736
+ if not isinstance(page_ids, list) or not all(
737
+ isinstance(x, str) for x in page_ids
738
+ ):
739
+ raise HTTPException(400, "page_ids must be a list of strings")
740
+ targets = page_ids
741
+ else:
742
+ # No explicit list = apply to every page. Used by the
743
+ # master "全局" toggle's bulk-ON path.
744
+ targets = [p["id"] for p in store.list_wiki_pages(limit=10000)]
745
+ updated = 0
746
+ for pid in targets:
747
+ existing = store.get_wiki_page(pid)
748
+ if not existing:
749
+ continue
750
+ # Avoid bumping updated_at / version when scope is
751
+ # already at the target value.
752
+ if (existing.get("scope") or "global") == normalised:
753
+ updated += 1
754
+ continue
755
+ from ...wiki.scope import build_scope_audit
756
+ scope_cfg = _wiki_scope_config(store)
757
+ manual_audit = build_scope_audit(
758
+ existing.get("auto_classification") or {},
759
+ scope=normalised,
760
+ decision="manual-override",
761
+ enabled=scope_cfg["enabled"],
762
+ mode=scope_cfg["mode"],
763
+ existing=existing,
764
+ )
765
+ store.upsert_wiki_page(
766
+ slug=existing["slug"],
767
+ title=existing["title"],
768
+ body=existing["body"],
769
+ summary=existing.get("summary") or "",
770
+ tags=existing.get("tags") or [],
771
+ importance=existing.get("importance") or 0.5,
772
+ evidence_ids=existing.get("evidence_ids") or [],
773
+ run_id=existing.get("run_id"),
774
+ scope=normalised,
775
+ auto_classification=manual_audit,
776
+ )
777
+ updated += 1
778
+ return {"ok": True, "updated": updated, "scope": normalised}
779
+
780
+
781
+ @app.post("/api/wiki/{page_id}/resummarize")
782
+ def wiki_resummarize(page_id: str):
783
+ """Re-run the consolidator's wiki step targeting just this page.
784
+
785
+ Useful when the user has edited a page manually and wants the
786
+ next consolidation pass to refresh its evidence list, or when
787
+ they want to regenerate a single page without touching others.
788
+ """
789
+ existing = store.get_wiki_page(page_id)
790
+ if not existing:
791
+ raise HTTPException(404, "wiki page not found")
792
+ from ...llm.providers import build_provider, default_config, validate_config
793
+ cfg, _ = validate_config(store.get_setting("llm_consolidator", default_config()))
794
+ provider = build_provider(cfg)
795
+ from ...jobs.llm_consolidate import LLMConsolidator
796
+ cons = LLMConsolidator(store, provider, cfg.get("behaviour") or {})
797
+ # Synthesize on the memories currently pointed at by evidence_ids.
798
+ evidence = existing.get("evidence_ids") or []
799
+ memories = []
800
+ for mid in evidence:
801
+ m = store.get_memory(mid)
802
+ if m is not None:
803
+ memories.append(m)
804
+ if not memories:
805
+ raise HTTPException(400,
806
+ "no evidence memories for this page; rerun consolidation to refresh")
807
+ result = cons._synth_wiki_pages(
808
+ memories=memories, pre_drop=set(), cfg=cfg.get("behaviour") or {},
809
+ stats=type("S", (), {"notes": [], "llm_calls": 0})(),
810
+ run_id=None,
811
+ )
812
+ return {"ok": True, **result}