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 +28 -0
- memlink/_frontmatter.py +28 -0
- memlink/cli.py +418 -0
- memlink/converter.py +510 -0
- memlink/generic_reader.py +214 -0
- memlink/models.py +120 -0
- memlink/ombre_reader.py +184 -0
- memlink/ombre_writer.py +189 -0
- memlink/openclaw_reader.py +300 -0
- memlink/openclaw_writer.py +366 -0
- memlink/plugin.py +90 -0
- memlink/py.typed +0 -0
- memlink/registry.py +114 -0
- memlink/serialization.py +76 -0
- memlink/testing.py +144 -0
- memlink/validators.py +322 -0
- memlink_bridge-0.1.0.dist-info/METADATA +243 -0
- memlink_bridge-0.1.0.dist-info/RECORD +22 -0
- memlink_bridge-0.1.0.dist-info/WHEEL +5 -0
- memlink_bridge-0.1.0.dist-info/entry_points.txt +11 -0
- memlink_bridge-0.1.0.dist-info/licenses/LICENSE +21 -0
- memlink_bridge-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
memlink/_frontmatter.py
ADDED
|
@@ -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 ────────────────────────────────────────────────────────
|