loop-memory 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|