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
@@ -0,0 +1,183 @@
1
+ """Always-loaded cards (``load = "always"``): the boot set, its rendering, and its write protection.
2
+
3
+ A card whose frontmatter says ``load = "always"`` holds session-level context: an agent persona, the
4
+ user's language, standing instructions. Every session reads it at start (MCP ``initialize``
5
+ instructions, ``manifest()``, ``mindslice boot``), whether or not a query would have found it.
6
+ Otherwise it is an ordinary card: searchable, readable, and subject to lineage like any other.
7
+
8
+ The boot set is each always-loaded card resolved as ``read()`` resolves it: a superseded card
9
+ forwards to its replacement, and baselines and extensions are composed in. Its *footprint* is
10
+ every card whose content (or lineage) decides what the set contains. Because those cards shape
11
+ every session, a write that touches the footprint is staged for the user's approval by default
12
+ (``write(stage=None)``), and ``apply()`` is that approval.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from typing import TYPE_CHECKING, Any, Iterable
20
+
21
+ from ..retrieval import query
22
+ from ..model.addressing import format_address, resolve_ref
23
+ from ..model.card import LOAD_ALWAYS, parse_card
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from ..storage.config import MountTable
27
+ from ..workspace import Workspace
28
+
29
+ # Extensions composed into one boot card (read() pages them; the boot set shows them all up to this).
30
+ MAX_EXTENSIONS = 50
31
+
32
+ HEADER = (
33
+ "# Always-loaded memory\n\n"
34
+ "These cards come from the user's MindSlice memory and apply to the whole session (persona, language, "
35
+ "standing instructions). Follow them unless the user says otherwise. Changes to them are staged for the "
36
+ "user's approval."
37
+ )
38
+
39
+
40
+ def _card(c: dict[str, Any]) -> dict[str, Any]:
41
+ return {"address": c["address"], "title": c["title"], "summary": c.get("summary", ""), "body": c.get("body", "")}
42
+
43
+
44
+ def collect(ctx: "query._Ctx") -> tuple[list[dict[str, Any]], set[str]]:
45
+ """``(entries, footprint)``: the rendered boot cards and every address that shapes them."""
46
+ entries: list[dict[str, Any]] = []
47
+ footprint: set[str] = set()
48
+ shown: set[str] = set()
49
+ for row in ctx.idx.always_cards():
50
+ address = format_address(row["mount"], row["path"])
51
+ r = query.resolve(ctx, address, max_backlinks=0, max_extensions=MAX_EXTENSIONS, bump_telemetry=False)
52
+ baselines = r.get("baselines", [])
53
+ extensions = r.get("extensions", [])
54
+ parts = [r["address"], *(b["address"] for b in baselines), *(e["address"] for e in extensions)]
55
+ footprint.update([address, *r.get("lineage_path", []), *parts])
56
+ if all(a in shown for a in parts):
57
+ continue # everything it would show is already in the set (e.g. an always-loaded extension)
58
+ shown.update(parts)
59
+ entry = _card(r) | {"mount": r["mount"], "role": r["role"], "sha256": r["sha256"]}
60
+ if r.get("redirected_from"):
61
+ entry["redirected_from"] = r["redirected_from"]
62
+ if r.get("status") != "active":
63
+ entry["status"] = r["status"]
64
+ if baselines:
65
+ entry["baselines"] = [_card(b) for b in baselines]
66
+ if extensions:
67
+ entry["extensions"] = [_card(e) for e in extensions]
68
+ if r.get("total_extensions", 0) > len(extensions):
69
+ entry["total_extensions"] = r["total_extensions"]
70
+ entries.append(entry)
71
+ return entries, footprint
72
+
73
+
74
+ def pending_approval(ws: "Workspace") -> list[dict[str, Any]]:
75
+ out = []
76
+ for path, entry in ws.staging.entries().items():
77
+ reason = entry.get("requires_approval")
78
+ if reason:
79
+ out.append({"id": path, "op": entry.get("op", "write"), "reason": reason})
80
+ return out
81
+
82
+
83
+ def is_empty_workspace(ws: "Workspace", table: "MountTable") -> bool:
84
+ """True when only ``@overlay`` is mounted, no cards are indexed, and nothing is staged."""
85
+ if len(table.all()) > 1 or ws.staging.entries():
86
+ return False
87
+ return ws.index.conn.execute("SELECT 1 FROM cards LIMIT 1").fetchone() is None
88
+
89
+
90
+ def snapshot(ws: "Workspace", table: "MountTable") -> dict[str, Any]:
91
+ """The boot set for a caller that already holds the workspace lock."""
92
+ entries, _ = collect(query._Ctx(ws, table, True))
93
+ out: dict[str, Any] = {"cards": entries, "total": len(entries)}
94
+ pending = pending_approval(ws)
95
+ if pending:
96
+ out["pending_approval"] = pending
97
+ if not entries and is_empty_workspace(ws, table):
98
+ out["empty_workspace"] = True
99
+ return out
100
+
101
+
102
+ def boot(ws: "Workspace") -> dict[str, Any]:
103
+ with ws._reading() as table:
104
+ return snapshot(ws, table)
105
+
106
+
107
+ def fingerprint(entry: dict[str, Any]) -> str:
108
+ return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
109
+
110
+
111
+ def render_markdown(cards: Iterable[dict[str, Any]], header: str = HEADER) -> str:
112
+ """The boot cards as one Markdown block, ready for a system prompt. Empty when there are none."""
113
+ out: list[str] = []
114
+
115
+ def block(c: dict[str, Any], heading: str) -> None:
116
+ out.append(f"{heading} {c['title']} ({c['address']})")
117
+ for part in (c.get("summary", ""), c.get("body", "")):
118
+ if part.strip():
119
+ out.append("")
120
+ out.append(part.strip())
121
+ out.append("")
122
+
123
+ for c in cards:
124
+ for base in c.get("baselines", []):
125
+ block(base, "## Baseline:")
126
+ block(c, "##")
127
+ for ext in c.get("extensions", []):
128
+ block(ext, "### Extension:")
129
+ if not out:
130
+ return ""
131
+ prefix = header.rstrip() + "\n\n" if header.strip() else ""
132
+ return prefix + "\n".join(out).rstrip() + "\n"
133
+
134
+
135
+ def protection_reason(
136
+ ws: "Workspace",
137
+ table: "MountTable",
138
+ path: str,
139
+ texts: Iterable[str | None],
140
+ load: str | None,
141
+ lineage: Iterable[tuple[str, str]],
142
+ ) -> str | None:
143
+ """Why a write to ``@overlay/<path>`` changes the always-loaded context (None if it does not).
144
+
145
+ ``texts`` are the card's current versions (overlay and staged), ``load`` and ``lineage`` the
146
+ write's own ``load`` argument and ``extends`` / ``supersedes`` references (named by kind in
147
+ ``lineage``: ``[("extends", ref), ...]``).
148
+ """
149
+ overlay = table.overlay
150
+ address = format_address(overlay.alias, path)
151
+ if load == LOAD_ALWAYS:
152
+ return f"it makes {address} always-loaded"
153
+ parsed = [parse_card(t) for t in texts if t is not None]
154
+ if any(p.load == LOAD_ALWAYS for p in parsed):
155
+ return f"{address} is always-loaded"
156
+ ctx = query._Ctx(ws, table, True)
157
+ _, footprint = collect(ctx)
158
+ if not footprint:
159
+ return None
160
+ if address in footprint:
161
+ return f"{address} is part of the always-loaded context"
162
+ cats = ws.index.catalogs()
163
+ refs = list(lineage)
164
+ for p in parsed:
165
+ refs += [("extends", raw) for raw in p.extends] + [("supersedes", raw) for raw in p.supersedes]
166
+ for verb, raw in refs:
167
+ res = resolve_ref(raw, overlay.alias, cats)
168
+ if res.resolved and res.address in footprint:
169
+ return f"it {verb} {res.address}, which is part of the always-loaded context"
170
+ return None
171
+
172
+
173
+ __all__ = [
174
+ "boot",
175
+ "snapshot",
176
+ "collect",
177
+ "is_empty_workspace",
178
+ "render_markdown",
179
+ "protection_reason",
180
+ "pending_approval",
181
+ "fingerprint",
182
+ "HEADER",
183
+ ]
@@ -0,0 +1,98 @@
1
+ """Inbound impact of retiring a card: who still links to it or extends it.
2
+
3
+ When ``write()`` supersedes or deletes a card, the cards that ``[[link]]`` to it or ``extends`` it keep
4
+ pointing at the old card: a link whose target names an old value keeps stating that value, and an
5
+ extension keeps resting on a premise that changed. This module lists them, straight from the index
6
+ (plus staged overrides for ``stage=True``), so the caller can update or rebase them. It is pure
7
+ mechanism: no ranking, no judgment about which ones matter. Results are ordered by mount precedence,
8
+ then address, then target, and bounded by ``MAX_AFFECTED`` per list (totals are always reported).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any, Iterable
14
+
15
+ from ..model.addressing import MountCatalog, format_address, resolve_ref
16
+
17
+ if TYPE_CHECKING: # pragma: no cover
18
+ from ..model.card import ParsedCard
19
+ from ..retrieval.index import Index
20
+
21
+ MAX_AFFECTED = 25
22
+ _KINDS = (("wikilink", "affected_backlinks", "links_to"), ("extends", "affected_extensions", "extends"))
23
+
24
+
25
+ def _split(address: str) -> tuple[str, str]:
26
+ mount, _, path = address.partition("/")
27
+ return mount, path
28
+
29
+
30
+ def supersede_targets(parsed: "ParsedCard", src_mount: str, cats: dict[str, MountCatalog]) -> list[str]:
31
+ """Resolved addresses of the cards ``parsed`` supersedes, in order, without duplicates."""
32
+ out: list[str] = []
33
+ for raw in parsed.supersedes:
34
+ res = resolve_ref(raw, src_mount, cats)
35
+ if res.resolved and res.address and res.address not in out:
36
+ out.append(res.address)
37
+ return out
38
+
39
+
40
+ def inbound_impact(
41
+ index: "Index",
42
+ overlay_alias: str,
43
+ overlay_precedence: int,
44
+ targets: Iterable[str],
45
+ *,
46
+ overrides: dict[str, "ParsedCard | None"] | None = None,
47
+ cats: dict[str, MountCatalog] | None = None,
48
+ exclude: Iterable[str] = (),
49
+ dangling: bool = False,
50
+ ) -> dict[str, Any]:
51
+ """``affected_backlinks`` / ``affected_extensions`` (+ totals) for cards that reference ``targets``.
52
+
53
+ ``overrides`` are staged overlay cards (path -> parsed card, None for a staged delete): indexed
54
+ links from those paths are replaced by the staged versions' own references, resolved with
55
+ ``cats``. ``exclude`` drops sources (the card being written). ``dangling=True`` matches references
56
+ that name a target by path but no longer resolve (the target was already deleted).
57
+ """
58
+ targets = list(dict.fromkeys(targets))
59
+ overrides = overrides or {}
60
+ skip = set(exclude) | set(targets)
61
+ found: dict[str, dict[tuple[str, str], int]] = {kind: {} for kind, _, _ in _KINDS}
62
+ for target in targets:
63
+ mount, path = _split(target)
64
+ for kind, _, _ in _KINDS:
65
+ rows = index.dangling_inbound(kind, mount, path) if dangling else index.inbound(kind, mount, path)
66
+ for r in rows:
67
+ if r["src_mount"] == overlay_alias and r["src_path"] in overrides:
68
+ continue # superseded by its staged version, handled below
69
+ src = format_address(r["src_mount"], r["src_path"])
70
+ if src not in skip:
71
+ found[kind][(src, target)] = r["precedence"]
72
+ if overrides and cats is not None:
73
+ wanted = set(targets)
74
+ for path, parsed in overrides.items():
75
+ src = format_address(overlay_alias, path)
76
+ if parsed is None or src in skip:
77
+ continue
78
+ refs = {"wikilink": parsed.wikilink_targets(), "extends": parsed.extends}
79
+ for kind, raws in refs.items():
80
+ for raw in raws:
81
+ res = resolve_ref(raw, overlay_alias, cats)
82
+ if res.resolved and res.address in wanted:
83
+ found[kind][(src, res.address)] = overlay_precedence
84
+ out: dict[str, Any] = {}
85
+ for kind, key, field in _KINDS:
86
+ ordered = sorted(found[kind].items(), key=lambda kv: (-kv[1], kv[0][0], kv[0][1]))
87
+ items = []
88
+ for (src, target), _ in ordered[:MAX_AFFECTED]:
89
+ item: dict[str, Any] = {"address": src, field: target}
90
+ if _split(src)[0] != overlay_alias:
91
+ item["read_only"] = True
92
+ items.append(item)
93
+ out[key] = items
94
+ out["total_" + key] = len(ordered)
95
+ return out
96
+
97
+
98
+ __all__ = ["MAX_AFFECTED", "inbound_impact", "supersede_targets"]
@@ -0,0 +1,113 @@
1
+ """Preview and journal a slice-pointer rollback without reverting overlay or staging."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from ..errors import RecoveryError
9
+ from ..model.addressing import normalize_alias
10
+ from ..storage.config import Mount, MountTable, dump_mounts, load_slice_manifest, portable_path
11
+ from ..storage.history import event, file_action, file_sha
12
+ from ..storage.snapshots import inventory
13
+ from .audit import history_path
14
+ from .validation import Report, SliceSource, lint
15
+
16
+
17
+ def _issues(ws, table):
18
+ report = Report(str(ws.root))
19
+ sources = []
20
+ for m in table.all():
21
+ if not m.root.is_dir():
22
+ report.add('error', 'missing_mount', f'mount {m.alias} has no directory', address=m.alias)
23
+ continue
24
+ nested = tuple(o.root for o in table.all() if o.alias != m.alias and m.root in o.root.parents)
25
+ sources.append(SliceSource(m.alias, m.root, load_slice_manifest(m.root), m.kind, tuple(ws._exclude()) + nested))
26
+ lint(sources, report)
27
+ return report.errors + report.warnings
28
+
29
+
30
+ def rollback(ws, alias, path=None, *, dry_run=False, revision=None):
31
+ alias = normalize_alias(alias)
32
+ with ws._writing() as table:
33
+ current = table.get(alias)
34
+ if current is None or current.kind == 'overlay':
35
+ raise RecoveryError('rollback requires a mounted read-only slice', alias=alias)
36
+ if path is None:
37
+ previous = next((e.get('previous_path') for e in reversed(ws.history.events())
38
+ if e['event'] == 'baked' and e.get('alias') == alias
39
+ and history_path(ws, e['path']) == current.root), None)
40
+ if not previous:
41
+ raise RecoveryError('no recorded predecessor for this slice; specify --to /path/to/version', alias=alias)
42
+ target = history_path(ws, previous)
43
+ else:
44
+ target = Path(path).expanduser().absolute()
45
+ if target == current.root:
46
+ raise RecoveryError('target is already mounted', path=str(target))
47
+ if any(target.resolve() == m.root.resolve() for m in table.all()):
48
+ raise RecoveryError('target is already mounted under another alias', path=str(target))
49
+ if any(target.resolve() == p.resolve() or target.resolve().is_relative_to(p.resolve())
50
+ for p in (table.overlay.root, ws.staging.root, ws.cache_dir, ws.history.root)):
51
+ raise RecoveryError('rollback target must be a slice directory, not live or internal workspace storage')
52
+ target_files = inventory(target)
53
+ replacement = Mount(alias, portable_path(ws.root, target), current.precedence, current.role, 'slice', target)
54
+ projected = MountTable(table.overlay, [replacement if m.alias == alias else m for m in table.mounts])
55
+ before = _issues(ws, table)
56
+ after = _issues(ws, projected)
57
+ def identity(issue):
58
+ return (issue['code'], issue.get('address'), issue['message'])
59
+ existing = {identity(i) for i in before}
60
+ blockers = [i for i in after if identity(i) not in existing and
61
+ (i['severity'] == 'error' or i['code'] in ('broken_wikilink', 'unmounted_dependency', 'missing_dependency'))]
62
+ # A malformed destination is never accepted just because the current version has the same defect.
63
+ blockers += [i for i in after if i['severity'] == 'error' and
64
+ (i.get('address', '').startswith(alias + '/') or
65
+ (i.get('file') and Path(i['file']).is_relative_to(target))) and i not in blockers]
66
+ state = ws.status()
67
+ overlay = [
68
+ f"{table.overlay.alias}/{c['path']}" for c in ws.index.all_cards(table.overlay.alias)]
69
+ plan = {'alias': alias, 'previous_path': str(current.root), 'path': str(target),
70
+ 'version': load_slice_manifest(target).version,
71
+ 'precedence': current.precedence, 'role': current.role,
72
+ 'overlay_preserved': overlay,
73
+ 'overlay_same_paths': [f"{table.overlay.alias}/{r['path']}" for r in ws.index.all_cards(table.overlay.alias)
74
+ if (target / (r['path'] + '.md')).is_file()],
75
+ 'pending_preserved': [i['address'] for i in state['pending_review']['files']],
76
+ 'blockers': blockers, 'warnings': [i for i in after if i not in blockers], 'ready': not blockers,
77
+ 'note': 'Only the slice pointer changes. Existing overlay overrides and pending proposals remain; this does not undo the whole workspace.'}
78
+ # Bind confirmation to the mount table, destination bytes, all live card bytes, and pending proposals.
79
+ live_files = {f"{r['mount']}/{r['path']}": file_sha(table.get(r['mount']).root / (r['path'] + '.md'))
80
+ for r in ws.index.all_cards()}
81
+ fingerprint = {'plan': plan, 'target': target_files, 'live': live_files,
82
+ 'mounts': (ws.root / 'mounts.toml').read_text(encoding='utf-8'),
83
+ 'staging': inventory(ws.staging.root)}
84
+ plan['revision'] = hashlib.sha256(json.dumps(fingerprint, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
85
+ if dry_run:
86
+ return plan
87
+ if revision is not None and revision != plan['revision']:
88
+ raise RecoveryError('rollback preview is stale; review the target and remaining overlay again')
89
+ if blockers:
90
+ raise RecoveryError('rollback would introduce invalid references or cards; resolve the blockers first', plan=plan)
91
+ record = event('rolled_back', alias=alias, previous_path=str(current.root), path=str(target), version=plan['version'])
92
+ guards = [{'path': str(target / name), 'sha256': info['sha256']} for name, info in target_files['files'].items()]
93
+ ws.history.transact([file_action(ws.root / 'mounts.toml', dump_mounts(projected))], [record], guards=guards)
94
+ ws._sync(ws._reload_table())
95
+ return dict(plan, history_event_id=record['event_id'], rolled_back=True)
96
+
97
+
98
+ def render_rollback(plan):
99
+ lines = [f"{plan['alias']}: {plan['previous_path']} -> {plan['path']} (v{plan['version'] or 'unspecified'})",
100
+ plan['note'], f"Overlay cards retained: {len(plan['overlay_preserved'])}",
101
+ f"Pending proposals retained: {len(plan['pending_preserved'])}"]
102
+ for address in plan['overlay_preserved']:
103
+ lines.append(' Overlay: ' + address)
104
+ if plan['overlay_same_paths']:
105
+ lines.append('Overlay cards sharing target paths: ' + ', '.join(plan['overlay_same_paths']))
106
+ lines.append('Use explain CARD to check which version an unqualified read selects.')
107
+ for address in plan['pending_preserved']:
108
+ lines.append(' Pending: ' + address)
109
+ for issue in plan['blockers']:
110
+ lines.append('Blocked: ' + issue['message'])
111
+ for issue in plan['warnings']:
112
+ lines.append('Warning: ' + issue['message'])
113
+ return '\n'.join(lines) + '\n'