memlink-bridge 0.1.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.
memlink/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """memlink — AI Memory Interchange Layer."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .models import JSONValue, Memory, Relationship, Source
6
+ from .plugin import Capabilities, FormatPlugin, ReadResult, Severity, ValidationIssue
7
+ from .converter import ConversionAnalysis, FeatureImpact, analyze_conversion, compare_memories
8
+ from .registry import get_reader, get_writer, list_formats
9
+
10
+ __all__ = [
11
+ "__version__",
12
+ "Memory",
13
+ "Source",
14
+ "Relationship",
15
+ "JSONValue",
16
+ "FormatPlugin",
17
+ "Capabilities",
18
+ "ReadResult",
19
+ "ValidationIssue",
20
+ "Severity",
21
+ "ConversionAnalysis",
22
+ "FeatureImpact",
23
+ "analyze_conversion",
24
+ "compare_memories",
25
+ "get_reader",
26
+ "get_writer",
27
+ "list_formats",
28
+ ]
@@ -0,0 +1,28 @@
1
+ """Shared YAML frontmatter parser — used by all Readers and validators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import yaml
6
+
7
+
8
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
9
+ """Parse YAML frontmatter from a Markdown file.
10
+
11
+ Returns (frontmatter_dict, body_text).
12
+ If no frontmatter found, returns ({}, text).
13
+ If YAML is invalid, returns ({}, text) — never raises.
14
+ """
15
+ if not text.startswith("---"):
16
+ return {}, text
17
+
18
+ parts = text.split("---", 2)
19
+ if len(parts) < 3:
20
+ return {}, text
21
+
22
+ try:
23
+ fm = yaml.safe_load(parts[1])
24
+ if not isinstance(fm, dict):
25
+ return {}, text
26
+ return fm, parts[2]
27
+ except yaml.YAMLError:
28
+ return {}, text
memlink/cli.py ADDED
@@ -0,0 +1,418 @@
1
+ """CLI entry point — argparse-based command routing.
2
+
3
+ Minimal v0.1 CLI: convert / validate / inspect.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ import time
11
+ from enum import IntEnum
12
+ from pathlib import Path
13
+
14
+
15
+ class ExitCode(IntEnum):
16
+ SUCCESS = 0
17
+ DIFF_FOUND = 1
18
+ VALIDATION_ERROR = 2
19
+ IO_ERROR = 3
20
+ CONCURRENT_MODIFICATION = 4
21
+ FORMAT_INCOMPATIBLE = 5
22
+ USER_ABORT = 130
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> None:
26
+ parser = _build_parser()
27
+ args = parser.parse_args(argv)
28
+
29
+ if not args.command:
30
+ parser.print_help()
31
+ sys.exit(ExitCode.SUCCESS)
32
+
33
+ try:
34
+ _dispatch(args)
35
+ except KeyboardInterrupt:
36
+ print("\nAborted.", file=sys.stderr)
37
+ sys.exit(ExitCode.USER_ABORT)
38
+ except FileNotFoundError as e:
39
+ print(f"Error: {e}", file=sys.stderr)
40
+ sys.exit(ExitCode.IO_ERROR)
41
+ except Exception as e:
42
+ print(f"Error: {e}", file=sys.stderr)
43
+ sys.exit(ExitCode.IO_ERROR)
44
+
45
+
46
+ # ── Parser ─────────────────────────────────────────────────────────
47
+
48
+ def _build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="memlink",
51
+ description="AI Memory Interchange Layer — bridge AI memory formats",
52
+ )
53
+ parser.add_argument("--version", action="version", version="memlink 0.1.0")
54
+ sub = parser.add_subparsers(dest="command")
55
+
56
+ # convert
57
+ p = sub.add_parser("convert", help="Convert between memory formats")
58
+ p.add_argument("--from", "-f", dest="from_fmt", required=True, help="Source format (ombre, openclaw)")
59
+ p.add_argument("--to", "-t", dest="to_fmt", required=True, help="Target format (ombre, openclaw)")
60
+ p.add_argument("--source", "-s", type=Path, required=True, help="Source directory")
61
+ p.add_argument("--target", "-T", type=Path, required=True, help="Target directory")
62
+ p.add_argument("--output-mode", choices=["daily-notes", "structured"], default="daily-notes",
63
+ help="OpenClaw output mode (default: daily-notes)")
64
+ p.add_argument("--kind", "-k", nargs="+", help="Only convert specific kind(s)")
65
+ p.add_argument("--dry-run", action="store_true", help="Parse only, don't write")
66
+ p.add_argument("--include-archived", action="store_true")
67
+ p.add_argument("--verbose", "-v", action="count", default=0)
68
+
69
+ # shortcuts
70
+ p = sub.add_parser("ombre2claw", help="Ombre → OpenClaw (shortcut)")
71
+ p.add_argument("--source", "-s", type=Path, required=True)
72
+ p.add_argument("--target", "-T", type=Path, required=True)
73
+ p.add_argument("--output-mode", choices=["daily-notes", "structured"], default="daily-notes")
74
+ p.add_argument("--verbose", "-v", action="count", default=0)
75
+
76
+ p = sub.add_parser("claw2ombre", help="OpenClaw → Ombre (shortcut)")
77
+ p.add_argument("--source", "-s", type=Path, required=True)
78
+ p.add_argument("--target", "-T", type=Path, required=True)
79
+ p.add_argument("--verbose", "-v", action="count", default=0)
80
+
81
+ # validate
82
+ p = sub.add_parser("validate", help="Validate memory files")
83
+ p.add_argument("--level", choices=["schema", "semantic", "roundtrip"], default="schema")
84
+ p.add_argument("--source", "-s", type=Path, required=True, help="Directory or file to validate")
85
+ p.add_argument("--format", choices=["pretty", "json"], default="pretty")
86
+
87
+ # diff
88
+ p = sub.add_parser("diff", help="Compare two memory directories")
89
+ p.add_argument("--source", "-s", type=Path, nargs=2, required=True, metavar=("DIR1", "DIR2"))
90
+ p.add_argument("--ignore", help="Ignore field groups: timestamps,tags,importance")
91
+ p.add_argument("--format", choices=["pretty", "json"], default="pretty")
92
+
93
+ # stats
94
+ p = sub.add_parser("stats", help="Show memory statistics")
95
+ p.add_argument("--source", "-s", type=Path, required=True)
96
+
97
+ # inspect
98
+ p = sub.add_parser("inspect", help="Inspect a single memory file")
99
+ p.add_argument("file", type=Path, help="File to inspect")
100
+ p.add_argument("--format", "-f", choices=["ombre", "openclaw"], help="Force format detection")
101
+
102
+ # formats
103
+ sub.add_parser("formats", help="List installed format plugins")
104
+
105
+ return parser
106
+
107
+
108
+ # ── Dispatch ───────────────────────────────────────────────────────
109
+
110
+ def _dispatch(args) -> None:
111
+ if args.command == "formats":
112
+ _cmd_formats()
113
+ elif args.command == "convert":
114
+ _cmd_convert(args)
115
+ elif args.command == "validate":
116
+ _cmd_validate(args)
117
+ elif args.command == "inspect":
118
+ _cmd_inspect(args)
119
+ elif args.command == "diff":
120
+ _cmd_diff(args)
121
+ elif args.command == "stats":
122
+ _cmd_stats(args)
123
+ elif args.command == "ombre2claw":
124
+ args.from_fmt, args.to_fmt, args.dry_run = "ombre", "openclaw", False
125
+ _cmd_convert(args)
126
+ elif args.command == "claw2ombre":
127
+ args.from_fmt, args.to_fmt, args.dry_run = "openclaw", "ombre", False
128
+ _cmd_convert(args)
129
+ else:
130
+ sys.exit(0)
131
+
132
+
133
+ # ── Commands ───────────────────────────────────────────────────────
134
+
135
+ def _cmd_formats() -> None:
136
+ from .registry import list_formats
137
+ fmts = list_formats()
138
+ if not fmts:
139
+ print("No format plugins installed.")
140
+ return
141
+ print(f"{'Format':<15} {'Reader':<10} {'Writer':<10}")
142
+ print("-" * 35)
143
+ for name, caps in fmts.items():
144
+ print(f"{name:<15} {'yes' if caps['reader'] else 'no':<10} {'yes' if caps['writer'] else 'no':<10}")
145
+
146
+
147
+ def _cmd_convert(args) -> None:
148
+ from .registry import get_reader, get_writer
149
+ from .converter import analyze_conversion
150
+
151
+ src_plugin = get_reader(args.from_fmt)
152
+ writer_kwargs = {}
153
+ if args.to_fmt == "openclaw":
154
+ writer_kwargs["output_mode"] = args.output_mode
155
+ dst_plugin = get_writer(args.to_fmt, **writer_kwargs)
156
+
157
+ # Read first to compute analysis
158
+ result = src_plugin.read(args.source)
159
+ memories = result.memories
160
+ total = len(memories)
161
+ print(f"Read: {total} memories from {args.from_fmt}")
162
+
163
+ # Analyze before writing
164
+ analysis = analyze_conversion(memories, src_plugin, dst_plugin)
165
+ _print_compatibility(analysis, total, args.verbose)
166
+
167
+ # Dry run
168
+ if args.dry_run:
169
+ print(f"\nDry run: would convert {total} memories")
170
+ return
171
+
172
+ # Write
173
+ start = time.perf_counter()
174
+ write_warnings = dst_plugin.write(memories, args.target)
175
+ elapsed = time.perf_counter() - start
176
+
177
+ if write_warnings and args.verbose:
178
+ for w in write_warnings[:10]:
179
+ print(f" [write] {w}")
180
+
181
+ all_warnings = result.warnings + write_warnings
182
+ print(f"Warnings: {len(all_warnings)}" if all_warnings else "Warnings: 0")
183
+ print(f"Time: {elapsed:.2f}s")
184
+
185
+
186
+ def _print_compatibility(analysis, total: int, verbosity: int) -> None:
187
+ """Print structured Compatibility Report."""
188
+ if not analysis.impacts:
189
+ if verbosity >= 1:
190
+ print("Compatibility: fully supported")
191
+ return
192
+
193
+ ICONS = {"lost": "[!]", "degraded": "[~]", "preserved": "[ok]"}
194
+ TITLES = {"lost": "Not supported", "degraded": "Degraded", "preserved": "Preserved via metadata"}
195
+
196
+ # Group by severity
197
+ by_sev: dict[str, list] = {}
198
+ for imp in analysis.impacts:
199
+ by_sev.setdefault(imp.severity, []).append(imp)
200
+
201
+ print("\nCompatibility Report:")
202
+
203
+ for sev in ("lost", "preserved", "degraded"):
204
+ items = by_sev.get(sev, [])
205
+ if not items:
206
+ continue
207
+ title = TITLES.get(sev, sev)
208
+ print(f" {ICONS.get(sev, '?')} {title}:")
209
+ for imp in items:
210
+ pct = f" ({imp.count * 100 // total}%)" if verbosity >= 1 else ""
211
+ print(f" {imp.label}: {imp.count} field values{pct}")
212
+ if verbosity >= 2:
213
+ print(f" → {imp.reason}")
214
+ if verbosity >= 2 and imp.recoverable:
215
+ print(f" → Recoverable via roundtrip")
216
+
217
+
218
+ def _cmd_validate(args) -> None:
219
+ from .validators import validate_schema, validate_semantic, validate_roundtrip
220
+
221
+ if args.level == "schema":
222
+ issues = validate_schema(args.source)
223
+ elif args.level == "semantic":
224
+ issues = validate_semantic(args.source)
225
+ elif args.level == "roundtrip":
226
+ issues = validate_roundtrip(args.source)
227
+ else:
228
+ issues = []
229
+
230
+ errors = [i for i in issues if i.severity == "error"]
231
+ warnings = [i for i in issues if i.severity == "warning"]
232
+
233
+ if args.format == "json":
234
+ import json
235
+ out = {
236
+ "total": len(errors) + len(warnings),
237
+ "errors": [{"code": e.code, "path": e.path, "message": e.message} for e in errors],
238
+ "warnings": [{"code": w.code, "path": w.path, "message": w.message} for w in warnings],
239
+ }
240
+ print(json.dumps(out, indent=2))
241
+ else:
242
+ if errors:
243
+ print(f"Errors: {len(errors)}")
244
+ for e in errors:
245
+ print(f" {e.code.value if hasattr(e.code, 'value') else e.code} {e.path}: {e.message}")
246
+ if warnings:
247
+ print(f"Warnings: {len(warnings)}")
248
+ for w in warnings[:10]:
249
+ print(f" {w.code} {w.path}: {w.message}")
250
+ if not errors and not warnings:
251
+ print(f"All files valid ({args.level})")
252
+
253
+ sys.exit(ExitCode.VALIDATION_ERROR if errors else ExitCode.SUCCESS)
254
+
255
+
256
+ def _cmd_diff(args) -> None:
257
+ from .registry import get_reader
258
+ from .converter import ConversionAnalysis
259
+
260
+ dir1, dir2 = args.source
261
+ ignore = set((args.ignore or "").split(","))
262
+
263
+ r1 = get_reader(_detect_format(dir1))
264
+ r2 = get_reader(_detect_format(dir2))
265
+
266
+ m1 = r1.read(dir1).memories
267
+ m2 = r2.read(dir2).memories
268
+
269
+ ids1 = {m.id for m in m1}
270
+ ids2 = {m.id for m in m2}
271
+
272
+ only_in_1 = sorted(ids1 - ids2)
273
+ only_in_2 = sorted(ids2 - ids1)
274
+ common = ids1 & ids2
275
+
276
+ if args.format == "json":
277
+ import json
278
+ print(json.dumps({
279
+ "only_in_source": len(only_in_1),
280
+ "only_in_target": len(only_in_2),
281
+ "common": len(common),
282
+ }, indent=2))
283
+ else:
284
+ print(f"Only in source: {len(only_in_1)}")
285
+ print(f"Only in target: {len(only_in_2)}")
286
+ print(f"Common: {len(common)}")
287
+
288
+ if only_in_1:
289
+ print(f"\n Added ({len(only_in_1)}):")
290
+ for i in only_in_1[:10]:
291
+ print(f" + {i}")
292
+ if only_in_2:
293
+ print(f"\n Removed ({len(only_in_2)}):")
294
+ for i in only_in_2[:10]:
295
+ print(f" - {i}")
296
+
297
+ sys.exit(ExitCode.DIFF_FOUND if only_in_1 or only_in_2 else ExitCode.SUCCESS)
298
+
299
+
300
+ def _cmd_stats(args) -> None:
301
+ from .registry import get_reader
302
+
303
+ fmt = _detect_format(args.source)
304
+ reader = get_reader(fmt)
305
+ result = reader.read(args.source)
306
+ mems = result.memories
307
+
308
+ kinds: dict[str, int] = {}
309
+ domains: dict[str, int] = {}
310
+ total_tags = 0
311
+ total_body = 0
312
+ oldest = None
313
+ newest = None
314
+
315
+ for m in mems:
316
+ kinds[m.kind] = kinds.get(m.kind, 0) + 1
317
+ for d in m.domains:
318
+ if d:
319
+ domains[d] = domains.get(d, 0) + 1
320
+ total_tags += len(m.tags)
321
+ body_len = len(m.body or "")
322
+ total_body += body_len
323
+ if m.created_at:
324
+ if not oldest or m.created_at < oldest:
325
+ oldest = m.created_at
326
+ if not newest or m.created_at > newest:
327
+ newest = m.created_at
328
+
329
+ n = len(mems)
330
+ print(f"Total: {n} memories ({fmt})")
331
+ for k, v in sorted(kinds.items()):
332
+ bar = "█" * (v * 20 // n) if n else ""
333
+ print(f" {k:<12} {v:>4} ({v*100//n:>2}%) {bar}")
334
+ print(f"Domains: {len(domains)} unique")
335
+ if domains:
336
+ top = sorted(domains.items(), key=lambda x: x[1], reverse=True)[:5]
337
+ for d, c in top:
338
+ print(f" {d:<20} {c}")
339
+ print(f"Tags: {total_tags/n:.1f} avg per memory" if n else "Tags: 0")
340
+ print(f"Body: {total_body//n} chars avg" if n else "Body: 0")
341
+ if oldest:
342
+ print(f"Oldest: {oldest.date()}")
343
+ if newest:
344
+ print(f"Newest: {newest.date()}")
345
+
346
+
347
+ def _detect_format(path: Path) -> str:
348
+ """Auto-detect format from directory structure."""
349
+ if (path / "MEMORY.md").exists() or (path / "memory").exists():
350
+ return "openclaw"
351
+ # Check for ombre-buckets structure
352
+ for subdir in ["dynamic", "permanent", "feel"]:
353
+ if (path / subdir).exists():
354
+ return "ombre"
355
+ return "ombre" # default
356
+
357
+
358
+ def _cmd_inspect(args) -> None:
359
+ from .registry import get_reader, list_formats
360
+ from .models import Memory
361
+
362
+ text = args.file.read_text(encoding="utf-8")
363
+ fmt = args.format
364
+
365
+ # Auto-detect format
366
+ if not fmt:
367
+ for name in list_formats():
368
+ try:
369
+ plugin = get_reader(name)
370
+ result = plugin.read(args.file.parent)
371
+ if result.memories:
372
+ fmt = name
373
+ break
374
+ except (NotImplementedError, KeyError):
375
+ continue
376
+ if not fmt:
377
+ print("Could not detect format. Use --format to specify.")
378
+ sys.exit(ExitCode.IO_ERROR)
379
+
380
+ plugin = get_reader(fmt)
381
+ try:
382
+ result = plugin.read(args.file.parent)
383
+ except NotImplementedError:
384
+ print(f"'{fmt}' plugin does not support reading.")
385
+ sys.exit(ExitCode.IO_ERROR)
386
+
387
+ # Match by filename stem or bucket_id
388
+ stem = args.file.stem
389
+ mem = next((m for m in result.memories if stem == m.id or stem in str(m.id)), None)
390
+ if not mem and result.memories:
391
+ mem = result.memories[0] # fallback: first memory
392
+ if not mem:
393
+ print("No memory parsed from file.")
394
+ sys.exit(ExitCode.SUCCESS)
395
+
396
+ print(f"Format: {fmt}")
397
+ print(f"Source: {mem.source.uri if mem.source else 'N/A'}")
398
+ print()
399
+ print("Canonical:")
400
+ print(f" id: {mem.id}")
401
+ print(f" name: {mem.name}")
402
+ print(f" kind: {mem.kind}")
403
+ print(f" status: {mem.status}")
404
+ print(f" domains: {mem.domains}")
405
+ print(f" tags: {mem.tags}")
406
+ print(f" importance: {mem.importance_score} ({mem.importance_label or 'N/A'})")
407
+ print(f" valence/arousal: {mem.valence}/{mem.arousal}")
408
+ print(f" pinned: {mem.pinned}")
409
+ if mem.body:
410
+ body_preview = mem.body[:200].replace('\n', ' ')
411
+ print(f" body: {body_preview}{'...' if len(mem.body or '') > 200 else ''}")
412
+ if result.warnings:
413
+ print(f"\nWarnings: {len(result.warnings)}")
414
+ for w in result.warnings[:5]:
415
+ print(f" - {w}")
416
+
417
+
418
+ # ── Helpers ────────────────────────────────────────────────────────