memgit 0.12.0__tar.gz → 0.13.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 (75) hide show
  1. {memgit-0.12.0 → memgit-0.13.0}/PKG-INFO +5 -1
  2. {memgit-0.12.0 → memgit-0.13.0}/README.md +4 -0
  3. {memgit-0.12.0 → memgit-0.13.0}/memgit/__init__.py +1 -1
  4. {memgit-0.12.0 → memgit-0.13.0}/memgit/cli.py +78 -1
  5. {memgit-0.12.0 → memgit-0.13.0}/memgit/delivery.py +3 -1
  6. {memgit-0.12.0 → memgit-0.13.0}/memgit/hooks.py +5 -2
  7. {memgit-0.12.0 → memgit-0.13.0}/memgit/http_server.py +5 -0
  8. memgit-0.13.0/memgit/markup.py +278 -0
  9. {memgit-0.12.0 → memgit-0.13.0}/memgit/mcp_server.py +78 -9
  10. {memgit-0.12.0 → memgit-0.13.0}/memgit/repo.py +8 -5
  11. {memgit-0.12.0 → memgit-0.13.0}/memgit.egg-info/PKG-INFO +5 -1
  12. {memgit-0.12.0 → memgit-0.13.0}/memgit.egg-info/SOURCES.txt +2 -0
  13. {memgit-0.12.0 → memgit-0.13.0}/pyproject.toml +1 -1
  14. memgit-0.13.0/tests/test_markup.py +233 -0
  15. {memgit-0.12.0 → memgit-0.13.0}/LICENSE +0 -0
  16. {memgit-0.12.0 → memgit-0.13.0}/memgit/audit.py +0 -0
  17. {memgit-0.12.0 → memgit-0.13.0}/memgit/backup.py +0 -0
  18. {memgit-0.12.0 → memgit-0.13.0}/memgit/cloud/__init__.py +0 -0
  19. {memgit-0.12.0 → memgit-0.13.0}/memgit/cloud/client.py +0 -0
  20. {memgit-0.12.0 → memgit-0.13.0}/memgit/cloud/commands.py +0 -0
  21. {memgit-0.12.0 → memgit-0.13.0}/memgit/cloud/crypto.py +0 -0
  22. {memgit-0.12.0 → memgit-0.13.0}/memgit/cloud/state.py +0 -0
  23. {memgit-0.12.0 → memgit-0.13.0}/memgit/cloud/sync.py +0 -0
  24. {memgit-0.12.0 → memgit-0.13.0}/memgit/env.py +0 -0
  25. {memgit-0.12.0 → memgit-0.13.0}/memgit/evaluate.py +0 -0
  26. {memgit-0.12.0 → memgit-0.13.0}/memgit/gitdigest.py +0 -0
  27. {memgit-0.12.0 → memgit-0.13.0}/memgit/graph.py +0 -0
  28. {memgit-0.12.0 → memgit-0.13.0}/memgit/importer.py +0 -0
  29. {memgit-0.12.0 → memgit-0.13.0}/memgit/license.py +0 -0
  30. {memgit-0.12.0 → memgit-0.13.0}/memgit/links.py +0 -0
  31. {memgit-0.12.0 → memgit-0.13.0}/memgit/metrics.py +0 -0
  32. {memgit-0.12.0 → memgit-0.13.0}/memgit/models.py +0 -0
  33. {memgit-0.12.0 → memgit-0.13.0}/memgit/org.py +0 -0
  34. {memgit-0.12.0 → memgit-0.13.0}/memgit/project.py +0 -0
  35. {memgit-0.12.0 → memgit-0.13.0}/memgit/sanitize.py +0 -0
  36. {memgit-0.12.0 → memgit-0.13.0}/memgit/savings.py +0 -0
  37. {memgit-0.12.0 → memgit-0.13.0}/memgit/scorer.py +0 -0
  38. {memgit-0.12.0 → memgit-0.13.0}/memgit/store.py +0 -0
  39. {memgit-0.12.0 → memgit-0.13.0}/memgit/tiers.py +0 -0
  40. {memgit-0.12.0 → memgit-0.13.0}/memgit/tokens.py +0 -0
  41. {memgit-0.12.0 → memgit-0.13.0}/memgit/toon.py +0 -0
  42. {memgit-0.12.0 → memgit-0.13.0}/memgit/usage.py +0 -0
  43. {memgit-0.12.0 → memgit-0.13.0}/memgit.egg-info/dependency_links.txt +0 -0
  44. {memgit-0.12.0 → memgit-0.13.0}/memgit.egg-info/entry_points.txt +0 -0
  45. {memgit-0.12.0 → memgit-0.13.0}/memgit.egg-info/requires.txt +0 -0
  46. {memgit-0.12.0 → memgit-0.13.0}/memgit.egg-info/top_level.txt +0 -0
  47. {memgit-0.12.0 → memgit-0.13.0}/setup.cfg +0 -0
  48. {memgit-0.12.0 → memgit-0.13.0}/tests/test_accrue.py +0 -0
  49. {memgit-0.12.0 → memgit-0.13.0}/tests/test_advanced.py +0 -0
  50. {memgit-0.12.0 → memgit-0.13.0}/tests/test_aliases.py +0 -0
  51. {memgit-0.12.0 → memgit-0.13.0}/tests/test_audit.py +0 -0
  52. {memgit-0.12.0 → memgit-0.13.0}/tests/test_backup.py +0 -0
  53. {memgit-0.12.0 → memgit-0.13.0}/tests/test_cloud_guard.py +0 -0
  54. {memgit-0.12.0 → memgit-0.13.0}/tests/test_core.py +0 -0
  55. {memgit-0.12.0 → memgit-0.13.0}/tests/test_delivery.py +0 -0
  56. {memgit-0.12.0 → memgit-0.13.0}/tests/test_dependency_floor.py +0 -0
  57. {memgit-0.12.0 → memgit-0.13.0}/tests/test_env_inventory.py +0 -0
  58. {memgit-0.12.0 → memgit-0.13.0}/tests/test_git_args.py +0 -0
  59. {memgit-0.12.0 → memgit-0.13.0}/tests/test_license.py +0 -0
  60. {memgit-0.12.0 → memgit-0.13.0}/tests/test_lock_exclusion.py +0 -0
  61. {memgit-0.12.0 → memgit-0.13.0}/tests/test_org.py +0 -0
  62. {memgit-0.12.0 → memgit-0.13.0}/tests/test_savings.py +0 -0
  63. {memgit-0.12.0 → memgit-0.13.0}/tests/test_server_lifecycle.py +0 -0
  64. {memgit-0.12.0 → memgit-0.13.0}/tests/test_setup.py +0 -0
  65. {memgit-0.12.0 → memgit-0.13.0}/tests/test_store_repo.py +0 -0
  66. {memgit-0.12.0 → memgit-0.13.0}/tests/test_tiers.py +0 -0
  67. {memgit-0.12.0 → memgit-0.13.0}/tests/test_toon.py +0 -0
  68. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v020.py +0 -0
  69. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v030.py +0 -0
  70. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v040.py +0 -0
  71. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v060.py +0 -0
  72. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v070.py +0 -0
  73. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v080.py +0 -0
  74. {memgit-0.12.0 → memgit-0.13.0}/tests/test_v081.py +0 -0
  75. {memgit-0.12.0 → memgit-0.13.0}/tests/test_version_stamps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.12.0
3
+ Version: 0.13.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
@@ -284,6 +284,8 @@ Once registered via MCP, every AI tool gets 6 tools:
284
284
 
285
285
  The tool descriptions teach the AI **judgment** — "does this request depend on state you don't have in context?" — rather than keyword triggers. Measured cost of the whole tool surface: ~1,150 tokens once per session; a `resume_session` reply is ~335.
286
286
 
287
+ **A save that arrives damaged is refused, not stored** (v0.13.0). A model sometimes closes a value with a tag named after the field (`</rule>`), and the host then delivers the next parameters inside that value: `why`, `when`, `tags` and `type_code` never arrive, and the memory is stored with defaults. On one machine that was 748 of 3,025 saves. `save_memory` now refuses any value carrying tool-call markup, names the field and the parameters it swallowed, and the model retries in the same turn. Markup quoted on purpose inside backticks is allowed. Every successful save echoes the length of each field it stored, which fields arrived empty, and which values came from a default. Damaged memories already in a store are never served back into context: the digest, the recall hook and read results show each field cut at the damage. `memgit doctor` counts them, and `memgit doctor --repair-markup` splits them back into their fields.
288
+
287
289
  ---
288
290
 
289
291
  ## Core operating guide (v0.5.0)
@@ -415,6 +417,8 @@ memgit doctor # hygiene + scope losses: split labels, quaran
415
417
  memgit doctor --audit # every split and stranded label, the save landing rate, throughput
416
418
  memgit doctor --audit --json # the same report as JSON
417
419
  memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
420
+ memgit doctor --repair-markup # dry run: memories whose fields swallowed other parameters on save
421
+ memgit doctor --repair-markup --yes [--project LABEL] # split them back into their fields; one checkpoint
418
422
  memgit lint # validate all memories (flags unknown provenance)
419
423
  memgit fsck # verify store integrity
420
424
 
@@ -250,6 +250,8 @@ Once registered via MCP, every AI tool gets 6 tools:
250
250
 
251
251
  The tool descriptions teach the AI **judgment** — "does this request depend on state you don't have in context?" — rather than keyword triggers. Measured cost of the whole tool surface: ~1,150 tokens once per session; a `resume_session` reply is ~335.
252
252
 
253
+ **A save that arrives damaged is refused, not stored** (v0.13.0). A model sometimes closes a value with a tag named after the field (`</rule>`), and the host then delivers the next parameters inside that value: `why`, `when`, `tags` and `type_code` never arrive, and the memory is stored with defaults. On one machine that was 748 of 3,025 saves. `save_memory` now refuses any value carrying tool-call markup, names the field and the parameters it swallowed, and the model retries in the same turn. Markup quoted on purpose inside backticks is allowed. Every successful save echoes the length of each field it stored, which fields arrived empty, and which values came from a default. Damaged memories already in a store are never served back into context: the digest, the recall hook and read results show each field cut at the damage. `memgit doctor` counts them, and `memgit doctor --repair-markup` splits them back into their fields.
254
+
253
255
  ---
254
256
 
255
257
  ## Core operating guide (v0.5.0)
@@ -381,6 +383,8 @@ memgit doctor # hygiene + scope losses: split labels, quaran
381
383
  memgit doctor --audit # every split and stranded label, the save landing rate, throughput
382
384
  memgit doctor --audit --json # the same report as JSON
383
385
  memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
386
+ memgit doctor --repair-markup # dry run: memories whose fields swallowed other parameters on save
387
+ memgit doctor --repair-markup --yes [--project LABEL] # split them back into their fields; one checkpoint
384
388
  memgit lint # validate all memories (flags unknown provenance)
385
389
  memgit fsck # verify store integrity
386
390
 
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.13.0"
@@ -193,6 +193,15 @@ def add(slug, rule, type_code, why, when, tags, priority, body, project,
193
193
  tag_list = [t.strip() for t in tags.split(',')] if tags else []
194
194
  if body == '-':
195
195
  body = sys.stdin.read().strip() or None
196
+ from .markup import check_save_arguments
197
+ problems = check_save_arguments(
198
+ {'slug': slug, 'rule': rule, 'why': why, 'when': when, 'body': body,
199
+ 'tags': tag_list})
200
+ if problems:
201
+ err.print('[red]refused: ' + '; '.join(problems) + '. Nothing was '
202
+ 'saved. Wrap markup in backticks to quote it on '
203
+ 'purpose.[/red]')
204
+ sys.exit(1)
196
205
  # Same scoping semantics as MCP save_memory: absent → this workspace,
197
206
  # --global (or explicit empty string) → deliberately global. Detection
198
207
  # failing must never silently produce a global memory — quarantine it
@@ -2491,8 +2500,15 @@ def gc(dry_run, squash_keep, reflog_keep, fmt_json):
2491
2500
  'save landing rate, and throughput')
2492
2501
  @click.option('--json', 'fmt_json', is_flag=True,
2493
2502
  help='Emit the audit as JSON')
2503
+ @click.option('--repair-markup', is_flag=True,
2504
+ help='Split memories whose fields carry tool-call markup back '
2505
+ 'into their fields. Dry run unless --yes is given')
2506
+ @click.option('--project', 'repair_project', default=None, metavar='LABEL',
2507
+ help='With --repair-markup: only this project')
2508
+ @click.option('--yes', 'confirm', is_flag=True,
2509
+ help='With --repair-markup: write the repair as one checkpoint')
2494
2510
  def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2495
- full_audit, fmt_json):
2511
+ full_audit, fmt_json, repair_markup, repair_project, confirm):
2496
2512
  """Diagnose and repair store hygiene: provenance, usage ledger, caches.
2497
2513
 
2498
2514
  With no options, prints a report: quarantined (`_unknown`) and
@@ -2575,6 +2591,10 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2575
2591
  console.print(f'[green]doctor[/green] removed {deleted} stale + '
2576
2592
  f'{pruned} named session-cache file(s)')
2577
2593
 
2594
+ if repair_markup:
2595
+ acted = True
2596
+ _repair_markup(repo, repair_project, confirm)
2597
+
2578
2598
  if acted:
2579
2599
  return
2580
2600
 
@@ -2622,6 +2642,20 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2622
2642
  # Scope losses run in the DEFAULT report, not behind the flag. A split
2623
2643
  # raises no error and passes fsck, so the only way anyone learns of one is
2624
2644
  # a report that volunteers it. `--audit` adds the detail, never the finding.
2645
+ from .markup import TEXT_FIELDS, has_markup
2646
+ damaged = [m for m in mnemonics
2647
+ if any(has_markup(getattr(m, f)) for f in TEXT_FIELDS)]
2648
+ if damaged:
2649
+ by_proj = Counter(m.project or '(global)' for m in damaged)
2650
+ console.print(f'[yellow]{len(damaged)} memories carry tool-call markup '
2651
+ f'in their fields[/yellow] — parameters swallowed on '
2652
+ f'save; repair with `memgit doctor --repair-markup` '
2653
+ f'(dry run), then add --yes')
2654
+ console.print(' by project: ' + ' · '.join(
2655
+ f'{p} ({n})' for p, n in by_proj.most_common(8)))
2656
+ else:
2657
+ console.print('no memory carries tool-call markup in its fields')
2658
+
2625
2659
  from .audit import audit as _audit
2626
2660
  rep = _audit(repo)
2627
2661
  _render_scope_losses(rep, full_audit)
@@ -2629,6 +2663,49 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2629
2663
  console.print_json(data=rep)
2630
2664
 
2631
2665
 
2666
+ def _repair_markup(repo, project, confirm) -> None:
2667
+ """Split damaged memories back into their fields; dry run without confirm.
2668
+
2669
+ Covers superseded memories too: a swallowed supersedes inside one of them
2670
+ is still a broken chain. One checkpoint for the whole repair, so a single
2671
+ `memgit rollback` undoes it while no other save has landed since.
2672
+ """
2673
+ from .markup import repair
2674
+ mems = repo.list()
2675
+ if project is not None:
2676
+ mems = [m for m in mems if (m.project or '') == project]
2677
+ reps = [r for r in (repair(m) for m in mems) if r is not None]
2678
+ fixed = [r for r in reps if r.fixed is not None]
2679
+ failed = [r for r in reps if r.fixed is None]
2680
+ retyped = [r for r in fixed if r.type_changed]
2681
+ conflicts = [r for r in fixed if r.conflicts]
2682
+ mode = 'repaired' if confirm else 'would repair'
2683
+ console.print(f'[bold]doctor --repair-markup[/bold] {mode} {len(fixed)} '
2684
+ f'memor{"ies" if len(fixed) != 1 else "y"}'
2685
+ + (f' in {project}' if project is not None else ''))
2686
+ for r in retyped:
2687
+ console.print(f' type {r.slug}: {r.type_changed[0]} → '
2688
+ f'{r.type_changed[1]}')
2689
+ for r in conflicts:
2690
+ console.print(f' [yellow]merged[/yellow] {r.slug}: '
2691
+ + ', '.join(f'{a}→{b}' for a, b in r.conflicts)
2692
+ + ' held different text; both kept')
2693
+ for r in failed:
2694
+ console.print(f' [red]skipped[/red] {r.slug}: '
2695
+ + '; '.join(r.unparsed))
2696
+ if not fixed:
2697
+ return
2698
+ if not confirm:
2699
+ console.print('[dim]dry run — nothing written; add --yes to apply[/dim]')
2700
+ return
2701
+ for r in fixed:
2702
+ repo.add(r.fixed)
2703
+ sha = repo.commit(
2704
+ message=f'doctor: repair {len(fixed)} memories with tool-call markup '
2705
+ 'in their fields', trigger='explicit')
2706
+ console.print(f'[green]doctor[/green] checkpoint {(sha or "")[:8]}')
2707
+
2708
+
2632
2709
  def _render_scope_losses(rep: dict, full: bool) -> None:
2633
2710
  """Print the split, strand and landing-rate findings from an audit."""
2634
2711
  splits, stranded = rep['splits'], rep['stranded']
@@ -31,6 +31,8 @@ from dataclasses import dataclass, field
31
31
  from pathlib import Path
32
32
  from typing import Callable, Optional
33
33
 
34
+ from .markup import clean_for_context
35
+
34
36
  MARKER_START = "<!-- MEMGIT:BEGIN -->"
35
37
  MARKER_END = "<!-- MEMGIT:END -->"
36
38
 
@@ -420,7 +422,7 @@ def compute_auto_section(repo, project, now, curated: str = "") -> str:
420
422
  ]
421
423
  total = 0
422
424
  for _score, m in candidates:
423
- rule = (m.rule or "").strip()
425
+ rule = clean_for_context(m.rule).strip()
424
426
  line = f"- [{m.slug}] {rule[:120]}"
425
427
  if len(lines) - 2 >= _AUTO_MAX_ITEMS or total + len(line) > _AUTO_MAX_CHARS:
426
428
  break
@@ -26,6 +26,8 @@ import sys
26
26
  from pathlib import Path
27
27
  from typing import Optional
28
28
 
29
+ from .markup import clean_for_context
30
+
29
31
  # Prompts shorter than this can't carry enough signal to search on.
30
32
  MIN_PROMPT_CHARS = 20
31
33
  # BM25 score below which a match is noise, not recall (empirically, real
@@ -132,7 +134,8 @@ def prompt_recall() -> int:
132
134
  lines = ['<memgit-recall># Saved memories relevant to this request:']
133
135
  for r in results:
134
136
  m = r.mnemonic
135
- rule = m.rule if len(m.rule) <= 220 else m.rule[:219] + '…'
137
+ rule = clean_for_context(m.rule)
138
+ rule = rule if len(rule) <= 220 else rule[:219] + '…'
136
139
  detail = ' (full detail: get_memory)' if m.body else ''
137
140
  # Unverified = candidate content (imported/injection-flagged): surface it
138
141
  # but never as trusted — the agent must not follow it as instruction.
@@ -231,7 +234,7 @@ def _depth_hint(results, mnemonics, seen: set[str],
231
234
  # the same state always advertises the same example.
232
235
  example = max(best_pool,
233
236
  key=lambda m: (m.priority, len(m.rule or ''), m.slug))
234
- teaser = (example.rule or '').strip().replace('\n', ' ')
237
+ teaser = clean_for_context(example.rule).strip().replace('\n', ' ')
235
238
  if len(teaser) > 110:
236
239
  teaser = teaser[:109] + '…'
237
240
  line = (f'- +{best_n} more saved on \'{best_tag}\', including '
@@ -238,6 +238,11 @@ class MemgitHandler(BaseHTTPRequestHandler):
238
238
  if m:
239
239
  slug = m.group(1)
240
240
  body = self._read_body()
241
+ from .markup import check_save_arguments, refusal_message
242
+ problems = check_save_arguments(body)
243
+ if problems:
244
+ self._error(refusal_message(problems))
245
+ return
241
246
  rule = body.get("rule", "").strip()
242
247
  if not rule:
243
248
  self._error("rule is required")
@@ -0,0 +1,278 @@
1
+ """Tool-call markup that leaked into a save's field values.
2
+
3
+ A model sometimes closes a save_memory value with a tag named after the field
4
+ (`</rule>`) instead of the tool-call format's own closer. The host parser does
5
+ not accept that as the end of the value, so the next parameter's opening tag
6
+ and text become part of the value, and every parameter until the next closer
7
+ it does accept never arrives as a parameter. Measured 2026-10-07 on one
8
+ machine: 748 of 3,025 save_memory calls (24.7%), and 0 of 50,967 calls to
9
+ every other tool in the same transcripts.
10
+
11
+ The damage is silent unless something looks: the save still succeeds, the
12
+ missing fields take their defaults, and the damaged text is injected back
13
+ into later sessions, where it teaches the next save the same mistake.
14
+
15
+ This module holds the one definition of "markup" that the save path refuses,
16
+ the context renderers strip, and `memgit doctor --repair-markup` splits back
17
+ into fields.
18
+
19
+ What counts, outside backtick code spans (so a memory can quote the markup
20
+ deliberately, as the lessons about this defect do):
21
+
22
+ - a parameter opening tag `<parameter name="...">`, a parameter closing tag,
23
+ an invoke tag, or a function_calls tag, with or without a namespace prefix;
24
+ - a closing tag named after a save_memory field (`</rule>`, `</why>`, ...)
25
+ when nothing but whitespace or more markup follows it. A `</body>` in the
26
+ middle of a note about HTML is prose, not damage.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import dataclasses
31
+ import json
32
+ import re
33
+ from typing import Any
34
+
35
+ #: Text fields of a memory, in the order save_memory declares them.
36
+ TEXT_FIELDS = ('rule', 'why', 'when', 'body')
37
+ #: List fields of a memory.
38
+ LIST_FIELDS = ('tags', 'related', 'supersedes')
39
+ #: Every argument name save_memory accepts.
40
+ SAVE_PARAMS = frozenset(
41
+ ('slug', 'project', 'type_code', 'type', 'priority', 'unverified')
42
+ + TEXT_FIELDS + LIST_FIELDS)
43
+ #: Names a closing tag can carry and still read as a swallowed field closer.
44
+ #: memory_type and desc are not parameters, but models have sent both.
45
+ _CLOSER_NAMES = sorted(SAVE_PARAMS | {'memory_type', 'desc'})
46
+ _TYPE_CODES = ('fb', 'us', 'pj', 'rf', 'cn', 'lx', 'co', 'tr')
47
+
48
+ _NS = r'(?:[A-Za-z_][\w.-]*:)?'
49
+ # Markup that is never prose: tool-call structure itself.
50
+ _STRUCTURAL = re.compile(
51
+ rf'<{_NS}parameter\s+name\s*=\s*"(?P<param>[^"]*)"\s*>'
52
+ rf'|</{_NS}parameter\s*>'
53
+ rf'|<{_NS}invoke\b[^>]*>'
54
+ rf'|</{_NS}invoke\s*>'
55
+ rf'|</?{_NS}function_calls\s*>')
56
+ # A closer named after a field. Only damage when it ends the value or more
57
+ # markup follows it, which the caller checks.
58
+ _FIELD_CLOSER = re.compile(rf'</(?:{"|".join(_CLOSER_NAMES)})\s*>')
59
+ # Only a CLOSED fence is a code span: an unclosed one (```mermaid in prose)
60
+ # would otherwise hide every token after it.
61
+ _CODE = re.compile(r'```.*?```|`[^`\n]*`', re.S)
62
+
63
+
64
+ def _mask_code(text: str) -> str:
65
+ """Blank out backtick code spans, keeping every offset where it was."""
66
+ return _CODE.sub(lambda mo: ' ' * len(mo.group(0)), text)
67
+
68
+
69
+ def _tokens(text: str) -> list[re.Match]:
70
+ """Markup tokens in `text`, in order, ignoring anything inside code spans.
71
+
72
+ Matches are taken against the masked copy, so their offsets index the
73
+ original text as well.
74
+ """
75
+ masked = _mask_code(text)
76
+ structural = list(_STRUCTURAL.finditer(masked))
77
+ starts = sorted(mo.start() for mo in structural)
78
+ out = list(structural)
79
+ for mo in _FIELD_CLOSER.finditer(masked):
80
+ rest = masked[mo.end():]
81
+ if not rest.strip():
82
+ out.append(mo)
83
+ continue
84
+ nxt = len(rest) - len(rest.lstrip())
85
+ pos = mo.end() + nxt
86
+ if pos in starts or _FIELD_CLOSER.match(masked, pos):
87
+ out.append(mo)
88
+ out.sort(key=lambda mo: mo.start())
89
+ return out
90
+
91
+
92
+ def first_markup(text: str | None) -> int | None:
93
+ """Offset of the first markup token in `text`, or None when clean."""
94
+ if not text:
95
+ return None
96
+ toks = _tokens(text)
97
+ return toks[0].start() if toks else None
98
+
99
+
100
+ def has_markup(text: str | None) -> bool:
101
+ return first_markup(text) is not None
102
+
103
+
104
+ def clean_for_context(text: str | None) -> str:
105
+ """`text` cut at its first markup token, for injection into context.
106
+
107
+ Whatever follows the first token is the next parameter's value, which
108
+ belongs to another field, so cutting there leaves the field's own text.
109
+ Never used to rewrite the store: `memgit doctor --repair-markup` does that.
110
+ """
111
+ if not text:
112
+ return text or ''
113
+ i = first_markup(text)
114
+ return text if i is None else text[:i].rstrip()
115
+
116
+
117
+ def swallowed_params(text: str | None) -> list[str]:
118
+ """Parameter names whose opening tag sits inside `text`, in order."""
119
+ if not text:
120
+ return []
121
+ names = [mo.group('param') for mo in _tokens(text)
122
+ if mo.re is _STRUCTURAL and mo.group('param')]
123
+ return list(dict.fromkeys(names))
124
+
125
+
126
+ def check_save_arguments(arguments: dict[str, Any]) -> list[str]:
127
+ """Problems that make a save_memory call unsafe to store, one per field.
128
+
129
+ Empty means the values are clean. Checks every string argument and every
130
+ string inside a list argument, since a damaged call can push markup into
131
+ any of them.
132
+ """
133
+ problems: list[str] = []
134
+ for key, value in arguments.items():
135
+ values = value if isinstance(value, list) else [value]
136
+ for v in values:
137
+ if not isinstance(v, str):
138
+ continue
139
+ toks = _tokens(v)
140
+ if not toks:
141
+ continue
142
+ shown = ', '.join(dict.fromkeys(t.group(0) for t in toks[:3]))
143
+ msg = f"'{key}' contains tool-call markup ({shown})"
144
+ lost = [p for p in swallowed_params(v) if p != key]
145
+ if lost:
146
+ msg += ('; these parameters landed inside it instead of '
147
+ 'arriving: ' + ', '.join(lost))
148
+ problems.append(msg)
149
+ break
150
+ return problems
151
+
152
+
153
+ def refusal_message(problems: list[str]) -> str:
154
+ """The error a damaged save gets back, written for the model to act on."""
155
+ return (
156
+ 'save_memory refused: the arguments carry tool-call markup, so some '
157
+ 'parameters were swallowed into another field and would have been '
158
+ 'stored wrong. ' + ' | '.join(problems) + '. Nothing was saved. '
159
+ 'Call save_memory again with each value passed as its own parameter, '
160
+ 'closing every value with the parameter closing tag, never with a tag '
161
+ 'named after the field such as </rule>. Keep rule to one sentence and '
162
+ 'put detail in body. To quote markup on purpose, wrap it in backticks.'
163
+ )
164
+
165
+
166
+ # ── repair ────────────────────────────────────────────────────────────────
167
+
168
+
169
+ def _split(field: str, value: str) -> dict[str, str]:
170
+ """Split one damaged value into {parameter name: text}.
171
+
172
+ Text before the first token belongs to `field`. A parameter opening tag
173
+ starts a new segment under its name; an invoke or function_calls opener
174
+ starts the next tool call, whose text is dropped.
175
+ """
176
+ segs: dict[str, list[str]] = {}
177
+ cur: str | None = field
178
+ pos = 0
179
+ for mo in _tokens(value):
180
+ if cur:
181
+ segs.setdefault(cur, []).append(value[pos:mo.start()])
182
+ pos = mo.end()
183
+ tok = mo.group(0)
184
+ if mo.re is _STRUCTURAL and mo.group('param'):
185
+ cur = mo.group('param')
186
+ elif re.match(rf'<{_NS}(?:invoke\b|function_calls)', tok):
187
+ cur = None
188
+ if cur:
189
+ segs.setdefault(cur, []).append(value[pos:])
190
+ return {k: ''.join(v).strip() for k, v in segs.items()}
191
+
192
+
193
+ def _as_list(text: str) -> list[str] | None:
194
+ try:
195
+ v = json.loads(text)
196
+ except (ValueError, TypeError):
197
+ return None
198
+ return [str(x) for x in v] if isinstance(v, list) else None
199
+
200
+
201
+ @dataclasses.dataclass
202
+ class Repair:
203
+ slug: str
204
+ fixed: Any = None # the repaired Mnemonic, or None
205
+ type_changed: tuple | None = None # (old, new)
206
+ priority_changed: tuple | None = None
207
+ conflicts: list = dataclasses.field(default_factory=list)
208
+ unparsed: list = dataclasses.field(default_factory=list)
209
+
210
+
211
+ def repair(m) -> Repair | None:
212
+ """Split a damaged memory's fields back apart. None when `m` is clean.
213
+
214
+ Swallowed text fields fill the real field when it is empty, and are
215
+ appended to it when both hold different text (reported as a conflict).
216
+ Swallowed tags, related and supersedes merge into the real lists. A
217
+ swallowed type_code or priority is restored. The timestamp is kept, so
218
+ the repair does not read as a fresh save. `fixed` is None when something
219
+ could not be parsed, and the reason is in `unparsed`.
220
+ """
221
+ from .sanitize import detect_injection
222
+
223
+ if not any(has_markup(getattr(m, f)) for f in TEXT_FIELDS):
224
+ return None
225
+ rep = Repair(slug=m.slug)
226
+ new = {f: (getattr(m, f) or '') for f in TEXT_FIELDS}
227
+ lists = {f: list(getattr(m, f) or []) for f in LIST_FIELDS}
228
+ extra: dict[str, str] = {}
229
+ parts = {f: _split(f, new[f]) for f in TEXT_FIELDS}
230
+ for f in TEXT_FIELDS:
231
+ new[f] = parts[f].get(f, '')
232
+ for f in TEXT_FIELDS:
233
+ for k, v in parts[f].items():
234
+ if k == f or not v:
235
+ continue
236
+ k = {'type': 'type_code', 'memory_type': 'type_code'}.get(k, k)
237
+ if k in TEXT_FIELDS:
238
+ if not new[k].strip():
239
+ new[k] = v
240
+ elif v.strip() != new[k].strip():
241
+ rep.conflicts.append((f, k))
242
+ new[k] = (new[k].rstrip() + '\n\n' + v).strip()
243
+ elif k in LIST_FIELDS:
244
+ lv = _as_list(v)
245
+ if lv is None:
246
+ rep.unparsed.append(f'{k} is not a JSON list: {v[:60]!r}')
247
+ else:
248
+ lists[k] = list(dict.fromkeys(lists[k] + lv))
249
+ else:
250
+ extra[k] = v
251
+ tc, pr = m.type_code, m.priority
252
+ if extra.get('type_code') in _TYPE_CODES and extra['type_code'] != tc:
253
+ rep.type_changed = (tc, extra['type_code'])
254
+ tc = extra['type_code']
255
+ if extra.get('priority', '').strip() in ('1', '2', '3'):
256
+ p = int(extra['priority'])
257
+ if p != pr:
258
+ rep.priority_changed = (pr, p)
259
+ pr = p
260
+ still = [f for f in TEXT_FIELDS if has_markup(new[f])]
261
+ if still:
262
+ rep.unparsed.append('markup remains in ' + ', '.join(still))
263
+ if not new['rule'].strip():
264
+ rep.unparsed.append('rule would be empty')
265
+ if rep.unparsed:
266
+ return rep
267
+ unv = m.unverified
268
+ # The detector may have flagged the merged blob; judge the clean text.
269
+ if unv and not detect_injection(new['rule'], new['body']):
270
+ unv = False
271
+ rep.fixed = dataclasses.replace(
272
+ m, type_code=tc, priority=pr, unverified=unv, sha=None,
273
+ rule=new['rule'],
274
+ why=new['why'] or None, when=new['when'] or None,
275
+ body=new['body'] or None,
276
+ tags=lists['tags'], related=lists['related'],
277
+ supersedes=lists['supersedes'])
278
+ return rep
@@ -16,6 +16,7 @@ from mcp.types import (
16
16
  )
17
17
 
18
18
  from . import __version__
19
+ from .markup import clean_for_context, has_markup
19
20
  from .models import Mnemonic
20
21
  from .repo import Repository
21
22
  from .scorer import score as bm25_score
@@ -80,6 +81,11 @@ _TYPE_DESCRIPTIONS = (
80
81
 
81
82
  _TYPE_ENUM = ["fb", "us", "pj", "rf", "cn", "lx", "co", "tr"]
82
83
 
84
+ #: A rule longer than this draws a warning on save. The schema asks for about
85
+ #: 200; on 2026-10-07 the median clean rule was 306 characters and the median
86
+ #: rule damaged by swallowed parameters was 992.
87
+ _RULE_WARN_CHARS = 400
88
+
83
89
 
84
90
  def _default_store() -> Path:
85
91
  from .repo import default_store_candidates
@@ -122,16 +128,20 @@ def _load_repo(store_path: Path | None) -> Repository | None:
122
128
 
123
129
  def _mnem_to_dict(m: Mnemonic, score: float | None = None,
124
130
  include_body: bool = False) -> dict[str, Any]:
131
+ # Tool-call markup inside a stored field is the residue of a save whose
132
+ # parameters were swallowed. Serving it back teaches the next save the
133
+ # same mistake, so reads carry the field's own text and a flag instead.
134
+ damaged = any(has_markup(getattr(m, f)) for f in ("rule", "why", "when", "body"))
125
135
  d: dict[str, Any] = {
126
136
  "slug": m.slug,
127
137
  "type": m.type_code,
128
138
  "priority": m.priority,
129
- "rule": m.rule,
139
+ "rule": clean_for_context(m.rule),
130
140
  }
131
141
  if m.why:
132
- d["why"] = m.why
142
+ d["why"] = clean_for_context(m.why)
133
143
  if m.when:
134
- d["when"] = m.when
144
+ d["when"] = clean_for_context(m.when)
135
145
  if m.tags:
136
146
  d["tags"] = m.tags
137
147
  if m.desc:
@@ -142,8 +152,11 @@ def _mnem_to_dict(m: Mnemonic, score: float | None = None,
142
152
  # surface candidate status on read so the agent knows not to trust it
143
153
  # blindly (and can promote it with verify_memory)
144
154
  d["unverified"] = True
155
+ if damaged:
156
+ d["damaged"] = ("fields were stored with swallowed parameters; shown "
157
+ "cut at the damage. Repair: memgit doctor --repair-markup")
145
158
  if include_body and m.body:
146
- d["body"] = m.body
159
+ d["body"] = clean_for_context(m.body)
147
160
  elif m.body:
148
161
  d["has_body"] = True # full detail available via get_memory
149
162
  if score is not None:
@@ -730,7 +743,8 @@ def run_server(store_path: Path | None = None) -> None:
730
743
 
731
744
  lines = [f"# {len(mnemonics)} memories"]
732
745
  for m in mnemonics:
733
- rule_preview = m.rule[:80] + ".." if len(m.rule) > 80 else m.rule
746
+ shown = clean_for_context(m.rule)
747
+ rule_preview = shown[:80] + ".." if len(shown) > 80 else shown
734
748
  proj = f" {m.project}" if m.project else ""
735
749
  sup = (f" ⊘superseded-by:{resolve_head(m.slug, all_mems)}"
736
750
  if m.slug in hidden else "")
@@ -739,16 +753,37 @@ def run_server(store_path: Path | None = None) -> None:
739
753
  return [TextContent(type="text", text="\n".join(lines))]
740
754
 
741
755
  elif name == "save_memory":
742
- slug = arguments.get("slug", "").strip()
743
- rule = arguments.get("rule", "").strip()
756
+ # Refuse before anything else when a value carries tool-call
757
+ # markup: the parameters after it were swallowed into that value
758
+ # and would be stored as defaults. Raising marks the result
759
+ # isError, so the model retries the call in the same turn.
760
+ from .markup import (SAVE_PARAMS, check_save_arguments,
761
+ refusal_message)
762
+ problems = check_save_arguments(arguments)
763
+ if problems:
764
+ raise ValueError(refusal_message(problems))
765
+
766
+ slug = (arguments.get("slug") or "").strip()
767
+ rule = (arguments.get("rule") or "").strip()
744
768
 
745
769
  if not slug or not rule:
746
- return [TextContent(type="text", text="Error: slug and rule are required.")]
770
+ missing = [k for k, v in (("slug", slug), ("rule", rule)) if not v]
771
+ return [TextContent(type="text", text=(
772
+ "Error: slug and rule are required (missing: "
773
+ + ", ".join(missing) + "; arguments received: "
774
+ + ", ".join(sorted(arguments)) + ")."))]
747
775
 
748
776
  # Accept "type" as an alias: read surfaces return the field as
749
777
  # "type", so an operator mirroring get_memory output naturally
750
778
  # passes it back under that name.
751
- type_code = arguments.get("type_code") or arguments.get("type") or "fb"
779
+ defaulted: list[str] = []
780
+ type_code = arguments.get("type_code") or arguments.get("type")
781
+ if not type_code:
782
+ type_code = "fb"
783
+ defaulted.append("type_code")
784
+ if "priority" not in arguments:
785
+ defaulted.append("priority")
786
+ unknown_args = sorted(k for k in arguments if k not in SAVE_PARAMS)
752
787
  why = arguments.get("why")
753
788
  when = arguments.get("when")
754
789
  body = arguments.get("body")
@@ -891,6 +926,40 @@ def run_server(store_path: Path | None = None) -> None:
891
926
  "high-authority injection until you confirm it with "
892
927
  "verify_memory."
893
928
  )
929
+ # Echo what was stored, so a call that lost fields on the way in
930
+ # is visible in the answer rather than only in the store.
931
+ out["stored"] = {
932
+ "rule_chars": len(rule),
933
+ "why_chars": len(why or ""),
934
+ "when_chars": len(when or ""),
935
+ "body_chars": len(body or ""),
936
+ "tags": len(m.tags),
937
+ }
938
+ empty = [f for f, v in (("why", why), ("when", when),
939
+ ("body", body)) if not (v or "").strip()]
940
+ if not m.tags:
941
+ empty.append("tags")
942
+ if empty:
943
+ out["stored"]["empty"] = empty
944
+ if defaulted:
945
+ out["defaulted"] = defaulted
946
+ if "type_code" in defaulted:
947
+ warnings.append(
948
+ "no type_code arrived, so this was stored as 'fb' "
949
+ "(feedback). If it is a project fact, lesson, tracker or "
950
+ "anything else, save it again with type_code set.")
951
+ if unknown_args:
952
+ warnings.append(
953
+ "ignored unknown argument(s): " + ", ".join(unknown_args)
954
+ + ". save_memory takes type_code (or type), not "
955
+ "memory_type; anything else here may mean two tool calls "
956
+ "were merged into one.")
957
+ if len(rule) > _RULE_WARN_CHARS:
958
+ warnings.append(
959
+ f"rule is {len(rule)} characters; it is shown in every "
960
+ "search result and digest, so keep it to one sentence "
961
+ f"(about 200, at most {_RULE_WARN_CHARS}) and move the "
962
+ "detail to body.")
894
963
  if warnings:
895
964
  out["warnings"] = warnings
896
965
  return [TextContent(type="text", text=json.dumps(out, indent=2))]
@@ -15,6 +15,7 @@ from typing import Optional
15
15
  from .models import (
16
16
  Checkpoint, DiffSummary, MindState, MindStateEntry, Mnemonic, Thread,
17
17
  )
18
+ from .markup import clean_for_context
18
19
  from .store import ObjectStore, object_cache as _object_cache
19
20
 
20
21
 
@@ -1138,8 +1139,8 @@ class Repository:
1138
1139
  else:
1139
1140
  tracker_pool = [m for m in mnemonics if not m.project]
1140
1141
  trackers = [
1141
- {'slug': m.slug, 'rule': m.rule, 'timestamp': m.timestamp,
1142
- 'tags': m.tags}
1142
+ {'slug': m.slug, 'rule': clean_for_context(m.rule),
1143
+ 'timestamp': m.timestamp, 'tags': m.tags}
1143
1144
  for m in sorted((m for m in tracker_pool
1144
1145
  if m.type_code == 'tr' and not m.unverified),
1145
1146
  key=lambda m: m.timestamp, reverse=True)
@@ -1150,7 +1151,8 @@ class Repository:
1150
1151
  # to confirm) but tags them, so they're never mistaken for trusted state.
1151
1152
  recent_mems = [
1152
1153
  {'slug': m.slug, 'type': m.type_code, 'priority': m.priority,
1153
- 'timestamp': m.timestamp, 'rule': m.rule, 'project': m.project,
1154
+ 'timestamp': m.timestamp, 'rule': clean_for_context(m.rule),
1155
+ 'project': m.project,
1154
1156
  'unverified': m.unverified}
1155
1157
  for m in pool if m.slug not in tracker_slugs
1156
1158
  ][:recent]
@@ -1163,7 +1165,7 @@ class Repository:
1163
1165
  if not m.project or project_affinity(m.project, project) >= 1
1164
1166
  ]
1165
1167
  critical = [
1166
- {'slug': m.slug, 'rule': m.rule}
1168
+ {'slug': m.slug, 'rule': clean_for_context(m.rule)}
1167
1169
  for m in sorted(critical_pool, key=lambda m: m.slug)
1168
1170
  if m.priority == 3 and not m.unverified # candidates never "always apply"
1169
1171
  ]
@@ -1181,7 +1183,8 @@ class Repository:
1181
1183
  else:
1182
1184
  core_pool = [m for m in mnemonics if not m.project]
1183
1185
  core = [
1184
- {'slug': m.slug, 'rule': m.rule, 'body': m.body}
1186
+ {'slug': m.slug, 'rule': clean_for_context(m.rule),
1187
+ 'body': clean_for_context(m.body) or None}
1185
1188
  for m in sorted(core_pool, key=lambda m: m.slug)
1186
1189
  if m.type_code == 'co' and not m.unverified # never inject unverified guidance
1187
1190
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.12.0
3
+ Version: 0.13.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
@@ -284,6 +284,8 @@ Once registered via MCP, every AI tool gets 6 tools:
284
284
 
285
285
  The tool descriptions teach the AI **judgment** — "does this request depend on state you don't have in context?" — rather than keyword triggers. Measured cost of the whole tool surface: ~1,150 tokens once per session; a `resume_session` reply is ~335.
286
286
 
287
+ **A save that arrives damaged is refused, not stored** (v0.13.0). A model sometimes closes a value with a tag named after the field (`</rule>`), and the host then delivers the next parameters inside that value: `why`, `when`, `tags` and `type_code` never arrive, and the memory is stored with defaults. On one machine that was 748 of 3,025 saves. `save_memory` now refuses any value carrying tool-call markup, names the field and the parameters it swallowed, and the model retries in the same turn. Markup quoted on purpose inside backticks is allowed. Every successful save echoes the length of each field it stored, which fields arrived empty, and which values came from a default. Damaged memories already in a store are never served back into context: the digest, the recall hook and read results show each field cut at the damage. `memgit doctor` counts them, and `memgit doctor --repair-markup` splits them back into their fields.
288
+
287
289
  ---
288
290
 
289
291
  ## Core operating guide (v0.5.0)
@@ -415,6 +417,8 @@ memgit doctor # hygiene + scope losses: split labels, quaran
415
417
  memgit doctor --audit # every split and stranded label, the save landing rate, throughput
416
418
  memgit doctor --audit --json # the same report as JSON
417
419
  memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
420
+ memgit doctor --repair-markup # dry run: memories whose fields swallowed other parameters on save
421
+ memgit doctor --repair-markup --yes [--project LABEL] # split them back into their fields; one checkpoint
418
422
  memgit lint # validate all memories (flags unknown provenance)
419
423
  memgit fsck # verify store integrity
420
424
 
@@ -15,6 +15,7 @@ memgit/http_server.py
15
15
  memgit/importer.py
16
16
  memgit/license.py
17
17
  memgit/links.py
18
+ memgit/markup.py
18
19
  memgit/mcp_server.py
19
20
  memgit/metrics.py
20
21
  memgit/models.py
@@ -54,6 +55,7 @@ tests/test_env_inventory.py
54
55
  tests/test_git_args.py
55
56
  tests/test_license.py
56
57
  tests/test_lock_exclusion.py
58
+ tests/test_markup.py
57
59
  tests/test_org.py
58
60
  tests/test_savings.py
59
61
  tests/test_server_lifecycle.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memgit"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,233 @@
1
+ """save_memory values that carry tool-call markup.
2
+
3
+ Every fixture shape below is one measured on 2026-10-07 in a live store, where
4
+ 748 of 3,025 save_memory calls arrived with the parameters after one field
5
+ swallowed into it. The save path must refuse each of them, the read and
6
+ injection surfaces must not serve the markup back, and doctor must split a
7
+ stored one back into its fields.
8
+ """
9
+ import asyncio
10
+ import json
11
+ from datetime import datetime, timezone
12
+
13
+ import pytest
14
+ from click.testing import CliRunner
15
+ from mcp import types
16
+
17
+ from memgit import markup
18
+ from memgit.models import Mnemonic
19
+ from memgit.repo import Repository
20
+
21
+ P = '<parameter name="{}">'
22
+ END = '</parameter>'
23
+
24
+ #: (field, damaged value, parameters it swallowed), one per measured shape.
25
+ SHAPES = [
26
+ ('rule', 'X is true.</rule>\n' + P.format('why') + 'because Y', ['why']),
27
+ ('rule', 'X is true.</rule>\n' + P.format('type_code') + 'pj', ['type_code']),
28
+ ('rule', 'X is true.</rule>\n' + P.format('why') + 'because Y</why>\n'
29
+ + P.format('when') + 'always', ['why', 'when']),
30
+ ('body', 'Long detail here.</body>\n', []),
31
+ ('rule', 'X is true.' + END + '\n' + P.format('why') + 'because Y', ['why']),
32
+ ('why', 'because Y' + END + '\n' + P.format('when') + 'always', ['when']),
33
+ ('rule', 'X is true.</rule>\n' + P.format('why') + 'Y' + END + '\n'
34
+ + P.format('when') + 'W' + END + '\n' + P.format('body') + 'B' + END
35
+ + '\n' + P.format('tags') + '["a", "b"]', ['why', 'when', 'body', 'tags']),
36
+ ('why', 'because Y</why>\n' + P.format('when') + 'always' + END + '\n'
37
+ + P.format('tags') + '["a"]', ['when', 'tags']),
38
+ # the start of the next tool call, and namespaced markup
39
+ ('rule', 'X is true.' + END + '\n</invoke>\n<invoke name="ToolSearch">', []),
40
+ ('rule', 'X.</ns:parameter>\n<ns:parameter name="why">Y', ['why']),
41
+ # an unclosed fence in prose must not hide the damage after it
42
+ ('rule', 'Outline renders ```mermaid blocks.' + END + '\n'
43
+ + P.format('why') + 'Y', ['why']),
44
+ ]
45
+
46
+
47
+ @pytest.fixture
48
+ def server(tmp_path, monkeypatch):
49
+ """The real MCP server with its handlers registered, never run on stdio."""
50
+ from memgit import mcp_server
51
+ store = tmp_path / 'store'
52
+ store.mkdir()
53
+ Repository.init(store)
54
+ holder = {}
55
+
56
+ class Capture(mcp_server.Server):
57
+ def __init__(self, *a, **k):
58
+ super().__init__(*a, **k)
59
+ holder['s'] = self
60
+
61
+ monkeypatch.setattr(mcp_server, 'Server', Capture)
62
+ monkeypatch.setattr(asyncio, 'run', lambda coro: coro.close())
63
+ monkeypatch.setenv('MEMGIT_PROJECT', 'proj')
64
+ mcp_server.run_server(store)
65
+ srv = holder['s']
66
+
67
+ def call(arguments):
68
+ req = types.CallToolRequest(
69
+ method='tools/call',
70
+ params=types.CallToolRequestParams(name='save_memory',
71
+ arguments=arguments))
72
+ loop = asyncio.new_event_loop()
73
+ try:
74
+ res = loop.run_until_complete(
75
+ srv.request_handlers[types.CallToolRequest](req)).root
76
+ finally:
77
+ loop.close()
78
+ return res.isError, res.content[0].text
79
+
80
+ call.repo = lambda: Repository(store / '.memgit')
81
+ return call
82
+
83
+
84
+ @pytest.fixture
85
+ def repo(tmp_path):
86
+ store = tmp_path / 'r'
87
+ store.mkdir()
88
+ return Repository.init(store)
89
+
90
+
91
+ class TestDetector:
92
+ @pytest.mark.parametrize('field,value,lost', SHAPES)
93
+ def test_every_measured_shape_is_markup(self, field, value, lost):
94
+ assert markup.has_markup(value)
95
+ assert [p for p in markup.swallowed_params(value) if p != field] == lost
96
+
97
+ @pytest.mark.parametrize('text', [
98
+ 'Quote it in code: `</rule><parameter name="why">` is the defect.',
99
+ 'Inject the script before </body> in the template, then reload.',
100
+ 'The closing tag </rule> is prose here and continues afterwards.',
101
+ '```\n</rule>\n<parameter name="why">x\n```',
102
+ 'Plain text with <b>html</b> and a <div> or two.',
103
+ '',
104
+ ])
105
+ def test_prose_and_quoted_markup_are_not(self, text):
106
+ assert not markup.has_markup(text)
107
+
108
+ def test_clean_for_context_cuts_at_the_damage(self):
109
+ assert markup.clean_for_context(SHAPES[0][1]) == 'X is true.'
110
+ assert markup.clean_for_context('fine') == 'fine'
111
+ assert markup.clean_for_context(None) == ''
112
+
113
+
114
+ class TestSaveRefuses:
115
+ @pytest.mark.parametrize('field,value,lost', SHAPES)
116
+ def test_damaged_save_is_refused_and_nothing_is_stored(
117
+ self, server, field, value, lost):
118
+ args = {'slug': 'damaged', 'rule': 'a clean rule', 'type_code': 'pj'}
119
+ args[field] = value
120
+ is_error, text = server(args)
121
+ assert is_error
122
+ assert 'refused' in text and f"'{field}'" in text
123
+ for p in lost:
124
+ assert p in text
125
+ assert server.repo().get('damaged') is None
126
+
127
+ def test_markup_inside_a_tag_is_refused(self, server):
128
+ is_error, _ = server({'slug': 's', 'rule': 'r', 'type_code': 'pj',
129
+ 'tags': ['ok', 'x</tags>\n' + P.format('why')]})
130
+ assert is_error
131
+
132
+ def test_quoted_markup_saves(self, server):
133
+ is_error, text = server({
134
+ 'slug': 'lesson', 'type_code': 'lx', 'tags': ['memgit'],
135
+ 'rule': 'A save leaked `</rule><parameter name="why">` into rule.'})
136
+ assert not is_error
137
+ assert json.loads(text)['status'] == 'ok'
138
+
139
+
140
+ class TestSaveEchoes:
141
+ def test_clean_save_reports_what_was_stored(self, server):
142
+ _, text = server({'slug': 'a', 'rule': 'r', 'type_code': 'pj',
143
+ 'priority': 2, 'why': 'w', 'when': 'n',
144
+ 'body': 'b', 'tags': ['t']})
145
+ out = json.loads(text)
146
+ assert out['stored'] == {'rule_chars': 1, 'why_chars': 1,
147
+ 'when_chars': 1, 'body_chars': 1, 'tags': 1}
148
+ assert 'defaulted' not in out and 'warnings' not in out
149
+
150
+ def test_missing_type_and_fields_are_named(self, server):
151
+ out = json.loads(server({'slug': 'b', 'rule': 'r'})[1])
152
+ assert out['type'] == 'fb'
153
+ assert out['defaulted'] == ['type_code', 'priority']
154
+ assert out['stored']['empty'] == ['why', 'when', 'body', 'tags']
155
+ assert any("stored as 'fb'" in w for w in out['warnings'])
156
+
157
+ def test_unknown_argument_is_warned(self, server):
158
+ out = json.loads(server({'slug': 'c', 'rule': 'r', 'type_code': 'pj',
159
+ 'memory_type': 'pj'})[1])
160
+ assert any('memory_type' in w for w in out['warnings'])
161
+
162
+ def test_long_rule_is_warned(self, server):
163
+ out = json.loads(server({'slug': 'd', 'rule': 'x' * 401,
164
+ 'type_code': 'pj'})[1])
165
+ assert any('401 characters' in w for w in out['warnings'])
166
+
167
+
168
+ def _stored(repo, **kw):
169
+ base = dict(type_code='fb', slug='m', rule='r', priority=2,
170
+ timestamp=datetime(2026, 9, 20, tzinfo=timezone.utc),
171
+ project='proj')
172
+ base.update(kw)
173
+ m = Mnemonic(**base)
174
+ repo.add(m)
175
+ repo.commit(message='seed', trigger='explicit')
176
+ return m
177
+
178
+
179
+ class TestRepair:
180
+ def test_splits_fields_lists_and_type(self, repo):
181
+ rule = ('FINAL timesheet: 43.0 hours.</rule>\n' + P.format('type_code')
182
+ + 'pj' + END + '\n' + P.format('why') + 'rebuilt' + END + '\n'
183
+ + P.format('tags') + '["ofo", "timesheet"]' + END + '\n'
184
+ + P.format('supersedes') + '["old-sheet"]')
185
+ _stored(repo, rule=rule, tags=['kept'])
186
+ rep = markup.repair(repo.get('m'))
187
+ f = rep.fixed
188
+ assert f.rule == 'FINAL timesheet: 43.0 hours.'
189
+ assert f.type_code == 'pj' and rep.type_changed == ('fb', 'pj')
190
+ assert f.why == 'rebuilt'
191
+ assert f.tags == ['kept', 'ofo', 'timesheet']
192
+ assert f.supersedes == ['old-sheet']
193
+ assert f.timestamp == datetime(2026, 9, 20, tzinfo=timezone.utc)
194
+
195
+ def test_drops_the_next_tool_call(self, repo):
196
+ _stored(repo, rule='X.' + END + '\n</invoke>\n<invoke name="Bash">'
197
+ + P.format('command') + 'ls')
198
+ f = markup.repair(repo.get('m')).fixed
199
+ assert f.rule == 'X.' and f.why is None
200
+
201
+ def test_stray_body_closer(self, repo):
202
+ _stored(repo, body='Detail.</body>\n')
203
+ assert markup.repair(repo.get('m')).fixed.body == 'Detail.'
204
+
205
+ def test_clean_memory_is_left_alone(self, repo):
206
+ _stored(repo, rule='Quoting `</rule>` is fine.')
207
+ assert markup.repair(repo.get('m')) is None
208
+
209
+ def test_doctor_dry_run_then_apply(self, repo, tmp_path, monkeypatch):
210
+ from memgit.cli import cli
211
+ _stored(repo, rule='X.</rule>\n' + P.format('why') + 'Y')
212
+ monkeypatch.chdir(tmp_path / 'r')
213
+ monkeypatch.setenv('MEMGIT_STORE', str(tmp_path / 'r'))
214
+ r = CliRunner().invoke(cli, ['doctor'])
215
+ assert '1 memories carry tool-call markup' in r.output
216
+ r = CliRunner().invoke(cli, ['doctor', '--repair-markup'])
217
+ assert 'would repair 1' in r.output
218
+ assert repo.get('m').rule.startswith('X.</rule>')
219
+ r = CliRunner().invoke(cli, ['doctor', '--repair-markup', '--yes'])
220
+ assert 'repaired 1' in r.output, r.output
221
+ m = repo.get('m')
222
+ assert (m.rule, m.why) == ('X.', 'Y')
223
+
224
+
225
+ class TestContextNeverServesMarkup:
226
+ def test_resume_digest_and_recall_are_clean(self, repo):
227
+ damaged = 'Always X.</rule>\n' + P.format('why') + 'Y'
228
+ _stored(repo, slug='crit', rule=damaged, priority=3)
229
+ _stored(repo, slug='trk', rule=damaged, type_code='tr')
230
+ ctx = repo.resume_context(project='proj')
231
+ blob = json.dumps(ctx, default=str)
232
+ assert '<parameter' not in blob and '</rule>' not in blob
233
+ assert 'Always X.' in blob
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes