memdebug 0.2.0__tar.gz → 0.3.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 (90) hide show
  1. {memdebug-0.2.0 → memdebug-0.3.0}/CHANGELOG.md +21 -0
  2. {memdebug-0.2.0 → memdebug-0.3.0}/PKG-INFO +60 -7
  3. {memdebug-0.2.0 → memdebug-0.3.0}/README.md +59 -6
  4. {memdebug-0.2.0 → memdebug-0.3.0}/ROADMAP.md +8 -8
  5. {memdebug-0.2.0 → memdebug-0.3.0}/docs/agents.md +1 -1
  6. {memdebug-0.2.0 → memdebug-0.3.0}/docs/releasing.md +4 -3
  7. {memdebug-0.2.0 → memdebug-0.3.0}/docs/threat-model.md +22 -1
  8. {memdebug-0.2.0 → memdebug-0.3.0}/pyproject.toml +1 -1
  9. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/__init__.py +1 -1
  10. memdebug-0.3.0/src/memdebug/adapters/fileops.py +190 -0
  11. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/folder.py +5 -0
  12. memdebug-0.3.0/src/memdebug/adapters/folder_restore.py +275 -0
  13. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/restore.py +5 -159
  14. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/cli.py +103 -12
  15. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/monitor.py +24 -0
  16. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/paths.py +5 -0
  17. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/rollback_flow.py +12 -3
  18. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/selftest.py +49 -0
  19. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/pages.py +80 -3
  20. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/style.py +1 -0
  21. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_demo.py +9 -0
  22. memdebug-0.3.0/tests/test_folder_restore.py +473 -0
  23. memdebug-0.3.0/tests/test_folder_rollback_cli.py +235 -0
  24. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_restore.py +3 -3
  25. memdebug-0.3.0/tests/test_viewer_putback.py +257 -0
  26. {memdebug-0.2.0 → memdebug-0.3.0}/.gitattributes +0 -0
  27. {memdebug-0.2.0 → memdebug-0.3.0}/.github/dependabot.yml +0 -0
  28. {memdebug-0.2.0 → memdebug-0.3.0}/.github/workflows/ci.yml +0 -0
  29. {memdebug-0.2.0 → memdebug-0.3.0}/.github/workflows/release.yml +0 -0
  30. {memdebug-0.2.0 → memdebug-0.3.0}/.gitignore +0 -0
  31. {memdebug-0.2.0 → memdebug-0.3.0}/CONTRIBUTING.md +0 -0
  32. {memdebug-0.2.0 → memdebug-0.3.0}/LICENSE +0 -0
  33. {memdebug-0.2.0 → memdebug-0.3.0}/NOTICE +0 -0
  34. {memdebug-0.2.0 → memdebug-0.3.0}/SECURITY.md +0 -0
  35. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/__main__.py +0 -0
  36. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/__init__.py +0 -0
  37. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/base.py +0 -0
  38. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/common.py +0 -0
  39. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/markdown_git.py +0 -0
  40. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/mem0.py +0 -0
  41. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/openwebui.py +0 -0
  42. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/agents.py +0 -0
  43. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/backends.py +0 -0
  44. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/demo.py +0 -0
  45. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/describe.py +0 -0
  46. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/diff.py +0 -0
  47. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/docker_source.py +0 -0
  48. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/errors.py +0 -0
  49. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/hints.py +0 -0
  50. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/ledger.py +0 -0
  51. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/models.py +0 -0
  52. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/reconcile.py +0 -0
  53. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/report.py +0 -0
  54. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/stores.py +0 -0
  55. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/sync.py +0 -0
  56. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/textsafe.py +0 -0
  57. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/__init__.py +0 -0
  58. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/html.py +0 -0
  59. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/redline.py +0 -0
  60. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/server.py +0 -0
  61. {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/witness.py +0 -0
  62. {memdebug-0.2.0 → memdebug-0.3.0}/tests/conftest.py +0 -0
  63. {memdebug-0.2.0 → memdebug-0.3.0}/tests/payloads.py +0 -0
  64. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_agents.py +0 -0
  65. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_cli.py +0 -0
  66. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_diff.py +0 -0
  67. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_docker_source.py +0 -0
  68. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_folder_and_openwebui.py +0 -0
  69. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_hints.py +0 -0
  70. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_ledger.py +0 -0
  71. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_ledger_read.py +0 -0
  72. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_markdown_git.py +0 -0
  73. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_mem0_adapter.py +0 -0
  74. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_models.py +0 -0
  75. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_platform.py +0 -0
  76. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_provenance_openwebui.py +0 -0
  77. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_readable_ids_and_honest_wording.py +0 -0
  78. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_reconcile.py +0 -0
  79. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_release_hygiene.py +0 -0
  80. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_report.py +0 -0
  81. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_rollback_cli.py +0 -0
  82. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_snapshots.py +0 -0
  83. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_stores_and_monitor.py +0 -0
  84. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_sync.py +0 -0
  85. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_textsafe.py +0 -0
  86. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer.py +0 -0
  87. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer_html.py +0 -0
  88. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer_redline.py +0 -0
  89. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer_rollback.py +0 -0
  90. {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_witness.py +0 -0
@@ -3,6 +3,27 @@
3
3
  All notable changes. The format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [Semantic Versioning](https://semver.org/)
4
4
  (alpha: anything may change before 1.0).
5
5
 
6
+ ## [0.3.0] - 2026-10-07
7
+
8
+ ### Added
9
+ - **Rollback for plain folders.** `memdebug rollback store NAME --to s1` restores a watched folder of notes (and, by the same command, git notes) to a
10
+ snapshot: a dry run first, everything it replaces saved first byte for byte in a private folder next to the ledger, every write undone if a later
11
+ step fails, and the rollback recorded in the ledger and itself undoable. A folder is rebuilt from the snapshot's text, so line endings are LF (or CRLF
12
+ if the file it replaces uses CRLF throughout), and text a snapshot could not keep faithfully is skipped, never written.
13
+ - `memdebug selftest` has a new check, "folder rollback is safe", that proves the plain-folder rollback claims on your machine without needing git.
14
+ - `memdebug snapshot store NAME` saves a known-good copy of a watched store to roll back to. It refuses while the ledger holds an outside-history
15
+ change or flagged wording since the store's last snapshot, until you add `--include-changes`.
16
+
17
+ ### Changed
18
+ - **The viewer** shows how to put a store back: snapshot pages, the compare page, the page of a change made outside a store's history and the
19
+ overview's "Needs a look" give the `memdebug rollback store` command as text to copy (the viewer itself still only reads). A store name from the
20
+ ledger is only put into a command if it is safe to paste; for an outside change the snapshot offered is always one taken before it. A rollback of a
21
+ plain folder is described as in place with a backup, not as a commit.
22
+ - The file-writing safety code of the git rollback engine (links, junctions, case clashes, atomic writes, undo) now lives in one shared module used by
23
+ both engines. No behaviour change.
24
+ - `memdebug demo` now ends by pointing at `memdebug setup`, the guided way to watch your own agents, instead of an advanced command.
25
+ - Documentation: the README explains installing from PyPI, with a route that works on Windows without pipx.
26
+
6
27
  ## [0.2.0] - 2026-10-07
7
28
 
8
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memdebug
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Inspect, compare and roll back what an AI agent's memory holds. Local-first and agent-neutral.
5
5
  Project-URL: Homepage, https://github.com/juraj-jumic/memdebug
6
6
  Project-URL: Source, https://github.com/juraj-jumic/memdebug
@@ -45,15 +45,29 @@ It is an **observer**: it never sits between the agent and its memory, never tal
45
45
  computer. It does not block attacks as they happen (run it next to runtime guards), and it does not yet say *which
46
46
  conversation* wrote a memory. See [docs/threat-model.md](docs/threat-model.md) for exactly what it does and does not do.
47
47
 
48
- > **Status: alpha (0.2).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
48
+ > **Status: alpha (0.3).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
49
49
  > the author also runs them on Windows 11, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is built and what is next.
50
50
 
51
51
  ## Try it in a minute
52
52
 
53
- pip install . # needs Python 3.10+ and git 2.31+
53
+ You need Python 3.10 or newer and git 2.31 or newer. Install memdebug from PyPI into its own virtual environment, which works the same way
54
+ everywhere:
55
+
56
+ python -m venv memdebug-env
57
+ memdebug-env\Scripts\activate # Windows (PowerShell or cmd); on macOS and Linux: source memdebug-env/bin/activate
58
+ pip install memdebug
59
+
60
+ If you use [pipx](https://pipx.pypa.io/), `pipx install memdebug` does the same and keeps the `memdebug` command available everywhere. pipx is not
61
+ installed on Windows by default; to get it: `py -m pip install --user pipx`, then `py -m pipx ensurepath`, then open a new terminal. If your shell
62
+ cannot find the `memdebug` command after installing, `python -m memdebug` (on Windows `py -m memdebug`) does the same thing.
63
+
64
+ Then:
65
+
54
66
  memdebug demo # made-up agent, made-up attack, the real tools; nothing of yours is touched
55
67
  memdebug demo --serve # ...and then look at it in the browser viewer
56
68
 
69
+ (To work on memdebug itself, install from a checkout instead: see [CONTRIBUTING.md](CONTRIBUTING.md).)
70
+
57
71
  The demo plants an instruction into a note behind git's back, shows memdebug catching it, rolls the file back without losing
58
72
  the planted text, and shows the ledger noticing a tampered copy. It works in a throwaway folder and removes it afterwards.
59
73
 
@@ -79,7 +93,7 @@ Or register stores yourself (this is what a script would do):
79
93
  | Store | What it is | Change history | "Changed outside the history" | Rollback |
80
94
  | --- | --- | --- | --- | --- |
81
95
  | markdown (git) | markdown notes in a git repository | git history | an uncommitted edit | yes |
82
- | folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | not yet |
96
+ | folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | yes, from a snapshot (see below) |
83
97
  | openwebui | the `memory` table of Open WebUI's `webui.db`, copied from its Docker container (or from a copy you made) | none | not applicable | no |
84
98
  | mem0 | self-hosted Mem0 | Mem0's `history.db` | a change made directly in storage | no |
85
99
 
@@ -92,7 +106,9 @@ provider's cloud: there is nothing local to watch, and memdebug says so rather t
92
106
 
93
107
  ## Tools for people who want more
94
108
 
95
- memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1 # a dry run; add --apply
109
+ memdebug snapshot store NAME [--label TEXT] # save a known-good copy of a watched store, to roll back to later
110
+ memdebug rollback store NAME --to s1 # a dry run; add --apply. Works for folders and git notes
111
+ memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1
96
112
  memdebug report --format markdown|json|sarif [--out FILE] [--fail-on findings|hints] # for people, programs and CI
97
113
  memdebug witness --file E:\memdebug-witness.txt # a second copy of the ledger's fingerprint, kept somewhere else
98
114
  memdebug verify --witness E:\memdebug-witness.txt # catches a rewritten or cut-short ledger
@@ -129,6 +145,12 @@ the secret moves into a cookie and disappears from the address bar. It uses only
129
145
  library and sends no JavaScript at all. Run `memdebug verify` once first if the ledger is from an
130
146
  older version (the viewer itself never upgrades or creates anything).
131
147
 
148
+ The viewer can only look, so where you may want to act it shows you the command instead, as text to paste into a terminal: a snapshot page, the
149
+ compare page, the page of a change made outside a store's history, and the overview's "Needs a look" all say how to put a watched store back
150
+ (`memdebug rollback store NAME --to sN`), and for an outside change they point at the last snapshot taken *before* it, never one that already includes
151
+ it. Stores memdebug only reads (Open WebUI, Mem0) say so instead of offering a command. A rollback of a plain folder is described as it really is:
152
+ changed in place, with the replaced files saved in a backup folder, not as a git commit.
153
+
132
154
  Security of the viewer, threat by threat: [docs/threat-model.md](docs/threat-model.md#the-viewer). Its limits: it is plain HTTP on your own
133
155
  machine, the first link (with the secret) stays in your browser history, and processes running as you can read the secret.
134
156
  Choose Auto, Light or Dark at the top right.
@@ -163,7 +185,38 @@ installed. Adding a store type is described in [CONTRIBUTING.md](CONTRIBUTING.md
163
185
 
164
186
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read from a store is untrusted; adapters only read).
165
187
 
166
- ## Rolling back (markdown/git)
188
+ ## Rolling back a watched store (folders and git notes)
189
+
190
+ `memdebug rollback store NAME --to s1` does the same job by the name you see in `memdebug stores`, for a plain folder of notes as well as for
191
+ git notes. First save a known-good copy while the store is healthy:
192
+
193
+ ```
194
+ memdebug snapshot store claude-code-shop --label "good, after the move" # a snapshot you can come back to
195
+ memdebug snapshot list # names and dates of snapshots
196
+ memdebug rollback store claude-code-shop --to s2 # shows what would change; writes nothing
197
+ memdebug rollback store claude-code-shop --to s2 --apply # does it, after you type the snapshot id
198
+ ```
199
+
200
+ `snapshot store` refuses to save while the ledger holds a change made outside the store's own history, or wording flagged as worth a second look,
201
+ since the store's last snapshot: a snapshot is what a rollback later treats as good. Look at the flagged changes first (`memdebug serve`), then add
202
+ `--include-changes` if they are fine. This does not go away if you run it twice.
203
+
204
+ For a **plain folder** there is no git to take exact file versions from, so it works from the snapshot's text, and it is honest about what that means:
205
+
206
+ * **Backups are files.** Anything the rollback would replace or remove is first copied, byte for byte, into a private folder next to your ledger
207
+ (`backups\<store>\<date>-<id>`, with a `manifest.json`), and each copy is read back and checked before your notes are touched. Get a file
208
+ back by copying it out of that folder's `files` folder. These copies contain your memory text and memdebug never deletes them: delete old ones
209
+ yourself when you no longer need them.
210
+ * **Line endings.** A snapshot does not keep them. Files come back as UTF-8 with LF line endings, or with CRLF if the file being replaced uses CRLF
211
+ throughout. A file with mixed endings comes back with LF.
212
+ * **Nothing it cannot restore faithfully is written.** Text that was cut, marked too large, or had bytes that are not valid text is skipped, and
213
+ the dry run says which files and why.
214
+ * **Everything else is the same** as for git notes: the plan writes nothing, applying refuses if anything changed since you saw the plan, links,
215
+ junctions and unsafe names are refused, every write is undone if a later step fails, and the rollback is recorded and can itself be undone.
216
+
217
+ Open WebUI and Mem0 keep their memory in databases that memdebug only ever reads, so they cannot be rolled back; change those in the app.
218
+
219
+ ## Rolling back markdown in git, by path
167
220
 
168
221
  `memdebug rollback markdown` puts a memory folder back to what a snapshot held. It is the only command that changes
169
222
  your files, so it is cautious by design.
@@ -193,7 +246,7 @@ What it guarantees:
193
246
  * **It can be undone.** It takes a snapshot just before and just after, and records a `ROLLBACK` entry in the ledger.
194
247
  To undo a rollback, roll back to the "before" snapshot it printed.
195
248
 
196
- `memdebug selftest` proves the "no programs run" and "exact bytes" claims on your computer.
249
+ `memdebug selftest` proves the "no programs run" and "exact bytes" claims, and the folder-rollback claims, on your computer.
197
250
 
198
251
  ## Fonts
199
252
 
@@ -12,15 +12,29 @@ It is an **observer**: it never sits between the agent and its memory, never tal
12
12
  computer. It does not block attacks as they happen (run it next to runtime guards), and it does not yet say *which
13
13
  conversation* wrote a memory. See [docs/threat-model.md](docs/threat-model.md) for exactly what it does and does not do.
14
14
 
15
- > **Status: alpha (0.2).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
15
+ > **Status: alpha (0.3).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
16
16
  > the author also runs them on Windows 11, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is built and what is next.
17
17
 
18
18
  ## Try it in a minute
19
19
 
20
- pip install . # needs Python 3.10+ and git 2.31+
20
+ You need Python 3.10 or newer and git 2.31 or newer. Install memdebug from PyPI into its own virtual environment, which works the same way
21
+ everywhere:
22
+
23
+ python -m venv memdebug-env
24
+ memdebug-env\Scripts\activate # Windows (PowerShell or cmd); on macOS and Linux: source memdebug-env/bin/activate
25
+ pip install memdebug
26
+
27
+ If you use [pipx](https://pipx.pypa.io/), `pipx install memdebug` does the same and keeps the `memdebug` command available everywhere. pipx is not
28
+ installed on Windows by default; to get it: `py -m pip install --user pipx`, then `py -m pipx ensurepath`, then open a new terminal. If your shell
29
+ cannot find the `memdebug` command after installing, `python -m memdebug` (on Windows `py -m memdebug`) does the same thing.
30
+
31
+ Then:
32
+
21
33
  memdebug demo # made-up agent, made-up attack, the real tools; nothing of yours is touched
22
34
  memdebug demo --serve # ...and then look at it in the browser viewer
23
35
 
36
+ (To work on memdebug itself, install from a checkout instead: see [CONTRIBUTING.md](CONTRIBUTING.md).)
37
+
24
38
  The demo plants an instruction into a note behind git's back, shows memdebug catching it, rolls the file back without losing
25
39
  the planted text, and shows the ledger noticing a tampered copy. It works in a throwaway folder and removes it afterwards.
26
40
 
@@ -46,7 +60,7 @@ Or register stores yourself (this is what a script would do):
46
60
  | Store | What it is | Change history | "Changed outside the history" | Rollback |
47
61
  | --- | --- | --- | --- | --- |
48
62
  | markdown (git) | markdown notes in a git repository | git history | an uncommitted edit | yes |
49
- | folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | not yet |
63
+ | folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | yes, from a snapshot (see below) |
50
64
  | openwebui | the `memory` table of Open WebUI's `webui.db`, copied from its Docker container (or from a copy you made) | none | not applicable | no |
51
65
  | mem0 | self-hosted Mem0 | Mem0's `history.db` | a change made directly in storage | no |
52
66
 
@@ -59,7 +73,9 @@ provider's cloud: there is nothing local to watch, and memdebug says so rather t
59
73
 
60
74
  ## Tools for people who want more
61
75
 
62
- memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1 # a dry run; add --apply
76
+ memdebug snapshot store NAME [--label TEXT] # save a known-good copy of a watched store, to roll back to later
77
+ memdebug rollback store NAME --to s1 # a dry run; add --apply. Works for folders and git notes
78
+ memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1
63
79
  memdebug report --format markdown|json|sarif [--out FILE] [--fail-on findings|hints] # for people, programs and CI
64
80
  memdebug witness --file E:\memdebug-witness.txt # a second copy of the ledger's fingerprint, kept somewhere else
65
81
  memdebug verify --witness E:\memdebug-witness.txt # catches a rewritten or cut-short ledger
@@ -96,6 +112,12 @@ the secret moves into a cookie and disappears from the address bar. It uses only
96
112
  library and sends no JavaScript at all. Run `memdebug verify` once first if the ledger is from an
97
113
  older version (the viewer itself never upgrades or creates anything).
98
114
 
115
+ The viewer can only look, so where you may want to act it shows you the command instead, as text to paste into a terminal: a snapshot page, the
116
+ compare page, the page of a change made outside a store's history, and the overview's "Needs a look" all say how to put a watched store back
117
+ (`memdebug rollback store NAME --to sN`), and for an outside change they point at the last snapshot taken *before* it, never one that already includes
118
+ it. Stores memdebug only reads (Open WebUI, Mem0) say so instead of offering a command. A rollback of a plain folder is described as it really is:
119
+ changed in place, with the replaced files saved in a backup folder, not as a git commit.
120
+
99
121
  Security of the viewer, threat by threat: [docs/threat-model.md](docs/threat-model.md#the-viewer). Its limits: it is plain HTTP on your own
100
122
  machine, the first link (with the secret) stays in your browser history, and processes running as you can read the secret.
101
123
  Choose Auto, Light or Dark at the top right.
@@ -130,7 +152,38 @@ installed. Adding a store type is described in [CONTRIBUTING.md](CONTRIBUTING.md
130
152
 
131
153
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read from a store is untrusted; adapters only read).
132
154
 
133
- ## Rolling back (markdown/git)
155
+ ## Rolling back a watched store (folders and git notes)
156
+
157
+ `memdebug rollback store NAME --to s1` does the same job by the name you see in `memdebug stores`, for a plain folder of notes as well as for
158
+ git notes. First save a known-good copy while the store is healthy:
159
+
160
+ ```
161
+ memdebug snapshot store claude-code-shop --label "good, after the move" # a snapshot you can come back to
162
+ memdebug snapshot list # names and dates of snapshots
163
+ memdebug rollback store claude-code-shop --to s2 # shows what would change; writes nothing
164
+ memdebug rollback store claude-code-shop --to s2 --apply # does it, after you type the snapshot id
165
+ ```
166
+
167
+ `snapshot store` refuses to save while the ledger holds a change made outside the store's own history, or wording flagged as worth a second look,
168
+ since the store's last snapshot: a snapshot is what a rollback later treats as good. Look at the flagged changes first (`memdebug serve`), then add
169
+ `--include-changes` if they are fine. This does not go away if you run it twice.
170
+
171
+ For a **plain folder** there is no git to take exact file versions from, so it works from the snapshot's text, and it is honest about what that means:
172
+
173
+ * **Backups are files.** Anything the rollback would replace or remove is first copied, byte for byte, into a private folder next to your ledger
174
+ (`backups\<store>\<date>-<id>`, with a `manifest.json`), and each copy is read back and checked before your notes are touched. Get a file
175
+ back by copying it out of that folder's `files` folder. These copies contain your memory text and memdebug never deletes them: delete old ones
176
+ yourself when you no longer need them.
177
+ * **Line endings.** A snapshot does not keep them. Files come back as UTF-8 with LF line endings, or with CRLF if the file being replaced uses CRLF
178
+ throughout. A file with mixed endings comes back with LF.
179
+ * **Nothing it cannot restore faithfully is written.** Text that was cut, marked too large, or had bytes that are not valid text is skipped, and
180
+ the dry run says which files and why.
181
+ * **Everything else is the same** as for git notes: the plan writes nothing, applying refuses if anything changed since you saw the plan, links,
182
+ junctions and unsafe names are refused, every write is undone if a later step fails, and the rollback is recorded and can itself be undone.
183
+
184
+ Open WebUI and Mem0 keep their memory in databases that memdebug only ever reads, so they cannot be rolled back; change those in the app.
185
+
186
+ ## Rolling back markdown in git, by path
134
187
 
135
188
  `memdebug rollback markdown` puts a memory folder back to what a snapshot held. It is the only command that changes
136
189
  your files, so it is cautious by design.
@@ -160,7 +213,7 @@ What it guarantees:
160
213
  * **It can be undone.** It takes a snapshot just before and just after, and records a `ROLLBACK` entry in the ledger.
161
214
  To undo a rollback, roll back to the "before" snapshot it printed.
162
215
 
163
- `memdebug selftest` proves the "no programs run" and "exact bytes" claims on your computer.
216
+ `memdebug selftest` proves the "no programs run" and "exact bytes" claims, and the folder-rollback claims, on your computer.
164
217
 
165
218
  ## Fonts
166
219
 
@@ -3,7 +3,7 @@
3
3
  Where memdebug is and where it is going. It is alpha software: the pieces below that are built work and are tested,
4
4
  but expect rough edges and breaking changes before 1.0. Order is a plan, not a promise.
5
5
 
6
- ## Built (0.2)
6
+ ## Built (0.3)
7
7
 
8
8
  * Tamper-evident ledger (hash chain) with `verify`, snapshots whose content is chained in, and compare.
9
9
  * **Witness:** a second copy of the ledger's head kept elsewhere, so a rewritten or cut-short ledger is noticed.
@@ -16,6 +16,9 @@ but expect rough edges and breaking changes before 1.0. Order is a plan, not a p
16
16
  (exit code 1 when something needs a look), `status`, `watch`.
17
17
  * Detection of changes that bypassed a store's own history, with false-alarm guards.
18
18
  * **Reports** in Markdown, JSON and SARIF, with CI exit codes.
19
+ * **Rollback for plain folders** (`memdebug rollback store`, and `memdebug snapshot store` to save a known-good copy first), rebuilt from the snapshot's
20
+ text with backups of anything replaced; Open WebUI and Mem0 still cannot be rolled back.
21
+ * **Published on PyPI** as `memdebug` (`pip install memdebug`), released by a tag-triggered workflow that needs an approval click.
19
22
  * **A first slice of provenance for Open WebUI:** its own `created_by` label and a chat-timing comparison, as evidence only.
20
23
  * **Hints** ("worth a second look"): heuristics for instructions to send data, remove confirmation or weaken safeguards,
21
24
  hidden characters and secret-like strings. Labelled as guesses; secrets are never repeated.
@@ -27,19 +30,16 @@ but expect rough edges and breaking changes before 1.0. Order is a plan, not a p
27
30
 
28
31
  ## Next, roughly in this order
29
32
 
30
- 1. **Rollback for plain folders.** Folders have no git history, so this needs a private store of exact file versions kept by
31
- memdebug, with the same guarantees as the git version (dry run, backups, undo on failure). It adds a second write path into
32
- your files, so it gets the same adversarial testing before it ships.
33
+ 1. **Rollback for plain folders: built** (see Built). Still to do: a way to list and clean up old backups, and trying it on real agent memory folders.
33
34
  2. **More provenance.** A first slice exists for Open WebUI (the app's own label and how close a chat was). Still to do: which conversation turn
34
35
  wrote a memory and what the assistant had just read, and readers for other agents' session logs (Claude Code transcripts, OpenClaw).
35
36
  The earlier plan, in full: **Provenance from session logs.** Which conversation turn wrote a memory, and whether it came from the user or from
36
37
  something the agent read. This turns a change log into an incident-response tool. It needs a reader per agent (first
37
38
  candidates: Open WebUI chat records, Claude Code session transcripts), and depends on seeing real log formats.
38
39
  3. **More agents,** each added only once its memory location is documented (candidates: Cursor, Cline, Aider, Continue, Goose, Claude Desktop's local files).
39
- 4. **Release.** Prepared: the package builds, passes PyPI's metadata check and installs and runs from a clean environment; the name `memdebug` was
40
- free on PyPI when checked; CI builds and installs the package on every push; a tag-triggered publish workflow uses PyPI trusted publishing.
41
- Left for the owner (they need accounts): register the trusted publisher on PyPI and tag the release. See docs/releasing.md.
42
- CI and the release dry run have run on GitHub and passed on all three systems.
40
+ 4. **Releases.** 0.2.0 was published to GitHub and PyPI on 2026-10-07 through the tag-triggered workflow (PyPI trusted publishing, with a
41
+ required approval and provenance attestations). The install from PyPI has been checked in a clean Linux environment and on Windows 11 (pipx, Python 3.14); macOS
42
+ still needs the same check. Still to do: signed release notes and a smoother route for people without Python (item 5).
43
43
  5. **Better setup for non-advanced users:** an installer with no Python knowledge needed, and a way to keep `watch` running
44
44
  without a terminal.
45
45
  6. **Viewer:** filter by store, show hints in the overview, a status page that matches `memdebug status`.
@@ -20,7 +20,7 @@ only. It does not list or open anything else in the folder.
20
20
  ChatGPT, Claude's apps (claude.ai on the web, desktop and phone), Gemini and Copilot keep their memory in the provider's cloud.
21
21
  There is nothing on this computer to point memdebug at, and memdebug cannot tell whether those apps are installed, so it never
22
22
  claims to have found them. Review or clear that memory in each app's settings. To keep a record of how it changes, copy it
23
- into a markdown file now and then, in a folder you add to memdebug (`memdebug add <folder>`).
23
+ into a markdown file now and then, in a folder you add to memdebug (`memdebug add <folder>`). A watched folder can be rolled back to a snapshot with `memdebug rollback store NAME --to s1`.
24
24
 
25
25
  ## Adding an agent
26
26
 
@@ -31,10 +31,11 @@ proposes new versions of the upload and download actions) so a problem shows up
31
31
  ## Each release
32
32
 
33
33
  1. Run `memdebug selftest` and the tests on your own machine (Windows is the one CI cannot fully stand in for).
34
- 2. Update `__version__` in `src/memdebug/__init__.py` and `version` in `pyproject.toml` (they must match) and move the changelog entry from
35
- "unreleased" to the release date.
34
+ 2. Update `__version__` in `src/memdebug/__init__.py` and `version` in `pyproject.toml` (they must match) and rename the changelog's `[Unreleased]` heading to the new
35
+ version with the release date, for example `## [0.2.1] - 2026-11-02`.
36
36
  3. Commit, then tag and push: `git tag v0.2.0 && git push origin v0.2.0`. The workflow refuses to publish if the tag and the version differ.
37
- 4. Approve the `pypi` environment if you required that. After a minute: `pipx install memdebug`, then `memdebug demo`.
37
+ 4. Approve the `pypi` environment if you required that. After a minute, in a clean virtual environment (not your working one), check the real
38
+ install: `pip install memdebug==<version>`, then `memdebug --version` and `memdebug demo`. Do this on Windows as well as Linux or macOS.
38
39
 
39
40
  ## If something goes wrong
40
41
 
@@ -83,6 +83,7 @@ a control, that the attack works against ordinary git, and reports SKIP rather t
83
83
  | Other users or programs on the machine | 192-bit secret, HttpOnly SameSite=Strict cookie, constant-time comparison; nothing is served without it |
84
84
  | Another program taking the port | The port is taken exclusively (matters on Windows); `selftest` checks it |
85
85
  | Changing anything | Only GET and HEAD; the ledger is opened read-only. The theme switch sets one cookie and nothing else |
86
+ | A command shown for copying that a hostile ledger turned into something else | The "put it back" guidance is only text, never a link or a button. A store name from the ledger goes into a command only if it passes the same strict rule as a watched store's name (lowercase letters, digits, dot, dash, underscore); anything else gets a `<name>` placeholder, so pasting a command can never do more than the command says. Stores memdebug cannot write to never get one. For an outside change it names only a snapshot taken before it |
86
87
  | Odd, oversized or slow requests | Strict limits on request line, path, query, headers, connections and time; only fixed routes and validated ids; nothing from the request is echoed |
87
88
 
88
89
  ## Rollback (markdown/git)
@@ -101,6 +102,26 @@ Rollback is the only part of memdebug that changes your files, so it is the most
101
102
  | A failure half-way | Every step is journaled; on any failure (or Ctrl+C) files, index and branch are put back and you are told |
102
103
  | An unrecorded rollback | The ledger record is validated before any file is touched; the state is snapshotted before and after; a `ROLLBACK` entry is chained into the ledger; undoing is rolling back to the "before" snapshot |
103
104
 
105
+
106
+ ## Rollback of a plain folder
107
+
108
+ Rollback of a watched store by name (`memdebug rollback store`) uses the same file-writing code as the git engine (`adapters/fileops.py`), so links,
109
+ junctions, case clashes, atomic writes and the undo journal are identical. What differs, because a folder has no git history:
110
+
111
+ | Threat | Protection |
112
+ | --- | --- |
113
+ | Restoring text a snapshot could not keep faithfully | The snapshot's text is the only source. Text that was cut, marked too large or had undecodable bytes is refused and named in the plan, never written. Line endings are LF, or CRLF if the file being replaced uses CRLF throughout; mixed endings become LF. |
114
+ | Losing what the rollback replaces | Every file that would be overwritten or removed is first copied byte for byte into a private folder next to the ledger (0700/0600 on POSIX), the copy is read back and checked, and nothing in the notes is touched if that fails. |
115
+ | Backups landing inside the notes (and being read as memories), or the notes inside the backups | The plan refuses (a blocker) when either folder contains the other, and applying refuses again. |
116
+ | A "good" snapshot that is really an attack | `memdebug snapshot store` refuses while the ledger holds an outside-history change or flagged wording since the store's last snapshot. It reads the ledger, so looking again does not clear it; only a deliberate `--include-changes` does. |
117
+ | The folder changing between the plan and the write | Applying re-plans and refuses if the plan differs; every target is re-checked before the first backup or write. |
118
+ | A snapshot (or a tampered ledger) naming unsafe files | Names are validated by the same rule as the reader (no `..`, absolute, device or case-clashing names), and files outside the store's subfolder, or outside a named-files store, are never written. |
119
+
120
+ Known limits: backups hold your memory text and are never deleted by memdebug; a plain-folder rollback cannot restore exact bytes for files whose
121
+ line endings were mixed; a store with no history cannot show an outside-history bypass, only the changes observed between looks. The Windows
122
+ permissions of the backup folder are those of your user profile (memdebug sets no ACL), and the junction guard is exercised on every platform by
123
+ simulation, but real junctions are only tested on Windows runs of the suite.
124
+
104
125
  ## Stores, settings and discovery
105
126
 
106
127
  | Threat | Defence |
@@ -147,7 +168,7 @@ Read these. They are why this is alpha software.
147
168
  installation. It cannot do more than you could already do with `docker exec`, but it does require that access.
148
169
  * **Stores without a history cannot show a bypass.** For a plain folder or Open WebUI memdebug sees only the state at each look, so a change
149
170
  made and reverted between looks is invisible, and an attacker's edit looks like any other. Prefer a git repository when you can.
150
- * **Rollback for plain folders, Open WebUI and Mem0 does not exist yet.**
171
+ * **Rollback for Open WebUI and Mem0 does not exist** (memdebug only reads those). Plain folders and git notes can be rolled back.
151
172
  * **Rollback restores files, not the world.** It does not change what a *running* agent has already loaded (restart the
152
173
  session), does not rebuild anything derived from the memory (summaries, embeddings), cannot undo what the agent did
153
174
  because of a bad memory, and a still-running agent can write the same text back. Stop the agent first.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memdebug"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Inspect, compare and roll back what an AI agent's memory holds. Local-first and agent-neutral."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """memdebug: inspect, compare and roll back agent memory."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,190 @@
1
+ """The file-level half of a rollback, shared by the git engine (restore.py) and the plain-folder engine (folder_restore.py).
2
+
3
+ Nothing here knows about git. It reads, checks, writes and removes files under one root folder, and it is the only code that
4
+ does, so the same rules hold for every store type:
5
+
6
+ * links, junctions and anything that is not a plain file or folder are never followed and never written through;
7
+ * a file whose name differs from another in the same folder only by letter case is refused;
8
+ * files are written through a temporary file in the same folder and an atomic rename;
9
+ * every write is journaled so that a failed step can be undone, newest first.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import secrets
15
+ import stat
16
+ from typing import Sequence
17
+
18
+ from ..errors import RestoreError
19
+ from ..textsafe import safe_text
20
+ from .markdown_git import MAX_FILE_BYTES, _is_reparse_point, _text_from_bytes
21
+
22
+ _O_BINARY = getattr(os, "O_BINARY", 0)
23
+ _O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
24
+
25
+
26
+ def _is_link(info: os.stat_result) -> bool:
27
+ return stat.S_ISLNK(info.st_mode) or _is_reparse_point(info)
28
+
29
+
30
+ class FileOps:
31
+ """Subclasses set `self._root` (the folder every path is relative to) and plan what to write."""
32
+
33
+ _root: str | os.PathLike[str]
34
+
35
+ def _inspect(self, relpath: str) -> tuple[str, bytes | None]:
36
+ """Read a working-tree file without following links: ("ok", bytes), ("missing", None), ("large", None) or
37
+ ("unsafe", None)."""
38
+ current = str(self._root)
39
+ parts = relpath.split("/")
40
+ for part in parts[:-1]:
41
+ current = os.path.join(current, part)
42
+ try:
43
+ info = os.lstat(current)
44
+ except FileNotFoundError:
45
+ return "missing", None
46
+ except OSError:
47
+ return "unsafe", None
48
+ if _is_link(info) or not stat.S_ISDIR(info.st_mode):
49
+ return "unsafe", None
50
+ full = os.path.join(current, parts[-1])
51
+ try:
52
+ info = os.lstat(full)
53
+ except FileNotFoundError:
54
+ return "missing", None
55
+ except OSError:
56
+ return "unsafe", None
57
+ if _is_link(info) or not stat.S_ISREG(info.st_mode):
58
+ return "unsafe", None
59
+ if info.st_size > MAX_FILE_BYTES:
60
+ return "large", None
61
+ try:
62
+ fd = os.open(full, os.O_RDONLY | _O_BINARY | _O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0))
63
+ except OSError:
64
+ return "unsafe", None
65
+ try:
66
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
67
+ return "unsafe", None
68
+ with os.fdopen(fd, "rb", closefd=False) as handle:
69
+ data = handle.read(MAX_FILE_BYTES + 1)
70
+ except OSError:
71
+ return "unsafe", None
72
+ finally:
73
+ os.close(fd)
74
+ return ("large", None) if len(data) > MAX_FILE_BYTES else ("ok", data)
75
+
76
+ def _check_target(self, rel: str, must_exist: bool = False) -> None:
77
+ current = str(self._root)
78
+ parts = rel.split("/")
79
+ for part in parts[:-1]:
80
+ current = os.path.join(current, part)
81
+ try:
82
+ info = os.lstat(current)
83
+ except FileNotFoundError:
84
+ if must_exist:
85
+ raise RestoreError(f"{safe_text(rel, 60)}: a folder disappeared") from None
86
+ break
87
+ if _is_link(info) or not stat.S_ISDIR(info.st_mode):
88
+ raise RestoreError(f"{safe_text(rel, 60)}: a folder on the way is a link or not a folder")
89
+ else:
90
+ try:
91
+ names = os.listdir(current)
92
+ except OSError as exc:
93
+ raise RestoreError(f"{safe_text(rel, 60)}: cannot read its folder ({exc.strerror})") from exc
94
+ name = parts[-1]
95
+ if name not in names and any(other.casefold() == name.casefold() for other in names):
96
+ raise RestoreError(f"{safe_text(rel, 60)}: another file in that folder differs only by letter case")
97
+ target = os.path.join(current, name)
98
+ try:
99
+ info = os.lstat(target)
100
+ except FileNotFoundError:
101
+ if must_exist:
102
+ raise RestoreError(f"{safe_text(rel, 60)}: the file disappeared") from None
103
+ return
104
+ if _is_link(info) or not stat.S_ISREG(info.st_mode):
105
+ raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
106
+
107
+ def _write_file(self, rel: str, data: bytes) -> list[str]:
108
+ """Replace or create a file through a temporary file and an atomic rename. Returns the folders it created."""
109
+ created: list[str] = []
110
+ try:
111
+ current = str(self._root)
112
+ parts = rel.split("/")
113
+ for part in parts[:-1]:
114
+ current = os.path.join(current, part)
115
+ try:
116
+ info = os.lstat(current)
117
+ except FileNotFoundError:
118
+ os.mkdir(current)
119
+ created.append(current)
120
+ info = os.lstat(current)
121
+ if _is_link(info) or not stat.S_ISDIR(info.st_mode):
122
+ raise RestoreError(f"{safe_text(rel, 60)}: a folder on the way is a link or not a folder")
123
+ target = os.path.join(current, parts[-1])
124
+ mode = None
125
+ try:
126
+ info = os.lstat(target)
127
+ if _is_link(info) or not stat.S_ISREG(info.st_mode):
128
+ raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
129
+ mode = stat.S_IMODE(info.st_mode)
130
+ except FileNotFoundError:
131
+ pass
132
+ temporary = os.path.join(current, f".memdebug-{secrets.token_hex(8)}.tmp")
133
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_BINARY | _O_NOFOLLOW, 0o666)
134
+ try:
135
+ with os.fdopen(fd, "wb") as handle:
136
+ handle.write(data)
137
+ handle.flush()
138
+ os.fsync(handle.fileno())
139
+ if mode is not None and os.name == "posix":
140
+ os.chmod(temporary, mode)
141
+ os.replace(temporary, target)
142
+ except BaseException:
143
+ try:
144
+ os.unlink(temporary)
145
+ except OSError:
146
+ pass
147
+ raise
148
+ return created
149
+ except BaseException:
150
+ for folder in reversed(created):
151
+ try:
152
+ os.rmdir(folder)
153
+ except OSError:
154
+ pass
155
+ raise
156
+
157
+ def _remove_file(self, rel: str) -> None:
158
+ self._check_target(rel, must_exist=True)
159
+ os.unlink(os.path.join(str(self._root), *rel.split("/")))
160
+
161
+ def _verify_files(self, items: Sequence) -> None:
162
+ """Every item must now be on disk exactly as planned."""
163
+ for item in items:
164
+ status, data = self._inspect(item.path)
165
+ if item.action == "remove":
166
+ if status != "missing":
167
+ raise RestoreError(f"{safe_text(item.path, 60)} is still there after removal")
168
+ elif status != "ok" or _text_from_bytes(data or b"") != item.target_text:
169
+ raise RestoreError(f"{safe_text(item.path, 60)} does not hold the restored text after writing")
170
+
171
+ def _undo_files(self, journal: Sequence, created_dirs: Sequence[str]) -> list[str]:
172
+ """Put the files back, newest step first. Returns what could not be undone."""
173
+ problems: list[str] = []
174
+ for path, old in reversed(journal):
175
+ try:
176
+ if old is None:
177
+ full = os.path.join(str(self._root), *path.split("/"))
178
+ if os.path.lexists(full):
179
+ os.unlink(full)
180
+ else:
181
+ self._write_file(path, old)
182
+ except Exception as exc:
183
+ problems.append(f"{safe_text(path, 60)}: {safe_text(exc, 80)}")
184
+ for folder in reversed(created_dirs):
185
+ try:
186
+ os.rmdir(folder)
187
+ except OSError:
188
+ pass
189
+ return problems
190
+
@@ -62,6 +62,11 @@ class FolderAdapter(MarkdownGitAdapter):
62
62
  raise AdapterError("the files to watch must be 1 to 10 plain markdown file names in the folder itself")
63
63
  self._only = names
64
64
 
65
+ @property
66
+ def only(self) -> tuple[str, ...] | None:
67
+ """The named files this store is limited to, or None when it covers the whole folder."""
68
+ return self._only
69
+
65
70
  def read_history(self, max_rows: int) -> HistoryRead:
66
71
  return HistoryRead(events=[], refs=set(), truncated=False)
67
72