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.
Files changed (39) hide show
  1. {memgit-0.3.1 → memgit-0.4.0}/PKG-INFO +15 -5
  2. {memgit-0.3.1 → memgit-0.4.0}/README.md +14 -4
  3. {memgit-0.3.1 → memgit-0.4.0}/memgit/__init__.py +1 -1
  4. {memgit-0.3.1 → memgit-0.4.0}/memgit/cli.py +154 -61
  5. memgit-0.4.0/memgit/hooks.py +212 -0
  6. {memgit-0.3.1 → memgit-0.4.0}/memgit/importer.py +11 -31
  7. {memgit-0.3.1 → memgit-0.4.0}/memgit/mcp_server.py +30 -4
  8. memgit-0.4.0/memgit/project.py +90 -0
  9. {memgit-0.3.1 → memgit-0.4.0}/memgit/repo.py +44 -9
  10. {memgit-0.3.1 → memgit-0.4.0}/memgit/scorer.py +13 -4
  11. {memgit-0.3.1 → memgit-0.4.0}/memgit/toon.py +33 -6
  12. {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/PKG-INFO +15 -5
  13. {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/SOURCES.txt +4 -1
  14. {memgit-0.3.1 → memgit-0.4.0}/pyproject.toml +1 -1
  15. memgit-0.4.0/tests/test_v040.py +371 -0
  16. {memgit-0.3.1 → memgit-0.4.0}/LICENSE +0 -0
  17. {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/__init__.py +0 -0
  18. {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/client.py +0 -0
  19. {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/commands.py +0 -0
  20. {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/crypto.py +0 -0
  21. {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/state.py +0 -0
  22. {memgit-0.3.1 → memgit-0.4.0}/memgit/cloud/sync.py +0 -0
  23. {memgit-0.3.1 → memgit-0.4.0}/memgit/gitdigest.py +0 -0
  24. {memgit-0.3.1 → memgit-0.4.0}/memgit/graph.py +0 -0
  25. {memgit-0.3.1 → memgit-0.4.0}/memgit/http_server.py +0 -0
  26. {memgit-0.3.1 → memgit-0.4.0}/memgit/models.py +0 -0
  27. {memgit-0.3.1 → memgit-0.4.0}/memgit/store.py +0 -0
  28. {memgit-0.3.1 → memgit-0.4.0}/memgit/tokens.py +0 -0
  29. {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/dependency_links.txt +0 -0
  30. {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/entry_points.txt +0 -0
  31. {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/requires.txt +0 -0
  32. {memgit-0.3.1 → memgit-0.4.0}/memgit.egg-info/top_level.txt +0 -0
  33. {memgit-0.3.1 → memgit-0.4.0}/setup.cfg +0 -0
  34. {memgit-0.3.1 → memgit-0.4.0}/tests/test_advanced.py +0 -0
  35. {memgit-0.3.1 → memgit-0.4.0}/tests/test_setup.py +0 -0
  36. {memgit-0.3.1 → memgit-0.4.0}/tests/test_store_repo.py +0 -0
  37. {memgit-0.3.1 → memgit-0.4.0}/tests/test_toon.py +0 -0
  38. {memgit-0.3.1 → memgit-0.4.0}/tests/test_v020.py +0 -0
  39. {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.1
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 every new session **starts** with this digest in context — no tool call, no judgment required:
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 a SessionStart hook (~/.claude/settings.json)
218
+ memgit setup hooks # installs all four hooks (~/.claude/settings.json)
219
219
  ```
220
220
 
221
- The 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.
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 SessionStart hook auto-inject resume digest
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 every new session **starts** with this digest in context — no tool call, no judgment required:
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 a SessionStart hook (~/.claude/settings.json)
184
+ memgit setup hooks # installs all four hooks (~/.claude/settings.json)
185
185
  ```
186
186
 
187
- The 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.
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 SessionStart hook auto-inject resume digest
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)
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.0"
@@ -29,11 +29,10 @@ def _require_repo() -> Repository:
29
29
 
30
30
  # ── Root group ────────────────────────────────────────────────────────────────
31
31
 
32
- try:
33
- from importlib.metadata import version as _pkg_version
34
- _version = _pkg_version('memgit')
35
- except Exception:
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 (see `memgit list`)')
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
- console.print(f'[green]staged[/green] {slug} [{sha[:8]}]')
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] [{m.type_code}] priority={p_label}{proj} sha={sha_s}')
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 the memgit SessionStart hook')
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 a Claude Code SessionStart hook that injects `memgit resume`.
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
- After this, every new Claude Code session (including /clear and resume)
1936
- automatically starts with your last checkpoints, work in flight, and
1937
- critical rules in context — the model doesn't have to remember to look.
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
- # `|| true`: a broken store must never block session start
1946
- hook_command = f'{base} resume --plain 2>/dev/null || true'
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
- def _is_memgit_hook(h: dict) -> bool:
1961
- return any('memgit resume' in inner.get('command', '')
1962
- for inner in h.get('hooks', []) if isinstance(inner, dict))
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
- existing = [h for h in session_start if isinstance(h, dict) and _is_memgit_hook(h)]
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
- if not existing:
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
- entry = {'hooks': [{'type': 'command', 'command': hook_command}]}
1982
- if existing:
1983
- if existing[0] == entry and len(existing) == 1:
1984
- console.print(f'[green][/green] already installed in {settings_path}')
1985
- return
1986
- session_start[:] = [h for h in session_start if h not in existing]
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