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/__init__.py +3 -0
- memgit/cli.py +1267 -0
- memgit/graph.py +486 -0
- memgit/http_server.py +231 -0
- memgit/importer.py +121 -0
- memgit/mcp_server.py +418 -0
- memgit/models.py +80 -0
- memgit/repo.py +714 -0
- memgit/scorer.py +123 -0
- memgit/store.py +176 -0
- memgit/tokens.py +48 -0
- memgit/toon.py +356 -0
- memgit-0.1.1.dist-info/METADATA +457 -0
- memgit-0.1.1.dist-info/RECORD +18 -0
- memgit-0.1.1.dist-info/WHEEL +5 -0
- memgit-0.1.1.dist-info/entry_points.txt +2 -0
- memgit-0.1.1.dist-info/licenses/LICENSE +21 -0
- memgit-0.1.1.dist-info/top_level.txt +1 -0
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)
|