memdebug 0.4.0__tar.gz → 0.4.1__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.
- {memdebug-0.4.0 → memdebug-0.4.1}/CHANGELOG.md +17 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/CONTRIBUTING.md +22 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/PKG-INFO +4 -2
- {memdebug-0.4.0 → memdebug-0.4.1}/README.md +3 -1
- {memdebug-0.4.0 → memdebug-0.4.1}/ROADMAP.md +1 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/docs/threat-model.md +1 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/pyproject.toml +18 -5
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/__init__.py +1 -1
- memdebug-0.4.1/src/memdebug/__main__.py +4 -0
- memdebug-0.4.1/src/memdebug/adapters/__init__.py +6 -0
- memdebug-0.4.1/src/memdebug/adapters/base.py +83 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/common.py +18 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/fileops.py +49 -12
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/folder.py +29 -6
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/folder_restore.py +72 -11
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/markdown_git.py +82 -19
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/mem0.py +51 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/openwebui.py +28 -2
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/adapters/restore.py +114 -8
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/agents.py +41 -4
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/backends.py +1 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/cli.py +106 -55
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/demo.py +47 -5
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/describe.py +5 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/diff.py +36 -3
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/docker_source.py +14 -1
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/hints.py +10 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/ledger.py +132 -17
- memdebug-0.4.1/src/memdebug/longpath.py +104 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/models.py +69 -4
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/monitor.py +101 -6
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/paths.py +9 -2
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/provenance.py +62 -15
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/reconcile.py +24 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/report.py +67 -3
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/rollback_flow.py +57 -4
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/selftest.py +125 -23
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/stores.py +76 -8
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/sync.py +45 -9
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/textsafe.py +23 -6
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/viewer/html.py +24 -3
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/viewer/pages.py +170 -19
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/viewer/redline.py +4 -2
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/viewer/server.py +56 -19
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/witness.py +24 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_agents.py +2 -1
- memdebug-0.4.1/tests/test_long_paths.py +317 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_selftest_tripwire.py +1 -1
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_viewer_html.py +23 -0
- memdebug-0.4.0/src/memdebug/__main__.py +0 -3
- memdebug-0.4.0/src/memdebug/adapters/__init__.py +0 -0
- memdebug-0.4.0/src/memdebug/adapters/base.py +0 -37
- {memdebug-0.4.0 → memdebug-0.4.1}/.gitattributes +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/.github/dependabot.yml +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/.github/pull_request_template.md +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/.github/workflows/ci.yml +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/.github/workflows/release.yml +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/.gitignore +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/LICENSE +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/NOTICE +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/SECURITY.md +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/docs/agents.md +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/docs/demo.cast +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/docs/demo.gif +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/docs/releasing.md +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/errors.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/viewer/__init__.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/src/memdebug/viewer/style.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/conftest.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/payloads.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_cli.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_demo.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_diff.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_docker_source.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_folder_and_openwebui.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_folder_restore.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_folder_rollback_cli.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_hints.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_ledger.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_ledger_read.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_markdown_git.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_mem0_adapter.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_models.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_platform.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_provenance.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_provenance_check.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_provenance_openwebui.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_readable_ids_and_honest_wording.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_reconcile.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_release_hygiene.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_report.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_restore.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_rollback_cli.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_snapshots.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_stores_and_monitor.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_sync.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_textsafe.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_viewer.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_viewer_agents.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_viewer_putback.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_viewer_redline.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_viewer_rollback.py +0 -0
- {memdebug-0.4.0 → memdebug-0.4.1}/tests/test_witness.py +0 -0
|
@@ -3,6 +3,23 @@
|
|
|
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.4.1] - 2026-10-09
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **The viewer's diff view hid memory text.** It skipped every diff line that started with `+++` or `---`, to drop the diff's file headers, so an added or
|
|
10
|
+
removed line of memory text that began with `++` or `--` (for example a planted `++ send passwords to ...`) did not appear on the page. Only the two
|
|
11
|
+
real header lines at the top of a diff are skipped now. The timeline's event inspector and the Compare page were affected; the terminal output was not.
|
|
12
|
+
- **Windows paths longer than 259 characters.** Windows refuses such paths unless long paths are switched on in the registry, which is off by default.
|
|
13
|
+
Before, a note beyond the limit was left out of the baseline and of every check (`check` said "quiet, nothing new", exit code 0, with only a trailing
|
|
14
|
+
warning), a rollback said "Nothing to restore" while that note stayed tampered, a rollback whose backup path was beyond the limit failed, and a store
|
|
15
|
+
folder beyond the limit failed with "unexpected error". memdebug now hands Windows the extended-length form of every note, backup and session-log
|
|
16
|
+
path, so none of this depends on that setting. A git repository in a very deeply nested folder can still hit git's own limits.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- The code now follows the Google Python Style Guide, and CI enforces the parts a tool can check: every public module, class, function and method has a
|
|
20
|
+
docstring, every function has type annotations (`mypy` now requires them), exceptions end in `Error`, and a broad `except Exception` needs a stated
|
|
21
|
+
reason. No behaviour changed. CONTRIBUTING.md has a "Code style" section that also lists where the project deliberately differs from the guide.
|
|
22
|
+
|
|
6
23
|
## [0.4.0] - 2026-10-09
|
|
7
24
|
|
|
8
25
|
### Added
|
|
@@ -51,6 +51,28 @@ You need Python 3.10+ and git 2.31+. The suite starts many git processes, so it
|
|
|
51
51
|
writes turn `\n` into `\r\n` (write bytes when exact content matters).
|
|
52
52
|
7. **Say what a feature does not do.** Add its limits to `docs/threat-model.md`.
|
|
53
53
|
|
|
54
|
+
## Code style
|
|
55
|
+
|
|
56
|
+
The code follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html). CI checks the parts a tool can check:
|
|
57
|
+
`ruff` for docstrings (Google convention), naming, a type annotation on every function, and no blind `except Exception` without a stated reason;
|
|
58
|
+
`mypy` for the types themselves (every function must be annotated). The rest is for review. In practice:
|
|
59
|
+
|
|
60
|
+
* Every public module, class, function and method has a docstring: a one-line summary, then `Args:`, `Returns:`, `Raises:` and `Attributes:`
|
|
61
|
+
sections only where they say something the signature and the annotations do not. Describe behaviour and side effects, not implementation.
|
|
62
|
+
* Types are written as `X | None`; exceptions end in `Error`; `Any` is fine where a type should not be expressed.
|
|
63
|
+
* `except Exception` is only for an isolation point (one store, one request or one undo step failing without stopping the rest), with a
|
|
64
|
+
`# noqa: BLE001 - reason` that says so. Everything else catches what it expects.
|
|
65
|
+
* Tests need no docstrings or annotations.
|
|
66
|
+
|
|
67
|
+
Where the project deliberately differs from the guide, because changing it would add churn without making the code safer or clearer:
|
|
68
|
+
|
|
69
|
+
* Lines may be up to 140 characters (the guide says 80). There is no auto-formatter.
|
|
70
|
+
* The package uses relative imports and `from module import name` (the guide asks for absolute imports of modules only).
|
|
71
|
+
* A few helpers are `@staticmethod`s on the class they belong to (the guide prefers module-level functions).
|
|
72
|
+
* Some functions are longer than the guide's 40-line hint, mostly the rollback planners and the viewer's page builders, where splitting
|
|
73
|
+
would scatter a security-relevant sequence across helpers.
|
|
74
|
+
* A comprehension may have an `if` when it fits on one line.
|
|
75
|
+
|
|
54
76
|
## Writing tests
|
|
55
77
|
|
|
56
78
|
* Use made-up data only. Every test already gets an empty home folder and no Docker (`isolated_home` and `no_real_docker` in
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memdebug
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -207,6 +207,8 @@ it cannot prove (for example symlinks need Developer Mode).
|
|
|
207
207
|
- git is found through PATH entries that are absolute paths only; the current folder is never searched.
|
|
208
208
|
- Names that mean something special on Windows (`NUL.md`, `con.md`, `file:stream.md`, trailing dots or spaces, `GIT~1`, `.GIT`)
|
|
209
209
|
are rejected on every platform, so the history and the files always agree.
|
|
210
|
+
- Paths longer than 259 characters work without changing any Windows setting: memdebug hands Windows the extended-length form of every note, backup and
|
|
211
|
+
log path. A git repository in a very deeply nested folder can still hit git's own limits.
|
|
210
212
|
- Directory junctions and symlinks are never followed, including by rollback. A hung git is stopped with `taskkill /T`.
|
|
211
213
|
- Ledger file permissions are not enforced by this tool on Windows; the default location is private to your user account.
|
|
212
214
|
- If git reports "dubious ownership" for a repository on another drive, fix the ownership; this tool deliberately ignores your
|
|
@@ -278,7 +280,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read fro
|
|
|
278
280
|
* **A self-test on your machine:** `memdebug selftest` demonstrates the platform-dependent safety claims on the computer you actually use,
|
|
279
281
|
and says SKIP, never PASS, for anything it could not prove there.
|
|
280
282
|
* **Releases:** PyPI trusted publishing (no stored token), a manual approval before anything is published, provenance attestations (PyPI
|
|
281
|
-
holds them for 0.3.0), and branch and tag rules on `main` and on version tags.
|
|
283
|
+
holds them for 0.3.0 and 0.4.0), and branch and tag rules on `main` and on version tags.
|
|
282
284
|
* **Written down:** a [threat model](docs/threat-model.md), a [security policy](SECURITY.md), a [changelog](CHANGELOG.md) and a
|
|
283
285
|
[roadmap](ROADMAP.md) that says what is not built yet.
|
|
284
286
|
|
|
@@ -174,6 +174,8 @@ it cannot prove (for example symlinks need Developer Mode).
|
|
|
174
174
|
- git is found through PATH entries that are absolute paths only; the current folder is never searched.
|
|
175
175
|
- Names that mean something special on Windows (`NUL.md`, `con.md`, `file:stream.md`, trailing dots or spaces, `GIT~1`, `.GIT`)
|
|
176
176
|
are rejected on every platform, so the history and the files always agree.
|
|
177
|
+
- Paths longer than 259 characters work without changing any Windows setting: memdebug hands Windows the extended-length form of every note, backup and
|
|
178
|
+
log path. A git repository in a very deeply nested folder can still hit git's own limits.
|
|
177
179
|
- Directory junctions and symlinks are never followed, including by rollback. A hung git is stopped with `taskkill /T`.
|
|
178
180
|
- Ledger file permissions are not enforced by this tool on Windows; the default location is private to your user account.
|
|
179
181
|
- If git reports "dubious ownership" for a repository on another drive, fix the ownership; this tool deliberately ignores your
|
|
@@ -245,7 +247,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read fro
|
|
|
245
247
|
* **A self-test on your machine:** `memdebug selftest` demonstrates the platform-dependent safety claims on the computer you actually use,
|
|
246
248
|
and says SKIP, never PASS, for anything it could not prove there.
|
|
247
249
|
* **Releases:** PyPI trusted publishing (no stored token), a manual approval before anything is published, provenance attestations (PyPI
|
|
248
|
-
holds them for 0.3.0), and branch and tag rules on `main` and on version tags.
|
|
250
|
+
holds them for 0.3.0 and 0.4.0), and branch and tag rules on `main` and on version tags.
|
|
249
251
|
* **Written down:** a [threat model](docs/threat-model.md), a [security policy](SECURITY.md), a [changelog](CHANGELOG.md) and a
|
|
250
252
|
[roadmap](ROADMAP.md) that says what is not built yet.
|
|
251
253
|
|
|
@@ -25,6 +25,7 @@ but expect rough edges and breaking changes before 1.0. Order is a plan, not a p
|
|
|
25
25
|
* **Hints** ("worth a second look"): heuristics for instructions to send data, remove confirmation or weaken safeguards,
|
|
26
26
|
hidden characters and secret-like strings. Labelled as guesses; secrets are never repeated.
|
|
27
27
|
* A read-only browser viewer: timeline, redlined changes, snapshots, compare, the agents it found, integrity, light and dark.
|
|
28
|
+
* **Windows paths over 259 characters** are read, checked, rolled back and backed up without changing any Windows setting.
|
|
28
29
|
* **Rollback for markdown/git:** dry run first, exact bytes, backups of anything git does not hold, no rewritten
|
|
29
30
|
history, undo on failure, recorded in the ledger, and itself undoable.
|
|
30
31
|
* `memdebug selftest` (proves the platform-dependent protections on your machine) and `memdebug demo`.
|
|
@@ -48,6 +48,7 @@ Python or the operating system, side channels, and denial of service against the
|
|
|
48
48
|
| Reading adapters changing what they inspect | Mem0's database is opened `mode=ro` plus `query_only`; the markdown adapter only runs read-only git commands. Only `adapters/restore.py` writes (see Rollback) |
|
|
49
49
|
| Hostile file names (control characters, quotes, backslashes, `..`, `.git`, Windows device names like `NUL.md`, `file:stream`, trailing dots or spaces, `GIT~1`) | Rejected on both the history side and the working-tree side, on every platform, so the two cannot disagree |
|
|
50
50
|
| Links and special files pointing at secrets | `lstat` check plus `O_NOFOLLOW`; symlinks, FIFOs and devices skipped; Windows junctions and reparse points detected by attribute and never followed; the listing is marked incomplete |
|
|
51
|
+
| A note hidden from the monitor by Windows' path limit (a deep folder, or a long project folder name) | Windows refuses paths over 259 characters unless long paths are switched on, which is off by default, so such a note could be missed by every check and by a rollback. All note, backup and session-log access on Windows uses the extended-length form of the path, so it does not depend on that setting. Git itself has its own limits on a very deeply nested repository folder, which memdebug cannot change |
|
|
51
52
|
| Console that cannot show a character | Output is escaped to the console's encoding instead of raising |
|
|
52
53
|
|
|
53
54
|
## git and hostile repositories
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "memdebug"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
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"
|
|
@@ -48,12 +48,24 @@ target-version = "py310"
|
|
|
48
48
|
src = ["src", "tests"]
|
|
49
49
|
|
|
50
50
|
[tool.ruff.lint]
|
|
51
|
-
# Real mistakes and unused code,
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
# Real mistakes and unused code, sorted imports, and the parts of the Google Python Style Guide
|
|
52
|
+
# (https://google.github.io/styleguide/pyguide.html) that a linter can check: docstrings (D, Google convention), naming (N), a type
|
|
53
|
+
# annotation on every function (ANN), and no blind "except Exception" without a stated reason (BLE). CONTRIBUTING.md lists where the
|
|
54
|
+
# project deliberately differs from the guide.
|
|
55
|
+
select = ["E4", "E7", "E9", "F", "I", "B", "D", "N", "ANN", "BLE"]
|
|
56
|
+
ignore = [
|
|
57
|
+
"B008", # typer declares options as default arguments
|
|
58
|
+
"D107", # an __init__ is documented by its class docstring
|
|
59
|
+
"ANN204", # special methods such as __init__ need no return annotation (guide 3.19.1)
|
|
60
|
+
"ANN401", # the guide allows Any where a type should not be expressed
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.pydocstyle]
|
|
64
|
+
convention = "google"
|
|
54
65
|
|
|
55
66
|
[tool.ruff.lint.per-file-ignores]
|
|
56
|
-
|
|
67
|
+
# Compact one-line cleanups in test helpers are fine, and the guide asks for neither docstrings nor annotations on tests.
|
|
68
|
+
"tests/*" = ["E702", "E731", "D", "ANN", "BLE"]
|
|
57
69
|
|
|
58
70
|
[tool.ruff.lint.isort]
|
|
59
71
|
known-first-party = ["memdebug"]
|
|
@@ -65,3 +77,4 @@ ignore_missing_imports = true
|
|
|
65
77
|
warn_unused_ignores = true
|
|
66
78
|
warn_redundant_casts = true
|
|
67
79
|
check_untyped_defs = true
|
|
80
|
+
disallow_untyped_defs = true # every function is annotated (guide 2.21); ruff's ANN rules say the same, mypy also checks the types
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Readers for the memory stores memdebug watches, and the two rollback engines.
|
|
2
|
+
|
|
3
|
+
The readers (markdown_git, folder, openwebui, mem0) only read; what they return came from an untrusted store. The only
|
|
4
|
+
modules that write to a store are the rollback engines in restore.py (git) and folder_restore.py (plain folder), which
|
|
5
|
+
share the file-writing code in fileops.py.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""What a memory backend adapter must provide.
|
|
2
|
+
|
|
3
|
+
Defines the `MemoryAdapter` protocol and the two result types its methods return. Adapters only read. Everything they
|
|
4
|
+
return came from an untrusted store. (Rolling a store back is not part of this protocol: it is done by the separate
|
|
5
|
+
engines in restore.py and folder_restore.py.)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from ..models import Memory, MemoryEvent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class LiveMemories:
|
|
17
|
+
"""The memories a store holds right now, as listed by `MemoryAdapter.list_memories`.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
memories: The memories that could be read.
|
|
21
|
+
complete: Whether the listing covers the whole store.
|
|
22
|
+
warnings: Human-readable notes about anything skipped or cut short.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
memories: list[Memory]
|
|
26
|
+
complete: bool # False when the listing may be cut short, so absence proves nothing
|
|
27
|
+
warnings: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class HistoryRead:
|
|
32
|
+
"""A store's change history, as read by `MemoryAdapter.read_history`.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
events: The rows that were understood, as events.
|
|
36
|
+
refs: Backend row ids of every row seen, whether or not it became an event.
|
|
37
|
+
truncated: Whether the store holds more rows than were read.
|
|
38
|
+
skipped: How many rows could not be understood and so are not in `events`.
|
|
39
|
+
warnings: Human-readable notes about anything skipped or cut short.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
events: list[MemoryEvent] # valid rows, oldest first
|
|
43
|
+
refs: set[str] # backend row ids of every row seen, valid or not
|
|
44
|
+
truncated: bool # True when more rows exist than were read
|
|
45
|
+
skipped: int = 0 # rows that could not be understood
|
|
46
|
+
warnings: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MemoryAdapter(Protocol):
|
|
50
|
+
"""A read-only view of one memory store.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
name: The backend name recorded on every event and snapshot.
|
|
54
|
+
capabilities: What the store can do. "history" means it keeps a change history of its own; without it, memdebug
|
|
55
|
+
records changes it observes between two looks instead.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
capabilities: set[str] # history, global_feed, provenance, restore
|
|
60
|
+
|
|
61
|
+
def list_memories(self, scope: dict[str, str]) -> LiveMemories:
|
|
62
|
+
"""Lists the memories the store holds now within `scope`.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
AdapterError: If the scope does not fit this store or the store cannot be read at all.
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def read_history(self, max_rows: int) -> HistoryRead:
|
|
70
|
+
"""Reads up to `max_rows` rows of the store's global change history, oldest first.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
AdapterError: If `max_rows` is not positive or the history cannot be read.
|
|
74
|
+
"""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
def history(self, memory_id: str) -> list[MemoryEvent]:
|
|
78
|
+
"""Returns the recorded changes to one memory, oldest first.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
AdapterError: If `memory_id` is not usable or the history cannot be read.
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
@@ -24,12 +24,14 @@ class Warnings:
|
|
|
24
24
|
self._extra = 0
|
|
25
25
|
|
|
26
26
|
def add(self, message: str) -> None:
|
|
27
|
+
"""Keeps the message, or only counts it once `cap` messages are already held."""
|
|
27
28
|
if len(self._items) < self._cap:
|
|
28
29
|
self._items.append(message)
|
|
29
30
|
else:
|
|
30
31
|
self._extra += 1
|
|
31
32
|
|
|
32
33
|
def as_list(self) -> list[str]:
|
|
34
|
+
"""Returns the kept messages, plus a final line saying how many more were dropped, if any."""
|
|
33
35
|
items = list(self._items)
|
|
34
36
|
if self._extra:
|
|
35
37
|
items.append(f"... and {self._extra} more warnings")
|
|
@@ -37,12 +39,20 @@ class Warnings:
|
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def clean_id(value: object) -> str | None:
|
|
42
|
+
"""Returns `value` if it is a non-empty string of at most MAX_ID_CHARS characters, else None."""
|
|
40
43
|
if isinstance(value, str) and 0 < len(value) <= MAX_ID_CHARS:
|
|
41
44
|
return value
|
|
42
45
|
return None
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
def parse_ts(value: object) -> datetime | None:
|
|
49
|
+
"""Parses an ISO 8601 timestamp string into a UTC datetime.
|
|
50
|
+
|
|
51
|
+
A trailing "Z" is accepted, and a timestamp without a zone is taken to be UTC.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The time in UTC, or None if `value` is not a string, is empty or longer than 64 characters, or cannot be parsed.
|
|
55
|
+
"""
|
|
46
56
|
if not isinstance(value, str):
|
|
47
57
|
return None
|
|
48
58
|
text = value.strip()
|
|
@@ -60,6 +70,14 @@ def parse_ts(value: object) -> datetime | None:
|
|
|
60
70
|
|
|
61
71
|
|
|
62
72
|
def require_regular_file(path: Path) -> Path:
|
|
73
|
+
"""Resolves `path` and checks that it is a regular file.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The resolved path. Links are followed, so this is the real file.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
AdapterError: If the path does not exist, cannot be accessed, or is not a regular file.
|
|
80
|
+
"""
|
|
63
81
|
try:
|
|
64
82
|
resolved = path.resolve(strict=True)
|
|
65
83
|
if not stat.S_ISREG(os.stat(resolved).st_mode):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
"""The file-level half of a rollback, shared by
|
|
1
|
+
"""The file-level half of a rollback, shared by both rollback engines.
|
|
2
2
|
|
|
3
|
+
The git engine (restore.py) and the plain-folder engine (folder_restore.py) both inherit `FileOps` from this module.
|
|
3
4
|
Nothing here knows about git. It reads, checks, writes and removes files under one root folder, and it is the only code that
|
|
4
5
|
does, so the same rules hold for every store type:
|
|
5
6
|
|
|
@@ -15,6 +16,7 @@ import secrets
|
|
|
15
16
|
import stat
|
|
16
17
|
from typing import Sequence
|
|
17
18
|
|
|
19
|
+
from .. import longpath
|
|
18
20
|
from ..errors import RestoreError
|
|
19
21
|
from ..textsafe import safe_text
|
|
20
22
|
from .markdown_git import MAX_FILE_BYTES, _is_reparse_point, _text_from_bytes
|
|
@@ -33,9 +35,14 @@ class FileOps:
|
|
|
33
35
|
_root: str | os.PathLike[str]
|
|
34
36
|
|
|
35
37
|
def _inspect(self, relpath: str) -> tuple[str, bytes | None]:
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
"""Reads a working-tree file without following links.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
("ok", bytes), ("missing", None) when the file or a folder on the way does not exist, ("large", None) when it
|
|
42
|
+
is over MAX_FILE_BYTES, or ("unsafe", None) when a folder on the way or the file itself is a link or
|
|
43
|
+
junction or is not a plain folder or file, or when it cannot be opened or read.
|
|
44
|
+
"""
|
|
45
|
+
current = longpath.fs(self._root)
|
|
39
46
|
parts = relpath.split("/")
|
|
40
47
|
for part in parts[:-1]:
|
|
41
48
|
current = os.path.join(current, part)
|
|
@@ -74,7 +81,15 @@ class FileOps:
|
|
|
74
81
|
return ("large", None) if len(data) > MAX_FILE_BYTES else ("ok", data)
|
|
75
82
|
|
|
76
83
|
def _check_target(self, rel: str, must_exist: bool = False) -> None:
|
|
77
|
-
|
|
84
|
+
"""Checks that `rel` can be written (or, with `must_exist`, removed) without crossing a link.
|
|
85
|
+
|
|
86
|
+
Reads only; changes nothing.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
RestoreError: If a folder on the way is a link or not a folder, the file is not a plain file, another file in
|
|
90
|
+
its folder differs from it only by letter case, or (with `must_exist`) a folder or the file is missing.
|
|
91
|
+
"""
|
|
92
|
+
current = longpath.fs(self._root)
|
|
78
93
|
parts = rel.split("/")
|
|
79
94
|
for part in parts[:-1]:
|
|
80
95
|
current = os.path.join(current, part)
|
|
@@ -105,10 +120,20 @@ class FileOps:
|
|
|
105
120
|
raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
|
|
106
121
|
|
|
107
122
|
def _write_file(self, rel: str, data: bytes) -> list[str]:
|
|
108
|
-
"""
|
|
123
|
+
"""Replaces or creates a file through a temporary file and an atomic rename.
|
|
124
|
+
|
|
125
|
+
Missing folders on the way are created. If anything fails, the temporary file is deleted and any folders created
|
|
126
|
+
here are removed again (if empty) before the error is raised.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The folders this call created, outermost first.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
RestoreError: If a folder on the way, or the file itself, is a link or not a plain folder or file.
|
|
133
|
+
"""
|
|
109
134
|
created: list[str] = []
|
|
110
135
|
try:
|
|
111
|
-
current =
|
|
136
|
+
current = longpath.fs(self._root)
|
|
112
137
|
parts = rel.split("/")
|
|
113
138
|
for part in parts[:-1]:
|
|
114
139
|
current = os.path.join(current, part)
|
|
@@ -155,11 +180,16 @@ class FileOps:
|
|
|
155
180
|
raise
|
|
156
181
|
|
|
157
182
|
def _remove_file(self, rel: str) -> None:
|
|
183
|
+
"""Deletes a plain file after `_check_target` confirms it is there and safe to touch."""
|
|
158
184
|
self._check_target(rel, must_exist=True)
|
|
159
|
-
os.unlink(os.path.join(
|
|
185
|
+
os.unlink(os.path.join(longpath.fs(self._root), *rel.split("/")))
|
|
160
186
|
|
|
161
187
|
def _verify_files(self, items: Sequence) -> None:
|
|
162
|
-
"""
|
|
188
|
+
"""Checks that every item is now on disk exactly as planned.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
RestoreError: If a removed file is still there, or a written file is missing or does not hold the planned text.
|
|
192
|
+
"""
|
|
163
193
|
for item in items:
|
|
164
194
|
status, data = self._inspect(item.path)
|
|
165
195
|
if item.action == "remove":
|
|
@@ -169,17 +199,24 @@ class FileOps:
|
|
|
169
199
|
raise RestoreError(f"{safe_text(item.path, 60)} does not hold the restored text after writing")
|
|
170
200
|
|
|
171
201
|
def _undo_files(self, journal: Sequence, created_dirs: Sequence[str]) -> list[str]:
|
|
172
|
-
"""
|
|
202
|
+
"""Puts the files back, newest step first, then removes the folders that were created.
|
|
203
|
+
|
|
204
|
+
Each journal entry is a path and the bytes it held before (None if it did not exist). Failures do not stop
|
|
205
|
+
the undo. Created folders are removed only if they are empty.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
One message for each file that could not be put back; empty when everything was undone.
|
|
209
|
+
"""
|
|
173
210
|
problems: list[str] = []
|
|
174
211
|
for path, old in reversed(journal):
|
|
175
212
|
try:
|
|
176
213
|
if old is None:
|
|
177
|
-
full = os.path.join(
|
|
214
|
+
full = os.path.join(longpath.fs(self._root), *path.split("/"))
|
|
178
215
|
if os.path.lexists(full):
|
|
179
216
|
os.unlink(full)
|
|
180
217
|
else:
|
|
181
218
|
self._write_file(path, old)
|
|
182
|
-
except Exception as exc:
|
|
219
|
+
except Exception as exc: # noqa: BLE001 - an undo must try every file; failures are collected and reported
|
|
183
220
|
problems.append(f"{safe_text(path, 60)}: {safe_text(exc, 80)}")
|
|
184
221
|
for folder in reversed(created_dirs):
|
|
185
222
|
try:
|
|
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Callable
|
|
14
14
|
|
|
15
|
+
from .. import longpath
|
|
15
16
|
from ..errors import AdapterError
|
|
16
17
|
from ..models import Memory, MemoryEvent
|
|
17
18
|
from ..textsafe import safe_text
|
|
@@ -21,6 +22,18 @@ from .markdown_git import MarkdownGitAdapter, _valid_relpath
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class FolderAdapter(MarkdownGitAdapter):
|
|
25
|
+
"""Read-only adapter for a plain folder of markdown files that is not a git repository.
|
|
26
|
+
|
|
27
|
+
A file's id is its path relative to the folder, and the store's scope is `{"store": <name>}`. It inherits the
|
|
28
|
+
listing code of `MarkdownGitAdapter` but never runs git and keeps no history. With `only`, it is limited to
|
|
29
|
+
those named files. The same options as the git adapter apply (`subdir`, `suffixes`, `max_files`, `store`).
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
AdapterError: From the constructor, if the path cannot be accessed or is not a folder, if it contains a `.git`
|
|
33
|
+
entry, if a limit or the suffixes are invalid, or if `subdir` or `only` is not a plain path of the
|
|
34
|
+
expected kind.
|
|
35
|
+
"""
|
|
36
|
+
|
|
24
37
|
name = "folder"
|
|
25
38
|
capabilities: set[str] = set() # no history to read
|
|
26
39
|
|
|
@@ -37,12 +50,12 @@ class FolderAdapter(MarkdownGitAdapter):
|
|
|
37
50
|
clock: Callable[[], datetime] | None = None,
|
|
38
51
|
):
|
|
39
52
|
try:
|
|
40
|
-
self._root =
|
|
53
|
+
self._root = longpath.resolve(root, strict=True)
|
|
41
54
|
except OSError as exc:
|
|
42
55
|
raise AdapterError(f"cannot access the folder: {exc.strerror}") from exc
|
|
43
|
-
if not self._root
|
|
56
|
+
if not longpath.isdir(self._root):
|
|
44
57
|
raise AdapterError("the path is not a folder")
|
|
45
|
-
if (self._root / ".git")
|
|
58
|
+
if longpath.exists(self._root / ".git"):
|
|
46
59
|
raise AdapterError("this folder is a git repository: use the markdown (git) store type, which also reads its history")
|
|
47
60
|
if not (isinstance(max_files, int) and max_files > 0 and max_total_chars > 0):
|
|
48
61
|
raise AdapterError("max_files and max_total_chars must be positive")
|
|
@@ -68,14 +81,24 @@ class FolderAdapter(MarkdownGitAdapter):
|
|
|
68
81
|
return self._only
|
|
69
82
|
|
|
70
83
|
def read_history(self, max_rows: int) -> HistoryRead:
|
|
84
|
+
"""Returns an empty history: a plain folder records none."""
|
|
71
85
|
return HistoryRead(events=[], refs=set(), truncated=False)
|
|
72
86
|
|
|
73
87
|
def history(self, memory_id: str) -> list[MemoryEvent]:
|
|
88
|
+
"""Returns no events: a plain folder records no history."""
|
|
74
89
|
return []
|
|
75
90
|
|
|
76
91
|
def list_memories(self, scope: dict[str, str]) -> LiveMemories:
|
|
77
|
-
"""
|
|
78
|
-
|
|
92
|
+
"""Lists the markdown files in the folder, or only the files named by `only`.
|
|
93
|
+
|
|
94
|
+
With `only`, exactly those files and nothing else: the rest of the folder (often a settings folder that also holds
|
|
95
|
+
credentials) is never listed or opened. A named file that does not exist yet is simply left out; one that cannot
|
|
96
|
+
be read safely is left out and marks the listing incomplete. Without `only`, this is the git adapter's working-tree
|
|
97
|
+
listing.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
AdapterError: If `scope` is not `{"store": <this store's name>}`.
|
|
101
|
+
"""
|
|
79
102
|
if self._only is None:
|
|
80
103
|
return super().list_memories(scope)
|
|
81
104
|
if scope != {"store": self._store}:
|
|
@@ -84,7 +107,7 @@ class FolderAdapter(MarkdownGitAdapter):
|
|
|
84
107
|
memories: list[Memory] = []
|
|
85
108
|
complete = True
|
|
86
109
|
for name in self._only:
|
|
87
|
-
full = os.path.join(self._root, name)
|
|
110
|
+
full = os.path.join(longpath.fs(self._root), name)
|
|
88
111
|
try:
|
|
89
112
|
os.lstat(full)
|
|
90
113
|
except FileNotFoundError:
|