mindslice 0.1.0__py3-none-any.whl

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 (50) hide show
  1. mindslice/__init__.py +58 -0
  2. mindslice/__main__.py +5 -0
  3. mindslice/baking/__init__.py +1 -0
  4. mindslice/baking/archive.py +66 -0
  5. mindslice/baking/bake.py +299 -0
  6. mindslice/baking/bake_batch.py +537 -0
  7. mindslice/baking/slice_transfer.py +265 -0
  8. mindslice/errors.py +104 -0
  9. mindslice/interfaces/__init__.py +1 -0
  10. mindslice/interfaces/bake_review.py +230 -0
  11. mindslice/interfaces/cli.py +392 -0
  12. mindslice/interfaces/cli_addressing.py +52 -0
  13. mindslice/interfaces/doctor.py +184 -0
  14. mindslice/interfaces/mcp_server.py +536 -0
  15. mindslice/interfaces/render.py +346 -0
  16. mindslice/interfaces/staging_review.py +92 -0
  17. mindslice/model/__init__.py +1 -0
  18. mindslice/model/addressing.py +251 -0
  19. mindslice/model/card.py +641 -0
  20. mindslice/model/lineage.py +228 -0
  21. mindslice/model/text.py +19 -0
  22. mindslice/model/tomlfmt.py +129 -0
  23. mindslice/operations/__init__.py +1 -0
  24. mindslice/operations/audit.py +195 -0
  25. mindslice/operations/boot.py +183 -0
  26. mindslice/operations/impact.py +98 -0
  27. mindslice/operations/recovery.py +113 -0
  28. mindslice/operations/staging.py +641 -0
  29. mindslice/operations/status.py +137 -0
  30. mindslice/operations/validation.py +250 -0
  31. mindslice/operations/writer.py +481 -0
  32. mindslice/retrieval/__init__.py +1 -0
  33. mindslice/retrieval/index.py +634 -0
  34. mindslice/retrieval/lexical.py +215 -0
  35. mindslice/retrieval/query.py +593 -0
  36. mindslice/storage/__init__.py +1 -0
  37. mindslice/storage/card_io.py +14 -0
  38. mindslice/storage/config.py +192 -0
  39. mindslice/storage/fsutil.py +141 -0
  40. mindslice/storage/history.py +182 -0
  41. mindslice/storage/locking.py +125 -0
  42. mindslice/storage/snapshots.py +262 -0
  43. mindslice/workspace.py +385 -0
  44. mindslice-0.1.0.dist-info/METADATA +98 -0
  45. mindslice-0.1.0.dist-info/RECORD +50 -0
  46. mindslice-0.1.0.dist-info/WHEEL +5 -0
  47. mindslice-0.1.0.dist-info/entry_points.txt +2 -0
  48. mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
  50. mindslice-0.1.0.dist-info/top_level.txt +1 -0
mindslice/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """MindSlice: a layered, pluggable, human-readable long-term memory engine for LLM agents.
2
+
3
+ Plain Markdown + TOML on disk is the single source of truth; ``.cache/index.sqlite``
4
+ is a disposable acceleration cache. Standard library only (Python 3.12+).
5
+
6
+ >>> from mindslice import Workspace
7
+ >>> ws = Workspace.init("workspace")
8
+ >>> ws.write("user-preferences/tooling", summary="The user prefers uv.", content="- Use `uv run` for scripts.")
9
+ >>> ws.search("uv scripts")["results"][0]["address"]
10
+ '@overlay/user-preferences/tooling'
11
+ """
12
+
13
+ from .errors import (
14
+ AlreadyExistsError,
15
+ AmbiguousAddressError,
16
+ ApplyConflictError,
17
+ BakeConflictError,
18
+ FrontmatterError,
19
+ HistoryError,
20
+ InvalidAddressError,
21
+ InvalidArgumentsError,
22
+ LineageCycleError,
23
+ LockTimeoutError,
24
+ MindSliceError,
25
+ MountError,
26
+ NotFoundError,
27
+ PreconditionFailedError,
28
+ ReadOnlyMountError,
29
+ RecoveryError,
30
+ UnresolvedExportDependencyError,
31
+ WorkspaceNotFoundError,
32
+ )
33
+ from .workspace import Workspace
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "Workspace",
39
+ "MindSliceError",
40
+ "AlreadyExistsError",
41
+ "AmbiguousAddressError",
42
+ "ApplyConflictError",
43
+ "BakeConflictError",
44
+ "FrontmatterError",
45
+ "HistoryError",
46
+ "InvalidAddressError",
47
+ "InvalidArgumentsError",
48
+ "LineageCycleError",
49
+ "LockTimeoutError",
50
+ "MountError",
51
+ "NotFoundError",
52
+ "PreconditionFailedError",
53
+ "ReadOnlyMountError",
54
+ "RecoveryError",
55
+ "UnresolvedExportDependencyError",
56
+ "WorkspaceNotFoundError",
57
+ "__version__",
58
+ ]
mindslice/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .interfaces.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Slice preparation, reference transfer, batch planning and export."""
@@ -0,0 +1,66 @@
1
+ """Deterministic archives of a baked slice directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import io
7
+ import os
8
+ import tarfile
9
+ import tempfile
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ from ..storage.fsutil import fsync_dir
14
+
15
+
16
+ def archive_format(output: Path) -> str:
17
+ name = output.name.lower()
18
+ if name.endswith(".zip"):
19
+ return "zip"
20
+ if name.endswith((".tar.gz", ".tgz")):
21
+ return "tar.gz"
22
+ return "directory"
23
+
24
+
25
+ def write_archive(tree: Path, output: Path, fmt: str) -> None:
26
+ """Write all files, with fixed archive metadata, then atomically publish the archive."""
27
+ name = output.name
28
+ for suffix in (".tar.gz", ".tgz", ".zip"):
29
+ if name.lower().endswith(suffix):
30
+ name = name[:-len(suffix)]
31
+ break
32
+ root = name if name.endswith(".slice") else name + ".slice"
33
+ entries = [(root, None)]
34
+ entries += [(root + "/" + p.relative_to(tree).as_posix(), None if p.is_dir() else p.read_bytes())
35
+ for p in sorted(tree.rglob("*"))]
36
+ output.parent.mkdir(parents=True, exist_ok=True)
37
+ fd, temporary = tempfile.mkstemp(prefix=f".{output.name}.", dir=output.parent)
38
+ os.close(fd)
39
+ try:
40
+ if fmt == "zip":
41
+ with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
42
+ for path, data in entries:
43
+ info = zipfile.ZipInfo(path + "/" if data is None else path, date_time=(1980, 1, 1, 0, 0, 0))
44
+ info.external_attr = ((0o40755 << 16) | 0x10) if data is None else 0o100644 << 16
45
+ if data is not None:
46
+ info.compress_type = zipfile.ZIP_DEFLATED
47
+ archive.writestr(info, b"" if data is None else data)
48
+ else:
49
+ with open(temporary, "wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz:
50
+ with tarfile.open(fileobj=gz, mode="w", format=tarfile.PAX_FORMAT) as archive:
51
+ for path, data in entries:
52
+ info = tarfile.TarInfo(path)
53
+ info.mtime = 0
54
+ info.uid = info.gid = 0
55
+ info.uname = info.gname = ""
56
+ info.mode = 0o755 if data is None else 0o644
57
+ if data is None:
58
+ info.type = tarfile.DIRTYPE
59
+ archive.addfile(info)
60
+ else:
61
+ info.size = len(data)
62
+ archive.addfile(info, io.BytesIO(data))
63
+ os.replace(temporary, output)
64
+ fsync_dir(output.parent)
65
+ finally:
66
+ Path(temporary).unlink(missing_ok=True)
@@ -0,0 +1,299 @@
1
+ """``bake()``: create or update a slice and mount it under the requested alias.
2
+
3
+ A new alias requires an explicit ``prefix`` selecting overlay cards. ``bake(alias)`` without a
4
+ prefix builds the next version of a mounted slice: a copy of its folder (``.git`` excluded) plus the
5
+ overlay cards, at a new path. The new folder then takes over the mount (same alias, precedence and role, so every
6
+ ``@alias/...`` reference keeps resolving), the overlay copies are retired, and the old folder is left
7
+ exactly as it was: no longer mounted, and a way back (``mount`` it again). It is a curation step for
8
+ the slice's owner (CLI and Python, never an agent tool).
9
+
10
+ Which cards: with ``prefix`` / ``tags``, the overlay cards they select; without, the overlay cards
11
+ that belong to the slice: those that extend or supersede one of its cards, or sit
12
+ at the same path as one. ``include_deps`` pulls in overlay cards the selection references.
13
+
14
+ How each card is written into the new slice:
15
+
16
+ * references to the slice itself (``@alias/x``) and to other baked cards become slice-relative
17
+ (``x``); references to other mounts stay qualified and are added to ``slice.toml`` dependencies;
18
+ * a card that supersedes the slice card at its own path, or shadows it, replaces that file (the
19
+ card's ``supersedes`` drops the reference to itself); a superseded card at another path stays,
20
+ as the route that forwards its hits;
21
+ * a card that *extends* the slice card at its own path can't replace it without losing the
22
+ baseline: bake refuses it (``BakeConflictError``) until it is consolidated into one card.
23
+
24
+ The new slice's ``slice.toml`` version is bumped (``1.2.3`` -> ``1.2.4``) when it has that form. Its
25
+ default path is ``<workspace>/slices/<name>.slice``, or ``<name>-2.slice``, ``-3`` ... when taken.
26
+ ``dry_run`` reports the plan and writes nothing. The new folder is built in a temporary directory and
27
+ moved into place whole; the mount table is switched in one atomic write.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ import shutil
35
+ import tempfile
36
+ from pathlib import Path
37
+ from typing import TYPE_CHECKING, Any, Sequence
38
+
39
+ from ..model.addressing import format_address, normalize_alias, resolve_ref
40
+ from .archive import archive_format, write_archive
41
+ from ..model.card import normalize_newlines, parse_card, truncate
42
+ from ..storage.config import (
43
+ DESCRIPTION_CHARS,
44
+ MOUNTS_FILE,
45
+ OVERVIEW_PATH,
46
+ SLICE_FILE,
47
+ Mount,
48
+ MountTable,
49
+ SliceManifest,
50
+ dump_slice_manifest,
51
+ load_slice_manifest,
52
+ portable_path,
53
+ dump_mounts,
54
+ )
55
+ from ..errors import AlreadyExistsError, BakeConflictError, InvalidAddressError, InvalidArgumentsError, MountError, NotFoundError
56
+ from ..storage.fsutil import fsync_dir
57
+ from ..storage.history import event, file_action, file_sha
58
+ from ..model.text import fold
59
+ from .slice_transfer import dependency_closure, export_text, overlay_cards, retire_overlay_paths, rewrite_refs
60
+ from ..operations.validation import validate_slice_dir
61
+ from ..model.card import reassemble
62
+
63
+ if TYPE_CHECKING: # pragma: no cover
64
+ from ..workspace import Workspace
65
+
66
+
67
+ _SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
68
+
69
+
70
+ def bump_version(version: str) -> str:
71
+ m = _SEMVER_RE.match(version.strip())
72
+ return f"{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}" if m else version
73
+
74
+
75
+ def default_output(ws: "Workspace", alias: str, mount: Mount | None) -> Path:
76
+ """``<workspace>/slices/<name>.slice``, or the first free ``<name>-N.slice``."""
77
+ stem = re.sub(r"-\d+$", "", mount.root.name.removesuffix(".slice")) if mount else alias.lstrip("@")
78
+ stem = stem or "slice"
79
+ slices = ws.root / "slices"
80
+ n = 1
81
+ while True:
82
+ candidate = slices / (f"{stem}.slice" if n == 1 else f"{stem}-{n}.slice")
83
+ if (mount is None or candidate != mount.root) and not candidate.exists():
84
+ return candidate
85
+ n += 1
86
+
87
+
88
+ def bake(
89
+ ws: "Workspace",
90
+ alias: str,
91
+ output_path: str | os.PathLike[str] | None = None,
92
+ prefix: str | None = None,
93
+ tags: str | Sequence[str] | None = None,
94
+ include_deps: bool = False,
95
+ dry_run: bool = False,
96
+ *,
97
+ name: str | None = None,
98
+ description: str | None = None,
99
+ version: str | None = None,
100
+ ) -> dict[str, Any]:
101
+ alias = normalize_alias(alias)
102
+ tag_set = {fold(t) for t in ([tags] if isinstance(tags, str) else (tags or [])) if fold(t)}
103
+ if prefix is not None:
104
+ prefix = prefix.strip().lstrip("./").lstrip("/")
105
+ with ws._writing() as table:
106
+ mount = table.get(alias)
107
+ overlay = table.overlay
108
+ if mount is None and prefix is None:
109
+ raise InvalidArgumentsError(f"--prefix is required to create {alias}; use prefix='' to select all overlay cards",
110
+ alias=alias)
111
+ if alias == overlay.alias:
112
+ raise MountError(f"bake moves {overlay.alias} cards into a slice; name the slice, not {alias}")
113
+ if mount and not mount.root.is_dir():
114
+ raise MountError(f"{alias}: {mount.root} is not a directory", alias=alias)
115
+ idx = ws.index
116
+ cats = idx.catalogs()
117
+ cards = overlay_cards(ws, table)
118
+ slice_paths = {r["path"] for r in idx.all_cards(alias)}
119
+
120
+ def targets_slice(raw: str) -> str | None:
121
+ res = resolve_ref(raw, overlay.alias, cats)
122
+ return res.path if res.resolved and res.mount == alias else None
123
+
124
+ def selected(path: str, parsed) -> bool:
125
+ if path == OVERVIEW_PATH and (mount or prefix or tag_set):
126
+ return False
127
+ if prefix is not None or tag_set or mount is None:
128
+ return (not prefix or path.startswith(prefix)) and tag_set <= {fold(t) for t in parsed.tags}
129
+ return path in slice_paths or any(targets_slice(r) for r in parsed.extends + parsed.supersedes)
130
+
131
+ chosen = sorted(p for p, (_, parsed) in cards.items() if selected(p, parsed))
132
+ if not chosen:
133
+ how = f"prefix={prefix!r} tags={sorted(tag_set)}" if (prefix is not None or tag_set) else (
134
+ f"none extends, supersedes or shares a path with a card in {alias}; pass prefix or tags to choose")
135
+ raise NotFoundError(f"no {overlay.alias} cards to bake into {alias} ({how})", alias=alias)
136
+ included, pulled, external, warnings = dependency_closure(cards, chosen, overlay.alias, cats, include_deps)
137
+ external.discard(alias)
138
+
139
+ staged_paths = ws.staging.entries()
140
+ staged = sorted(p for p in included if p in staged_paths)
141
+ conflicts = [{"address": format_address(overlay.alias, p), "reason": "staged change pending (apply or discard it first)",
142
+ "fix": f"`mindslice apply {p}` to accept the staged change, or `mindslice discard {p}` to drop it"}
143
+ for p in staged]
144
+ for path in sorted(included):
145
+ parsed = cards[path][1]
146
+ if path in slice_paths and any(targets_slice(r) == path for r in parsed.extends):
147
+ conflicts.append({"address": format_address(overlay.alias, path),
148
+ "reason": f"extends {format_address(alias, path)} at the same path; merge the two "
149
+ "into one card first",
150
+ "fix": "rewrite the overlay card with the combined text and drop its `extends` link, "
151
+ "so one card replaces the slice's"})
152
+ if conflicts:
153
+ raise BakeConflictError(f"bake into {alias} stopped before writing: {len(conflicts)} card(s) can't move as they are",
154
+ conflicts=conflicts,
155
+ next_steps=[f"{c['address']}: {c['fix']}" for c in conflicts]
156
+ + [f"Then run the same `mindslice bake {alias}` command again; "
157
+ "`--dry-run` previews it without writing."])
158
+
159
+ def mapper(raw: str) -> str:
160
+ res = resolve_ref(raw, overlay.alias, cats)
161
+ if res.resolved and res.path and (res.mount == alias or (res.mount == overlay.alias and res.path in included)):
162
+ return res.path
163
+ if res.resolved and res.mount and res.path:
164
+ return format_address(res.mount, res.path)
165
+ return raw
166
+
167
+ files: dict[str, str] = {}
168
+ created: list[str] = []
169
+ replaced: list[str] = []
170
+ for path in sorted(included):
171
+ text, parsed = cards[path]
172
+ new_text = rewrite_refs(text, parsed, mapper) if mount else export_text(text, parsed, overlay.alias, cats)
173
+ new_parsed = parse_card(new_text)
174
+ if path in new_parsed.supersedes: # it replaces the file it superseded; a self-reference would be a cycle
175
+ fields = dict(new_parsed.frontmatter)
176
+ fields["supersedes"] = [r for r in new_parsed.supersedes if r != path]
177
+ new_text = reassemble(new_parsed, new_text, fields, None)
178
+ files[path] = normalize_newlines(new_text)
179
+ (replaced if path in slice_paths else created).append(format_address(alias, path))
180
+
181
+ sm = load_slice_manifest(mount.root) if mount else SliceManifest(
182
+ name=alias.lstrip("@"), version="0.1.0", alias=alias,
183
+ description=f"Baked from {overlay.alias} (prefix={prefix})", present=True)
184
+ declared = set()
185
+ for dep in sm.dependencies:
186
+ try:
187
+ declared.add(normalize_alias(dep))
188
+ except InvalidAddressError:
189
+ pass
190
+ added_deps = sorted(external - declared)
191
+ requested = default_output(ws, alias, mount) if output_path is None else Path(output_path).expanduser().absolute()
192
+ requested = Path(os.path.abspath(requested))
193
+ fmt = archive_format(requested)
194
+ output = requested if fmt == "directory" else default_output(ws, alias, mount)
195
+ if fmt != "directory" and output in requested.parents:
196
+ raise MountError("the archive cannot go inside the slice directory being created", output=str(requested))
197
+ if fmt != "directory" and requested.exists():
198
+ raise AlreadyExistsError(f"{requested} already exists", path=str(requested))
199
+ if mount and (output == mount.root or mount.root in output.parents):
200
+ raise MountError(f"the new slice can't go inside {alias}'s own folder ({mount.root}); pass another output",
201
+ alias=alias, output=str(output))
202
+ if output.exists() and (not output.is_dir() or any(output.iterdir())):
203
+ raise AlreadyExistsError(f"{output} already exists and is not empty", path=str(output))
204
+ for destination in {output, requested}:
205
+ resolved = destination.resolve()
206
+ for root in (ws.staging.root, ws.cache_dir, ws.history.root):
207
+ if resolved == root.resolve() or root.resolve() in resolved.parents:
208
+ raise MountError("cannot bake into MindSlice internal directories", output=str(destination))
209
+ for existing in table.all():
210
+ if resolved == existing.root.resolve() or existing.root.resolve() in resolved.parents:
211
+ raise MountError(f"cannot bake inside mounted {existing.alias}'s folder", output=str(destination))
212
+ new_version = version if version is not None else (bump_version(sm.version) if mount else sm.version)
213
+ if name is not None:
214
+ sm.name = name
215
+ elif not sm.present:
216
+ sm.name = mount.root.name.removesuffix(".slice")
217
+ if description is not None or mount is None:
218
+ sm.description, _ = truncate(" ".join((description if description is not None else sm.description).split()),
219
+ DESCRIPTION_CHARS)
220
+ result: dict[str, Any] = {
221
+ "alias": alias,
222
+ "output": str(requested),
223
+ "path": str(output),
224
+ "previous_path": str(mount.root) if mount else None,
225
+ "format": fmt,
226
+ "name": sm.name,
227
+ "cards": sorted(included),
228
+ "dependencies": sorted(declared | external),
229
+ "dry_run": dry_run,
230
+ "created": created,
231
+ "replaced": replaced,
232
+ "included_dependencies": pulled,
233
+ "dependencies_added": added_deps,
234
+ "warnings": warnings,
235
+ }
236
+ if new_version:
237
+ result["version"] = new_version
238
+ if dry_run:
239
+ return result
240
+
241
+ output.parent.mkdir(parents=True, exist_ok=True)
242
+ tmp_root = Path(tempfile.mkdtemp(prefix=f".{output.name}.", dir=output.parent))
243
+ try:
244
+ tree = tmp_root / output.name
245
+ if mount:
246
+ shutil.copytree(mount.root, tree, symlinks=False, ignore=shutil.ignore_patterns(".git", ".history", ".bake-plan.json"))
247
+ else:
248
+ tree.mkdir()
249
+ for path, text in files.items():
250
+ dest = tree / f"{path}.md"
251
+ dest.parent.mkdir(parents=True, exist_ok=True)
252
+ dest.write_bytes(text.encode("utf-8"))
253
+ sm.alias = alias
254
+ sm.dependencies = list(sm.dependencies) + added_deps
255
+ sm.version = new_version
256
+ (tree / SLICE_FILE).write_bytes(dump_slice_manifest(sm).encode("utf-8"))
257
+ if fmt != "directory":
258
+ write_archive(tree, requested, fmt)
259
+ if output.exists():
260
+ output.rmdir() # an empty directory (checked above)
261
+ os.replace(tree, output)
262
+ fsync_dir(output.parent)
263
+ finally:
264
+ shutil.rmtree(tmp_root, ignore_errors=True)
265
+ validation = validate_slice_dir(output, ws)
266
+
267
+ # The new folder takes over the mount: same alias, precedence and role; the old folder is untouched.
268
+ new_mount = Mount(alias, portable_path(ws.root, output), mount.precedence if mount else 50,
269
+ mount.role if mount else "baseline", "slice", output)
270
+ mounts = [new_mount if m.alias == alias else m for m in table.mounts] if mount else [*table.mounts, new_mount]
271
+ actions = [file_action(ws.root / MOUNTS_FILE, dump_mounts(MountTable(overlay, mounts)))]
272
+ events = []
273
+ retired = retire_overlay_paths(ws, table, alias, sorted(included), [], cats, actions=actions, events=events)
274
+ active = ws.history.applied()
275
+ baked_cards = []
276
+ for path in sorted(included):
277
+ source = format_address(overlay.alias, path)
278
+ before = file_sha(overlay.root / f"{path}.md")
279
+ tip, ids = active.get(source, (None, []))
280
+ baked_cards.append({"source": source, "target": format_address(alias, path),
281
+ "before_sha256": before, "after_sha256": file_sha(output / f"{path}.md"),
282
+ "change_ids": ids if tip == before else []})
283
+ baked = event("baked", alias=alias, path=str(output), output=str(requested),
284
+ previous_path=str(mount.root) if mount else None, version=new_version, cards=baked_cards,
285
+ change_ids=list(dict.fromkeys(id for card in baked_cards for id in card["change_ids"])),
286
+ retired_overlay_cards=retired.get("retired_overlay_cards", []))
287
+ events.append(baked)
288
+ guards = [{"path": str(p), "sha256": file_sha(p)} for p in sorted(output.rglob("*")) if p.is_file()]
289
+ ws.history.transact(actions, events, guards=guards)
290
+ table = ws._reload_table()
291
+ ws._sync(table)
292
+ result.update(retired)
293
+ result["history_event_id"] = baked["event_id"]
294
+ result["validation"] = {"ok": validation["ok"], "errors": validation["errors"],
295
+ "warnings": len(validation["warnings"])}
296
+ return result
297
+
298
+
299
+ __all__ = ["bake"]