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/cli.py
ADDED
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
"""memgit CLI — git for AI memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from .models import Mnemonic
|
|
15
|
+
from .repo import Repository
|
|
16
|
+
from .toon import mnemonic_to_markdown, serialize_mnemonic
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
err = Console(stderr=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _require_repo() -> Repository:
|
|
23
|
+
repo = Repository.find()
|
|
24
|
+
if repo is None:
|
|
25
|
+
err.print('[red]Not in a memgit repository. Run `memgit init` first.[/red]')
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
return repo
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Root group ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
@click.version_option('0.1.0', prog_name='memgit')
|
|
34
|
+
def cli():
|
|
35
|
+
"""memgit — git for AI memory.
|
|
36
|
+
|
|
37
|
+
Version-controlled context persistence for Claude Code and other AI tools.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── init ──────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@cli.command()
|
|
45
|
+
@click.argument('directory', default='.', type=click.Path())
|
|
46
|
+
def init(directory):
|
|
47
|
+
"""Initialize a memgit repository."""
|
|
48
|
+
path = Path(directory).resolve()
|
|
49
|
+
if (path / '.memgit').exists():
|
|
50
|
+
console.print(f'[yellow]Already initialized:[/yellow] {path / ".memgit"}')
|
|
51
|
+
return
|
|
52
|
+
repo = Repository.init(path)
|
|
53
|
+
console.print(f'[green]Initialized[/green] memgit repository in [cyan]{repo.path}[/cyan]')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── add ───────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
@cli.command()
|
|
59
|
+
@click.argument('slug')
|
|
60
|
+
@click.argument('rule')
|
|
61
|
+
@click.option('--type', '-t', 'type_code', default='fb',
|
|
62
|
+
type=click.Choice(['fb', 'us', 'pj', 'rf', 'cn', 'lx']),
|
|
63
|
+
help='fb=feedback us=user pj=project rf=reference cn=convention lx=lesson')
|
|
64
|
+
@click.option('--why', '-w', default=None, help='Reasoning / why this rule exists')
|
|
65
|
+
@click.option('--when', '-W', default=None, help='When / where to apply')
|
|
66
|
+
@click.option('--tags', default=None, help='Comma-separated tags')
|
|
67
|
+
@click.option('--priority', '-p', default=2, type=click.IntRange(1, 3),
|
|
68
|
+
help='1=low 2=medium 3=critical (always loaded)')
|
|
69
|
+
def add(slug, rule, type_code, why, when, tags, priority):
|
|
70
|
+
"""Add or update a mnemonic.
|
|
71
|
+
|
|
72
|
+
SLUG kebab-case identifier (e.g. ig-pipeline-no-fallback)\n
|
|
73
|
+
RULE the primary fact / rule (quoted if it contains spaces)
|
|
74
|
+
"""
|
|
75
|
+
repo = _require_repo()
|
|
76
|
+
tag_list = [t.strip() for t in tags.split(',')] if tags else []
|
|
77
|
+
|
|
78
|
+
m = Mnemonic(
|
|
79
|
+
type_code=type_code,
|
|
80
|
+
slug=slug,
|
|
81
|
+
timestamp=datetime.now(timezone.utc),
|
|
82
|
+
rule=rule,
|
|
83
|
+
why=why,
|
|
84
|
+
when=when,
|
|
85
|
+
tags=tag_list,
|
|
86
|
+
priority=priority,
|
|
87
|
+
)
|
|
88
|
+
sha = repo.add(m)
|
|
89
|
+
console.print(f'[green]staged[/green] {slug} [{sha[:8]}]')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── remove ────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
@cli.command()
|
|
95
|
+
@click.argument('slug')
|
|
96
|
+
def remove(slug):
|
|
97
|
+
"""Remove a mnemonic from the index (does not delete history)."""
|
|
98
|
+
repo = _require_repo()
|
|
99
|
+
if repo.remove(slug):
|
|
100
|
+
console.print(f'[yellow]removed[/yellow] {slug}')
|
|
101
|
+
else:
|
|
102
|
+
console.print(f'[dim]not found: {slug}[/dim]')
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── commit ────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
@cli.command()
|
|
108
|
+
@click.option('--message', '-m', default=None, help='Checkpoint message')
|
|
109
|
+
def commit(message):
|
|
110
|
+
"""Create a checkpoint of the current memory state."""
|
|
111
|
+
repo = _require_repo()
|
|
112
|
+
sha = repo.commit(message=message)
|
|
113
|
+
if sha is None:
|
|
114
|
+
console.print('[dim]Nothing to commit — memory state unchanged.[/dim]')
|
|
115
|
+
else:
|
|
116
|
+
console.print(f'[green]checkpoint[/green] {sha[:8]}')
|
|
117
|
+
ck = repo.store.read_checkpoint(sha)
|
|
118
|
+
console.print(f' {ck.message}')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── status ────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
@cli.command()
|
|
124
|
+
def status():
|
|
125
|
+
"""Show current repository status."""
|
|
126
|
+
repo = _require_repo()
|
|
127
|
+
thread = repo.current_thread()
|
|
128
|
+
head = repo.head_sha()
|
|
129
|
+
|
|
130
|
+
console.print(f'Thread: [cyan]{thread}[/cyan]')
|
|
131
|
+
if head:
|
|
132
|
+
console.print(f'HEAD: [yellow]{head[:8]}[/yellow]')
|
|
133
|
+
ck = repo.store.read_checkpoint(head)
|
|
134
|
+
console.print(f' {ck.message}')
|
|
135
|
+
else:
|
|
136
|
+
console.print('HEAD: [dim]none[/dim]')
|
|
137
|
+
|
|
138
|
+
# Staged vs committed
|
|
139
|
+
index = repo.get_index()
|
|
140
|
+
if head:
|
|
141
|
+
ms = repo.store.read_mindstate(
|
|
142
|
+
repo.store.read_checkpoint(head).mindstate_sha
|
|
143
|
+
)
|
|
144
|
+
committed = {e.slug: e.mnem_sha for e in ms.entries}
|
|
145
|
+
else:
|
|
146
|
+
committed = {}
|
|
147
|
+
|
|
148
|
+
new_slugs = [s for s in index if s not in committed]
|
|
149
|
+
updated = [s for s in index if s in committed and index[s] != committed[s]]
|
|
150
|
+
removed = [s for s in committed if s not in index]
|
|
151
|
+
|
|
152
|
+
if new_slugs or updated or removed:
|
|
153
|
+
console.print('\n[bold]Staged changes (not yet committed):[/bold]')
|
|
154
|
+
for s in new_slugs:
|
|
155
|
+
console.print(f' [green]new[/green] {s}')
|
|
156
|
+
for s in updated:
|
|
157
|
+
console.print(f' [yellow]updated[/yellow] {s}')
|
|
158
|
+
for s in removed:
|
|
159
|
+
console.print(f' [red]removed[/red] {s}')
|
|
160
|
+
console.print('\n[dim] (run "memgit commit" to checkpoint)[/dim]')
|
|
161
|
+
else:
|
|
162
|
+
total = len(index)
|
|
163
|
+
console.print(f'\n[dim]Clean — {total} mnemonic{"s" if total != 1 else ""} committed[/dim]')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── log ───────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
@cli.command()
|
|
169
|
+
@click.option('--limit', '-n', default=10, help='Max checkpoints to show')
|
|
170
|
+
@click.option('--oneline', is_flag=True, help='Compact one-line format')
|
|
171
|
+
def log(limit, oneline):
|
|
172
|
+
"""Show checkpoint history."""
|
|
173
|
+
repo = _require_repo()
|
|
174
|
+
checkpoints = repo.log(limit=limit)
|
|
175
|
+
if not checkpoints:
|
|
176
|
+
console.print('[dim]No checkpoints yet.[/dim]')
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
for ck in checkpoints:
|
|
180
|
+
sha_s = ck.sha[:8] if ck.sha else '????????'
|
|
181
|
+
ts = ck.timestamp.strftime('%Y-%m-%d %H:%M')
|
|
182
|
+
|
|
183
|
+
if oneline:
|
|
184
|
+
console.print(f'[yellow]{sha_s}[/yellow] {ts} {ck.message}')
|
|
185
|
+
else:
|
|
186
|
+
console.print(f'\n[yellow]checkpoint {sha_s}[/yellow]')
|
|
187
|
+
console.print(f' Date: {ts}')
|
|
188
|
+
console.print(f' Trigger: {ck.trigger}')
|
|
189
|
+
console.print(f' Author: {ck.author}')
|
|
190
|
+
console.print(f' Message: {ck.message}')
|
|
191
|
+
if ck.diff_summary:
|
|
192
|
+
d = ck.diff_summary
|
|
193
|
+
parts = []
|
|
194
|
+
if d.added:
|
|
195
|
+
parts.append(f'[green]+{len(d.added)}[/green]')
|
|
196
|
+
if d.modified:
|
|
197
|
+
parts.append(f'[yellow]~{len(d.modified)}[/yellow]')
|
|
198
|
+
if d.removed:
|
|
199
|
+
parts.append(f'[red]-{len(d.removed)}[/red]')
|
|
200
|
+
if parts:
|
|
201
|
+
console.print(f' Changes: {" ".join(parts)}')
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── diff ──────────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
@cli.command()
|
|
207
|
+
@click.argument('sha1', required=False)
|
|
208
|
+
@click.argument('sha2', required=False)
|
|
209
|
+
@click.option('--full', is_flag=True, help='Show rule text for changed mnemonics')
|
|
210
|
+
def diff(sha1, sha2, full):
|
|
211
|
+
"""Show diff between two checkpoints (default: HEAD^ vs HEAD)."""
|
|
212
|
+
repo = _require_repo()
|
|
213
|
+
|
|
214
|
+
if full:
|
|
215
|
+
changes = repo.diff_full(sha1, sha2)
|
|
216
|
+
for slug, status, old_m, new_m in changes:
|
|
217
|
+
if status == 'unchanged':
|
|
218
|
+
continue
|
|
219
|
+
color = {'added': 'green', 'removed': 'red', 'modified': 'yellow'}[status]
|
|
220
|
+
marker = {'added': '+', 'removed': '-', 'modified': '~'}[status]
|
|
221
|
+
console.print(f'[{color}]{marker} {slug}[/{color}]')
|
|
222
|
+
if status in ('added', 'modified') and new_m:
|
|
223
|
+
console.print(f' [dim]RULE:[/dim] {new_m.rule}')
|
|
224
|
+
if status == 'modified' and old_m:
|
|
225
|
+
console.print(f' [dim]WAS:[/dim] {old_m.rule}')
|
|
226
|
+
else:
|
|
227
|
+
d = repo.diff(sha1, sha2)
|
|
228
|
+
for s in d.added:
|
|
229
|
+
console.print(f'[green]+ {s}[/green]')
|
|
230
|
+
for s in d.modified:
|
|
231
|
+
console.print(f'[yellow]~ {s}[/yellow]')
|
|
232
|
+
for s in d.removed:
|
|
233
|
+
console.print(f'[red]- {s}[/red]')
|
|
234
|
+
if not d.added and not d.modified and not d.removed:
|
|
235
|
+
console.print('[dim]No changes[/dim]')
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── show ──────────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
@cli.command()
|
|
241
|
+
@click.argument('slug')
|
|
242
|
+
@click.option('--toon', is_flag=True, help='Show raw TOON format')
|
|
243
|
+
@click.option('--markdown', 'fmt_markdown', is_flag=True, help='Show Claude Code markdown format')
|
|
244
|
+
def show(slug, toon, fmt_markdown):
|
|
245
|
+
"""Show a mnemonic."""
|
|
246
|
+
repo = _require_repo()
|
|
247
|
+
m = repo.get(slug)
|
|
248
|
+
if m is None:
|
|
249
|
+
err.print(f'[red]No mnemonic: {slug}[/red]')
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
if fmt_markdown:
|
|
253
|
+
print(mnemonic_to_markdown(m))
|
|
254
|
+
elif toon:
|
|
255
|
+
print(serialize_mnemonic(m))
|
|
256
|
+
else:
|
|
257
|
+
sha_s = m.sha[:8] if m.sha else '?'
|
|
258
|
+
p_label = {1: 'low', 2: 'medium', 3: '[bold red]CRITICAL[/bold red]'}[m.priority]
|
|
259
|
+
console.print(f'[bold cyan]{m.slug}[/bold cyan] [{m.type_code}] priority={p_label} sha={sha_s}')
|
|
260
|
+
console.print(f'')
|
|
261
|
+
console.print(f'[bold]RULE[/bold] {m.rule}')
|
|
262
|
+
if m.why:
|
|
263
|
+
console.print(f'[bold]WHY[/bold] {m.why}')
|
|
264
|
+
if m.when:
|
|
265
|
+
console.print(f'[bold]WHEN[/bold] {m.when}')
|
|
266
|
+
if m.desc:
|
|
267
|
+
console.print(f'[bold]DESC[/bold] {m.desc}')
|
|
268
|
+
if m.who:
|
|
269
|
+
console.print(f'[bold]WHO[/bold] {m.who}')
|
|
270
|
+
if m.where:
|
|
271
|
+
console.print(f'[bold]WHERE[/bold] {m.where}')
|
|
272
|
+
if m.inc:
|
|
273
|
+
console.print(f'[bold]INC[/bold] {m.inc}')
|
|
274
|
+
if m.cost:
|
|
275
|
+
console.print(f'[bold]COST[/bold] {m.cost}')
|
|
276
|
+
if m.tags:
|
|
277
|
+
console.print(f'[dim]Tags: {", ".join(m.tags)}[/dim]')
|
|
278
|
+
if m.related:
|
|
279
|
+
console.print(f'[dim]Related: {", ".join(m.related)}[/dim]')
|
|
280
|
+
if m.supersedes:
|
|
281
|
+
console.print(f'[dim]Supersedes: {", ".join(m.supersedes)}[/dim]')
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── list ──────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
@cli.command(name='list')
|
|
287
|
+
@click.option('--type', '-t', 'type_filter', default=None,
|
|
288
|
+
type=click.Choice(['fb', 'us', 'pj', 'rf', 'cn', 'lx']),
|
|
289
|
+
help='Filter by type')
|
|
290
|
+
@click.option('--priority', '-p', default=None, type=click.IntRange(1, 3), help='Filter by priority')
|
|
291
|
+
@click.option('--toon', is_flag=True, help='Show TOON format')
|
|
292
|
+
def list_cmd(type_filter, priority, toon):
|
|
293
|
+
"""List all mnemonics in the current thread."""
|
|
294
|
+
repo = _require_repo()
|
|
295
|
+
mnemonics = repo.list()
|
|
296
|
+
if type_filter:
|
|
297
|
+
mnemonics = [m for m in mnemonics if m.type_code == type_filter]
|
|
298
|
+
if priority:
|
|
299
|
+
mnemonics = [m for m in mnemonics if m.priority == priority]
|
|
300
|
+
mnemonics.sort(key=lambda m: (m.type_code, m.slug))
|
|
301
|
+
|
|
302
|
+
if not mnemonics:
|
|
303
|
+
console.print('[dim]No mnemonics.[/dim]')
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
if toon:
|
|
307
|
+
for m in mnemonics:
|
|
308
|
+
print(serialize_mnemonic(m))
|
|
309
|
+
print()
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
table = Table(show_header=True, header_style='bold', box=None, pad_edge=False)
|
|
313
|
+
table.add_column('Slug', style='cyan', min_width=20)
|
|
314
|
+
table.add_column('T', width=2)
|
|
315
|
+
table.add_column('P', width=1)
|
|
316
|
+
table.add_column('Rule', max_width=70)
|
|
317
|
+
|
|
318
|
+
for m in mnemonics:
|
|
319
|
+
p_str = '!' if m.priority == 3 else str(m.priority)
|
|
320
|
+
rule_preview = m.rule[:68] + '..' if len(m.rule) > 68 else m.rule
|
|
321
|
+
table.add_row(m.slug, m.type_code, p_str, rule_preview)
|
|
322
|
+
|
|
323
|
+
console.print(table)
|
|
324
|
+
console.print(f'\n[dim]{len(mnemonics)} mnemonic{"s" if len(mnemonics) != 1 else ""}[/dim]')
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── import ────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
@cli.group(name='import')
|
|
330
|
+
def import_group():
|
|
331
|
+
"""Import memories from other sources."""
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@import_group.command(name='claude-code')
|
|
336
|
+
@click.argument('path', required=False, type=click.Path(exists=True, file_okay=False))
|
|
337
|
+
@click.option('--dry-run', is_flag=True, help='Preview without importing')
|
|
338
|
+
@click.option('--no-commit', is_flag=True, help='Stage but do not checkpoint')
|
|
339
|
+
def import_claude_code(path, dry_run, no_commit):
|
|
340
|
+
"""Import Claude Code memory markdown files.
|
|
341
|
+
|
|
342
|
+
PATH optional directory to read from (default: ~/.claude/projects/*/memory/)
|
|
343
|
+
"""
|
|
344
|
+
from .importer import from_claude_code
|
|
345
|
+
repo = _require_repo()
|
|
346
|
+
|
|
347
|
+
mem_dir = Path(path) if path else None
|
|
348
|
+
mnemonics = from_claude_code(mem_dir)
|
|
349
|
+
|
|
350
|
+
if not mnemonics:
|
|
351
|
+
console.print('[yellow]No memories found.[/yellow]')
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
console.print(f'Found [bold]{len(mnemonics)}[/bold] memories')
|
|
355
|
+
|
|
356
|
+
if dry_run:
|
|
357
|
+
for m in mnemonics:
|
|
358
|
+
rule_preview = m.rule[:60] + '..' if len(m.rule) > 60 else m.rule
|
|
359
|
+
console.print(f' [cyan]{m.slug}[/cyan] [{m.type_code}] {rule_preview}')
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
count = 0
|
|
363
|
+
for m in mnemonics:
|
|
364
|
+
try:
|
|
365
|
+
repo.add(m)
|
|
366
|
+
count += 1
|
|
367
|
+
except Exception as e:
|
|
368
|
+
err.print(f'[yellow]skip {m.slug}: {e}[/yellow]')
|
|
369
|
+
|
|
370
|
+
console.print(f'[green]Staged {count} memories[/green]')
|
|
371
|
+
|
|
372
|
+
if not no_commit:
|
|
373
|
+
sha = repo.commit(
|
|
374
|
+
message=f'Import {count} memories from Claude Code',
|
|
375
|
+
trigger='import',
|
|
376
|
+
)
|
|
377
|
+
if sha:
|
|
378
|
+
console.print(f'[green]Checkpoint[/green] {sha[:8]}')
|
|
379
|
+
else:
|
|
380
|
+
console.print('[dim]Nothing new to checkpoint[/dim]')
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@import_group.command(name='toon-file')
|
|
384
|
+
@click.argument('path', type=click.Path(exists=True, dir_okay=False))
|
|
385
|
+
@click.option('--dry-run', is_flag=True)
|
|
386
|
+
def import_toon_file(path, dry_run):
|
|
387
|
+
"""Import mnemonics from a .toon file."""
|
|
388
|
+
from .importer import from_toon_file
|
|
389
|
+
repo = _require_repo()
|
|
390
|
+
|
|
391
|
+
mnemonics = from_toon_file(Path(path))
|
|
392
|
+
if not mnemonics:
|
|
393
|
+
console.print('[yellow]No mnemonics found.[/yellow]')
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
console.print(f'Found [bold]{len(mnemonics)}[/bold] mnemonics')
|
|
397
|
+
if dry_run:
|
|
398
|
+
for m in mnemonics:
|
|
399
|
+
console.print(f' [cyan]{m.slug}[/cyan] [{m.type_code}]')
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
for m in mnemonics:
|
|
403
|
+
repo.add(m)
|
|
404
|
+
sha = repo.commit(trigger='import')
|
|
405
|
+
if sha:
|
|
406
|
+
console.print(f'[green]Imported {len(mnemonics)} mnemonics → checkpoint {sha[:8]}[/green]')
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ── export ────────────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
@cli.command()
|
|
412
|
+
@click.argument('slug')
|
|
413
|
+
@click.option('--toon', 'fmt', flag_value='toon', default=True, help='TOON format (default)')
|
|
414
|
+
@click.option('--markdown', 'fmt', flag_value='markdown', help='Claude Code markdown format')
|
|
415
|
+
def export(slug, fmt):
|
|
416
|
+
"""Export a mnemonic to stdout."""
|
|
417
|
+
repo = _require_repo()
|
|
418
|
+
m = repo.get(slug)
|
|
419
|
+
if m is None:
|
|
420
|
+
err.print(f'[red]No mnemonic: {slug}[/red]')
|
|
421
|
+
sys.exit(1)
|
|
422
|
+
if fmt == 'markdown':
|
|
423
|
+
print(mnemonic_to_markdown(m))
|
|
424
|
+
else:
|
|
425
|
+
print(serialize_mnemonic(m))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── fsck ──────────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
@cli.command()
|
|
431
|
+
@click.option('--rebuild-index', is_flag=True, help='Rebuild TOON_INDEX from HEAD')
|
|
432
|
+
def fsck(rebuild_index):
|
|
433
|
+
"""Verify repository integrity."""
|
|
434
|
+
repo = _require_repo()
|
|
435
|
+
console.print('Checking…')
|
|
436
|
+
errors = repo.fsck(rebuild_index=rebuild_index)
|
|
437
|
+
index = repo.get_index()
|
|
438
|
+
if errors:
|
|
439
|
+
for e in errors:
|
|
440
|
+
err.print(f'[red]{e}[/red]')
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
else:
|
|
443
|
+
console.print(f'[green]OK[/green] — {len(index)} objects verified'
|
|
444
|
+
+ (', index rebuilt' if rebuild_index else ''))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ── thread ────────────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
@cli.group()
|
|
450
|
+
def thread():
|
|
451
|
+
"""Manage memory threads (branches)."""
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@thread.command(name='list')
|
|
456
|
+
def thread_list():
|
|
457
|
+
"""List all threads."""
|
|
458
|
+
repo = _require_repo()
|
|
459
|
+
current = repo.current_thread()
|
|
460
|
+
threads = repo.thread_list()
|
|
461
|
+
for t in sorted(threads, key=lambda t: t.name):
|
|
462
|
+
marker = '*' if t.name == current else ' '
|
|
463
|
+
sha_s = t.head_sha[:8] if t.head_sha else '?'
|
|
464
|
+
console.print(f' {marker} [cyan]{t.name}[/cyan] [{sha_s}]')
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@thread.command(name='create')
|
|
468
|
+
@click.argument('name')
|
|
469
|
+
@click.option('--description', '-d', default='')
|
|
470
|
+
def thread_create(name, description):
|
|
471
|
+
"""Create a new thread from HEAD."""
|
|
472
|
+
repo = _require_repo()
|
|
473
|
+
t = repo.thread_create(name, description)
|
|
474
|
+
console.print(f'[green]Created thread[/green] {name} from {t.head_sha[:8]}')
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@thread.command(name='switch')
|
|
478
|
+
@click.argument('name')
|
|
479
|
+
def thread_switch(name):
|
|
480
|
+
"""Switch to a different thread."""
|
|
481
|
+
repo = _require_repo()
|
|
482
|
+
try:
|
|
483
|
+
repo.thread_switch(name)
|
|
484
|
+
console.print(f'[green]Switched to[/green] {name}')
|
|
485
|
+
except ValueError as e:
|
|
486
|
+
err.print(f'[red]{e}[/red]')
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ── lint ──────────────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
@cli.command()
|
|
493
|
+
def lint():
|
|
494
|
+
"""Lint all staged mnemonics."""
|
|
495
|
+
repo = _require_repo()
|
|
496
|
+
mnemonics = repo.list()
|
|
497
|
+
issues = 0
|
|
498
|
+
for m in mnemonics:
|
|
499
|
+
if not m.rule:
|
|
500
|
+
console.print(f'[red]{m.slug}[/red]: missing RULE')
|
|
501
|
+
issues += 1
|
|
502
|
+
if len(m.rule) > 400:
|
|
503
|
+
console.print(f'[yellow]{m.slug}[/yellow]: RULE too long ({len(m.rule)} chars, max 400)')
|
|
504
|
+
issues += 1
|
|
505
|
+
if not re.match(r'^[a-z0-9_-]+$', m.slug):
|
|
506
|
+
console.print(f'[yellow]{m.slug}[/yellow]: slug should be kebab-case [a-z0-9_-]')
|
|
507
|
+
issues += 1
|
|
508
|
+
if issues == 0:
|
|
509
|
+
console.print(f'[green]OK[/green] — {len(mnemonics)} mnemonics, no issues')
|
|
510
|
+
else:
|
|
511
|
+
console.print(f'[yellow]{issues} issue{"s" if issues != 1 else ""}[/yellow]')
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
import re # noqa: E402 — needed for lint command
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ── search ────────────────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
@cli.command()
|
|
520
|
+
@click.argument('query')
|
|
521
|
+
@click.option('--top', '-k', default=10, help='Max results to return')
|
|
522
|
+
@click.option('--toon', is_flag=True, help='Output TOON format (token-efficient)')
|
|
523
|
+
@click.option('--json', 'fmt_json', is_flag=True, help='Output JSON')
|
|
524
|
+
@click.option('--type', '-t', 'type_filter', default=None,
|
|
525
|
+
type=click.Choice(['fb', 'us', 'pj', 'rf', 'cn', 'lx']),
|
|
526
|
+
help='Filter by type before scoring')
|
|
527
|
+
def search(query, top, toon, fmt_json, type_filter):
|
|
528
|
+
"""Search memories by relevance.
|
|
529
|
+
|
|
530
|
+
Returns the top-k mnemonics scored against QUERY using BM25.
|
|
531
|
+
"""
|
|
532
|
+
import json
|
|
533
|
+
from .scorer import score as bm25_score
|
|
534
|
+
|
|
535
|
+
repo = _require_repo()
|
|
536
|
+
mnemonics = repo.list()
|
|
537
|
+
if type_filter:
|
|
538
|
+
mnemonics = [m for m in mnemonics if m.type_code == type_filter]
|
|
539
|
+
|
|
540
|
+
results = bm25_score(query, mnemonics, top_k=top)
|
|
541
|
+
|
|
542
|
+
if not results:
|
|
543
|
+
console.print('[dim]No results.[/dim]')
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
if fmt_json:
|
|
547
|
+
out = []
|
|
548
|
+
for r in results:
|
|
549
|
+
m = r.mnemonic
|
|
550
|
+
out.append({
|
|
551
|
+
'slug': m.slug,
|
|
552
|
+
'score': r.score,
|
|
553
|
+
'type': m.type_code,
|
|
554
|
+
'priority': m.priority,
|
|
555
|
+
'rule': m.rule,
|
|
556
|
+
'why': m.why,
|
|
557
|
+
'when': m.when,
|
|
558
|
+
'tags': m.tags,
|
|
559
|
+
'matched': r.matched_fields,
|
|
560
|
+
})
|
|
561
|
+
print(json.dumps(out, indent=2))
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if toon:
|
|
565
|
+
for r in results:
|
|
566
|
+
print(serialize_mnemonic(r.mnemonic))
|
|
567
|
+
print()
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
table = Table(show_header=True, header_style='bold', box=None, pad_edge=False)
|
|
571
|
+
table.add_column('Score', width=6, style='dim')
|
|
572
|
+
table.add_column('Slug', style='cyan', min_width=20)
|
|
573
|
+
table.add_column('T', width=2)
|
|
574
|
+
table.add_column('Rule', max_width=65)
|
|
575
|
+
|
|
576
|
+
for r in results:
|
|
577
|
+
m = r.mnemonic
|
|
578
|
+
rule_preview = m.rule[:63] + '..' if len(m.rule) > 63 else m.rule
|
|
579
|
+
table.add_row(f'{r.score:.2f}', m.slug, m.type_code, rule_preview)
|
|
580
|
+
|
|
581
|
+
console.print(table)
|
|
582
|
+
console.print(f'\n[dim]{len(results)} result{"s" if len(results) != 1 else ""} for "{query}"[/dim]')
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ── sync ──────────────────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
@cli.command()
|
|
588
|
+
@click.option('--message', '-m', default=None, help='Custom checkpoint message')
|
|
589
|
+
@click.option('--dry-run', is_flag=True, help='Show what would be imported, no writes')
|
|
590
|
+
def sync(message, dry_run):
|
|
591
|
+
"""Sync from Claude Code memory files and auto-checkpoint.
|
|
592
|
+
|
|
593
|
+
Imports all Claude Code markdown memory files, stages changes,
|
|
594
|
+
and creates a checkpoint if anything changed. Safe to run repeatedly.
|
|
595
|
+
"""
|
|
596
|
+
from .importer import from_claude_code
|
|
597
|
+
|
|
598
|
+
repo = _require_repo()
|
|
599
|
+
mnemonics = from_claude_code()
|
|
600
|
+
|
|
601
|
+
if not mnemonics:
|
|
602
|
+
console.print('[dim]No Claude Code memories found.[/dim]')
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
if dry_run:
|
|
606
|
+
console.print(f'Would import [bold]{len(mnemonics)}[/bold] memories:')
|
|
607
|
+
for m in mnemonics[:10]:
|
|
608
|
+
console.print(f' [cyan]{m.slug}[/cyan] [{m.type_code}]')
|
|
609
|
+
if len(mnemonics) > 10:
|
|
610
|
+
console.print(f' [dim]… and {len(mnemonics) - 10} more[/dim]')
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
count = 0
|
|
614
|
+
skipped = 0
|
|
615
|
+
for m in mnemonics:
|
|
616
|
+
try:
|
|
617
|
+
repo.add(m)
|
|
618
|
+
count += 1
|
|
619
|
+
except Exception:
|
|
620
|
+
skipped += 1
|
|
621
|
+
|
|
622
|
+
msg = message or f'sync: {count} memories from Claude Code'
|
|
623
|
+
sha = repo.commit(message=msg, trigger='session_end')
|
|
624
|
+
|
|
625
|
+
if sha:
|
|
626
|
+
console.print(f'[green]sync[/green] {sha[:8]} {count} staged' +
|
|
627
|
+
(f', {skipped} skipped' if skipped else ''))
|
|
628
|
+
else:
|
|
629
|
+
console.print(f'[dim]sync: no changes ({count} memories already current)[/dim]')
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# ── graph ─────────────────────────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
@cli.command()
|
|
635
|
+
@click.option('--output', '-o', default=None,
|
|
636
|
+
help='Output HTML file path (default: memgit-graph.html in repo dir)')
|
|
637
|
+
@click.option('--open', 'auto_open', is_flag=True, default=True,
|
|
638
|
+
help='Open in browser after generating (default: true)')
|
|
639
|
+
@click.option('--no-open', 'auto_open', flag_value=False,
|
|
640
|
+
help='Skip opening browser')
|
|
641
|
+
def graph(output, auto_open):
|
|
642
|
+
"""Generate an interactive HTML graph of the memory store.
|
|
643
|
+
|
|
644
|
+
Visualizes all mnemonics as a force-directed graph with:
|
|
645
|
+
- Nodes colored by type (fb/us/pj/rf/cn/lx)
|
|
646
|
+
- Node size by priority
|
|
647
|
+
- Edges from [[wikilink]] references and explicit related/supersedes links
|
|
648
|
+
- Filter by type, search by keyword, click to highlight neighbours
|
|
649
|
+
- Checkpoint timeline in the sidebar
|
|
650
|
+
"""
|
|
651
|
+
import webbrowser
|
|
652
|
+
from .graph import build_graph_data, render_html
|
|
653
|
+
|
|
654
|
+
repo = _require_repo()
|
|
655
|
+
data = build_graph_data(repo)
|
|
656
|
+
html = render_html(data)
|
|
657
|
+
|
|
658
|
+
out_path = Path(output) if output else (repo.path.parent / 'memgit-graph.html')
|
|
659
|
+
out_path.write_text(html, encoding='utf-8')
|
|
660
|
+
|
|
661
|
+
n = data['meta']['total']
|
|
662
|
+
e = data['meta']['edge_count']
|
|
663
|
+
console.print(f'[green]graph[/green] {out_path}')
|
|
664
|
+
console.print(f' {n} nodes · {e} edges · {len(data["checkpoints"])} checkpoints')
|
|
665
|
+
|
|
666
|
+
if auto_open:
|
|
667
|
+
webbrowser.open(out_path.as_uri())
|
|
668
|
+
console.print(f'[dim]opened in browser[/dim]')
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ── serve ─────────────────────────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
@cli.command()
|
|
674
|
+
@click.option('--store', default=None,
|
|
675
|
+
help='Path to memgit store dir (default: ~/.claude/memgit-store)')
|
|
676
|
+
@click.option('--http', 'use_http', is_flag=True, default=False,
|
|
677
|
+
help='Run as HTTP REST server instead of MCP stdio (for GPT Actions, Gemini, etc.)')
|
|
678
|
+
@click.option('--port', default=7474, show_default=True,
|
|
679
|
+
help='HTTP server port (only used with --http)')
|
|
680
|
+
def serve(store, use_http, port):
|
|
681
|
+
"""Start the memgit server.
|
|
682
|
+
|
|
683
|
+
Default (no flags): MCP stdio server — for Claude Code, Cursor, Windsurf, Cline, Continue.dev.
|
|
684
|
+
With --http: REST server — for GPT Custom Actions, Gemini function calling, any OpenAPI client.
|
|
685
|
+
"""
|
|
686
|
+
store_path = Path(store).resolve() if store else None
|
|
687
|
+
if use_http:
|
|
688
|
+
from .http_server import run_http_server
|
|
689
|
+
run_http_server(port=port, store_path=store_path)
|
|
690
|
+
else:
|
|
691
|
+
from .mcp_server import run_server
|
|
692
|
+
run_server(store_path)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# ── stats ─────────────────────────────────────────────────────────────────────
|
|
696
|
+
|
|
697
|
+
@cli.command()
|
|
698
|
+
def stats():
|
|
699
|
+
"""Show token-savings proof and store health metrics.
|
|
700
|
+
|
|
701
|
+
Compares loading ALL memories (the claude.md / dump approach) against
|
|
702
|
+
memgit's relevance-filtered search — and shows the real token and dollar
|
|
703
|
+
savings your team gets every session.
|
|
704
|
+
"""
|
|
705
|
+
repo = _require_repo()
|
|
706
|
+
s = repo.stats()
|
|
707
|
+
|
|
708
|
+
if s.get('total', 0) == 0:
|
|
709
|
+
console.print('[yellow]No memories yet. Run `memgit sync` or `memgit add` first.[/yellow]')
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
type_labels = {'fb': 'feedback', 'us': 'user', 'pj': 'project',
|
|
713
|
+
'rf': 'reference', 'cn': 'convention', 'lx': 'lesson'}
|
|
714
|
+
|
|
715
|
+
type_str = ' · '.join(
|
|
716
|
+
f"{s['by_type'].get(tc, 0)} {lbl}"
|
|
717
|
+
for tc, lbl in type_labels.items()
|
|
718
|
+
if s['by_type'].get(tc, 0) > 0
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
prio = s['priority_counts']
|
|
722
|
+
prio_str = f"{prio.get(3, 0)} critical · {prio.get(2, 0)} medium · {prio.get(1, 0)} low"
|
|
723
|
+
|
|
724
|
+
reduction = s['reduction_pct']
|
|
725
|
+
full_tok = s['full_tokens']
|
|
726
|
+
search_tok = s['avg_search_tokens']
|
|
727
|
+
crit_tok = s['critical_tokens']
|
|
728
|
+
|
|
729
|
+
weekly_tokens = s['weekly_savings_tokens']
|
|
730
|
+
weekly_usd = s['weekly_savings_usd']
|
|
731
|
+
|
|
732
|
+
ck_count = s['checkpoint_count']
|
|
733
|
+
first_ts = s['first_checkpoint_ts'].strftime('%Y-%m-%d') if s['first_checkpoint_ts'] else '—'
|
|
734
|
+
last_ts = s['last_checkpoint_ts'].strftime('%Y-%m-%d') if s['last_checkpoint_ts'] else '—'
|
|
735
|
+
|
|
736
|
+
# ── render ────────────────────────────────────────────────────────────────
|
|
737
|
+
from rich.rule import Rule
|
|
738
|
+
from rich.panel import Panel
|
|
739
|
+
from rich import box
|
|
740
|
+
|
|
741
|
+
console.print()
|
|
742
|
+
console.print(Rule('[bold cyan]memgit stats[/bold cyan]'))
|
|
743
|
+
console.print()
|
|
744
|
+
|
|
745
|
+
t = Table(show_header=False, box=None, padding=(0, 2))
|
|
746
|
+
t.add_column(style='dim', width=30)
|
|
747
|
+
t.add_column()
|
|
748
|
+
|
|
749
|
+
t.add_row('Total memories', f'[bold]{s["total"]}[/bold] {type_str}')
|
|
750
|
+
t.add_row('Priority breakdown', prio_str)
|
|
751
|
+
t.add_row('Checkpoints', f'{ck_count} {first_ts} → {last_ts}')
|
|
752
|
+
console.print(t)
|
|
753
|
+
console.print()
|
|
754
|
+
|
|
755
|
+
console.print('[bold]Token cost comparison[/bold]')
|
|
756
|
+
console.print()
|
|
757
|
+
|
|
758
|
+
bench = Table(box=box.SIMPLE, header_style='bold')
|
|
759
|
+
bench.add_column('Approach', style='', min_width=30)
|
|
760
|
+
bench.add_column('Tokens/session', justify='right')
|
|
761
|
+
bench.add_column('vs full load', justify='right')
|
|
762
|
+
bench.add_column('$/session (GPT-4o)', justify='right')
|
|
763
|
+
|
|
764
|
+
from .tokens import token_cost_usd
|
|
765
|
+
bench.add_row(
|
|
766
|
+
'[red]claude.md / dump all memories[/red]',
|
|
767
|
+
f'{full_tok:,}',
|
|
768
|
+
'100% baseline',
|
|
769
|
+
f'${token_cost_usd(full_tok):.4f}',
|
|
770
|
+
)
|
|
771
|
+
bench.add_row(
|
|
772
|
+
'[yellow]mem-search plugin (top-20 obs)[/yellow]',
|
|
773
|
+
f'{min(full_tok, search_tok * 2):,} [dim](est.)[/dim]',
|
|
774
|
+
f'~{min(100, round(100 * min(full_tok, search_tok * 2) / full_tok))}%',
|
|
775
|
+
f'${token_cost_usd(min(full_tok, search_tok * 2)):.4f}',
|
|
776
|
+
)
|
|
777
|
+
bench.add_row(
|
|
778
|
+
'[green]memgit search (BM25 top-8)[/green]',
|
|
779
|
+
f'{search_tok:,}',
|
|
780
|
+
f'[green]{100 - reduction}% ({reduction}% savings)[/green]',
|
|
781
|
+
f'[green]${token_cost_usd(search_tok):.4f}[/green]',
|
|
782
|
+
)
|
|
783
|
+
if crit_tok > 0:
|
|
784
|
+
bench.add_row(
|
|
785
|
+
'[cyan] + critical memories (always)[/cyan]',
|
|
786
|
+
f'+{crit_tok:,} [dim](overhead)[/dim]',
|
|
787
|
+
'',
|
|
788
|
+
'',
|
|
789
|
+
)
|
|
790
|
+
console.print(bench)
|
|
791
|
+
|
|
792
|
+
console.print()
|
|
793
|
+
console.print('[bold]Weekly savings [dim](10 sessions/week)[/dim][/bold]')
|
|
794
|
+
console.print()
|
|
795
|
+
|
|
796
|
+
savings = Table(box=None, show_header=False, padding=(0, 2))
|
|
797
|
+
savings.add_column(style='dim', width=30)
|
|
798
|
+
savings.add_column()
|
|
799
|
+
|
|
800
|
+
savings.add_row('Tokens saved/week', f'[green]{weekly_tokens:,}[/green]')
|
|
801
|
+
savings.add_row('Cost saved/week (GPT-4o)', f'[green]${weekly_usd:.4f}[/green]')
|
|
802
|
+
savings.add_row('', '')
|
|
803
|
+
savings.add_row('Annualised token savings', f'[bold green]{weekly_tokens * 52:,}[/bold green]')
|
|
804
|
+
savings.add_row('Annualised cost savings', f'[bold green]${weekly_usd * 52:.2f}[/bold green]')
|
|
805
|
+
console.print(savings)
|
|
806
|
+
|
|
807
|
+
console.print()
|
|
808
|
+
has_flat = (repo.path.parent / 'memories').exists()
|
|
809
|
+
has_git = (repo.path.parent / '.git').exists()
|
|
810
|
+
git_status = '[green]✓[/green]' if has_git else '[red]✗[/red] (run `memgit git init` to enable team sync)'
|
|
811
|
+
flat_status = '[green]✓[/green]' if has_flat else '[yellow]–[/yellow] (run `memgit git export`)'
|
|
812
|
+
console.print(f'[dim]Git sync:[/dim] {git_status} [dim]Flat memories/:[/dim] {flat_status}')
|
|
813
|
+
console.print()
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# ── squash ────────────────────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
@cli.command()
|
|
819
|
+
@click.option('--keep-last', type=int, default=None,
|
|
820
|
+
help='Keep this many recent checkpoints; squash the rest into one baseline')
|
|
821
|
+
@click.option('--older-than', 'older_than_days', type=int, default=None, metavar='DAYS',
|
|
822
|
+
help='Squash all checkpoints older than N days')
|
|
823
|
+
@click.option('--dry-run', is_flag=True, help='Preview what would be squashed without changing anything')
|
|
824
|
+
def squash(keep_last, older_than_days, dry_run):
|
|
825
|
+
"""Squash old checkpoints to keep history manageable at scale.
|
|
826
|
+
|
|
827
|
+
Like `git rebase --autosquash`, but for memory history. Collapses old
|
|
828
|
+
checkpoints into a single baseline so the store stays fast even at
|
|
829
|
+
10,000+ commits. The current memory state is always preserved — only
|
|
830
|
+
the historical chain is compressed.
|
|
831
|
+
|
|
832
|
+
Examples:
|
|
833
|
+
|
|
834
|
+
memgit squash --keep-last 100 Keep only the last 100 checkpoints
|
|
835
|
+
|
|
836
|
+
memgit squash --older-than 30 Squash everything older than 30 days
|
|
837
|
+
|
|
838
|
+
memgit squash --dry-run Preview without making changes
|
|
839
|
+
"""
|
|
840
|
+
repo = _require_repo()
|
|
841
|
+
result = repo.squash(
|
|
842
|
+
keep_last=keep_last,
|
|
843
|
+
older_than_days=older_than_days,
|
|
844
|
+
dry_run=dry_run,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
kept = result['kept']
|
|
848
|
+
squashed = result['squashed']
|
|
849
|
+
|
|
850
|
+
if squashed == 0:
|
|
851
|
+
console.print('[yellow]Nothing to squash (too few checkpoints).[/yellow]')
|
|
852
|
+
return
|
|
853
|
+
|
|
854
|
+
if dry_run:
|
|
855
|
+
console.print(f'[dim]dry-run:[/dim] would squash [yellow]{squashed}[/yellow] checkpoints '
|
|
856
|
+
f'(baseline: {result["baseline_ts"]}) '
|
|
857
|
+
f'→ keep [green]{kept}[/green] recent ones')
|
|
858
|
+
else:
|
|
859
|
+
console.print(f'[green]squash[/green] {squashed} old checkpoints '
|
|
860
|
+
f'→ baseline at {result["baseline_ts"]} '
|
|
861
|
+
f'[dim]({kept} kept, new HEAD: {result.get("new_head", "?")})[/dim]')
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
# ── git ───────────────────────────────────────────────────────────────────────
|
|
865
|
+
|
|
866
|
+
@cli.group()
|
|
867
|
+
def git():
|
|
868
|
+
"""Git-native sync — push/pull memories across machines and teammates.
|
|
869
|
+
|
|
870
|
+
memgit stores are plain git repos under the hood. Every memory is a
|
|
871
|
+
readable .toon file in memories/. Standard git commands work on them.
|
|
872
|
+
|
|
873
|
+
Quick start:
|
|
874
|
+
|
|
875
|
+
memgit git init Initialize git in your store
|
|
876
|
+
|
|
877
|
+
memgit git push Push memories to remote
|
|
878
|
+
|
|
879
|
+
memgit git pull Pull teammate memories from remote
|
|
880
|
+
|
|
881
|
+
memgit git export Write flat memories/ files (no push)
|
|
882
|
+
|
|
883
|
+
memgit git status Show what's changed since last push
|
|
884
|
+
"""
|
|
885
|
+
pass
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@git.command('init')
|
|
889
|
+
@click.option('--remote', default=None, help='Git remote URL to add as "origin" (optional)')
|
|
890
|
+
def git_init(remote):
|
|
891
|
+
"""Initialize git in the memory store for team sync.
|
|
892
|
+
|
|
893
|
+
After this, your memory store is a regular git repo. You can:
|
|
894
|
+
|
|
895
|
+
cd ~/.claude/memgit-store
|
|
896
|
+
|
|
897
|
+
git remote add origin git@github.com:yourteam/ai-memory.git
|
|
898
|
+
|
|
899
|
+
git push -u origin main
|
|
900
|
+
|
|
901
|
+
Then teammates run `memgit git pull` to get your memories.
|
|
902
|
+
"""
|
|
903
|
+
repo = _require_repo()
|
|
904
|
+
ok = repo.git_init()
|
|
905
|
+
if not ok:
|
|
906
|
+
err.print('[red]git init failed — is git installed?[/red]')
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
store_root = repo.path.parent
|
|
910
|
+
if remote:
|
|
911
|
+
try:
|
|
912
|
+
import subprocess
|
|
913
|
+
subprocess.run(['git', 'remote', 'add', 'origin', remote],
|
|
914
|
+
cwd=store_root, check=True, capture_output=True)
|
|
915
|
+
console.print(f'[green]git init[/green] {store_root} remote: {remote}')
|
|
916
|
+
except Exception:
|
|
917
|
+
console.print(f'[green]git init[/green] {store_root} [yellow](remote add failed — add manually)[/yellow]')
|
|
918
|
+
else:
|
|
919
|
+
console.print(f'[green]git init[/green] {store_root}')
|
|
920
|
+
console.print(f'[dim]Add a remote: cd {store_root} && git remote add origin <url>[/dim]')
|
|
921
|
+
|
|
922
|
+
# Write initial flat files
|
|
923
|
+
repo.write_flat()
|
|
924
|
+
mem_count = len(list((store_root / 'memories').glob('*.toon')))
|
|
925
|
+
console.print(f'[dim]memories/: {mem_count} .toon files ready to commit[/dim]')
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@git.command('export')
|
|
929
|
+
def git_export():
|
|
930
|
+
"""Write all memories as flat .toon files in memories/ without pushing.
|
|
931
|
+
|
|
932
|
+
Creates one file per memory: memories/{slug}.toon
|
|
933
|
+
|
|
934
|
+
Files are human-readable, greppable, and diff-friendly. You can also
|
|
935
|
+
search across all your memories with: grep -r "trading" memories/
|
|
936
|
+
"""
|
|
937
|
+
repo = _require_repo()
|
|
938
|
+
repo.write_flat()
|
|
939
|
+
store_root = repo.path.parent
|
|
940
|
+
count = len(list((store_root / 'memories').glob('*.toon')))
|
|
941
|
+
console.print(f'[green]export[/green] {count} memories → {store_root / "memories"}')
|
|
942
|
+
console.print(f'[dim]Grep them: grep -rl "your query" {store_root / "memories"}[/dim]')
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
@git.command('push')
|
|
946
|
+
@click.argument('remote', default='origin')
|
|
947
|
+
@click.argument('branch', default='main')
|
|
948
|
+
@click.option('--message', '-m', default=None, help='Git commit message')
|
|
949
|
+
def git_push(remote, branch, message):
|
|
950
|
+
"""Write flat files, git commit, and push to remote.
|
|
951
|
+
|
|
952
|
+
This is how you share memories with teammates:
|
|
953
|
+
|
|
954
|
+
memgit git push Push to origin/main
|
|
955
|
+
|
|
956
|
+
memgit git push upstream feature Push to upstream/feature
|
|
957
|
+
"""
|
|
958
|
+
repo = _require_repo()
|
|
959
|
+
ok, msg = repo.git_push(remote=remote, branch=branch, message=message)
|
|
960
|
+
color = 'green' if ok else 'red'
|
|
961
|
+
console.print(f'[{color}]{"push" if ok else "error"}[/{color}] {msg}')
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
@git.command('pull')
|
|
965
|
+
@click.argument('remote', default='origin')
|
|
966
|
+
@click.argument('branch', default='main')
|
|
967
|
+
def git_pull_cmd(remote, branch):
|
|
968
|
+
"""Pull memories from a git remote and import them.
|
|
969
|
+
|
|
970
|
+
After a `git pull`, memgit imports any new or updated memories from
|
|
971
|
+
the memories/ flat files and creates a new checkpoint.
|
|
972
|
+
|
|
973
|
+
Teammate workflow:
|
|
974
|
+
|
|
975
|
+
git clone git@github.com:yourteam/ai-memory.git ~/.claude/memgit-store
|
|
976
|
+
|
|
977
|
+
memgit git pull # then pull updates anytime
|
|
978
|
+
"""
|
|
979
|
+
repo = _require_repo()
|
|
980
|
+
ok, msg, count = repo.git_pull(remote=remote, branch=branch)
|
|
981
|
+
color = 'green' if ok else 'red'
|
|
982
|
+
console.print(f'[{color}]{"pull" if ok else "error"}[/{color}] {msg}')
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@git.command('status')
|
|
986
|
+
def git_status_cmd():
|
|
987
|
+
"""Show what's changed in the memory store since the last git commit."""
|
|
988
|
+
repo = _require_repo()
|
|
989
|
+
status = repo.git_status()
|
|
990
|
+
if status is None:
|
|
991
|
+
console.print('[yellow]Not a git repo. Run `memgit git init` first.[/yellow]')
|
|
992
|
+
return
|
|
993
|
+
if not status:
|
|
994
|
+
console.print('[green]Nothing to push — memories are in sync.[/green]')
|
|
995
|
+
else:
|
|
996
|
+
console.print('[bold]Changes since last git commit:[/bold]')
|
|
997
|
+
console.print(status)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
# ── setup ─────────────────────────────────────────────────────────────────────
|
|
1001
|
+
|
|
1002
|
+
import shutil as _shutil
|
|
1003
|
+
import json as _json
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _memgit_cmd() -> list[str]:
|
|
1007
|
+
"""Return the best command to launch `memgit serve`.
|
|
1008
|
+
|
|
1009
|
+
Priority: running binary path > which > python -m fallback.
|
|
1010
|
+
sys.argv[0] ensures we register the exact binary that ran setup,
|
|
1011
|
+
not whatever 'memgit' is first in PATH.
|
|
1012
|
+
"""
|
|
1013
|
+
import sys as _sys, os as _os
|
|
1014
|
+
argv0 = _sys.argv[0]
|
|
1015
|
+
if _os.path.isabs(argv0) and _os.path.isfile(argv0):
|
|
1016
|
+
return [argv0, 'serve']
|
|
1017
|
+
resolved = _os.path.realpath(argv0) if argv0 else None
|
|
1018
|
+
if resolved and _os.path.isfile(resolved):
|
|
1019
|
+
return [resolved, 'serve']
|
|
1020
|
+
binary = _shutil.which('memgit')
|
|
1021
|
+
if binary:
|
|
1022
|
+
return [binary, 'serve']
|
|
1023
|
+
return [_sys.executable, '-m', 'memgit.cli', 'serve']
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _mcp_server_entry() -> dict:
|
|
1027
|
+
cmd = _memgit_cmd()
|
|
1028
|
+
return {'command': cmd[0], 'args': cmd[1:]}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _write_json_safe(path: Path, data: dict) -> None:
|
|
1032
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1033
|
+
path.write_text(_json.dumps(data, indent=2) + '\n', encoding='utf-8')
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _patch_mcp_servers(config_path: Path, dry_run: bool = False) -> str:
|
|
1037
|
+
"""Upsert mcpServers.memgit in a JSON config file. Returns status string."""
|
|
1038
|
+
if config_path.exists():
|
|
1039
|
+
try:
|
|
1040
|
+
data = _json.loads(config_path.read_text(encoding='utf-8'))
|
|
1041
|
+
except _json.JSONDecodeError:
|
|
1042
|
+
data = {}
|
|
1043
|
+
else:
|
|
1044
|
+
data = {}
|
|
1045
|
+
|
|
1046
|
+
servers = data.setdefault('mcpServers', {})
|
|
1047
|
+
existing = servers.get('memgit')
|
|
1048
|
+
entry = _mcp_server_entry()
|
|
1049
|
+
|
|
1050
|
+
if existing == entry:
|
|
1051
|
+
return 'already registered'
|
|
1052
|
+
|
|
1053
|
+
servers['memgit'] = entry
|
|
1054
|
+
if not dry_run:
|
|
1055
|
+
_write_json_safe(config_path, data)
|
|
1056
|
+
return 'updated' if existing else 'registered'
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _patch_continue(config_path: Path, dry_run: bool = False) -> str:
|
|
1060
|
+
"""Patch Continue.dev config.json which uses a list, not a dict."""
|
|
1061
|
+
if config_path.exists():
|
|
1062
|
+
try:
|
|
1063
|
+
data = _json.loads(config_path.read_text(encoding='utf-8'))
|
|
1064
|
+
except _json.JSONDecodeError:
|
|
1065
|
+
data = {}
|
|
1066
|
+
else:
|
|
1067
|
+
data = {}
|
|
1068
|
+
|
|
1069
|
+
entry = _mcp_server_entry()
|
|
1070
|
+
entry['name'] = 'memgit'
|
|
1071
|
+
|
|
1072
|
+
servers: list = data.setdefault('mcpServers', [])
|
|
1073
|
+
for i, s in enumerate(servers):
|
|
1074
|
+
if s.get('name') == 'memgit':
|
|
1075
|
+
if s == entry:
|
|
1076
|
+
return 'already registered'
|
|
1077
|
+
servers[i] = entry
|
|
1078
|
+
if not dry_run:
|
|
1079
|
+
_write_json_safe(config_path, data)
|
|
1080
|
+
return 'updated'
|
|
1081
|
+
|
|
1082
|
+
servers.append(entry)
|
|
1083
|
+
if not dry_run:
|
|
1084
|
+
_write_json_safe(config_path, data)
|
|
1085
|
+
return 'registered'
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
# Targets: (label, config_path_fn, patch_fn)
|
|
1089
|
+
def _all_targets():
|
|
1090
|
+
home = Path.home()
|
|
1091
|
+
app_support = home / 'Library' / 'Application Support'
|
|
1092
|
+
linux_config = home / '.config'
|
|
1093
|
+
return [
|
|
1094
|
+
(
|
|
1095
|
+
'Claude Code',
|
|
1096
|
+
home / '.claude' / 'settings.json',
|
|
1097
|
+
_patch_mcp_servers,
|
|
1098
|
+
),
|
|
1099
|
+
(
|
|
1100
|
+
'Claude Desktop (macOS)',
|
|
1101
|
+
app_support / 'Claude' / 'claude_desktop_config.json',
|
|
1102
|
+
_patch_mcp_servers,
|
|
1103
|
+
),
|
|
1104
|
+
(
|
|
1105
|
+
'Claude Desktop (Linux)',
|
|
1106
|
+
linux_config / 'Claude' / 'claude_desktop_config.json',
|
|
1107
|
+
_patch_mcp_servers,
|
|
1108
|
+
),
|
|
1109
|
+
(
|
|
1110
|
+
'Cursor',
|
|
1111
|
+
home / '.cursor' / 'mcp.json',
|
|
1112
|
+
_patch_mcp_servers,
|
|
1113
|
+
),
|
|
1114
|
+
(
|
|
1115
|
+
'Windsurf',
|
|
1116
|
+
home / '.windsurf' / 'mcp.json',
|
|
1117
|
+
_patch_mcp_servers,
|
|
1118
|
+
),
|
|
1119
|
+
(
|
|
1120
|
+
'Cline (VS Code)',
|
|
1121
|
+
app_support / 'Code' / 'User' / 'globalStorage' / 'saoudrizwan.claude-dev' / 'settings' / 'cline_mcp_settings.json',
|
|
1122
|
+
_patch_mcp_servers,
|
|
1123
|
+
),
|
|
1124
|
+
(
|
|
1125
|
+
'Roo-Code (VS Code)',
|
|
1126
|
+
app_support / 'Code' / 'User' / 'globalStorage' / 'rooveterinaryinc.roo-cline' / 'settings' / 'cline_mcp_settings.json',
|
|
1127
|
+
_patch_mcp_servers,
|
|
1128
|
+
),
|
|
1129
|
+
(
|
|
1130
|
+
'Continue.dev',
|
|
1131
|
+
home / '.continue' / 'config.json',
|
|
1132
|
+
_patch_continue,
|
|
1133
|
+
),
|
|
1134
|
+
]
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
@cli.group()
|
|
1138
|
+
def setup():
|
|
1139
|
+
"""Register memgit with AI coding tools (MCP).
|
|
1140
|
+
|
|
1141
|
+
Writes the memgit MCP server entry into each tool's config file.
|
|
1142
|
+
Safe to run multiple times — only updates what's missing.
|
|
1143
|
+
"""
|
|
1144
|
+
pass
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _run_target(label: str, config_path: Path, patch_fn, dry_run: bool) -> None:
|
|
1148
|
+
exists = config_path.exists()
|
|
1149
|
+
if not exists and label.startswith('Claude Desktop (Linux)') and (Path.home() / 'Library').exists():
|
|
1150
|
+
# Skip Linux path on macOS
|
|
1151
|
+
return
|
|
1152
|
+
if not exists and 'Linux' in label and not (Path.home() / '.config').exists():
|
|
1153
|
+
return
|
|
1154
|
+
|
|
1155
|
+
try:
|
|
1156
|
+
status = patch_fn(config_path, dry_run=dry_run)
|
|
1157
|
+
icon = '[green]✓[/green]' if 'registered' in status or 'already' in status else '[yellow]↻[/yellow]'
|
|
1158
|
+
note = ' [dim](dry run)[/dim]' if dry_run else ''
|
|
1159
|
+
console.print(f'{icon} {label}: {status}{note}')
|
|
1160
|
+
console.print(f' [dim]{config_path}[/dim]')
|
|
1161
|
+
except Exception as e:
|
|
1162
|
+
console.print(f'[red]✗[/red] {label}: {e}')
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
@setup.command('all')
|
|
1166
|
+
@click.option('--dry-run', is_flag=True, help='Show what would be changed without writing files.')
|
|
1167
|
+
def setup_all(dry_run):
|
|
1168
|
+
"""Detect all installed AI tools and register memgit with each.
|
|
1169
|
+
|
|
1170
|
+
Safe to re-run. Skips tools that aren't installed (config dir missing).
|
|
1171
|
+
Only registers tools whose config file already exists OR whose parent dir exists.
|
|
1172
|
+
"""
|
|
1173
|
+
cmd = _memgit_cmd()
|
|
1174
|
+
console.print(f'[bold]memgit setup all[/bold] command=[cyan]{" ".join(cmd)}[/cyan]\n')
|
|
1175
|
+
|
|
1176
|
+
registered = 0
|
|
1177
|
+
skipped = 0
|
|
1178
|
+
for label, config_path, patch_fn in _all_targets():
|
|
1179
|
+
# Skip if neither the file nor its parent directory exists
|
|
1180
|
+
# (tool not installed on this machine)
|
|
1181
|
+
if not config_path.exists() and not config_path.parent.exists():
|
|
1182
|
+
skipped += 1
|
|
1183
|
+
continue
|
|
1184
|
+
_run_target(label, config_path, patch_fn, dry_run)
|
|
1185
|
+
registered += 1
|
|
1186
|
+
|
|
1187
|
+
console.print(f'\n[dim]{registered} tool(s) processed, {skipped} not installed (skipped)[/dim]')
|
|
1188
|
+
if not dry_run and registered:
|
|
1189
|
+
console.print('[dim]Restart each AI tool for changes to take effect.[/dim]')
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
@setup.command('claude-code')
|
|
1193
|
+
@click.option('--dry-run', is_flag=True)
|
|
1194
|
+
def setup_claude_code(dry_run):
|
|
1195
|
+
"""Register with Claude Code (~/.claude/settings.json)."""
|
|
1196
|
+
path = Path.home() / '.claude' / 'settings.json'
|
|
1197
|
+
_run_target('Claude Code', path, _patch_mcp_servers, dry_run)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
@setup.command('claude-desktop')
|
|
1201
|
+
@click.option('--dry-run', is_flag=True)
|
|
1202
|
+
def setup_claude_desktop(dry_run):
|
|
1203
|
+
"""Register with Claude Desktop app."""
|
|
1204
|
+
mac = Path.home() / 'Library' / 'Application Support' / 'Claude' / 'claude_desktop_config.json'
|
|
1205
|
+
linux = Path.home() / '.config' / 'Claude' / 'claude_desktop_config.json'
|
|
1206
|
+
path = mac if mac.parent.exists() else linux
|
|
1207
|
+
_run_target('Claude Desktop', path, _patch_mcp_servers, dry_run)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
@setup.command('cursor')
|
|
1211
|
+
@click.option('--dry-run', is_flag=True)
|
|
1212
|
+
def setup_cursor(dry_run):
|
|
1213
|
+
"""Register with Cursor (~/.cursor/mcp.json)."""
|
|
1214
|
+
path = Path.home() / '.cursor' / 'mcp.json'
|
|
1215
|
+
_run_target('Cursor', path, _patch_mcp_servers, dry_run)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
@setup.command('windsurf')
|
|
1219
|
+
@click.option('--dry-run', is_flag=True)
|
|
1220
|
+
def setup_windsurf(dry_run):
|
|
1221
|
+
"""Register with Windsurf (~/.windsurf/mcp.json)."""
|
|
1222
|
+
path = Path.home() / '.windsurf' / 'mcp.json'
|
|
1223
|
+
_run_target('Windsurf', path, _patch_mcp_servers, dry_run)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
@setup.command('cline')
|
|
1227
|
+
@click.option('--dry-run', is_flag=True)
|
|
1228
|
+
def setup_cline(dry_run):
|
|
1229
|
+
"""Register with Cline/Roo-Code (VS Code extension)."""
|
|
1230
|
+
base = Path.home() / 'Library' / 'Application Support' / 'Code' / 'User' / 'globalStorage'
|
|
1231
|
+
for slug, label in [
|
|
1232
|
+
('saoudrizwan.claude-dev', 'Cline'),
|
|
1233
|
+
('rooveterinaryinc.roo-cline', 'Roo-Code'),
|
|
1234
|
+
]:
|
|
1235
|
+
path = base / slug / 'settings' / 'cline_mcp_settings.json'
|
|
1236
|
+
if path.parent.exists() or path.exists():
|
|
1237
|
+
_run_target(label, path, _patch_mcp_servers, dry_run)
|
|
1238
|
+
else:
|
|
1239
|
+
console.print(f'[dim]skip {label} — not installed[/dim]')
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
@setup.command('continue')
|
|
1243
|
+
@click.option('--dry-run', is_flag=True)
|
|
1244
|
+
def setup_continue(dry_run):
|
|
1245
|
+
"""Register with Continue.dev (~/.continue/config.json)."""
|
|
1246
|
+
path = Path.home() / '.continue' / 'config.json'
|
|
1247
|
+
_run_target('Continue.dev', path, _patch_continue, dry_run)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
@setup.command('print-config')
|
|
1251
|
+
@click.argument('tool', default='generic',
|
|
1252
|
+
type=click.Choice(['claude-code', 'claude-desktop', 'cursor', 'windsurf', 'continue', 'generic']))
|
|
1253
|
+
def setup_print_config(tool):
|
|
1254
|
+
"""Print the config snippet to copy-paste manually.
|
|
1255
|
+
|
|
1256
|
+
Useful when you need to edit the config yourself or when auto-setup fails.
|
|
1257
|
+
"""
|
|
1258
|
+
entry = _mcp_server_entry()
|
|
1259
|
+
|
|
1260
|
+
if tool == 'continue':
|
|
1261
|
+
snippet = _json.dumps({'mcpServers': [{'name': 'memgit', **entry}]}, indent=2)
|
|
1262
|
+
else:
|
|
1263
|
+
snippet = _json.dumps({'mcpServers': {'memgit': entry}}, indent=2)
|
|
1264
|
+
|
|
1265
|
+
console.print(f'\n[bold]Config snippet for {tool}:[/bold]\n')
|
|
1266
|
+
console.print(snippet)
|
|
1267
|
+
console.print('\n[dim]Merge this into your tool\'s config file.[/dim]')
|