memgit 0.3.1__tar.gz → 0.4.0__tar.gz
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-0.3.1 → memgit-0.4.0}/PKG-INFO +15 -5
- {memgit-0.3.1 → memgit-0.4.0}/README.md +14 -4
- {memgit-0.3.1 → memgit-0.4.0}/memgit/__init__.py +1 -1
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cli.py +154 -61
- memgit-0.4.0/memgit/hooks.py +212 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/importer.py +11 -31
- {memgit-0.3.1 → memgit-0.4.0}/memgit/mcp_server.py +30 -4
- memgit-0.4.0/memgit/project.py +90 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/repo.py +44 -9
- {memgit-0.3.1 → memgit-0.4.0}/memgit/scorer.py +13 -4
- {memgit-0.3.1 → memgit-0.4.0}/memgit/toon.py +33 -6
- {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/PKG-INFO +15 -5
- {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/SOURCES.txt +4 -1
- {memgit-0.3.1 → memgit-0.4.0}/pyproject.toml +1 -1
- memgit-0.4.0/tests/test_v040.py +371 -0
- {memgit-0.3.1 → memgit-0.4.0}/LICENSE +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/__init__.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/client.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/commands.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/crypto.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/state.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/sync.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/gitdigest.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/graph.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/http_server.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/models.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/store.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit/tokens.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/dependency_links.txt +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/entry_points.txt +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/requires.txt +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/top_level.txt +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/setup.cfg +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/tests/test_advanced.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/tests/test_setup.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/tests/test_store_repo.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/tests/test_toon.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/tests/test_v020.py +0 -0
- {memgit-0.3.1 → memgit-0.4.0}/tests/test_v030.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memgit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://memgit.dev
|
|
@@ -212,13 +212,22 @@ memgit resume --plain # plain text, for piping into an AI context
|
|
|
212
212
|
memgit resume --json # for tooling
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
-
Wire it into Claude Code so
|
|
215
|
+
Wire it into Claude Code so memory becomes **automatic** — no tool call, no judgment required:
|
|
216
216
|
|
|
217
217
|
```bash
|
|
218
|
-
memgit setup hooks # installs
|
|
218
|
+
memgit setup hooks # installs all four hooks (~/.claude/settings.json)
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
| Hook | What it enforces |
|
|
222
|
+
|---|---|
|
|
223
|
+
| `SessionStart` | every session opens with the resume digest in context |
|
|
224
|
+
| `UserPromptSubmit` | each prompt is BM25-matched against the store; relevant memories are injected (silent when nothing clears the relevance bar; never repeats within a session) — `--no-recall` to skip |
|
|
225
|
+
| `Stop` (guard) | a session that did real work but saved nothing gets ONE nudge to save durable facts before finishing — `--no-guard` to skip |
|
|
226
|
+
| `Stop` (sync) | markdown memories are checkpointed asynchronously at session end |
|
|
227
|
+
|
|
228
|
+
Why hooks and not just good tool descriptions? We measured it: across 166 real sessions, hook-injected context was delivered in **100%** of them while voluntary memory-tool calls happened in **6%**. What a hook enforces happens.
|
|
229
|
+
|
|
230
|
+
The resume digest is deliberately bounded (~350 tokens measured on a 500-memory store): rules are clipped, the critical list is capped, and full text is one `get_memory` call away.
|
|
222
231
|
|
|
223
232
|
---
|
|
224
233
|
|
|
@@ -322,7 +331,8 @@ memgit setup windsurf
|
|
|
322
331
|
memgit setup cline
|
|
323
332
|
memgit setup continue
|
|
324
333
|
memgit setup gemini-cli
|
|
325
|
-
memgit setup hooks # Claude Code
|
|
334
|
+
memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
|
|
335
|
+
# capture guard + auto-sync at stop (--no-recall / --no-guard)
|
|
326
336
|
|
|
327
337
|
# Server
|
|
328
338
|
memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
|
|
@@ -178,13 +178,22 @@ memgit resume --plain # plain text, for piping into an AI context
|
|
|
178
178
|
memgit resume --json # for tooling
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
Wire it into Claude Code so
|
|
181
|
+
Wire it into Claude Code so memory becomes **automatic** — no tool call, no judgment required:
|
|
182
182
|
|
|
183
183
|
```bash
|
|
184
|
-
memgit setup hooks # installs
|
|
184
|
+
memgit setup hooks # installs all four hooks (~/.claude/settings.json)
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
| Hook | What it enforces |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `SessionStart` | every session opens with the resume digest in context |
|
|
190
|
+
| `UserPromptSubmit` | each prompt is BM25-matched against the store; relevant memories are injected (silent when nothing clears the relevance bar; never repeats within a session) — `--no-recall` to skip |
|
|
191
|
+
| `Stop` (guard) | a session that did real work but saved nothing gets ONE nudge to save durable facts before finishing — `--no-guard` to skip |
|
|
192
|
+
| `Stop` (sync) | markdown memories are checkpointed asynchronously at session end |
|
|
193
|
+
|
|
194
|
+
Why hooks and not just good tool descriptions? We measured it: across 166 real sessions, hook-injected context was delivered in **100%** of them while voluntary memory-tool calls happened in **6%**. What a hook enforces happens.
|
|
195
|
+
|
|
196
|
+
The resume digest is deliberately bounded (~350 tokens measured on a 500-memory store): rules are clipped, the critical list is capped, and full text is one `get_memory` call away.
|
|
188
197
|
|
|
189
198
|
---
|
|
190
199
|
|
|
@@ -288,7 +297,8 @@ memgit setup windsurf
|
|
|
288
297
|
memgit setup cline
|
|
289
298
|
memgit setup continue
|
|
290
299
|
memgit setup gemini-cli
|
|
291
|
-
memgit setup hooks # Claude Code
|
|
300
|
+
memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
|
|
301
|
+
# capture guard + auto-sync at stop (--no-recall / --no-guard)
|
|
292
302
|
|
|
293
303
|
# Server
|
|
294
304
|
memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
|
|
@@ -29,11 +29,10 @@ def _require_repo() -> Repository:
|
|
|
29
29
|
|
|
30
30
|
# ── Root group ────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
_version = '0.1.2'
|
|
32
|
+
# Source of truth is the code, not dist metadata: editable installs keep
|
|
33
|
+
# whatever metadata version existed at `pip install -e` time (observed: a
|
|
34
|
+
# 0.3.1 checkout reporting 0.1.0 via importlib.metadata).
|
|
35
|
+
from . import __version__ as _version
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
@click.group()
|
|
@@ -127,7 +126,8 @@ def init(directory):
|
|
|
127
126
|
@click.option('--body', '-b', default=None,
|
|
128
127
|
help='Full long-form detail (multi-line ok, or "-" to read stdin)')
|
|
129
128
|
@click.option('--project', '-P', default=None,
|
|
130
|
-
help='Project this memory belongs to (
|
|
129
|
+
help='Project this memory belongs to (default: derived from the '
|
|
130
|
+
'current directory; pass "" for a global memory)')
|
|
131
131
|
def add(slug, rule, type_code, why, when, tags, priority, body, project):
|
|
132
132
|
"""Add or update a mnemonic.
|
|
133
133
|
|
|
@@ -138,6 +138,13 @@ def add(slug, rule, type_code, why, when, tags, priority, body, project):
|
|
|
138
138
|
tag_list = [t.strip() for t in tags.split(',')] if tags else []
|
|
139
139
|
if body == '-':
|
|
140
140
|
body = sys.stdin.read().strip() or None
|
|
141
|
+
# Same scoping semantics as MCP save_memory: absent → this workspace,
|
|
142
|
+
# explicit empty → deliberately global.
|
|
143
|
+
if project is None:
|
|
144
|
+
from .project import project_label_from_path
|
|
145
|
+
project = project_label_from_path(Path.cwd())
|
|
146
|
+
elif not project.strip():
|
|
147
|
+
project = None
|
|
141
148
|
|
|
142
149
|
m = Mnemonic(
|
|
143
150
|
type_code=type_code,
|
|
@@ -152,7 +159,8 @@ def add(slug, rule, type_code, why, when, tags, priority, body, project):
|
|
|
152
159
|
project=project,
|
|
153
160
|
)
|
|
154
161
|
sha = repo.add(m)
|
|
155
|
-
|
|
162
|
+
from rich.markup import escape as _mesc
|
|
163
|
+
console.print(f'[green]staged[/green] {_mesc(m.slug)} {_mesc("[" + sha[:8] + "]")}')
|
|
156
164
|
|
|
157
165
|
|
|
158
166
|
# ── remove ────────────────────────────────────────────────────────────────────
|
|
@@ -355,12 +363,42 @@ def _format_resume_plain(ctx: dict) -> str:
|
|
|
355
363
|
if ctx.get('maintenance'):
|
|
356
364
|
lines.append('')
|
|
357
365
|
lines.append(f'## Maintenance needed\n- {ctx["maintenance"]}')
|
|
366
|
+
if ctx.get('project_is_new'):
|
|
367
|
+
lines.append('')
|
|
368
|
+
lines.append(
|
|
369
|
+
f'## This project has no memories yet ({ctx["project"]})\n'
|
|
370
|
+
'- memgit was adopted mid-project: nothing above is specific to this '
|
|
371
|
+
'workspace. Bootstrap it once — run `memgit onboard` for a repo '
|
|
372
|
+
'digest + seeding brief, then save 10-20 durable facts '
|
|
373
|
+
'(purpose, architecture, conventions, state, gotchas) via save_memory.'
|
|
374
|
+
)
|
|
358
375
|
lines.append('')
|
|
359
376
|
lines.append('(Check work-in-flight and the last checkpoints before assuming state; '
|
|
360
377
|
'use memgit search for anything task-specific.)')
|
|
361
378
|
return '\n'.join(lines)
|
|
362
379
|
|
|
363
380
|
|
|
381
|
+
# ── hook handlers (invoked by AI-tool hosts, not humans) ─────────────────────
|
|
382
|
+
|
|
383
|
+
@cli.group(name='hook')
|
|
384
|
+
def hook():
|
|
385
|
+
"""Hook handlers for AI-tool hosts (installed by `memgit setup hooks`)."""
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@hook.command('prompt-recall')
|
|
389
|
+
def hook_prompt_recall():
|
|
390
|
+
"""UserPromptSubmit: inject memories relevant to the prompt (stdin JSON)."""
|
|
391
|
+
from .hooks import prompt_recall
|
|
392
|
+
sys.exit(prompt_recall())
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@hook.command('stop-guard')
|
|
396
|
+
def hook_stop_guard():
|
|
397
|
+
"""Stop: nudge once if a substantive session saved nothing (stdin JSON)."""
|
|
398
|
+
from .hooks import stop_guard
|
|
399
|
+
sys.exit(stop_guard())
|
|
400
|
+
|
|
401
|
+
|
|
364
402
|
# ── onboard ───────────────────────────────────────────────────────────────────
|
|
365
403
|
|
|
366
404
|
ONBOARD_BRIEF = """\
|
|
@@ -602,35 +640,40 @@ def show(slug, toon, fmt_markdown):
|
|
|
602
640
|
elif toon:
|
|
603
641
|
print(serialize_mnemonic(m))
|
|
604
642
|
else:
|
|
643
|
+
# User content and shas go through markup escaping — `[pj]`,
|
|
644
|
+
# `[[wikilinks]]`, or a sha like [fadc1234] would otherwise be
|
|
645
|
+
# eaten as rich tags and silently altered on screen.
|
|
646
|
+
from rich.markup import escape as _mesc
|
|
605
647
|
sha_s = m.sha[:8] if m.sha else '?'
|
|
606
648
|
p_label = {1: 'low', 2: 'medium', 3: '[bold red]CRITICAL[/bold red]'}[m.priority]
|
|
607
|
-
proj = f' project={m.project}' if m.project else ''
|
|
608
|
-
console.print(f'[bold cyan]{m.slug}[/bold cyan] [
|
|
649
|
+
proj = f' project={_mesc(m.project)}' if m.project else ''
|
|
650
|
+
console.print(f'[bold cyan]{_mesc(m.slug)}[/bold cyan] {_mesc("[" + m.type_code + "]")} '
|
|
651
|
+
f'priority={p_label}{proj} sha={_mesc(sha_s)}')
|
|
609
652
|
console.print(f'')
|
|
610
|
-
console.print(f'[bold]RULE[/bold] {m.rule}')
|
|
653
|
+
console.print(f'[bold]RULE[/bold] {_mesc(m.rule)}')
|
|
611
654
|
if m.why:
|
|
612
|
-
console.print(f'[bold]WHY[/bold] {m.why}')
|
|
655
|
+
console.print(f'[bold]WHY[/bold] {_mesc(m.why)}')
|
|
613
656
|
if m.when:
|
|
614
|
-
console.print(f'[bold]WHEN[/bold] {m.when}')
|
|
657
|
+
console.print(f'[bold]WHEN[/bold] {_mesc(m.when)}')
|
|
615
658
|
if m.desc:
|
|
616
|
-
console.print(f'[bold]DESC[/bold] {m.desc}')
|
|
659
|
+
console.print(f'[bold]DESC[/bold] {_mesc(m.desc)}')
|
|
617
660
|
if m.body:
|
|
618
661
|
console.print(f'\n[bold]BODY[/bold]')
|
|
619
|
-
console.print(m.body)
|
|
662
|
+
console.print(m.body, markup=False)
|
|
620
663
|
if m.who:
|
|
621
|
-
console.print(f'[bold]WHO[/bold] {m.who}')
|
|
664
|
+
console.print(f'[bold]WHO[/bold] {_mesc(m.who)}')
|
|
622
665
|
if m.where:
|
|
623
|
-
console.print(f'[bold]WHERE[/bold] {m.where}')
|
|
666
|
+
console.print(f'[bold]WHERE[/bold] {_mesc(m.where)}')
|
|
624
667
|
if m.inc:
|
|
625
|
-
console.print(f'[bold]INC[/bold] {m.inc}')
|
|
668
|
+
console.print(f'[bold]INC[/bold] {_mesc(m.inc)}')
|
|
626
669
|
if m.cost:
|
|
627
|
-
console.print(f'[bold]COST[/bold] {m.cost}')
|
|
670
|
+
console.print(f'[bold]COST[/bold] {_mesc(m.cost)}')
|
|
628
671
|
if m.tags:
|
|
629
|
-
console.print(f'[dim]Tags: {", ".join(m.tags)}[/dim]')
|
|
672
|
+
console.print(f'[dim]Tags: {_mesc(", ".join(m.tags))}[/dim]')
|
|
630
673
|
if m.related:
|
|
631
|
-
console.print(f'[dim]Related: {", ".join(m.related)}[/dim]')
|
|
674
|
+
console.print(f'[dim]Related: {_mesc(", ".join(m.related))}[/dim]')
|
|
632
675
|
if m.supersedes:
|
|
633
|
-
console.print(f'[dim]Supersedes: {", ".join(m.supersedes)}[/dim]')
|
|
676
|
+
console.print(f'[dim]Supersedes: {_mesc(", ".join(m.supersedes))}[/dim]')
|
|
634
677
|
|
|
635
678
|
|
|
636
679
|
# ── list ──────────────────────────────────────────────────────────────────────
|
|
@@ -864,6 +907,7 @@ def lint():
|
|
|
864
907
|
console.print(f'[green]OK[/green] — {len(mnemonics)} mnemonics, no issues')
|
|
865
908
|
else:
|
|
866
909
|
console.print(f'[yellow]{issues} issue{"s" if issues != 1 else ""}[/yellow]')
|
|
910
|
+
sys.exit(1) # let scripts/CI gate on lint
|
|
867
911
|
|
|
868
912
|
|
|
869
913
|
import re # noqa: E402 — needed for lint command
|
|
@@ -1002,6 +1046,15 @@ def sync(message, dry_run):
|
|
|
1002
1046
|
mnemonics = from_claude_code()
|
|
1003
1047
|
|
|
1004
1048
|
if not mnemonics:
|
|
1049
|
+
# No markdown sources on this machine — but anything already staged
|
|
1050
|
+
# (MCP saves, CLI adds) must still be checkpointed, or it lingers
|
|
1051
|
+
# uncommitted forever on stores fed purely through MCP.
|
|
1052
|
+
if not dry_run:
|
|
1053
|
+
msg = message or _staged_diff_message(repo)
|
|
1054
|
+
sha = repo.commit(message=msg, trigger='session_end') if msg else None
|
|
1055
|
+
if sha:
|
|
1056
|
+
console.print(f'[green]sync[/green] {sha[:8]} {msg}')
|
|
1057
|
+
return
|
|
1005
1058
|
console.print('[dim]No Claude Code memories found.[/dim]')
|
|
1006
1059
|
return
|
|
1007
1060
|
|
|
@@ -1926,15 +1979,41 @@ def setup_gemini_cli(dry_run):
|
|
|
1926
1979
|
_run_target('Gemini CLI', path, _patch_mcp_servers, dry_run)
|
|
1927
1980
|
|
|
1928
1981
|
|
|
1982
|
+
#: substrings identifying a hook command as one of ours (any generation)
|
|
1983
|
+
_MEMGIT_HOOK_SIGNS = ('resume --plain', 'hook prompt-recall', 'hook stop-guard', ' sync')
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _is_memgit_hook_entry(h: dict) -> bool:
|
|
1987
|
+
return any(
|
|
1988
|
+
'memgit' in inner.get('command', '') and
|
|
1989
|
+
any(sign in inner.get('command', '') for sign in _MEMGIT_HOOK_SIGNS)
|
|
1990
|
+
for inner in h.get('hooks', []) if isinstance(inner, dict)
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
|
|
1929
1994
|
@setup.command('hooks')
|
|
1930
|
-
@click.option('--remove', is_flag=True, help='Uninstall
|
|
1995
|
+
@click.option('--remove', is_flag=True, help='Uninstall all memgit hooks')
|
|
1996
|
+
@click.option('--no-recall', is_flag=True,
|
|
1997
|
+
help='Skip the per-prompt auto-recall hook (UserPromptSubmit)')
|
|
1998
|
+
@click.option('--no-guard', is_flag=True,
|
|
1999
|
+
help='Skip the end-of-session capture guard (Stop)')
|
|
1931
2000
|
@click.option('--dry-run', is_flag=True, help='Show the change without writing')
|
|
1932
|
-
def setup_hooks(remove, dry_run):
|
|
1933
|
-
"""Install
|
|
2001
|
+
def setup_hooks(remove, no_recall, no_guard, dry_run):
|
|
2002
|
+
"""Install the Claude Code hooks that make memory automatic.
|
|
2003
|
+
|
|
2004
|
+
Four hooks, one principle: what a hook enforces happens, what a tool
|
|
2005
|
+
description suggests mostly doesn't (measured: 6% voluntary engagement
|
|
2006
|
+
vs 100% hook delivery).
|
|
1934
2007
|
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2008
|
+
\b
|
|
2009
|
+
SessionStart inject `memgit resume` — last checkpoints, work in
|
|
2010
|
+
flight, critical rules
|
|
2011
|
+
UserPromptSubmit inject memories relevant to each prompt (BM25,
|
|
2012
|
+
silent when nothing clears the relevance bar)
|
|
2013
|
+
Stop capture guard — a substantive session ending with
|
|
2014
|
+
zero memory writes gets ONE nudge to save durable
|
|
2015
|
+
facts; plus async `memgit sync` to checkpoint
|
|
2016
|
+
markdown memories
|
|
1938
2017
|
|
|
1939
2018
|
Hooks live in ~/.claude/settings.json (unlike MCP servers, which live
|
|
1940
2019
|
in ~/.claude.json).
|
|
@@ -1942,8 +2021,10 @@ def setup_hooks(remove, dry_run):
|
|
|
1942
2021
|
import shlex
|
|
1943
2022
|
settings_path = Path.home() / '.claude' / 'settings.json'
|
|
1944
2023
|
base = ' '.join(shlex.quote(p) for p in _memgit_base_cmd())
|
|
1945
|
-
|
|
1946
|
-
|
|
2024
|
+
|
|
2025
|
+
from .repo import default_store_candidates
|
|
2026
|
+
store = next((c for c in default_store_candidates() if (c / '.memgit').is_dir()),
|
|
2027
|
+
Path.home() / '.claude' / 'memgit-store')
|
|
1947
2028
|
|
|
1948
2029
|
if settings_path.exists():
|
|
1949
2030
|
try:
|
|
@@ -1955,43 +2036,55 @@ def setup_hooks(remove, dry_run):
|
|
|
1955
2036
|
data = {}
|
|
1956
2037
|
|
|
1957
2038
|
hooks = data.setdefault('hooks', {})
|
|
1958
|
-
session_start = hooks.setdefault('SessionStart', [])
|
|
1959
2039
|
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2040
|
+
# `|| true` + stderr silenced everywhere: a broken store must never
|
|
2041
|
+
# block the user's session.
|
|
2042
|
+
plan: dict[str, list[dict]] = {
|
|
2043
|
+
'SessionStart': [
|
|
2044
|
+
{'type': 'command',
|
|
2045
|
+
'command': f'{base} resume --plain 2>/dev/null || true'},
|
|
2046
|
+
],
|
|
2047
|
+
'UserPromptSubmit': [] if no_recall else [
|
|
2048
|
+
{'type': 'command',
|
|
2049
|
+
'command': f'{base} hook prompt-recall 2>/dev/null || true'},
|
|
2050
|
+
],
|
|
2051
|
+
'Stop': ([] if no_guard else [
|
|
2052
|
+
{'type': 'command',
|
|
2053
|
+
'command': f'{base} hook stop-guard 2>/dev/null || true'},
|
|
2054
|
+
]) + [
|
|
2055
|
+
{'type': 'command',
|
|
2056
|
+
'command': f'cd {shlex.quote(str(store))} && {base} sync 2>/dev/null || true',
|
|
2057
|
+
'async': True},
|
|
2058
|
+
],
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
changed = []
|
|
2062
|
+
for event in ('SessionStart', 'UserPromptSubmit', 'Stop'):
|
|
2063
|
+
entries = hooks.setdefault(event, [])
|
|
2064
|
+
had = [h for h in entries if isinstance(h, dict) and _is_memgit_hook_entry(h)]
|
|
2065
|
+
entries[:] = [h for h in entries if h not in had]
|
|
2066
|
+
if not remove and plan[event]:
|
|
2067
|
+
entries.append({'hooks': plan[event]})
|
|
2068
|
+
changed.append(event)
|
|
2069
|
+
if not entries:
|
|
2070
|
+
hooks.pop(event, None)
|
|
2071
|
+
if not hooks:
|
|
2072
|
+
data.pop('hooks', None)
|
|
1963
2073
|
|
|
1964
|
-
|
|
2074
|
+
if not dry_run:
|
|
2075
|
+
_write_json_safe(settings_path, data)
|
|
1965
2076
|
|
|
2077
|
+
suffix = ' [dim](dry run)[/dim]' if dry_run else ''
|
|
1966
2078
|
if remove:
|
|
1967
|
-
|
|
1968
|
-
console.print('[dim]No memgit SessionStart hook installed.[/dim]')
|
|
1969
|
-
return
|
|
1970
|
-
session_start[:] = [h for h in session_start if h not in existing]
|
|
1971
|
-
if not session_start:
|
|
1972
|
-
hooks.pop('SessionStart', None)
|
|
1973
|
-
if not hooks:
|
|
1974
|
-
data.pop('hooks', None)
|
|
1975
|
-
if not dry_run:
|
|
1976
|
-
_write_json_safe(settings_path, data)
|
|
1977
|
-
console.print(f'[yellow]removed[/yellow] memgit SessionStart hook from {settings_path}'
|
|
1978
|
-
+ (' [dim](dry run)[/dim]' if dry_run else ''))
|
|
2079
|
+
console.print(f'[yellow]removed[/yellow] all memgit hooks from {settings_path}{suffix}')
|
|
1979
2080
|
return
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
console.print(f'[
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
session_start.append(entry)
|
|
1988
|
-
|
|
1989
|
-
if not dry_run:
|
|
1990
|
-
_write_json_safe(settings_path, data)
|
|
1991
|
-
console.print(f'[green]✓[/green] SessionStart hook installed in {settings_path}'
|
|
1992
|
-
+ (' [dim](dry run)[/dim]' if dry_run else ''))
|
|
1993
|
-
console.print(f' [dim]{hook_command}[/dim]')
|
|
1994
|
-
console.print('[dim]Every new Claude Code session now starts with your memgit resume digest.[/dim]')
|
|
2081
|
+
console.print(f'[green]✓[/green] memgit hooks installed in {settings_path}{suffix}')
|
|
2082
|
+
for event in changed:
|
|
2083
|
+
for inner in plan[event]:
|
|
2084
|
+
tag = ' [dim](async)[/dim]' if inner.get('async') else ''
|
|
2085
|
+
console.print(f' [cyan]{event}[/cyan] [dim]{inner["command"]}[/dim]{tag}')
|
|
2086
|
+
console.print('[dim]Resume at session start, relevant memories per prompt, '
|
|
2087
|
+
'capture guard + sync at stop.[/dim]')
|
|
1995
2088
|
|
|
1996
2089
|
|
|
1997
2090
|
@setup.command('print-config')
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Claude Code hook handlers — guardrail-grade recall and capture.
|
|
2
|
+
|
|
3
|
+
Voluntary tool calls are not enough: measured across 166 real sessions,
|
|
4
|
+
Claude engaged the memory tools in 6% of them while the SessionStart
|
|
5
|
+
injection worked in 100%. What a hook enforces happens; what a tool
|
|
6
|
+
description suggests mostly doesn't. These handlers close the two gaps
|
|
7
|
+
that don't survive on model discipline alone:
|
|
8
|
+
|
|
9
|
+
- prompt-recall (UserPromptSubmit): BM25-match the user's prompt against
|
|
10
|
+
the store and inject the top hits as context. Recall stops depending on
|
|
11
|
+
the model thinking to search.
|
|
12
|
+
- stop-guard (Stop): when a substantive session is about to end with zero
|
|
13
|
+
memory writes, block the stop ONCE with instructions to save durable
|
|
14
|
+
facts (or finish if nothing qualifies). Capture stops depending on the
|
|
15
|
+
model remembering unprompted.
|
|
16
|
+
|
|
17
|
+
Both read the hook payload from stdin, never write to stdout unless they
|
|
18
|
+
have something to inject, and fail silent — a broken store must never
|
|
19
|
+
break the user's session.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
# Prompts shorter than this can't carry enough signal to search on.
|
|
30
|
+
MIN_PROMPT_CHARS = 20
|
|
31
|
+
# BM25 score below which a match is noise, not recall (empirically, real
|
|
32
|
+
# hits on a ~165-memory store score 8-32; near-misses land around 7).
|
|
33
|
+
MIN_RECALL_SCORE = 8.0
|
|
34
|
+
# Max memories injected per prompt.
|
|
35
|
+
RECALL_TOP_K = 3
|
|
36
|
+
# A session is "substantive" — worth a capture nudge — past this many
|
|
37
|
+
# tool uses. Small sessions rarely produce durable facts.
|
|
38
|
+
GUARD_MIN_TOOL_USES = 25
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_repo():
|
|
42
|
+
from .repo import Repository, default_store_candidates
|
|
43
|
+
for candidate in default_store_candidates():
|
|
44
|
+
memgit_dir = candidate / '.memgit'
|
|
45
|
+
if memgit_dir.is_dir():
|
|
46
|
+
return Repository(memgit_dir)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_payload() -> dict:
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(sys.stdin.read() or '{}')
|
|
53
|
+
except (json.JSONDecodeError, OSError):
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _session_cache_dir(repo, kind: str) -> Path:
|
|
58
|
+
d = repo.path / 'cache' / kind
|
|
59
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
try:
|
|
61
|
+
entries = list(d.iterdir())
|
|
62
|
+
if len(entries) > 512: # one file per session; prune the oldest half
|
|
63
|
+
entries.sort(key=lambda p: p.stat().st_mtime)
|
|
64
|
+
for old in entries[: len(entries) // 2]:
|
|
65
|
+
old.unlink(missing_ok=True)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prompt_recall() -> int:
|
|
72
|
+
"""UserPromptSubmit: inject memories relevant to this prompt.
|
|
73
|
+
|
|
74
|
+
stdout from a UserPromptSubmit hook is appended to the model's context,
|
|
75
|
+
so printing IS injecting. Silent (no output) when nothing clears the
|
|
76
|
+
relevance bar — an empty recall block on every prompt would train the
|
|
77
|
+
model to ignore the real ones.
|
|
78
|
+
"""
|
|
79
|
+
payload = _read_payload()
|
|
80
|
+
prompt = (payload.get('prompt') or '').strip()
|
|
81
|
+
if len(prompt) < MIN_PROMPT_CHARS or prompt.startswith('/'):
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
repo = _find_repo()
|
|
85
|
+
if repo is None:
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
from .project import project_label_from_path
|
|
89
|
+
from .scorer import score as bm25_score
|
|
90
|
+
|
|
91
|
+
cwd = payload.get('cwd') or '.'
|
|
92
|
+
project = project_label_from_path(Path(cwd))
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
mnemonics = repo.list()
|
|
96
|
+
results = bm25_score(prompt, mnemonics, top_k=RECALL_TOP_K,
|
|
97
|
+
boost_project=project)
|
|
98
|
+
except Exception:
|
|
99
|
+
return 0
|
|
100
|
+
# BM25 IDF collapses on small corpora (a term found in most of 5 docs
|
|
101
|
+
# scores near zero), so an absolute bar tuned on a mature store would
|
|
102
|
+
# silence recall entirely for exactly the users who just adopted.
|
|
103
|
+
# Ramp the bar up with store size instead.
|
|
104
|
+
threshold = max(1.0, MIN_RECALL_SCORE * min(1.0, len(mnemonics) / 50))
|
|
105
|
+
results = [r for r in results if r.score >= threshold]
|
|
106
|
+
if not results:
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
# Don't re-inject what this session has already seen: repeated blocks
|
|
110
|
+
# burn tokens and dull the signal.
|
|
111
|
+
seen: set[str] = set()
|
|
112
|
+
seen_file: Optional[Path] = None
|
|
113
|
+
session_id = payload.get('session_id')
|
|
114
|
+
if session_id:
|
|
115
|
+
try:
|
|
116
|
+
seen_file = _session_cache_dir(repo, 'recall') / str(session_id)
|
|
117
|
+
if seen_file.exists():
|
|
118
|
+
seen = set(seen_file.read_text().split())
|
|
119
|
+
except OSError:
|
|
120
|
+
seen_file = None
|
|
121
|
+
results = [r for r in results if r.mnemonic.slug not in seen]
|
|
122
|
+
if not results:
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
lines = ['<memgit-recall># Saved memories relevant to this request:']
|
|
126
|
+
for r in results:
|
|
127
|
+
m = r.mnemonic
|
|
128
|
+
rule = m.rule if len(m.rule) <= 220 else m.rule[:219] + '…'
|
|
129
|
+
detail = ' (full detail: get_memory)' if m.body else ''
|
|
130
|
+
lines.append(f'- [{m.slug}] {rule}{detail}')
|
|
131
|
+
lines.append('</memgit-recall>')
|
|
132
|
+
print('\n'.join(lines))
|
|
133
|
+
|
|
134
|
+
if seen_file is not None:
|
|
135
|
+
try:
|
|
136
|
+
seen.update(r.mnemonic.slug for r in results)
|
|
137
|
+
seen_file.write_text('\n'.join(sorted(seen)))
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Markers in a transcript that count as "this session captured memory".
|
|
144
|
+
# Anchored to tool_use JSON shapes: the plain tool NAME appears as text in
|
|
145
|
+
# every transcript (the host embeds the tool list), so bare substrings
|
|
146
|
+
# would match always and the guard would never fire.
|
|
147
|
+
import re as _re
|
|
148
|
+
|
|
149
|
+
_CAPTURE_RES = (
|
|
150
|
+
_re.compile(r'"name"\s*:\s*"mcp__memgit__save_memory"'), # MCP save
|
|
151
|
+
_re.compile(r'"file_path"\s*:\s*"[^"]*/memory/[^"]*\.md'), # md auto-memory write
|
|
152
|
+
_re.compile(r'memgit (add|sync)\b'), # CLI save/sync
|
|
153
|
+
)
|
|
154
|
+
_TOOL_USE_RE = _re.compile(r'"type"\s*:\s*"tool_use"')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def stop_guard() -> int:
|
|
158
|
+
"""Stop: block ending a substantive session that captured nothing.
|
|
159
|
+
|
|
160
|
+
Emits {"decision": "block", "reason": ...} at most ONCE per session
|
|
161
|
+
(marker file + stop_hook_active double-guard), and only when the
|
|
162
|
+
transcript shows real work with zero memory writes. The reason text
|
|
163
|
+
explicitly allows finishing without saving when nothing durable was
|
|
164
|
+
learned — this is a checkpoint, not a shakedown.
|
|
165
|
+
"""
|
|
166
|
+
payload = _read_payload()
|
|
167
|
+
if payload.get('stop_hook_active'):
|
|
168
|
+
return 0
|
|
169
|
+
transcript_path = payload.get('transcript_path')
|
|
170
|
+
if not transcript_path:
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
repo = _find_repo()
|
|
174
|
+
if repo is None:
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
session_id = payload.get('session_id') or Path(transcript_path).stem
|
|
178
|
+
try:
|
|
179
|
+
marker = _session_cache_dir(repo, 'stop-guard') / str(session_id)
|
|
180
|
+
if marker.exists():
|
|
181
|
+
return 0
|
|
182
|
+
except OSError:
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
text = Path(transcript_path).read_text(encoding='utf-8', errors='replace')
|
|
187
|
+
except OSError:
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
if len(_TOOL_USE_RE.findall(text)) < GUARD_MIN_TOOL_USES:
|
|
191
|
+
return 0
|
|
192
|
+
if any(rx.search(text) for rx in _CAPTURE_RES):
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
marker.write_text('nudged')
|
|
197
|
+
except OSError:
|
|
198
|
+
return 0 # if we can't record the nudge, don't risk nudging forever
|
|
199
|
+
|
|
200
|
+
print(json.dumps({
|
|
201
|
+
'decision': 'block',
|
|
202
|
+
'reason': (
|
|
203
|
+
'memgit capture check — this session did substantial work but '
|
|
204
|
+
'saved no memories. Before finishing: if you learned anything '
|
|
205
|
+
'durable (a decision made, a root cause found, a preference or '
|
|
206
|
+
'correction from the user, a gotcha in this codebase), save each '
|
|
207
|
+
'one now with the memgit save_memory tool — one-line rule, full '
|
|
208
|
+
'detail in body. If genuinely nothing durable was learned, just '
|
|
209
|
+
'finish your response; this check will not repeat.'
|
|
210
|
+
),
|
|
211
|
+
}))
|
|
212
|
+
return 0
|