memfmt 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.
memfmt/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """memfmt — an agent's memory as Markdown files you own.
2
+
3
+ from memfmt import load, serialise, write_dir
4
+
5
+ memory = load("./memory")
6
+ memory.procedures[0].reliability # '92% reliable'
7
+ write_dir(serialise(memory), "./memory")
8
+
9
+ Three kinds of memory: entities (what is true), episodes (what happened),
10
+ procedures (how to do something, with the record of it working). Plain
11
+ Markdown, `[[wikilinks]]` for relations, so Obsidian draws the graph with no
12
+ configuration and git gives you diffs, review and rollback for free.
13
+ """
14
+
15
+ from .model import (Entity, Episode, Knowledge, Memory, Procedure, Relation,
16
+ Revision, Step, canonical)
17
+ from .parse import (MemfmtError, file_type, load, parse, parse_frontmatter,
18
+ read_dir, write_dir)
19
+ from .serialize import INDEX, ROOT, TYPE_KEY, serialise, slugify
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ #: American spelling, same function — the CLI and half the world write it this way.
24
+ serialize = serialise
25
+
26
+ __all__ = [
27
+ "Entity", "Episode", "Knowledge", "Memory", "Procedure", "Relation",
28
+ "Revision", "Step", "canonical",
29
+ "load", "parse", "parse_frontmatter", "read_dir", "write_dir", "file_type",
30
+ "serialise", "serialize", "slugify",
31
+ "MemfmtError", "TYPE_KEY", "ROOT", "INDEX", "__version__",
32
+ ]
memfmt/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
memfmt/cli.py ADDED
@@ -0,0 +1,166 @@
1
+ """`memfmt` — read, check and pull from a memory folder.
2
+
3
+ Everything here works on files alone: no account, no server, no network. That
4
+ is the point. A tool that needs a login to be useful is a landing page.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .model import Memory
16
+ from .parse import MemfmtError, file_type, parse, parse_frontmatter, read_dir
17
+ from .serialize import INDEX, serialise
18
+
19
+ _WORD = re.compile(r"[a-z0-9]+")
20
+
21
+
22
+ def _words(text: str) -> set[str]:
23
+ return set(_WORD.findall((text or "").lower()))
24
+
25
+
26
+ def _counts(memory: Memory) -> str:
27
+ return (f"{len(memory.entities)} entities, "
28
+ f"{len(memory.episodes)} episodes, "
29
+ f"{len(memory.procedures)} procedures")
30
+
31
+
32
+ def cmd_stat(args) -> int:
33
+ memory = parse(read_dir(args.path))
34
+ print(_counts(memory))
35
+ tested = [p for p in memory.procedures if p.success_count + p.fail_count]
36
+ if tested:
37
+ print("\nprocedures with a track record:")
38
+ for p in sorted(tested, key=lambda p: -(p.success_count + p.fail_count)):
39
+ print(f" {p.name} v{p.version} "
40
+ f"{p.success_count}✓/{p.fail_count}✗ {p.reliability}")
41
+ untested = len(memory.procedures) - len(tested)
42
+ if untested:
43
+ print(f"\n{untested} untested — no evidence either way yet.")
44
+ return 0
45
+
46
+
47
+ def cmd_validate(args) -> int:
48
+ """Read every file, then write it back in memory and compare.
49
+
50
+ Anything that does not survive that is a place where a tool would lose a
51
+ piece of somebody's memory, so it is reported as a file, not a warning.
52
+ """
53
+ tree = read_dir(args.path)
54
+ typed = {}
55
+ for path, text in tree.items():
56
+ fields, _ = parse_frontmatter(text)
57
+ if file_type(fields):
58
+ typed[path] = text
59
+
60
+ if not typed:
61
+ print(f"no memfmt files under {args.path}", file=sys.stderr)
62
+ return 1
63
+
64
+ memory = parse(typed)
65
+ root = next((p.split("/", 1)[0] for p in typed if "/" in p), "memory")
66
+ again = serialise(memory, root=root)
67
+
68
+ lossy = []
69
+ for path, text in sorted(typed.items()):
70
+ if path.endswith(INDEX):
71
+ continue # regenerated, not authored
72
+ rewritten = again.get(path)
73
+ if rewritten is None:
74
+ lossy.append((path, "not reproduced"))
75
+ elif rewritten.strip() != text.strip():
76
+ lossy.append((path, "changes when rewritten"))
77
+
78
+ print(f"{len(typed)} files, {_counts(memory)}")
79
+ if lossy:
80
+ print(f"\n{len(lossy)} would not survive a round trip:")
81
+ for path, why in lossy:
82
+ print(f" {path} — {why}")
83
+ return 1
84
+ print("valid — every file survives a round trip unchanged")
85
+ return 0
86
+
87
+
88
+ def cmd_context(args) -> int:
89
+ """Print the memory most relevant to a question, ready to paste or pipe.
90
+
91
+ Relevance here is word overlap, which is honest about what files alone can
92
+ do. It is also where this stops being enough: past a few hundred files you
93
+ want embeddings, and that needs a server.
94
+ """
95
+ query = _words(" ".join(args.query))
96
+ if not query:
97
+ print("nothing to look for", file=sys.stderr)
98
+ return 1
99
+
100
+ scored = []
101
+ for path, text in read_dir(args.path).items():
102
+ fields, body = parse_frontmatter(text)
103
+ if not file_type(fields) or path.endswith(INDEX):
104
+ continue
105
+ words = _words(body)
106
+ overlap = query & words
107
+ if overlap:
108
+ # How many of the asked-about things a file mentions comes first;
109
+ # density only breaks ties. Ranking by density alone let a stub
110
+ # entity whose only word was "Railway" outrank the procedure that
111
+ # actually answered the question.
112
+ scored.append(((len(overlap), 1 / (1 + len(words))), path, body))
113
+
114
+ if not scored:
115
+ print("nothing relevant", file=sys.stderr)
116
+ return 1
117
+
118
+ budget = args.chars
119
+ used = 0
120
+ for _, path, body in sorted(scored, reverse=True)[:args.limit]:
121
+ chunk = body.strip()
122
+ if used + len(chunk) > budget:
123
+ break
124
+ print(f"<!-- {path} -->")
125
+ print(chunk)
126
+ print()
127
+ used += len(chunk)
128
+ return 0
129
+
130
+
131
+ def build_parser() -> argparse.ArgumentParser:
132
+ parser = argparse.ArgumentParser(
133
+ prog="memfmt",
134
+ description="An agent's memory as Markdown files you own.")
135
+ parser.add_argument("--version", action="version", version=f"memfmt {__version__}")
136
+ sub = parser.add_subparsers(dest="command", required=True)
137
+
138
+ p = sub.add_parser("stat", help="what is in a memory folder")
139
+ p.add_argument("path", nargs="?", default="memory", type=Path)
140
+ p.set_defaults(func=cmd_stat)
141
+
142
+ p = sub.add_parser("validate", help="check every file survives a round trip")
143
+ p.add_argument("path", nargs="?", default="memory", type=Path)
144
+ p.set_defaults(func=cmd_validate)
145
+
146
+ p = sub.add_parser("context", help="print the memory relevant to a question")
147
+ p.add_argument("path", type=Path)
148
+ p.add_argument("query", nargs="+")
149
+ p.add_argument("--limit", type=int, default=8, help="most files to print")
150
+ p.add_argument("--chars", type=int, default=8000, help="rough size budget")
151
+ p.set_defaults(func=cmd_context)
152
+
153
+ return parser
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ args = build_parser().parse_args(argv)
158
+ try:
159
+ return args.func(args)
160
+ except MemfmtError as err:
161
+ print(str(err), file=sys.stderr)
162
+ return 1
163
+
164
+
165
+ if __name__ == "__main__":
166
+ raise SystemExit(main())
memfmt/model.py ADDED
@@ -0,0 +1,141 @@
1
+ """The shape of an agent's memory.
2
+
3
+ Three kinds, because agents forget in three different ways: what is true
4
+ (entities and their facts), what happened (episodes), and how to do something
5
+ (procedures, with the track record that says whether to trust them).
6
+
7
+ These are plain dataclasses on purpose. The format is files; this module is
8
+ only the in-memory view of them, so that `parse(serialise(m)) == m` is a
9
+ statement you can test rather than hope for.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass
18
+ class Relation:
19
+ """An edge between two entities. `target` is the other entity's name, not an
20
+ id: names are what a human reads in a link, and the format is meant to stay
21
+ legible after the database that produced it is gone."""
22
+
23
+ type: str
24
+ target: str
25
+ direction: str = "outgoing"
26
+ detail: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class Knowledge:
31
+ """A longer note attached to an entity — a snippet, a config, a quote."""
32
+
33
+ type: str = "note"
34
+ title: str = ""
35
+ content: str = ""
36
+ artifact: str | None = None
37
+
38
+
39
+ @dataclass
40
+ class Entity:
41
+ name: str
42
+ entity_type: str | None = None
43
+ id: str | None = None
44
+ facts: list[str] = field(default_factory=list)
45
+ relations: list[Relation] = field(default_factory=list)
46
+ knowledge: list[Knowledge] = field(default_factory=list)
47
+ #: Frontmatter this library does not know about, kept verbatim so a
48
+ #: hand-written field survives being read and written back.
49
+ extra: dict = field(default_factory=dict)
50
+
51
+
52
+ @dataclass
53
+ class Episode:
54
+ """Something that happened, and how it turned out.
55
+
56
+ `outcome` is what makes an episode worth keeping: an event with no result
57
+ teaches nothing.
58
+ """
59
+
60
+ summary: str
61
+ id: str | None = None
62
+ happened: str | None = None
63
+ outcome: str | None = None
64
+ valence: str | None = None
65
+ importance: int | None = None
66
+ participants: list[str] = field(default_factory=list)
67
+ context: str | None = None
68
+ #: Frontmatter this library does not know about, kept verbatim so a
69
+ #: hand-written field survives being read and written back.
70
+ extra: dict = field(default_factory=dict)
71
+
72
+
73
+ @dataclass
74
+ class Step:
75
+ action: str
76
+ detail: str | None = None
77
+
78
+
79
+ @dataclass
80
+ class Revision:
81
+ """One change to a procedure. Kept because the reason a workflow changed is
82
+ usually more useful than the workflow itself."""
83
+
84
+ version_before: int
85
+ version_after: int
86
+ reason: str = ""
87
+ date: str | None = None
88
+
89
+
90
+ @dataclass
91
+ class Procedure:
92
+ """A workflow the agent learned, with the record of it working or not.
93
+
94
+ `success_count`/`fail_count` are the point. A procedure without a track
95
+ record is a guess written down; with one, it is evidence.
96
+ """
97
+
98
+ name: str
99
+ id: str | None = None
100
+ version: int = 1
101
+ success_count: int = 0
102
+ fail_count: int = 0
103
+ trigger: str | None = None
104
+ preconditions: list[str] = field(default_factory=list)
105
+ steps: list[Step] = field(default_factory=list)
106
+ evolution: list[Revision] = field(default_factory=list)
107
+ #: Frontmatter this library does not know about, kept verbatim so a
108
+ #: hand-written field survives being read and written back.
109
+ extra: dict = field(default_factory=dict)
110
+
111
+ @property
112
+ def reliability(self) -> str:
113
+ total = self.success_count + self.fail_count
114
+ if total == 0:
115
+ return "untested"
116
+ return f"{round(100 * self.success_count / total)}% reliable"
117
+
118
+
119
+ @dataclass
120
+ class Memory:
121
+ entities: list[Entity] = field(default_factory=list)
122
+ episodes: list[Episode] = field(default_factory=list)
123
+ procedures: list[Procedure] = field(default_factory=list)
124
+ profile: str | None = None
125
+
126
+
127
+ def canonical(memory: Memory) -> Memory:
128
+ """The same memory in a stable order.
129
+
130
+ A memory folder is a set of files, so reading one back cannot preserve the
131
+ order of the list that produced it. Two memories are equal when they hold
132
+ the same things, and this is how you ask that question — it is also what
133
+ keeps a git diff to the lines that actually changed.
134
+ """
135
+ return Memory(
136
+ entities=sorted(memory.entities, key=lambda e: (e.name, e.id or "")),
137
+ episodes=sorted(memory.episodes,
138
+ key=lambda e: (e.happened or "", e.summary, e.id or "")),
139
+ procedures=sorted(memory.procedures, key=lambda p: (p.name, p.id or "")),
140
+ profile=memory.profile,
141
+ )
memfmt/parse.py ADDED
@@ -0,0 +1,337 @@
1
+ """Files in, memory out — the half that makes this a format rather than a doc.
2
+
3
+ A serialiser alone gives you an export. A parser is what lets someone else's
4
+ tool read what your tool wrote, edit it by hand, keep it in git, and hand it
5
+ back. Everything here exists so that `parse(serialise(m)) == m`.
6
+
7
+ The YAML reader is deliberately a small hand-rolled subset instead of PyYAML:
8
+ this library writes the frontmatter it reads, the subset is `key: scalar` and
9
+ `key:` followed by ` - item`, and a format that drags in a parser dependency
10
+ is a format people think twice about adopting.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from .model import Entity, Episode, Knowledge, Memory, Procedure, Relation, Revision, Step
19
+ from .serialize import DASH, INDEX, LEGACY_TYPE_KEY, TYPE_KEY
20
+
21
+ #: Frontmatter fields the model holds as integers. Everything else stays text —
22
+ #: guessing types from shape would turn a version string like "2019" into a
23
+ #: number and quietly change what the file said.
24
+ _INT_FIELDS = {"importance", "version", "success_count", "fail_count"}
25
+
26
+ #: Frontmatter each kind of file is responsible for. Everything else is
27
+ #: somebody's own field and is handed back untouched on the way out.
28
+ _KNOWN = {
29
+ "entity": {TYPE_KEY, LEGACY_TYPE_KEY, "entity_type", "id"},
30
+ "episode": {TYPE_KEY, LEGACY_TYPE_KEY, "id", "happened", "outcome",
31
+ "valence", "importance", "participants"},
32
+ "procedure": {TYPE_KEY, LEGACY_TYPE_KEY, "id", "version",
33
+ "success_count", "fail_count"},
34
+ }
35
+
36
+ _FM = re.compile(r"\A---\n(.*?)\n---\n?", re.S)
37
+ _H1 = re.compile(r"^# (.*)$", re.M)
38
+ _RELIABILITY = re.compile(r"\s*\(v\d+ · [^)]*\)\s*$")
39
+ _RELATION = re.compile(
40
+ r"^- (?P<verb>.*?) (?P<arrow>→|←) \[\[(?P<link>[^\]]+)\]\](?P<rest>.*)$")
41
+ _KNOWLEDGE = re.compile(r"^\*\*\[(?P<kind>[^\]]*)\] (?P<title>.*?)\*\*(?P<rest>.*)$")
42
+ _STEP = re.compile(r"^\d+\. (?P<text>.*)$")
43
+ _REVISION = re.compile(
44
+ r"^- v(?P<before>\d+) → v(?P<after>\d+)(?: \((?P<date>[^)]*)\))?: (?P<reason>.*)$")
45
+
46
+
47
+ class MemfmtError(ValueError):
48
+ """A file claims to be memfmt but cannot be read as it."""
49
+
50
+
51
+ def _unquote(text: str) -> str:
52
+ text = text.strip()
53
+ if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
54
+ return text[1:-1].replace('\\"', '"').replace("\\\\", "\\")
55
+ return text
56
+
57
+
58
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
59
+ """Split a file into its frontmatter mapping and the body below it."""
60
+ match = _FM.match(text)
61
+ if not match:
62
+ return {}, text
63
+
64
+ fields: dict = {}
65
+ key: str | None = None
66
+ for raw in match.group(1).split("\n"):
67
+ if raw.startswith(" - ") and key is not None:
68
+ fields.setdefault(key, [])
69
+ if not isinstance(fields[key], list):
70
+ fields[key] = []
71
+ fields[key].append(_unquote(raw[4:]))
72
+ continue
73
+ if ":" not in raw:
74
+ continue
75
+ key, _, value = raw.partition(":")
76
+ key = key.strip()
77
+ value = value.strip()
78
+ if value == "":
79
+ fields[key] = [] # a list header; items follow
80
+ continue
81
+ value = _unquote(value)
82
+ if key in _INT_FIELDS:
83
+ try:
84
+ value = int(value)
85
+ except ValueError:
86
+ pass
87
+ fields[key] = value
88
+
89
+ return fields, text[match.end():]
90
+
91
+
92
+ def file_type(fields: dict) -> str | None:
93
+ return fields.get(TYPE_KEY) or fields.get(LEGACY_TYPE_KEY)
94
+
95
+
96
+ def _extra(fields: dict, kind: str) -> dict:
97
+ """Frontmatter this library has no opinion about.
98
+
99
+ Dropping it would mean a file someone hand-edited comes back smaller than
100
+ they left it, which is the one thing a memory format must never do.
101
+ """
102
+ known = _KNOWN[kind]
103
+ return {k: v for k, v in fields.items() if k not in known}
104
+
105
+
106
+ def _title(body: str) -> str:
107
+ match = _H1.search(body)
108
+ return match.group(1).strip() if match else ""
109
+
110
+
111
+ def _sections(body: str) -> dict[str, list[str]]:
112
+ """Body split by `## ` headings. Everything before the first one lands under
113
+ the empty key, which is where an episode's context and a procedure's
114
+ `**When**` line live."""
115
+ out: dict[str, list[str]] = {"": []}
116
+ current = ""
117
+ for line in body.split("\n"):
118
+ if line.startswith("## "):
119
+ current = line[3:].strip()
120
+ out.setdefault(current, [])
121
+ continue
122
+ out[current].append(line)
123
+ return out
124
+
125
+
126
+ def _bullets(lines: list[str]) -> list[str]:
127
+ return [ln[2:].strip() for ln in lines if ln.startswith("- ")]
128
+
129
+
130
+ def parse_entity(fields: dict, body: str) -> Entity:
131
+ sections = _sections(body)
132
+ entity = Entity(
133
+ name=_title(body),
134
+ entity_type=fields.get("entity_type") or None,
135
+ id=fields.get("id") or None,
136
+ facts=_bullets(sections.get("Facts", [])),
137
+ extra=_extra(fields, "entity"),
138
+ )
139
+
140
+ for line in sections.get("Relations", []):
141
+ match = _RELATION.match(line)
142
+ if not match:
143
+ continue
144
+ link = match.group("link")
145
+ # `[[stem|Original name]]` — the alias is the real name; the stem is
146
+ # only what the filename had to become.
147
+ target = link.split("|", 1)[1] if "|" in link else link
148
+ rest = match.group("rest")
149
+ detail = rest[len(DASH):].strip() if rest.startswith(DASH) else None
150
+ entity.relations.append(Relation(
151
+ type=match.group("verb").strip(),
152
+ target=target.strip(),
153
+ direction="incoming" if match.group("arrow") == "←" else "outgoing",
154
+ detail=detail or None,
155
+ ))
156
+
157
+ lines = sections.get("Knowledge", [])
158
+ i = 0
159
+ while i < len(lines):
160
+ match = _KNOWLEDGE.match(lines[i])
161
+ i += 1
162
+ if not match:
163
+ continue
164
+ rest = match.group("rest")
165
+ content = rest[len(DASH):].strip() if rest.startswith(DASH) else ""
166
+ artifact = None
167
+ # An optional fenced block belongs to the entry above it.
168
+ j = i
169
+ while j < len(lines) and lines[j].strip() == "":
170
+ j += 1
171
+ if j < len(lines) and lines[j].strip() == "```":
172
+ end = j + 1
173
+ while end < len(lines) and lines[end].strip() != "```":
174
+ end += 1
175
+ artifact = "\n".join(lines[j + 1:end])
176
+ i = end + 1
177
+ entity.knowledge.append(Knowledge(
178
+ type=match.group("kind") or "note",
179
+ title=match.group("title").strip(),
180
+ content=content,
181
+ artifact=artifact,
182
+ ))
183
+
184
+ return entity
185
+
186
+
187
+ def parse_episode(fields: dict, body: str) -> Episode:
188
+ lines = _sections(body).get("", [])
189
+ context: list[str] = []
190
+ seen_title = False
191
+ for line in lines:
192
+ if line.startswith("# "):
193
+ seen_title = True
194
+ continue
195
+ # The `**Outcome**` line is a rendering of the frontmatter field, not a
196
+ # second source of truth — skip it rather than fold it into context.
197
+ if line.startswith("**Outcome**"):
198
+ continue
199
+ if seen_title:
200
+ context.append(line)
201
+
202
+ participants = fields.get("participants")
203
+ return Episode(
204
+ summary=_title(body),
205
+ id=fields.get("id") or None,
206
+ happened=fields.get("happened") or None,
207
+ outcome=fields.get("outcome") or None,
208
+ valence=fields.get("valence") or None,
209
+ importance=fields.get("importance") if isinstance(fields.get("importance"), int) else None,
210
+ participants=participants if isinstance(participants, list) else [],
211
+ context="\n".join(context).strip() or None,
212
+ extra=_extra(fields, "episode"),
213
+ )
214
+
215
+
216
+ def parse_procedure(fields: dict, body: str) -> Procedure:
217
+ sections = _sections(body)
218
+ head = sections.get("", [])
219
+
220
+ trigger = None
221
+ preconditions: list[str] = []
222
+ in_pre = False
223
+ for line in head:
224
+ if line.startswith("**When**"):
225
+ rest = line[len("**When**"):]
226
+ trigger = rest[len(DASH):].strip() if rest.startswith(DASH) else rest.strip()
227
+ continue
228
+ if line.startswith("**Preconditions**"):
229
+ in_pre = True
230
+ continue
231
+ if in_pre:
232
+ if line.startswith("- "):
233
+ preconditions.append(line[2:].strip())
234
+ elif line.strip():
235
+ in_pre = False
236
+
237
+ steps = []
238
+ for line in sections.get("Steps", []):
239
+ match = _STEP.match(line)
240
+ if not match:
241
+ continue
242
+ text = match.group("text")
243
+ action, sep, detail = text.partition(DASH)
244
+ steps.append(Step(action=action.strip(), detail=detail.strip() if sep else None))
245
+
246
+ evolution = []
247
+ for line in sections.get("Evolution", []):
248
+ match = _REVISION.match(line)
249
+ if not match:
250
+ continue
251
+ evolution.append(Revision(
252
+ version_before=int(match.group("before")),
253
+ version_after=int(match.group("after")),
254
+ reason=match.group("reason").strip(),
255
+ date=match.group("date") or None,
256
+ ))
257
+
258
+ version = fields.get("version")
259
+ return Procedure(
260
+ # The `(v3 · 92% reliable)` suffix is derived from the frontmatter for
261
+ # the reader's benefit; the frontmatter stays the source of truth.
262
+ name=_RELIABILITY.sub("", _title(body)).strip(),
263
+ id=fields.get("id") or None,
264
+ version=version if isinstance(version, int) else 1,
265
+ success_count=fields.get("success_count") if isinstance(fields.get("success_count"), int) else 0,
266
+ fail_count=fields.get("fail_count") if isinstance(fields.get("fail_count"), int) else 0,
267
+ trigger=trigger or None,
268
+ preconditions=preconditions,
269
+ steps=steps,
270
+ evolution=evolution,
271
+ extra=_extra(fields, "procedure"),
272
+ )
273
+
274
+
275
+ def parse(tree: dict[str, str]) -> Memory:
276
+ """A `{path: text}` tree back into a Memory.
277
+
278
+ Files that carry no type marker are skipped rather than guessed at: a
279
+ memory folder living inside somebody's vault will sit next to notes that
280
+ are none of our business.
281
+ """
282
+ memory = Memory()
283
+ for path in sorted(tree):
284
+ if not path.endswith(".md"):
285
+ continue
286
+ fields, body = parse_frontmatter(tree[path])
287
+ kind = file_type(fields)
288
+ if kind == "entity":
289
+ memory.entities.append(parse_entity(fields, body))
290
+ elif kind == "episode":
291
+ memory.episodes.append(parse_episode(fields, body))
292
+ elif kind == "procedure":
293
+ memory.procedures.append(parse_procedure(fields, body))
294
+ elif kind == "profile":
295
+ text = body.split("# Profile", 1)[-1].strip()
296
+ memory.profile = text or None
297
+ return memory
298
+
299
+
300
+ # ---- directories ----------------------------------------------------------
301
+
302
+ def read_dir(root: str | Path) -> dict[str, str]:
303
+ """Every Markdown file under `root`, keyed by its path relative to it."""
304
+ root = Path(root)
305
+ if not root.is_dir():
306
+ raise MemfmtError(f"not a directory: {root}")
307
+ return {
308
+ str(p.relative_to(root)): p.read_text(encoding="utf-8")
309
+ for p in sorted(root.rglob("*.md"))
310
+ }
311
+
312
+
313
+ def write_dir(tree: dict[str, str], root: str | Path) -> list[str]:
314
+ """Write a tree under `root`, creating parents. Returns the paths written."""
315
+ root = Path(root)
316
+ written = []
317
+ for rel, text in sorted(tree.items()):
318
+ target = root / rel
319
+ # A path from a tree should never climb out of the root it is given.
320
+ if not str(target.resolve()).startswith(str(root.resolve())):
321
+ raise MemfmtError(f"path escapes the root: {rel}")
322
+ target.parent.mkdir(parents=True, exist_ok=True)
323
+ target.write_text(text, encoding="utf-8")
324
+ written.append(str(target))
325
+ return written
326
+
327
+
328
+ def load(root: str | Path) -> Memory:
329
+ """Read a memory directory straight into a Memory."""
330
+ return parse(read_dir(root))
331
+
332
+
333
+ __all__ = [
334
+ "parse", "parse_frontmatter", "parse_entity", "parse_episode",
335
+ "parse_procedure", "file_type", "read_dir", "write_dir", "load",
336
+ "MemfmtError", "INDEX",
337
+ ]
memfmt/serialize.py ADDED
@@ -0,0 +1,302 @@
1
+ """Memory in, `{path: text}` out.
2
+
3
+ Nothing is written here. The caller decides whether that becomes a directory,
4
+ a zip, or files in an Obsidian vault — which is why the same serialiser can
5
+ back a CLI, an HTTP endpoint and an editor plugin without the three drifting.
6
+
7
+ The output is deliberately boring Markdown: headings, bullets, `[[wikilinks]]`.
8
+ A person with no tooling can read it, and Obsidian draws the graph from it with
9
+ no configuration, because the links *are* the graph.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from datetime import date
16
+
17
+ from .model import Entity, Episode, Knowledge, Memory, Procedure, Relation, Revision
18
+
19
+ #: Frontmatter key that marks a file as belonging to this format. Namespaced so
20
+ #: it cannot collide with the frontmatter people already keep in their vaults.
21
+ TYPE_KEY = "memfmt_type"
22
+
23
+ #: Read as an alias when parsing, so trees written by Mengram before the format
24
+ #: was split out still load.
25
+ LEGACY_TYPE_KEY = "mengram_type"
26
+
27
+ ROOT = "memory"
28
+ INDEX = "MEMORY.md"
29
+
30
+ #: Separates a value from its note: `works at → [[Acme]] — since 2019`.
31
+ DASH = " — "
32
+
33
+ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
34
+ _SPACES = re.compile(r"\s+")
35
+ MAX_STEM = 80
36
+
37
+
38
+ def slugify(name: str) -> str:
39
+ """A filename that survives every filesystem while still reading as the name.
40
+
41
+ Punctuation is preserved wherever it is safe to: these stems are note titles
42
+ people will see and link by, not URL slugs. The original always goes in the
43
+ `# H1`, so nothing is lost when a long name has to be trimmed.
44
+ """
45
+ stem = _UNSAFE.sub("-", name or "")
46
+ stem = _SPACES.sub(" ", stem).strip(" .")
47
+ if len(stem) > MAX_STEM:
48
+ stem = stem[:MAX_STEM].rstrip(" .-")
49
+ return stem or "unnamed"
50
+
51
+
52
+ def _unique(stem: str, taken: set) -> str:
53
+ """Two names can collide once unsafe characters are replaced. Numbering the
54
+ later ones is ugly; silently overwriting someone's memory is worse."""
55
+ if stem not in taken:
56
+ taken.add(stem)
57
+ return stem
58
+ n = 2
59
+ while f"{stem} ({n})" in taken:
60
+ n += 1
61
+ stem = f"{stem} ({n})"
62
+ taken.add(stem)
63
+ return stem
64
+
65
+
66
+ def _needs_quotes(text: str) -> bool:
67
+ if text == "":
68
+ return True
69
+ if text[0] in "-?:,[]{}#&*!|>'\"%@`":
70
+ return True
71
+ return ":" in text or "\n" in text
72
+
73
+
74
+ def _scalar(value) -> str:
75
+ """One YAML scalar, quoted only when it would otherwise be misread.
76
+ Unquoted frontmatter is far easier to skim, and skimming is the point."""
77
+ if value is None:
78
+ return '""'
79
+ if isinstance(value, bool):
80
+ return "true" if value else "false"
81
+ if isinstance(value, (int, float)):
82
+ return str(value)
83
+ text = str(value)
84
+ if text != text.strip() or _needs_quotes(text):
85
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
86
+ return text
87
+
88
+
89
+ def frontmatter(fields: dict) -> str:
90
+ """YAML block. Empty values are dropped rather than written as nulls, so the
91
+ header stays short enough to read at a glance."""
92
+ lines = ["---"]
93
+ for key, value in fields.items():
94
+ if value is None or value == "" or value == []:
95
+ continue
96
+ if isinstance(value, list):
97
+ lines.append(f"{key}:")
98
+ lines.extend(f" - {_scalar(v)}" for v in value)
99
+ else:
100
+ lines.append(f"{key}: {_scalar(value)}")
101
+ lines.append("---")
102
+ return "\n".join(lines)
103
+
104
+
105
+ def _line(text: str) -> str:
106
+ """Collapse a value onto one line.
107
+
108
+ Facts, steps and relation notes are bullets, and a bullet that contains a
109
+ newline stops being one. Collapsing here keeps what the file says identical
110
+ to what a parser reads back.
111
+ """
112
+ return _SPACES.sub(" ", (text or "").strip())
113
+
114
+
115
+ def wikilink(name: str, targets: dict) -> str:
116
+ """A link Obsidian can follow. When the file had to be renamed to be safe,
117
+ the link carries an alias so it still reads as the real name."""
118
+ stem = targets.get(name)
119
+ if stem is None:
120
+ # The target is outside this tree. Link anyway: Obsidian renders it as
121
+ # an unresolved node, which is honest about something being there.
122
+ stem = slugify(name)
123
+ return f"[[{stem}]]" if stem == name else f"[[{stem}|{name}]]"
124
+
125
+
126
+ # ---- files ----------------------------------------------------------------
127
+
128
+ def entity_file(entity: Entity, targets: dict) -> str:
129
+ body = [
130
+ frontmatter({
131
+ TYPE_KEY: "entity",
132
+ "entity_type": entity.entity_type,
133
+ "id": entity.id,
134
+ **entity.extra,
135
+ }),
136
+ "",
137
+ f"# {entity.name}",
138
+ ]
139
+
140
+ facts = [_line(f) for f in entity.facts if _line(f)]
141
+ if facts:
142
+ body += ["", "## Facts", ""] + [f"- {f}" for f in facts]
143
+
144
+ relations = [r for r in entity.relations if r.target]
145
+ if relations:
146
+ body += ["", "## Relations", ""]
147
+ for r in relations:
148
+ arrow = "←" if r.direction == "incoming" else "→"
149
+ detail = f"{DASH}{_line(r.detail)}" if r.detail else ""
150
+ body.append(f"- {_line(r.type) or 'related to'} {arrow} "
151
+ f"{wikilink(r.target, targets)}{detail}")
152
+
153
+ knowledge = [k for k in entity.knowledge if k.content or k.title]
154
+ if knowledge:
155
+ body += ["", "## Knowledge", ""]
156
+ for k in knowledge:
157
+ body.append(f"**[{k.type or 'note'}] {_line(k.title)}**"
158
+ f"{DASH}{_line(k.content)}".rstrip(" —"))
159
+ if k.artifact:
160
+ body += ["", "```", str(k.artifact).rstrip(), "```"]
161
+
162
+ return "\n".join(body).rstrip() + "\n"
163
+
164
+
165
+ def episode_file(episode: Episode) -> str:
166
+ body = [
167
+ frontmatter({
168
+ TYPE_KEY: "episode",
169
+ "id": episode.id,
170
+ "happened": episode.happened,
171
+ "outcome": episode.outcome,
172
+ "valence": episode.valence,
173
+ "importance": episode.importance,
174
+ "participants": episode.participants,
175
+ **episode.extra,
176
+ }),
177
+ "",
178
+ f"# {episode.summary or 'untitled'}",
179
+ ]
180
+ if episode.context:
181
+ body += ["", episode.context.strip()]
182
+ if episode.outcome:
183
+ body += ["", f"**Outcome**{DASH}{_line(episode.outcome)}"]
184
+ return "\n".join(body).rstrip() + "\n"
185
+
186
+
187
+ def procedure_file(procedure: Procedure) -> str:
188
+ """The file this format exists for.
189
+
190
+ A workflow on its own is a guess someone wrote down. With `11 ✓ / 1 ✗` and
191
+ the revisions that produced it, it is evidence — and that is the part no
192
+ other memory format carries.
193
+ """
194
+ body = [
195
+ frontmatter({
196
+ TYPE_KEY: "procedure",
197
+ "id": procedure.id,
198
+ "version": procedure.version,
199
+ "success_count": procedure.success_count,
200
+ "fail_count": procedure.fail_count,
201
+ **procedure.extra,
202
+ }),
203
+ "",
204
+ f"# {procedure.name} (v{procedure.version} · {procedure.reliability})",
205
+ ]
206
+
207
+ if procedure.trigger:
208
+ body += ["", f"**When**{DASH}{_line(procedure.trigger)}"]
209
+
210
+ preconditions = [_line(p) for p in procedure.preconditions if _line(p)]
211
+ if preconditions:
212
+ body += ["", "**Preconditions**", ""] + [f"- {p}" for p in preconditions]
213
+
214
+ if procedure.steps:
215
+ body += ["", "## Steps", ""]
216
+ for i, step in enumerate(procedure.steps, 1):
217
+ text = _line(step.action)
218
+ if step.detail:
219
+ detail = _line(step.detail)
220
+ text = f"{text}{DASH}{detail}" if text else detail
221
+ body.append(f"{i}. {text}")
222
+
223
+ if procedure.evolution:
224
+ body += ["", "## Evolution", ""]
225
+ for entry in procedure.evolution:
226
+ stamp = f" ({entry.date})" if entry.date else ""
227
+ body.append(f"- v{entry.version_before} → v{entry.version_after}"
228
+ f"{stamp}: {_line(entry.reason)}")
229
+
230
+ return "\n".join(body).rstrip() + "\n"
231
+
232
+
233
+ def index_file(memory: Memory, entity_stems: list, generated: str | None = None) -> str:
234
+ body = [
235
+ frontmatter({TYPE_KEY: "index",
236
+ "generated": generated or date.today().isoformat()}),
237
+ "",
238
+ "# Memory",
239
+ "",
240
+ ]
241
+ if not (memory.entities or memory.episodes or memory.procedures):
242
+ body += [
243
+ "This memory is empty — nothing has been remembered yet.",
244
+ "",
245
+ "Facts, events and the workflows an agent learns will appear here",
246
+ "as files you own.",
247
+ ]
248
+ return "\n".join(body) + "\n"
249
+
250
+ body += [
251
+ f"- {len(memory.entities)} entities",
252
+ f"- {len(memory.episodes)} episodes",
253
+ f"- {len(memory.procedures)} procedures",
254
+ ]
255
+ if entity_stems:
256
+ body += ["", "## Entities", ""] + [f"- [[{s}]]" for s in sorted(entity_stems)]
257
+ return "\n".join(body).rstrip() + "\n"
258
+
259
+
260
+ def serialise(memory: Memory, root: str = ROOT,
261
+ generated: str | None = None) -> dict[str, str]:
262
+ """The whole memory as `{relative path: file text}`."""
263
+ prefix = f"{root}/" if root else ""
264
+
265
+ # Names resolve to stems before anything is written, so a relation
266
+ # serialised before its target still links to the right file.
267
+ taken: set = set()
268
+ targets = {e.name: _unique(slugify(e.name), taken) for e in memory.entities}
269
+
270
+ tree: dict[str, str] = {}
271
+ for entity in memory.entities:
272
+ tree[f"{prefix}entities/{targets[entity.name]}.md"] = entity_file(entity, targets)
273
+
274
+ ep_taken: set = set()
275
+ for episode in memory.episodes:
276
+ base = slugify(episode.summary or "episode")
277
+ stem = _unique(f"{episode.happened}-{base}" if episode.happened else base, ep_taken)
278
+ tree[f"{prefix}episodes/{stem}.md"] = episode_file(episode)
279
+
280
+ proc_taken: set = set()
281
+ for procedure in memory.procedures:
282
+ stem = _unique(slugify(procedure.name), proc_taken)
283
+ tree[f"{prefix}procedures/{stem}.md"] = procedure_file(procedure)
284
+
285
+ if memory.profile:
286
+ tree[f"{prefix}profile.md"] = "\n".join([
287
+ frontmatter({TYPE_KEY: "profile",
288
+ "generated": generated or date.today().isoformat()}),
289
+ "", "# Profile", "", memory.profile.strip(),
290
+ ]) + "\n"
291
+
292
+ tree[f"{prefix}{INDEX}"] = index_file(memory, list(targets.values()), generated)
293
+ return tree
294
+
295
+
296
+ # Kept so `from memfmt.serialize import *` reads sensibly in a REPL.
297
+ __all__ = [
298
+ "serialise", "slugify", "wikilink", "frontmatter",
299
+ "entity_file", "episode_file", "procedure_file", "index_file",
300
+ "TYPE_KEY", "LEGACY_TYPE_KEY", "ROOT", "INDEX", "DASH",
301
+ "Entity", "Episode", "Knowledge", "Memory", "Procedure", "Relation", "Revision",
302
+ ]
@@ -0,0 +1,256 @@
1
+ Metadata-Version: 2.5
2
+ Name: memfmt
3
+ Version: 0.1.0
4
+ Summary: An agent's memory as Markdown files you own — read, write and check the format.
5
+ Project-URL: Homepage, https://github.com/alibaizhanov/memfmt
6
+ Project-URL: Source, https://github.com/alibaizhanov/memfmt
7
+ Author: Ali Baizhanov
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,format,llm,markdown,memory,obsidian
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # memfmt
23
+
24
+ **An agent's memory as Markdown files you own.**
25
+
26
+ Every agent that remembers anything invents its own way to store it: one
27
+ `MEMORY.md` that grows until it stops fitting in context, a bespoke JSON blob,
28
+ a folder of notes with no rules. Nobody can read anybody else's, nothing
29
+ diffs cleanly, and moving between tools means writing a converter.
30
+
31
+ memfmt is a small spec and a dependency-free Python library for the boring
32
+ version of that: memory as plain Markdown, one thing per file, relations as
33
+ `[[wikilinks]]`. Git gives you diffs, review and rollback. Obsidian draws the
34
+ graph with no configuration, because the links *are* the graph.
35
+
36
+ ```
37
+ memory/
38
+ MEMORY.md index — what is in here
39
+ entities/Ali.md what is true
40
+ episodes/2026-07-30-deploy-failed.md what happened
41
+ procedures/deploy to Railway.md how to do it, and whether it works
42
+ profile.md
43
+ ```
44
+
45
+ No account, no server, no network. This library reads and writes files.
46
+
47
+ ---
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install memfmt
53
+ ```
54
+
55
+ ## Use it
56
+
57
+ ```bash
58
+ memfmt stat ./memory # what is in here
59
+ memfmt validate ./memory # would any file lose data if a tool rewrote it?
60
+ memfmt context ./memory "why did the deploy fail" # the relevant bits, to pipe into a model
61
+ ```
62
+
63
+ ```python
64
+ from memfmt import load, serialise, write_dir, canonical
65
+
66
+ memory = load("./memory")
67
+
68
+ for p in memory.procedures:
69
+ print(p.name, p.version, p.reliability) # deploy to Railway 3 92% reliable
70
+
71
+ write_dir(serialise(memory), "./memory")
72
+ ```
73
+
74
+ `memfmt context` is the one to try first. It picks the files relevant to a
75
+ question and prints them, so you can pipe your agent's own memory into a
76
+ prompt without a database:
77
+
78
+ ```bash
79
+ memfmt context ./memory "deploy railway pool" | pbcopy
80
+ ```
81
+
82
+ ---
83
+
84
+ ## The format
85
+
86
+ Three kinds of memory, because agents forget in three different ways.
87
+
88
+ ### Entities — what is true
89
+
90
+ `memory/entities/<name>.md`
91
+
92
+ ````markdown
93
+ ---
94
+ memfmt_type: entity
95
+ entity_type: person
96
+ id: e1
97
+ ---
98
+
99
+ # Ali
100
+
101
+ ## Facts
102
+
103
+ - prefers Rust for memory safety
104
+ - based in Tokyo
105
+
106
+ ## Relations
107
+
108
+ - works at → [[Mengram]] — since 2024
109
+ - mentored by ← [[Kenji]]
110
+
111
+ ## Knowledge
112
+
113
+ **[snippet] deploy command** — how the service ships
114
+
115
+ ```
116
+ railway up --detach
117
+ ```
118
+ ````
119
+
120
+ `→` is outgoing, `←` incoming. Text after ` — ` is a note on the relation.
121
+ When a name cannot be a filename, the link carries an alias and the real name
122
+ survives: `[[cloud-api.py|cloud/api.py]]`.
123
+
124
+ ### Episodes — what happened
125
+
126
+ `memory/episodes/<date>-<summary>.md`
127
+
128
+ ```markdown
129
+ ---
130
+ memfmt_type: episode
131
+ id: ep1
132
+ happened: 2026-07-30
133
+ outcome: rolled back, raised pool_max
134
+ valence: negative
135
+ importance: 4
136
+ participants:
137
+ - Ali
138
+ - Railway
139
+ ---
140
+
141
+ # deploy failed on a cold pool
142
+
143
+ Two workers booted at once and the session pooler refused the fourth client.
144
+
145
+ **Outcome** — rolled back, raised pool_max
146
+ ```
147
+
148
+ An event with no outcome teaches nothing, so `outcome` is the field that earns
149
+ an episode its place.
150
+
151
+ ### Procedures — how to do something, and whether it works
152
+
153
+ `memory/procedures/<name>.md`
154
+
155
+ ```markdown
156
+ ---
157
+ memfmt_type: procedure
158
+ id: p1
159
+ version: 3
160
+ success_count: 11
161
+ fail_count: 1
162
+ ---
163
+
164
+ # deploy to Railway (v3 · 92% reliable)
165
+
166
+ **When** — a change lands on main
167
+
168
+ **Preconditions**
169
+
170
+ - tests pass
171
+ - pool_max is set
172
+
173
+ ## Steps
174
+
175
+ 1. push to main — the webhook does the rest
176
+ 2. watch the boot log
177
+ 3. verify /health — expect 200 within 60s
178
+
179
+ ## Evolution
180
+
181
+ - v1 → v2 (2026-06-02): added the health check
182
+ - v2 → v3: wait for the pool before probing
183
+ ```
184
+
185
+ This is the file the format exists for. A workflow on its own is a guess
186
+ somebody wrote down. With `11 ✓ / 1 ✗` and the revisions that produced it, it
187
+ is evidence — and an agent can tell the difference between a step that has
188
+ worked eleven times and one nobody has ever run.
189
+
190
+ ---
191
+
192
+ ## Rules
193
+
194
+ A short list, because a format nobody can hold in their head gets implemented
195
+ wrong.
196
+
197
+ 1. **Frontmatter is the source of truth.** The `(v3 · 92% reliable)` in a
198
+ heading is rendered from it for the reader. Edit the heading and the
199
+ numbers do not change — the parser reads the frontmatter.
200
+ 2. **`memfmt_type` marks a file as ours.** Files without it are ignored, so a
201
+ memory folder can live inside a vault full of somebody's own notes.
202
+ 3. **Unknown fields are left alone.** Nothing is silently dropped for being
203
+ unrecognised.
204
+ 4. **Bullets are one line.** Facts, steps and relation notes are collapsed to
205
+ a single line when written, so what a file says and what a parser reads
206
+ back are the same thing.
207
+ 5. **A folder is a set, not a list.** Reading a directory cannot recover the
208
+ order of the list that wrote it. Use `canonical()` to compare two memories,
209
+ and to keep git diffs to the lines that actually changed.
210
+ 6. **Round-trip or it is not the format.** `parse(serialise(m)) == m`, and
211
+ serialising what you parsed is byte-identical. `memfmt validate` checks
212
+ exactly this against a real folder.
213
+
214
+ ---
215
+
216
+ ## Why files
217
+
218
+ Because the alternative is that your agent's memory lives somewhere you cannot
219
+ read, cannot grep, cannot correct, and cannot take with you.
220
+
221
+ Files give you the things a database makes hard: `git diff` on what your agent
222
+ learned this week, a pull request when it learns something wrong, `git revert`
223
+ when it learns something harmful, and a graph view for free. And when the tool
224
+ that wrote them goes away, the memory does not.
225
+
226
+ ## Where files stop being enough
227
+
228
+ Honestly: at a few hundred of them.
229
+
230
+ Word overlap is the best `memfmt context` can do without embeddings, and it
231
+ starts missing things that are phrased differently. Syncing a folder between
232
+ machines or a team is a real problem, not a `git pull` away. Deduplicating
233
+ facts that contradict each other needs a model.
234
+
235
+ That is a server's job, and memfmt does not pretend otherwise. If you get
236
+ there, [Mengram](https://mengram.io?utm_source=memfmt&utm_medium=readme)
237
+ writes this format today — `mengram export markdown ./memory` hands you a tree
238
+ this library reads — and adds the search, sync and deduplication that files
239
+ alone cannot do. Syncing a folder back into it is not built yet.
240
+
241
+ Either way the files stay yours, and if you never need a server, this library
242
+ does not expire.
243
+
244
+ ---
245
+
246
+ ## Contributing
247
+
248
+ The test suite is the specification in executable form. If you are proposing a
249
+ change to the format, the change to `tests/test_roundtrip.py` is the proposal.
250
+
251
+ ```bash
252
+ pip install -e ".[dev]"
253
+ pytest
254
+ ```
255
+
256
+ MIT licensed.
@@ -0,0 +1,11 @@
1
+ memfmt/__init__.py,sha256=cSuLOwTzoksiq42vwcCrF0_oCexyCQ9_R2xc_Psuww8,1289
2
+ memfmt/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ memfmt/cli.py,sha256=K32uF2DLZtiTr2XQOartQdsis_9rl0QiAbSD0HcoCfo,5584
4
+ memfmt/model.py,sha256=247pwkLr2_I4WcqkHKhC3Rf37dGXmXCXd-SDbYdX6fU,4440
5
+ memfmt/parse.py,sha256=HiI-kb_5pFfJIKBhnG2rk8kv0WDrO_6wHmJwYVK4V70,12086
6
+ memfmt/serialize.py,sha256=hiPAo_FBstnaBiMj9K5qYqYBiWM5sxTh8PcjMArcJCE,10625
7
+ memfmt-0.1.0.dist-info/METADATA,sha256=9bqQDEBZ6lx91gdUz6aoclObAssxmL10nZ9GbC6ionE,7253
8
+ memfmt-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ memfmt-0.1.0.dist-info/entry_points.txt,sha256=fr3X4furClzzvh1HeU6fi23TS7UCGWm0E4D_WJkBrbc,43
10
+ memfmt-0.1.0.dist-info/licenses/LICENSE,sha256=JNR_mU0FK5M85vy7kzyNjzR2YhN55sVd5M-QphfaJhI,1070
11
+ memfmt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ memfmt = memfmt.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali Baizhanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.