memgit 0.1.1__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.
memgit/scorer.py ADDED
@@ -0,0 +1,123 @@
1
+ """BM25-style relevance scoring for memory search."""
2
+
3
+ from __future__ import annotations
4
+ import math
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .models import Mnemonic
11
+
12
+
13
+ _PRIORITY_BOOST = {1: 0.8, 2: 1.0, 3: 1.3}
14
+
15
+
16
+ @dataclass
17
+ class ScoredMnemonic:
18
+ mnemonic: "Mnemonic"
19
+ score: float
20
+ matched_fields: list[str]
21
+
22
+
23
+ def _tokenize(text: str) -> list[str]:
24
+ return re.findall(r"[a-z0-9]+", text.lower())
25
+
26
+
27
+ def _field_tokens(m: "Mnemonic") -> dict[str, list[str]]:
28
+ """Return tokenized fields with their weights."""
29
+ return {
30
+ "slug": _tokenize(m.slug),
31
+ "rule": _tokenize(m.rule or ""),
32
+ "why": _tokenize(m.why or ""),
33
+ "when": _tokenize(m.when or ""),
34
+ "tags": _tokenize(" ".join(m.tags)),
35
+ "desc": _tokenize(m.desc or ""),
36
+ }
37
+
38
+
39
+ # Field importance multipliers
40
+ _FIELD_WEIGHT = {
41
+ "slug": 2.0,
42
+ "rule": 1.5,
43
+ "tags": 1.8,
44
+ "why": 1.0,
45
+ "when": 0.8,
46
+ "desc": 0.6,
47
+ }
48
+
49
+ # BM25 parameters
50
+ _K1 = 1.5
51
+ _B = 0.75
52
+
53
+
54
+ def _avg_doc_len(mnemonics: list["Mnemonic"]) -> float:
55
+ if not mnemonics:
56
+ return 1.0
57
+ total = sum(
58
+ sum(len(toks) for toks in _field_tokens(m).values())
59
+ for m in mnemonics
60
+ )
61
+ return total / len(mnemonics)
62
+
63
+
64
+ def score(
65
+ query: str,
66
+ mnemonics: list["Mnemonic"],
67
+ top_k: int = 10,
68
+ ) -> list[ScoredMnemonic]:
69
+ """Score mnemonics against query and return top-k by relevance."""
70
+ if not query.strip() or not mnemonics:
71
+ return []
72
+
73
+ query_terms = set(_tokenize(query))
74
+ if not query_terms:
75
+ return []
76
+
77
+ N = len(mnemonics)
78
+ avg_len = _avg_doc_len(mnemonics)
79
+
80
+ # Compute IDF per term across the corpus
81
+ df: dict[str, int] = {}
82
+ for m in mnemonics:
83
+ seen = set()
84
+ for toks in _field_tokens(m).values():
85
+ for tok in toks:
86
+ if tok in query_terms and tok not in seen:
87
+ df[tok] = df.get(tok, 0) + 1
88
+ seen.add(tok)
89
+
90
+ idf: dict[str, float] = {}
91
+ for term in query_terms:
92
+ n_t = df.get(term, 0)
93
+ idf[term] = math.log((N - n_t + 0.5) / (n_t + 0.5) + 1)
94
+
95
+ results: list[ScoredMnemonic] = []
96
+
97
+ for m in mnemonics:
98
+ fields = _field_tokens(m)
99
+ doc_len = sum(len(toks) for toks in fields.values())
100
+ score_val = 0.0
101
+ matched: list[str] = []
102
+
103
+ for term in query_terms:
104
+ for field_name, toks in fields.items():
105
+ tf = toks.count(term)
106
+ if tf == 0:
107
+ continue
108
+ if field_name not in matched:
109
+ matched.append(field_name)
110
+ weight = _FIELD_WEIGHT.get(field_name, 1.0)
111
+ norm_tf = (tf * (_K1 + 1)) / (
112
+ tf + _K1 * (1 - _B + _B * doc_len / avg_len)
113
+ )
114
+ score_val += weight * idf.get(term, 0.0) * norm_tf
115
+
116
+ # Priority boost
117
+ score_val *= _PRIORITY_BOOST.get(m.priority, 1.0)
118
+
119
+ if score_val > 0:
120
+ results.append(ScoredMnemonic(m, round(score_val, 4), matched))
121
+
122
+ results.sort(key=lambda r: r.score, reverse=True)
123
+ return results[:top_k]
memgit/store.py ADDED
@@ -0,0 +1,176 @@
1
+ """Content-addressed object store.
2
+
3
+ Objects are stored at .memgit/objects/{sha[0:2]}/{sha[2:4]}/{sha[4:]}
4
+ Each file is gzip-compressed: first line is type, rest is TOON content.
5
+
6
+ SHA computation per spec:
7
+ Mnemonic → SHA-256(canonical TOON text)
8
+ MindState → SHA-256(sorted "slug:sha\\n" pairs)
9
+ Checkpoint → SHA-256("CKPT1\\n" + JSON of core fields)
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import gzip
14
+ import hashlib
15
+ import json
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Union
19
+
20
+ from .models import Checkpoint, DiffSummary, MindState, MindStateEntry, Mnemonic
21
+ from .toon import (
22
+ format_ts,
23
+ parse_toon,
24
+ serialize_checkpoint,
25
+ serialize_mindstate,
26
+ serialize_mnemonic,
27
+ )
28
+
29
+
30
+ class ObjectStore:
31
+ def __init__(self, root: Path):
32
+ self.root = root
33
+ self.objects_dir = root / 'objects'
34
+
35
+ def _obj_path(self, sha: str) -> Path:
36
+ return self.objects_dir / sha[:2] / sha[2:4] / sha[4:]
37
+
38
+ def resolve_sha(self, abbrev: str) -> str | None:
39
+ """Resolve an abbreviated SHA (≥4 chars) to the full 64-char SHA.
40
+
41
+ Returns the full SHA if exactly one match, None if not found or ambiguous.
42
+ If abbrev is already 64 chars, returns it as-is.
43
+ """
44
+ if len(abbrev) >= 64:
45
+ return abbrev
46
+ if len(abbrev) < 4:
47
+ return None
48
+ prefix2 = abbrev[:2]
49
+ prefix4 = abbrev[2:4]
50
+ rest_prefix = abbrev[4:]
51
+ search_dir = self.objects_dir / prefix2 / prefix4
52
+ if not search_dir.exists():
53
+ return None
54
+ matches = [
55
+ prefix2 + prefix4 + p.name
56
+ for p in search_dir.iterdir()
57
+ if p.is_file() and p.name.startswith(rest_prefix)
58
+ ]
59
+ return matches[0] if len(matches) == 1 else None
60
+
61
+ def _write(self, sha: str, type_name: str, toon_content: str):
62
+ path = self._obj_path(sha)
63
+ if path.exists():
64
+ return # content-addressed: same SHA = same content
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ with gzip.open(path, 'wt', encoding='utf-8') as f:
67
+ f.write(f'{type_name}\n{toon_content}')
68
+
69
+ def _read(self, sha: str) -> tuple[str, str]:
70
+ path = self._obj_path(sha)
71
+ if not path.exists() and len(sha) < 64:
72
+ full = self.resolve_sha(sha)
73
+ if full:
74
+ path = self._obj_path(full)
75
+ with gzip.open(path, 'rt', encoding='utf-8') as f:
76
+ data = f.read()
77
+ idx = data.index('\n')
78
+ return data[:idx], data[idx + 1:]
79
+
80
+ def exists(self, sha: str) -> bool:
81
+ if self._obj_path(sha).exists():
82
+ return True
83
+ if len(sha) < 64:
84
+ return self.resolve_sha(sha) is not None
85
+ return False
86
+
87
+ # ── Mnemonic ──────────────────────────────────────────────────────────────
88
+
89
+ def mnemonic_sha(self, m: Mnemonic) -> str:
90
+ canonical = serialize_mnemonic(m, canonical=True)
91
+ return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
92
+
93
+ def write_mnemonic(self, m: Mnemonic) -> str:
94
+ sha = self.mnemonic_sha(m)
95
+ m.sha = sha
96
+ canonical = serialize_mnemonic(m, canonical=True)
97
+ self._write(sha, 'mnem', canonical)
98
+ return sha
99
+
100
+ def read_mnemonic(self, sha: str) -> Mnemonic:
101
+ type_name, content = self._read(sha)
102
+ assert type_name == 'mnem', f'Expected mnem, got {type_name}'
103
+ objs = parse_toon(content)
104
+ if not objs:
105
+ raise ValueError(f'Failed to parse mnemonic {sha[:8]}')
106
+ m = objs[0]
107
+ assert isinstance(m, Mnemonic), f'Expected Mnemonic, got {type(m)}'
108
+ m.sha = sha
109
+ return m
110
+
111
+ # ── MindState ─────────────────────────────────────────────────────────────
112
+
113
+ def mindstate_sha(self, ms: MindState) -> str:
114
+ entries = sorted(ms.entries, key=lambda e: e.slug)
115
+ lines = [f'{e.slug}:{e.mnem_sha}' for e in entries]
116
+ content = '\n'.join(lines)
117
+ return hashlib.sha256(content.encode('utf-8')).hexdigest()
118
+
119
+ def write_mindstate(self, ms: MindState) -> str:
120
+ sha = self.mindstate_sha(ms)
121
+ ms.sha = sha
122
+ toon = serialize_mindstate(ms)
123
+ self._write(sha, 'ms', toon)
124
+ return sha
125
+
126
+ def read_mindstate(self, sha: str) -> MindState:
127
+ type_name, content = self._read(sha)
128
+ assert type_name == 'ms', f'Expected ms, got {type_name}'
129
+ objs = parse_toon(content)
130
+ if not objs:
131
+ return MindState(timestamp=datetime.now(timezone.utc), sha=sha)
132
+ ms = objs[0]
133
+ assert isinstance(ms, MindState), f'Expected MindState, got {type(ms)}'
134
+ ms.sha = sha
135
+ return ms
136
+
137
+ # ── Checkpoint ────────────────────────────────────────────────────────────
138
+
139
+ def checkpoint_sha(self, ck: Checkpoint) -> str:
140
+ data = {
141
+ 'parent_sha': ck.parent_sha,
142
+ 'mindstate_sha': ck.mindstate_sha,
143
+ 'timestamp': format_ts(ck.timestamp),
144
+ 'trigger': ck.trigger,
145
+ 'message': ck.message,
146
+ 'author': ck.author,
147
+ }
148
+ content = 'CKPT1\n' + json.dumps(data, sort_keys=True)
149
+ return hashlib.sha256(content.encode('utf-8')).hexdigest()
150
+
151
+ def write_checkpoint(self, ck: Checkpoint) -> str:
152
+ sha = self.checkpoint_sha(ck)
153
+ ck.sha = sha
154
+ toon = serialize_checkpoint(ck)
155
+ self._write(sha, 'ck', toon)
156
+ return sha
157
+
158
+ def read_checkpoint(self, sha: str) -> Checkpoint:
159
+ type_name, content = self._read(sha)
160
+ assert type_name == 'ck', f'Expected ck, got {type_name}'
161
+ objs = parse_toon(content)
162
+ if not objs:
163
+ raise ValueError(f'Failed to parse checkpoint {sha[:8]}')
164
+ ck = objs[0]
165
+ assert isinstance(ck, Checkpoint), f'Expected Checkpoint, got {type(ck)}'
166
+ ck.sha = sha # override with full sha from the store path
167
+ return ck
168
+
169
+ # ── Stats ─────────────────────────────────────────────────────────────────
170
+
171
+ def object_count(self) -> int:
172
+ count = 0
173
+ for p in self.objects_dir.rglob('*'):
174
+ if p.is_file():
175
+ count += 1
176
+ return count
memgit/tokens.py ADDED
@@ -0,0 +1,48 @@
1
+ """Token counting utilities — approximation only, no external dependencies.
2
+
3
+ Uses a character-based model calibrated against GPT-4 tokenizer averages:
4
+ - ~4 chars/token for English prose
5
+ - Code/slugs are slightly denser (~3.5 chars/token)
6
+ - Good enough for the 3–5x comparisons we display in `memgit stats`
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import re
11
+
12
+
13
+ def count_tokens(text: str) -> int:
14
+ """Approximate token count for `text` using a char-density model."""
15
+ if not text:
16
+ return 0
17
+ # Strip whitespace normalization
18
+ text = text.strip()
19
+ # Count whitespace-separated tokens (rough word count)
20
+ words = len(re.findall(r'\S+', text))
21
+ # Each word averages ~1.3 tokens (handles punctuation, subwords, numbers)
22
+ return max(1, round(words * 1.3))
23
+
24
+
25
+ def memory_tokens(m) -> int:
26
+ """Token cost of a single Mnemonic as context."""
27
+ from .toon import serialize_mnemonic
28
+ return count_tokens(serialize_mnemonic(m))
29
+
30
+
31
+ def all_memories_tokens(mnemonics: list) -> int:
32
+ """Token cost of loading ALL memories (the claude.md / dump approach)."""
33
+ return sum(memory_tokens(m) for m in mnemonics)
34
+
35
+
36
+ def search_tokens(scored: list, query: str) -> int:
37
+ """Token cost of a search result set (top-k relevance approach)."""
38
+ return sum(memory_tokens(r.mnemonic) for r in scored)
39
+
40
+
41
+ # GPT-4o pricing (input, per million tokens) as of 2026
42
+ _GPT4O_PER_MTK = 5.0 # $5/1M tokens
43
+ _CLAUDE_SONNET_PER_MTK = 3.0 # $3/1M tokens
44
+
45
+
46
+ def token_cost_usd(tokens: int, model: str = 'gpt4o') -> float:
47
+ rate = _CLAUDE_SONNET_PER_MTK if model == 'claude' else _GPT4O_PER_MTK
48
+ return tokens * rate / 1_000_000
memgit/toon.py ADDED
@@ -0,0 +1,356 @@
1
+ """TOON format parser and serializer.
2
+
3
+ TOON — Thought Object Observation Notation
4
+ Line-oriented, sigil-prefixed format purpose-built for AI memory objects.
5
+ ~45-55% fewer tokens than equivalent markdown.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import re
10
+ from datetime import datetime, timezone
11
+ from typing import Union
12
+
13
+ from .models import Mnemonic, MindState, MindStateEntry, Checkpoint, DiffSummary
14
+
15
+ USER_TYPE_CODES = {"fb", "us", "pj", "rf", "cn", "lx"}
16
+
17
+
18
+ def _parse_ts(ts_str: str) -> datetime:
19
+ """Parse ISO 8601 compact UTC timestamp."""
20
+ s = ts_str.rstrip('Z')
21
+ try:
22
+ if 'T' in s:
23
+ # Normalize: 2026-06-14T08:22 → 2026-06-14T08:22:00
24
+ if len(s) == 16:
25
+ s += ':00'
26
+ return datetime.fromisoformat(s).replace(tzinfo=timezone.utc)
27
+ except ValueError:
28
+ pass
29
+ return datetime.now(timezone.utc)
30
+
31
+
32
+ def format_ts(dt: datetime) -> str:
33
+ """Format datetime to TOON compact UTC: 2026-06-14T08:22Z"""
34
+ return dt.strftime('%Y-%m-%dT%H:%MZ')
35
+
36
+
37
+ def parse_toon(text: str) -> list[Union[Mnemonic, MindState, Checkpoint]]:
38
+ """Parse a TOON file into a list of objects."""
39
+ text = text.replace('\r\n', '\n').replace('\r', '\n')
40
+ blocks = re.split(r'\n{2,}', text.strip())
41
+ results = []
42
+ for block in blocks:
43
+ block = block.strip()
44
+ if not block:
45
+ continue
46
+ obj = _parse_block(block)
47
+ if obj is not None:
48
+ results.append(obj)
49
+ return results
50
+
51
+
52
+ def _parse_block(block: str) -> Union[Mnemonic, MindState, Checkpoint, None]:
53
+ lines = block.split('\n')
54
+ if not lines:
55
+ return None
56
+
57
+ header = lines[0]
58
+ if not header.startswith('TOON1|'):
59
+ return None
60
+
61
+ parts = header.split('|')
62
+ if len(parts) < 4:
63
+ return None
64
+
65
+ type_code = parts[1]
66
+ slug = parts[2]
67
+ timestamp = _parse_ts(parts[3])
68
+ flags_str = parts[4] if len(parts) > 4 else ''
69
+ priority = 2
70
+ if flags_str.startswith('!'):
71
+ try:
72
+ priority = int(flags_str[1:])
73
+ except ValueError:
74
+ pass
75
+
76
+ field_lines = lines[1:]
77
+
78
+ if type_code == 'ms':
79
+ return _parse_ms(field_lines, timestamp, slug)
80
+ elif type_code == 'ck':
81
+ return _parse_ck(field_lines, timestamp, slug)
82
+ elif type_code in USER_TYPE_CODES:
83
+ return _parse_mnemonic(field_lines, type_code, slug, timestamp, priority)
84
+ return None
85
+
86
+
87
+ def _parse_ms(lines: list[str], timestamp: datetime, slug: str) -> MindState:
88
+ entries = []
89
+ for line in lines:
90
+ line = line.strip()
91
+ if line.startswith('ENTRY:'):
92
+ rest = line[6:]
93
+ if ':' in rest:
94
+ idx = rest.index(':')
95
+ s = rest[:idx].strip()
96
+ h = rest[idx+1:].strip()
97
+ entries.append(MindStateEntry(slug=s, mnem_sha=h))
98
+ ms = MindState(timestamp=timestamp, entries=entries)
99
+ ms.sha = slug # slug field stores sha[:16] for internal objects
100
+ return ms
101
+
102
+
103
+ def _parse_ck(lines: list[str], timestamp: datetime, slug: str) -> Checkpoint:
104
+ kv: dict[str, str] = {}
105
+ added, updated, removed = [], [], []
106
+
107
+ for line in lines:
108
+ line = line.strip()
109
+ if not line:
110
+ continue
111
+ if line.startswith('+'):
112
+ rest = line[1:]
113
+ if ':' in rest:
114
+ k, v = rest.split(':', 1)
115
+ k = k.strip().upper()
116
+ v = v.strip()
117
+ if k == 'ADD':
118
+ added.append(v)
119
+ elif k == 'UPD':
120
+ updated.append(v)
121
+ elif k == 'REM':
122
+ removed.append(v)
123
+ elif ':' in line:
124
+ k, v = line.split(':', 1)
125
+ kv[k.strip().upper()] = v.strip()
126
+
127
+ ck = Checkpoint(
128
+ mindstate_sha=kv.get('MSTATE', ''),
129
+ timestamp=timestamp,
130
+ trigger=kv.get('TRIGGER', 'explicit'),
131
+ message=kv.get('MSG', ''),
132
+ author=kv.get('AUTHOR', ''),
133
+ session_id=kv.get('SESSION', ''),
134
+ parent_sha=kv.get('PARENT') or None,
135
+ diff_summary=DiffSummary(added=added, modified=updated, removed=removed),
136
+ )
137
+ ck.sha = slug
138
+ return ck
139
+
140
+
141
+ def _parse_mnemonic(
142
+ lines: list[str],
143
+ type_code: str,
144
+ slug: str,
145
+ timestamp: datetime,
146
+ priority: int,
147
+ ) -> Mnemonic:
148
+ tags: list[str] = []
149
+ rule = None
150
+ why = who = when = desc = where = dl = inc = cost = source = None
151
+ supersedes: list[str] = []
152
+ related: list[str] = []
153
+
154
+ for line in lines:
155
+ line = line.strip()
156
+ if not line:
157
+ continue
158
+
159
+ if line.startswith('#'):
160
+ for tag in line.split():
161
+ t = tag.lstrip('#').strip()
162
+ if t:
163
+ tags.append(t)
164
+ elif line.startswith('~'):
165
+ rest = line[1:]
166
+ if ':' in rest:
167
+ k, v = rest.split(':', 1)
168
+ k = k.strip().upper()
169
+ v = v.strip()
170
+ if k == 'SUP':
171
+ supersedes = [s.strip() for s in v.split(',') if s.strip()]
172
+ elif k == 'REL':
173
+ related = [s.strip() for s in v.split(',') if s.strip()]
174
+ elif k == 'SRC':
175
+ source = v
176
+ elif ':' in line:
177
+ k, v = line.split(':', 1)
178
+ k = k.strip().upper()
179
+ v = v.strip()
180
+ if k == 'RULE':
181
+ rule = v
182
+ elif k == 'WHY':
183
+ why = v
184
+ elif k == 'WHEN':
185
+ when = v
186
+ elif k == 'DESC':
187
+ desc = v
188
+ elif k == 'WHO':
189
+ who = v
190
+ elif k == 'WHERE':
191
+ where = v
192
+ elif k == 'DL':
193
+ dl = v
194
+ elif k == 'INC':
195
+ inc = v
196
+ elif k == 'COST':
197
+ cost = v
198
+
199
+ return Mnemonic(
200
+ type_code=type_code,
201
+ slug=slug,
202
+ timestamp=timestamp,
203
+ rule=rule or desc or '',
204
+ priority=priority,
205
+ tags=tags,
206
+ why=why,
207
+ when=when,
208
+ desc=desc,
209
+ who=who,
210
+ where=where,
211
+ dl=dl,
212
+ inc=inc,
213
+ cost=cost,
214
+ supersedes=supersedes,
215
+ related=related,
216
+ source=source,
217
+ )
218
+
219
+
220
+ def serialize_mnemonic(m: Mnemonic, canonical: bool = False) -> str:
221
+ """Serialize Mnemonic to TOON.
222
+
223
+ canonical=True: sorted fields (used for SHA computation).
224
+ canonical=False: human-friendly output order.
225
+ """
226
+ flags = f'|!{m.priority}' if m.priority == 3 else ''
227
+ header = f'TOON1|{m.type_code}|{m.slug}|{format_ts(m.timestamp)}{flags}'
228
+ lines = [header]
229
+
230
+ if canonical:
231
+ # Deterministic field order for SHA: alphabetical by sigil
232
+ fields: list[tuple[str, str]] = []
233
+ if m.cost:
234
+ fields.append(('COST', m.cost))
235
+ if m.desc:
236
+ fields.append(('DESC', m.desc))
237
+ if m.dl:
238
+ fields.append(('DL', m.dl))
239
+ if m.inc:
240
+ fields.append(('INC', m.inc))
241
+ fields.append(('RULE', m.rule))
242
+ if m.tags:
243
+ fields.append(('TAGS', ' '.join(sorted(m.tags))))
244
+ if m.when:
245
+ fields.append(('WHEN', m.when))
246
+ if m.where:
247
+ fields.append(('WHERE', m.where))
248
+ if m.who:
249
+ fields.append(('WHO', m.who))
250
+ if m.why:
251
+ fields.append(('WHY', m.why))
252
+ if m.related:
253
+ fields.append(('~REL', ','.join(sorted(m.related))))
254
+ if m.source:
255
+ fields.append(('~SRC', m.source))
256
+ if m.supersedes:
257
+ fields.append(('~SUP', ','.join(sorted(m.supersedes))))
258
+
259
+ for k, v in fields:
260
+ if k == 'TAGS':
261
+ lines.append(f'#{v}')
262
+ elif k.startswith('~'):
263
+ lines.append(f'{k}:{v}')
264
+ else:
265
+ lines.append(f'{k}:{v}')
266
+ else:
267
+ if m.tags:
268
+ lines.append('#' + ' #'.join(m.tags))
269
+ lines.append(f'RULE:{m.rule}')
270
+ if m.why:
271
+ lines.append(f'WHY:{m.why}')
272
+ if m.when:
273
+ lines.append(f'WHEN:{m.when}')
274
+ if m.desc:
275
+ lines.append(f'DESC:{m.desc}')
276
+ if m.who:
277
+ lines.append(f'WHO:{m.who}')
278
+ if m.where:
279
+ lines.append(f'WHERE:{m.where}')
280
+ if m.dl:
281
+ lines.append(f'DL:{m.dl}')
282
+ if m.inc:
283
+ lines.append(f'INC:{m.inc}')
284
+ if m.cost:
285
+ lines.append(f'COST:{m.cost}')
286
+ if m.supersedes:
287
+ lines.append(f'~SUP:{",".join(m.supersedes)}')
288
+ if m.related:
289
+ lines.append(f'~REL:{",".join(m.related)}')
290
+ if m.source:
291
+ lines.append(f'~SRC:{m.source}')
292
+
293
+ return '\n'.join(lines)
294
+
295
+
296
+ def serialize_mindstate(ms: MindState) -> str:
297
+ """Serialize MindState to TOON."""
298
+ slug_field = ms.sha[:16] if ms.sha else '0' * 16
299
+ lines = [
300
+ f'TOON1|ms|{slug_field}|{format_ts(ms.timestamp)}',
301
+ f'COUNT:{ms.count}',
302
+ ]
303
+ for e in sorted(ms.entries, key=lambda e: e.slug):
304
+ lines.append(f'ENTRY:{e.slug}:{e.mnem_sha}')
305
+ return '\n'.join(lines)
306
+
307
+
308
+ def serialize_checkpoint(ck: Checkpoint) -> str:
309
+ """Serialize Checkpoint to TOON."""
310
+ slug_field = ck.sha[:16] if ck.sha else '0' * 16
311
+ lines = [f'TOON1|ck|{slug_field}|{format_ts(ck.timestamp)}']
312
+ if ck.parent_sha:
313
+ lines.append(f'PARENT:{ck.parent_sha}')
314
+ lines.append(f'MSTATE:{ck.mindstate_sha}')
315
+ lines.append(f'TRIGGER:{ck.trigger}')
316
+ lines.append(f'MSG:{ck.message}')
317
+ if ck.author:
318
+ lines.append(f'AUTHOR:{ck.author}')
319
+ if ck.session_id:
320
+ lines.append(f'SESSION:{ck.session_id}')
321
+ if ck.diff_summary:
322
+ d = ck.diff_summary
323
+ for s in d.added:
324
+ lines.append(f'+ADD:{s}')
325
+ for s in d.modified:
326
+ lines.append(f'+UPD:{s}')
327
+ for s in d.removed:
328
+ lines.append(f'+REM:{s}')
329
+ return '\n'.join(lines)
330
+
331
+
332
+ def mnemonic_to_markdown(m: Mnemonic) -> str:
333
+ """Convert a Mnemonic back to Claude Code markdown format."""
334
+ type_map = {
335
+ 'fb': 'feedback', 'us': 'user', 'pj': 'project',
336
+ 'rf': 'reference', 'cn': 'convention', 'lx': 'lesson',
337
+ }
338
+ type_str = type_map.get(m.type_code, 'feedback')
339
+ desc = m.rule[:120]
340
+
341
+ lines = [
342
+ '---',
343
+ f'name: {m.slug}',
344
+ f'description: {desc}',
345
+ 'metadata:',
346
+ f' type: {type_str}',
347
+ '---',
348
+ '',
349
+ m.rule,
350
+ '',
351
+ ]
352
+ if m.why:
353
+ lines += [f'**Why:** {m.why}', '']
354
+ if m.when:
355
+ lines += [f'**How to apply:** {m.when}', '']
356
+ return '\n'.join(lines)