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 +32 -0
- memfmt/__main__.py +3 -0
- memfmt/cli.py +166 -0
- memfmt/model.py +141 -0
- memfmt/parse.py +337 -0
- memfmt/serialize.py +302 -0
- memfmt-0.1.0.dist-info/METADATA +256 -0
- memfmt-0.1.0.dist-info/RECORD +11 -0
- memfmt-0.1.0.dist-info/WHEEL +4 -0
- memfmt-0.1.0.dist-info/entry_points.txt +2 -0
- memfmt-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|