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,629 @@
1
+ """The MEMORY.md exporter/importer.
2
+
3
+ The format is intentionally small and human-readable:
4
+
5
+ * ``MEMORY.md`` — a top-level summary. The first line is a YAML
6
+ front-matter block with metadata (schema version, export time,
7
+ agent_id, user_id). The body groups wiki pages by tag
8
+ (``# 偏好``, ``# 决策``, ``# 项目背景``, etc.) so the user can
9
+ scan it like any other Markdown file.
10
+
11
+ * ``pages/<slug>.md`` — one file per wiki page. The front-matter
12
+ carries ``title``, ``importance``, ``tags``, ``scope``; the body
13
+ is the page's body. Key facts become a bullet list at the end.
14
+
15
+ * ``memories.jsonl`` — every memory as one JSON object per line.
16
+ Stable key order, sorted by created_at for deterministic diffs.
17
+
18
+ * ``graph.json`` — ``{"entities": [...], "relations": [...]}``.
19
+
20
+ * ``sessions.json`` — ``{"sessions": [...]}``.
21
+
22
+ * ``meta.json`` — bundle metadata for the importer.
23
+
24
+ ``import_bundle`` is the inverse: it walks the directory, upserts
25
+ each wiki page (by slug), each memory (by external triple), each
26
+ entity and relation. Wiki pages are versioned via
27
+ ``snapshot_wiki_version`` so a `git revert` is a single SQL UPDATE.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import datetime as _dt
33
+ import hashlib
34
+ import json
35
+ import logging
36
+ import os
37
+ import re
38
+ import shutil
39
+ import time
40
+ import uuid
41
+ from collections.abc import Iterable
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from ..storage.sqlite_store import MemoryStore
47
+
48
+ log = logging.getLogger(__name__)
49
+
50
+
51
+ SCHEMA_VERSION = 1
52
+ BUNDLE_NAME = "loop-memory-bundle"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Public dataclass
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @dataclass
61
+ class ExportReport:
62
+ out_dir: str
63
+ memory_md_path: str
64
+ pages: list[str] = field(default_factory=list)
65
+ memories: int = 0
66
+ graph_entities: int = 0
67
+ graph_relations: int = 0
68
+ sessions: int = 0
69
+ elapsed_ms: float = 0.0
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {
73
+ "out_dir": self.out_dir,
74
+ "memory_md_path": self.memory_md_path,
75
+ "pages": self.pages,
76
+ "memories": self.memories,
77
+ "graph_entities": self.graph_entities,
78
+ "graph_relations": self.graph_relations,
79
+ "sessions": self.sessions,
80
+ "elapsed_ms": self.elapsed_ms,
81
+ }
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Export
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def export_bundle(
90
+ store: MemoryStore,
91
+ out_dir: str | Path,
92
+ *,
93
+ agent_id: str | None = None,
94
+ user_id: str | None = None,
95
+ scope: str = "global",
96
+ min_importance: float = 0.0,
97
+ ) -> ExportReport:
98
+ """Write a full white-box bundle to ``out_dir``.
99
+
100
+ Parameters mirror the common filters so an Agent can export
101
+ only its own namespace:
102
+
103
+ * ``agent_id`` / ``user_id``: optional, only export memories
104
+ with these tags (NULL means "any").
105
+ * ``scope``: only export wiki pages with this scope.
106
+ * ``min_importance``: drop pages + memories below this.
107
+ """
108
+ t0 = time.time()
109
+ out = Path(out_dir).expanduser().resolve()
110
+ out.mkdir(parents=True, exist_ok=True)
111
+ pages_dir = out / "pages"
112
+ pages_dir.mkdir(exist_ok=True)
113
+
114
+ pages = list(store.list_wiki_pages(limit=2000, scope=scope))
115
+ if min_importance > 0:
116
+ pages = [p for p in pages if float(p.get("importance") or 0) >= min_importance]
117
+
118
+ # Write per-page files + the master MEMORY.md
119
+ page_paths: list[str] = []
120
+ for p in pages:
121
+ path = _write_page_file(pages_dir, p)
122
+ page_paths.append(path)
123
+ memory_md = out / "MEMORY.md"
124
+ write_memory_md(pages, memory_md, agent_id=agent_id, user_id=user_id)
125
+
126
+ # memories.jsonl — one per row
127
+ mem_path = out / "memories.jsonl"
128
+ n_mem = 0
129
+ with mem_path.open("w", encoding="utf-8") as f:
130
+ # Walk the rows in created_at DESC so the diff is readable
131
+ rows = store.list_memories(
132
+ limit=100_000, agent_id=agent_id, user_id=user_id,
133
+ min_score=None,
134
+ )
135
+ rows = [r for r in rows if float(r.importance or 0) >= min_importance]
136
+ for r in rows:
137
+ d = _memory_to_dict(r)
138
+ f.write(json.dumps(d, ensure_ascii=False, sort_keys=True) + "\n")
139
+ n_mem += 1
140
+
141
+ # graph.json
142
+ ents_rows = list(_iter_entities(store))
143
+ rels_rows = list(_iter_relations(store))
144
+ (out / "graph.json").write_text(
145
+ json.dumps(
146
+ {"entities": ents_rows, "relations": rels_rows},
147
+ ensure_ascii=False, indent=2,
148
+ ),
149
+ encoding="utf-8",
150
+ )
151
+
152
+ # sessions.json
153
+ sessions = store.list_sessions(limit=10000)
154
+ (out / "sessions.json").write_text(
155
+ json.dumps(
156
+ {"sessions": [s.__dict__ if hasattr(s, "__dict__") else dict(s) for s in sessions]},
157
+ ensure_ascii=False, indent=2,
158
+ ),
159
+ encoding="utf-8",
160
+ )
161
+
162
+ # meta.json
163
+ meta = {
164
+ "bundle": BUNDLE_NAME,
165
+ "schema_version": SCHEMA_VERSION,
166
+ "exported_at": _dt.datetime.now().isoformat(timespec="seconds"),
167
+ "agent_id": agent_id,
168
+ "user_id": user_id,
169
+ "scope": scope,
170
+ "min_importance": min_importance,
171
+ "page_count": len(pages),
172
+ "memory_count": n_mem,
173
+ "graph_entities": len(ents_rows),
174
+ "graph_relations": len(rels_rows),
175
+ }
176
+ (out / "meta.json").write_text(
177
+ json.dumps(meta, ensure_ascii=False, indent=2),
178
+ encoding="utf-8",
179
+ )
180
+
181
+ # INDEX.md
182
+ index_lines = [
183
+ f"# {BUNDLE_NAME} — file index",
184
+ "",
185
+ "_Auto-generated. Re-run `loop-memory export` to refresh._",
186
+ "",
187
+ f"- [MEMORY.md](./MEMORY.md) — top-level summary ({len(pages)} pages)",
188
+ f"- [memories.jsonl](./memories.jsonl) — {n_mem} raw memories",
189
+ f"- [graph.json](./graph.json) — {len(ents_rows)} entities, {len(rels_rows)} relations",
190
+ f"- [sessions.json](./sessions.json) — {len(sessions)} sessions",
191
+ "- [meta.json](./meta.json) — bundle metadata",
192
+ "- [pages/](./pages/) — one Markdown file per wiki page",
193
+ ]
194
+ (out / "INDEX.md").write_text("\n".join(index_lines) + "\n", encoding="utf-8")
195
+
196
+ elapsed = (time.time() - t0) * 1000
197
+ return ExportReport(
198
+ out_dir=str(out),
199
+ memory_md_path=str(memory_md),
200
+ pages=page_paths,
201
+ memories=n_mem,
202
+ graph_entities=len(ents_rows),
203
+ graph_relations=len(rels_rows),
204
+ sessions=len(sessions),
205
+ elapsed_ms=round(elapsed, 1),
206
+ )
207
+
208
+
209
+ def write_memory_md(
210
+ pages: Iterable[dict[str, Any]],
211
+ out_path: str | Path,
212
+ *,
213
+ agent_id: str | None = None,
214
+ user_id: str | None = None,
215
+ ) -> str:
216
+ """Write the top-level MEMORY.md (also used by the simpler
217
+ ``loop-memory export`` command).
218
+
219
+ Groups pages by their first tag, falling back to "其它". The
220
+ file starts with a YAML front-matter block so downstream
221
+ tooling can parse it without re-implementing our heuristics.
222
+ """
223
+ out = Path(out_path)
224
+ pages = list(pages)
225
+ now = _dt.datetime.now().isoformat(timespec="seconds")
226
+ fm = [
227
+ "---",
228
+ f"schema_version: {SCHEMA_VERSION}",
229
+ f"generated_at: {now}",
230
+ f"agent_id: {agent_id or ''}",
231
+ f"user_id: {user_id or ''}",
232
+ f"page_count: {len(pages)}",
233
+ "---",
234
+ "",
235
+ "# 长期记忆",
236
+ "",
237
+ "_Auto-generated by `loop-memory export`. Edit `pages/*.md` "
238
+ "and re-import to keep this file in sync. Use `git` to track "
239
+ "history — every change shows up as a readable diff._",
240
+ "",
241
+ ]
242
+ # Group by tag (first tag wins; "其它" fallback)
243
+ by_tag: dict[str, list[dict[str, Any]]] = {}
244
+ for p in pages:
245
+ tags = p.get("tags") or []
246
+ cat = (tags[0] if tags else "其它")
247
+ by_tag.setdefault(str(cat), []).append(p)
248
+ for cat in sorted(by_tag.keys()):
249
+ fm.append(f"## {cat}")
250
+ fm.append("")
251
+ for p in by_tag[cat]:
252
+ title = p.get("title") or p.get("slug") or "?"
253
+ slug = p.get("slug") or ""
254
+ importance = float(p.get("importance") or 0)
255
+ fm.append(f"### {title} (`{slug}`, importance={importance:.2f})")
256
+ summary = p.get("summary") or ""
257
+ if summary:
258
+ fm.append("")
259
+ fm.append(f"> {summary}")
260
+ kf = p.get("key_facts") or []
261
+ if kf:
262
+ fm.append("")
263
+ fm.append("**Key facts:**")
264
+ for fact in kf:
265
+ fm.append(f"- {fact}")
266
+ fm.append("")
267
+ out.write_text("\n".join(fm) + "\n", encoding="utf-8")
268
+ return str(out)
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Import
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ @dataclass
277
+ class ImportReport:
278
+ pages_upserted: int = 0
279
+ memories_upserted: int = 0
280
+ entities_upserted: int = 0
281
+ relations_upserted: int = 0
282
+ sessions_upserted: int = 0
283
+ elapsed_ms: float = 0.0
284
+ bundle_path: str = ""
285
+
286
+ def to_dict(self) -> dict[str, Any]:
287
+ return self.__dict__.copy()
288
+
289
+
290
+ def import_bundle(
291
+ store: MemoryStore,
292
+ in_dir: str | Path,
293
+ *,
294
+ agent_id: str | None = None,
295
+ user_id: str | None = None,
296
+ dry_run: bool = False,
297
+ ) -> ImportReport:
298
+ """Re-hydrate a bundle into the live store.
299
+
300
+ * Wiki pages are upserted by slug; the new row is versioned
301
+ via ``snapshot_wiki_version`` so a `git revert` is one
302
+ ``UPDATE`` away.
303
+ * Memories are upserted by ``(agent_id, user_id, external_id)``.
304
+ If the bundle row has no ``external_id``, we mint a stable
305
+ one from a SHA-1 of the text so re-imports stay idempotent.
306
+ * Entities and relations are upserted; relations re-point to
307
+ the freshly-created entity ids.
308
+
309
+ ``dry_run=True`` walks the bundle and returns counts without
310
+ touching the store.
311
+ """
312
+ t0 = time.time()
313
+ in_path = Path(in_dir).expanduser().resolve()
314
+ if not in_path.is_dir():
315
+ raise FileNotFoundError(f"bundle not found: {in_path}")
316
+ # meta.json is optional; if present, sanity-check the version.
317
+ meta_path = in_path / "meta.json"
318
+ if meta_path.exists():
319
+ try:
320
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
321
+ if int(meta.get("schema_version", SCHEMA_VERSION)) > SCHEMA_VERSION:
322
+ log.warning("bundle schema_version > current; some fields may be ignored")
323
+ except Exception as e:
324
+ log.warning("could not parse meta.json: %s", e)
325
+ report = ImportReport(bundle_path=str(in_path))
326
+
327
+ pages = _read_pages(in_path)
328
+ for p in pages:
329
+ if dry_run:
330
+ report.pages_upserted += 1
331
+ continue
332
+ slug = p["slug"]
333
+ store.upsert_wiki_page(
334
+ slug=slug,
335
+ title=p.get("title") or slug,
336
+ body=p.get("body") or "",
337
+ summary=p.get("summary") or "",
338
+ tags=p.get("tags") or [],
339
+ importance=float(p.get("importance") or 0.5),
340
+ scope=p.get("scope") or "global",
341
+ key_facts=p.get("key_facts") or [],
342
+ )
343
+ report.pages_upserted += 1
344
+
345
+ mem_path = in_path / "memories.jsonl"
346
+ if mem_path.exists():
347
+ for line in mem_path.read_text(encoding="utf-8").splitlines():
348
+ if not line.strip():
349
+ continue
350
+ d = json.loads(line)
351
+ if dry_run:
352
+ report.memories_upserted += 1
353
+ continue
354
+ ext = d.get("external_id")
355
+ if not ext:
356
+ ext = "sha1:" + hashlib.sha1(d.get("text", "").encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
357
+ store.upsert_memory(
358
+ id=d.get("id"),
359
+ kind=d.get("kind") or "fact",
360
+ text=d.get("text") or "",
361
+ importance=float(d.get("importance") or 0.5),
362
+ source=d.get("source"),
363
+ session_id=d.get("session_id"),
364
+ tags=d.get("tags") or [],
365
+ agent_id=d.get("agent_id") or agent_id,
366
+ user_id=d.get("user_id") or user_id,
367
+ external_id=ext,
368
+ created_at=d.get("created_at"),
369
+ )
370
+ report.memories_upserted += 1
371
+
372
+ graph_path = in_path / "graph.json"
373
+ if graph_path.exists():
374
+ g = json.loads(graph_path.read_text(encoding="utf-8"))
375
+ for e in g.get("entities", []):
376
+ if dry_run:
377
+ report.entities_upserted += 1
378
+ continue
379
+ store.upsert_entity(
380
+ e.get("name") or "", e.get("kind") or "concept",
381
+ bump_weight=float(e.get("weight") or 0),
382
+ )
383
+ report.entities_upserted += 1
384
+ for r in g.get("relations", []):
385
+ if dry_run:
386
+ report.relations_upserted += 1
387
+ continue
388
+ store.upsert_relation(
389
+ r.get("src") or "", r.get("dst") or "",
390
+ kind=r.get("kind") or "co_occurs_with",
391
+ weight=float(r.get("weight") or 0.5),
392
+ evidence_id=r.get("evidence_id"),
393
+ )
394
+ report.relations_upserted += 1
395
+
396
+ sessions_path = in_path / "sessions.json"
397
+ if sessions_path.exists():
398
+ s_data = json.loads(sessions_path.read_text(encoding="utf-8"))
399
+ for s in s_data.get("sessions", []):
400
+ if dry_run:
401
+ report.sessions_upserted += 1
402
+ continue
403
+ store.upsert_session(
404
+ source=s.get("source") or "imported",
405
+ external_id=s.get("external_id"),
406
+ title=s.get("title"),
407
+ started_at=s.get("started_at"),
408
+ ended_at=s.get("ended_at"),
409
+ message_count=int(s.get("message_count") or 0),
410
+ metadata=s.get("metadata") or {},
411
+ )
412
+ report.sessions_upserted += 1
413
+
414
+ report.elapsed_ms = round((time.time() - t0) * 1000, 1)
415
+ return report
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Fork
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ def fork_snapshot(
424
+ store: MemoryStore,
425
+ *,
426
+ branch_tag: str | None = None,
427
+ ) -> dict[str, Any]:
428
+ """Snapshot every wiki page into ``wiki_versions`` with the
429
+ given ``branch_tag`` so the user can `git`-tag + restore later.
430
+
431
+ Returns ``{"tag": ..., "snapshotted": N, "elapsed_ms": ...}``.
432
+ """
433
+ if not branch_tag:
434
+ branch_tag = "fork-" + _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
435
+ t0 = time.time()
436
+ pages = store.list_wiki_pages(limit=10000)
437
+ n = 0
438
+ for p in pages:
439
+ if store.snapshot_wiki_version(p["id"], branch_tag=branch_tag):
440
+ n += 1
441
+ return {
442
+ "tag": branch_tag,
443
+ "snapshotted": n,
444
+ "elapsed_ms": round((time.time() - t0) * 1000, 1),
445
+ }
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # Internal helpers
450
+ # ---------------------------------------------------------------------------
451
+
452
+
453
+ def _write_page_file(pages_dir: Path, p: dict[str, Any]) -> str:
454
+ """Write a single wiki page as ``pages/<slug>.md``.
455
+
456
+ Front-matter mirrors MEMORY.md so any Markdown renderer that
457
+ understands YAML gets structured data for free.
458
+ """
459
+ slug = (p.get("slug") or uuid.uuid4().hex).strip()
460
+ fm = [
461
+ "---",
462
+ f"slug: {slug}",
463
+ f"title: {p.get('title') or ''}",
464
+ f"importance: {float(p.get('importance') or 0):.3f}",
465
+ f"scope: {p.get('scope') or 'global'}",
466
+ "tags:",
467
+ ]
468
+ for t in p.get("tags") or []:
469
+ fm.append(f" - {t}")
470
+ fm.append("---")
471
+ fm.append("")
472
+ fm.append(f"# {p.get('title') or slug}")
473
+ fm.append("")
474
+ if p.get("summary"):
475
+ fm.append(f"> {p['summary']}")
476
+ fm.append("")
477
+ kf = p.get("key_facts") or []
478
+ if kf:
479
+ fm.append("## Key facts")
480
+ fm.append("")
481
+ for fact in kf:
482
+ fm.append(f"- {fact}")
483
+ fm.append("")
484
+ fm.append("## Body")
485
+ fm.append("")
486
+ fm.append((p.get("body") or "").rstrip())
487
+ fm.append("")
488
+ path = pages_dir / f"{slug}.md"
489
+ path.write_text("\n".join(fm), encoding="utf-8")
490
+ return str(path)
491
+
492
+
493
+ def _read_pages(in_path: Path) -> list[dict[str, Any]]:
494
+ """Read every ``pages/<slug>.md`` and parse the front-matter.
495
+
496
+ Skips pages whose front-matter is corrupt and logs a warning
497
+ so a typo doesn't kill the whole import.
498
+ """
499
+ pages_dir = in_path / "pages"
500
+ if not pages_dir.is_dir():
501
+ return []
502
+ out: list[dict[str, Any]] = []
503
+ for f in sorted(pages_dir.glob("*.md")):
504
+ try:
505
+ out.append(_parse_page_file(f))
506
+ except Exception as e:
507
+ log.warning("could not parse %s: %s", f, e)
508
+ return out
509
+
510
+
511
+ _FM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.S)
512
+
513
+
514
+ def _parse_page_file(path: Path) -> dict[str, Any]:
515
+ raw = path.read_text(encoding="utf-8")
516
+ m = _FM_RE.match(raw)
517
+ if not m:
518
+ raise ValueError(f"missing front-matter: {path}")
519
+ fm_text, body = m.group(1), m.group(2)
520
+ fm: dict[str, Any] = {}
521
+ list_key: str | None = None
522
+ for line in fm_text.splitlines():
523
+ if not line.strip():
524
+ continue
525
+ if line.startswith(" - "):
526
+ if list_key:
527
+ fm.setdefault(list_key, []).append(line[4:].strip())
528
+ continue
529
+ if ":" in line:
530
+ k, _, v = line.partition(":")
531
+ k = k.strip()
532
+ v = v.strip()
533
+ if not v:
534
+ list_key = k
535
+ fm.setdefault(k, [])
536
+ else:
537
+ list_key = None
538
+ try:
539
+ fm[k] = float(v) if "." in v else v
540
+ except ValueError:
541
+ fm[k] = v
542
+ # Extract Key facts block
543
+ kf: list[str] = []
544
+ body_lines = body.splitlines()
545
+ i = 0
546
+ while i < len(body_lines):
547
+ if body_lines[i].strip() == "## Key facts":
548
+ j = i + 1
549
+ while j < len(body_lines) and body_lines[j].startswith("- "):
550
+ kf.append(body_lines[j][2:].strip())
551
+ j += 1
552
+ break
553
+ i += 1
554
+ # Extract body
555
+ body_idx = 0
556
+ for idx, line in enumerate(body_lines):
557
+ if line.strip() == "## Body":
558
+ body_idx = idx + 1
559
+ break
560
+ body_text = "\n".join(body_lines[body_idx:]).strip()
561
+ return {
562
+ "slug": fm.get("slug") or path.stem,
563
+ "title": fm.get("title") or path.stem,
564
+ "importance": float(fm.get("importance") or 0.5),
565
+ "scope": fm.get("scope") or "global",
566
+ "tags": list(fm.get("tags") or []),
567
+ "key_facts": kf,
568
+ "body": body_text,
569
+ "summary": "", # not currently in the per-page file
570
+ }
571
+
572
+
573
+ def _memory_to_dict(r) -> dict[str, Any]:
574
+ """Convert a StoredMemory dataclass to a JSON-safe dict."""
575
+ return {
576
+ "id": r.id,
577
+ "kind": r.kind,
578
+ "text": r.text,
579
+ "importance": round(float(r.importance or 0), 3),
580
+ "source": r.source,
581
+ "session_id": r.session_id,
582
+ "tags": list(r.tags or []),
583
+ "created_at": float(r.created_at or 0),
584
+ "agent_id": getattr(r, "agent_id", None),
585
+ "user_id": getattr(r, "user_id", None),
586
+ "external_id": getattr(r, "external_id", None),
587
+ }
588
+
589
+
590
+ def _iter_entities(store: MemoryStore) -> Iterable[dict[str, Any]]:
591
+ with store._conn() as c: # type: ignore[attr-defined]
592
+ rows = c.execute("SELECT * FROM entities ORDER BY name").fetchall()
593
+ for r in rows:
594
+ yield {
595
+ "id": r["id"],
596
+ "name": r["name"],
597
+ "kind": r["kind"],
598
+ "weight": float(r["weight"] or 0),
599
+ "mention_count": int(r["mention_count"] or 0),
600
+ }
601
+
602
+
603
+ def _iter_relations(store: MemoryStore) -> Iterable[dict[str, Any]]:
604
+ with store._conn() as c: # type: ignore[attr-defined]
605
+ rows = c.execute("SELECT * FROM relations ORDER BY id").fetchall()
606
+ for r in rows:
607
+ try:
608
+ evid = json.loads(r["evidence_ids"]) if r["evidence_ids"] else []
609
+ except Exception:
610
+ evid = []
611
+ yield {
612
+ "id": r["id"],
613
+ "src": r["src"],
614
+ "dst": r["dst"],
615
+ "kind": r["kind"],
616
+ "weight": float(r["weight"] or 0),
617
+ "evidence_ids": evid,
618
+ }
619
+
620
+
621
+ __all__ = [
622
+ "ExportReport",
623
+ "ImportReport",
624
+ "SCHEMA_VERSION",
625
+ "export_bundle",
626
+ "import_bundle",
627
+ "fork_snapshot",
628
+ "write_memory_md",
629
+ ]
File without changes