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,593 @@
1
+ """Group A read primitives: ``manifest()``, ``search()`` and ``read()`` (design section 4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any, Iterable, Sequence
7
+
8
+ from ..model.addressing import format_address, normalize_alias, parse_address
9
+ from ..model.card import KNOWN_FIELDS, LOAD_ALWAYS, SUMMARY_PREVIEW_CHARS, parse_card, truncate
10
+ from ..storage.config import DESCRIPTION_CHARS, OVERVIEW_PATH, MountTable, load_slice_manifest
11
+ from ..errors import InvalidAddressError, NotFoundError
12
+ from ..model.lineage import FollowSpec, LineageView, TrustPolicy
13
+ from ..storage.card_io import read_card_file
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from ..workspace import Workspace
17
+
18
+ _LINEAGE_KINDS = ("extends", "supersedes")
19
+
20
+
21
+ def _nonneg(**values: int) -> None:
22
+ for name, value in values.items():
23
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
24
+ raise ValueError(f"{name} must be a non-negative integer")
25
+
26
+
27
+ def _split(address: str) -> tuple[str, str]:
28
+ mount, _, path = address.partition("/")
29
+ return mount, path
30
+
31
+
32
+ def _collapse(text: str) -> str:
33
+ return " ".join((text or "").split())
34
+
35
+
36
+ def _page(total: int, offset: int, limit: int) -> int | None:
37
+ return offset + limit if offset + limit < total else None
38
+
39
+
40
+ def _as_str_list(value: str | Iterable[str] | None) -> list[str]:
41
+ if value is None:
42
+ return []
43
+ if isinstance(value, str):
44
+ return [value]
45
+ return [str(v) for v in value]
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # manifest()
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def manifest(
54
+ ws: "Workspace",
55
+ limit: int = 20,
56
+ offset: int = 0,
57
+ max_folders_per_mount: int = 10,
58
+ include_overview: bool = False,
59
+ include_always: bool = True,
60
+ ) -> dict[str, Any]:
61
+ """Mount map; on the first page (``offset=0``) also the always-loaded cards (``always_loaded``)."""
62
+ from ..operations.boot import snapshot # boot builds on this module
63
+
64
+ _nonneg(limit=limit, offset=offset, max_folders_per_mount=max_folders_per_mount)
65
+ with ws._reading() as table:
66
+ idx = ws.index
67
+ mounts = table.all()
68
+ mounted = {m.alias for m in mounts}
69
+ page = mounts[offset : offset + limit]
70
+ out = []
71
+ for m in page:
72
+ sm = load_slice_manifest(m.root)
73
+ folders, root_cards, total = idx.folder_counts(m.alias)
74
+ overview = idx.card(m.alias, OVERVIEW_PATH)
75
+ entry_card = overview if overview is not None else _readme(idx, m.alias)
76
+ description = sm.description or (entry_card["preview"] if entry_card is not None else "")
77
+ description, desc_truncated = truncate(_collapse(description), DESCRIPTION_CHARS)
78
+ entry: dict[str, Any] = {
79
+ "alias": m.alias,
80
+ "role": m.role,
81
+ "precedence": m.precedence,
82
+ "kind": m.kind,
83
+ "writable": m.writable,
84
+ "path": m.path,
85
+ "name": sm.name or m.root.name,
86
+ "description": description,
87
+ "card_count": total,
88
+ "root_cards": root_cards,
89
+ "folders": [{"name": name, "cards": count} for name, count in folders[:max_folders_per_mount]],
90
+ "total_folders": len(folders),
91
+ "folders_truncated": len(folders) > max_folders_per_mount,
92
+ "has_overview": overview is not None,
93
+ }
94
+ if sm.version:
95
+ entry["version"] = sm.version
96
+ if desc_truncated:
97
+ entry["description_truncated"] = True
98
+ if sm.dependencies:
99
+ entry["dependencies"] = list(sm.dependencies)
100
+ missing = []
101
+ for dep in sm.dependencies:
102
+ try:
103
+ if normalize_alias(dep) not in mounted:
104
+ missing.append(dep)
105
+ except InvalidAddressError:
106
+ missing.append(dep)
107
+ if missing:
108
+ entry["missing_dependencies"] = missing
109
+ if sm.errors:
110
+ entry["manifest_errors"] = sm.errors
111
+ if not m.root.is_dir():
112
+ entry["missing"] = True
113
+ if include_overview and overview is not None:
114
+ text, _ = read_card_file(m.root / f"{OVERVIEW_PATH}.md")
115
+ if text is not None:
116
+ entry["overview"] = parse_card(text).after.strip()
117
+ out.append(entry)
118
+ result: dict[str, Any] = {
119
+ "mounts": out,
120
+ "total_mounts": len(mounts),
121
+ "offset": offset,
122
+ "limit": limit,
123
+ "truncated": offset + limit < len(mounts),
124
+ "next_offset": _page(len(mounts), offset, limit),
125
+ }
126
+ pending = len(ws.staging.entries())
127
+ if pending:
128
+ result["staging"] = {"pending": pending}
129
+ if include_always and offset == 0:
130
+ boot = snapshot(ws, table)
131
+ if boot["cards"]:
132
+ result["always_loaded"] = boot["cards"]
133
+ if boot.get("pending_approval"):
134
+ result["staging"]["awaiting_approval"] = boot["pending_approval"]
135
+ if boot.get("empty_workspace"):
136
+ result["onboarding_hint"] = (
137
+ "Workspace is empty (no cards or mounted slices). If the user is greeting you, testing memory, or "
138
+ "asking how to start, invite them to share their reply preferences (write with load='always'), "
139
+ "preferred tools and project conventions (write into @overlay), or an existing notes/docs folder "
140
+ "to mount. Otherwise answer their request first and record durable preferences along the way."
141
+ )
142
+ return result
143
+
144
+
145
+ def _readme(idx, mount: str):
146
+ """A plain folder's README at its root: its description when there is no slice.toml or overview."""
147
+ for name in ("README", "readme", "Readme"):
148
+ row = idx.card(mount, name)
149
+ if row is not None:
150
+ return row
151
+ return None
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # shared helpers
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ class _Ctx:
160
+ """Per-request helper bundling the index, mount table and lineage view."""
161
+
162
+ def __init__(self, ws: "Workspace", table: MountTable, follow: FollowSpec) -> None:
163
+ self.ws = ws
164
+ self.table = table
165
+ self.idx = ws.index
166
+ self.lv = LineageView(self.idx, TrustPolicy(follow))
167
+ self._rows: dict[str, Any] = {}
168
+ self._outbound: dict[str, list[Any]] = {}
169
+
170
+ def row(self, address: str):
171
+ if address not in self._rows:
172
+ self._rows[address] = self.idx.card(*_split(address))
173
+ return self._rows[address]
174
+
175
+ def remember(self, row) -> str:
176
+ address = format_address(row["mount"], row["path"])
177
+ self._rows.setdefault(address, row)
178
+ return address
179
+
180
+ def outbound(self, address: str) -> list[Any]:
181
+ if address not in self._outbound:
182
+ self._outbound[address] = self.idx.outbound(*_split(address))
183
+ return self._outbound[address]
184
+
185
+ def lineage_targets(self, address: str, kind: str) -> list[str]:
186
+ return [
187
+ format_address(l["dst_mount"], l["dst_path"])
188
+ for l in self.outbound(address)
189
+ if l["kind"] == kind and l["resolved"]
190
+ ]
191
+
192
+ def unresolved_refs(self, address: str) -> list[dict[str, str]]:
193
+ return [{"kind": l["kind"], "ref": l["raw"]} for l in self.outbound(address) if not l["resolved"]]
194
+
195
+ def brief(self, address: str, summary_chars: int = SUMMARY_PREVIEW_CHARS) -> dict[str, Any]:
196
+ row = self.row(address)
197
+ if row is None:
198
+ return {"address": address, "missing": True}
199
+ summary, cut = truncate(_collapse(row["preview"]), summary_chars)
200
+ out = {
201
+ "address": address,
202
+ "role": row["role"],
203
+ "precedence": row["precedence"],
204
+ "title": row["title"],
205
+ "summary": summary,
206
+ }
207
+ if cut:
208
+ out["summary_truncated"] = True
209
+ return out
210
+
211
+ def status(self, address: str) -> dict[str, Any]:
212
+ """Supersession status fields shared by search previews and read results."""
213
+ out: dict[str, Any] = {}
214
+ fwd = self.lv.forward(address)
215
+ direct = self.lv.all_superseders(address)
216
+ untrusted = self.lv.untrusted_superseders(address)
217
+ if fwd.competing:
218
+ out["status"] = "competing_replacements"
219
+ out["competing_replacements"] = [self.brief(a) for a in fwd.terminals]
220
+ elif direct:
221
+ out["status"] = "superseded_by: " + ", ".join(direct)
222
+ else:
223
+ out["status"] = "active"
224
+ if direct:
225
+ out["superseded_by"] = direct
226
+ if untrusted:
227
+ out["superseded_by_untrusted"] = untrusted
228
+ if fwd.cycle:
229
+ out["lineage_cycle"] = True
230
+ return out
231
+
232
+ def bypassed_replacements(self, *starts: str) -> list[dict[str, str]]:
233
+ """Untrusted replacements of the cards a redirect from ``starts`` was forwarded through.
234
+
235
+ The trust policy skipped them; the landing card's own untrusted replacements are reported
236
+ separately, in ``superseded_by_untrusted``.
237
+ """
238
+ out: list[dict[str, str]] = []
239
+ for start in starts:
240
+ for old in sorted(self.lv.forward(start).superseded):
241
+ for alt in self.lv.untrusted_superseders(old):
242
+ item = {"address": alt, "supersedes": old}
243
+ if item not in out:
244
+ out.append(item)
245
+ return out
246
+
247
+ def card_payload(self, address: str) -> dict[str, Any]:
248
+ row = self.row(address)
249
+ mount = self.table.get(row["mount"]) if row is not None else None
250
+ if row is None or mount is None:
251
+ raise NotFoundError(f"{address} not found", address=address)
252
+ text, sha = read_card_file(mount.root / f"{row['path']}.md")
253
+ if text is None:
254
+ raise NotFoundError(f"{address} vanished from disk", address=address)
255
+ parsed = parse_card(text)
256
+ payload: dict[str, Any] = {
257
+ "address": address,
258
+ "mount": row["mount"],
259
+ "path": row["path"],
260
+ "role": row["role"],
261
+ "precedence": row["precedence"],
262
+ "title": parsed.display_title(row["path"]),
263
+ "aliases": parsed.aliases,
264
+ "tags": parsed.tags,
265
+ "summary": parsed.summary,
266
+ "body": parsed.body,
267
+ "sha256": sha,
268
+ "mtime_ns": row["mtime_ns"],
269
+ "lines": text.count("\n") + (0 if text.endswith("\n") or not text else 1),
270
+ }
271
+ if parsed.load == LOAD_ALWAYS:
272
+ payload["load"] = LOAD_ALWAYS
273
+ for kind in _LINEAGE_KINDS:
274
+ targets = self.lineage_targets(address, kind)
275
+ if targets:
276
+ payload[kind] = targets
277
+ extra = {k: v for k, v in parsed.frontmatter.items() if k not in KNOWN_FIELDS}
278
+ if extra:
279
+ payload["meta"] = extra
280
+ unresolved = self.unresolved_refs(address)
281
+ if unresolved:
282
+ payload["unresolved_refs"] = unresolved
283
+ if parsed.errors:
284
+ payload["parse_errors"] = parsed.errors
285
+ return payload
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # search()
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ def search(
294
+ ws: "Workspace",
295
+ query: str,
296
+ source: str | Sequence[str] | None = None,
297
+ tags: str | Sequence[str] | None = None,
298
+ k: int = 10,
299
+ offset: int = 0,
300
+ max_neighbors: int = 3,
301
+ follow_supersedes: FollowSpec = True,
302
+ ) -> dict[str, Any]:
303
+ _nonneg(k=k, offset=offset, max_neighbors=max_neighbors)
304
+ sources = [normalize_alias(s) for s in _as_str_list(source)] or None
305
+ tag_list = [t for t in _as_str_list(tags) if t.strip()]
306
+ with ws._reading() as table:
307
+ known = {m.alias for m in table.all()}
308
+ unknown = [s for s in sources or [] if s not in known]
309
+ if unknown:
310
+ raise NotFoundError(f"unknown mount(s): {', '.join(unknown)}", mounts=unknown)
311
+ ctx = _Ctx(ws, table, follow_supersedes)
312
+ idx = ctx.idx
313
+
314
+ def eligible(address: str) -> bool:
315
+ row = ctx.row(address)
316
+ if row is None or row["is_overview"]:
317
+ return False
318
+ if sources and row["mount"] not in sources:
319
+ return False
320
+ return idx.card_has_tags(row["mount"], row["path"], tag_list)
321
+
322
+ # Superseded hits are routes to their replacement; deduplicated groups keep the best
323
+ # value of each ranking signal (design: max(score(A), score(B))).
324
+ groups: dict[str, dict[str, Any]] = {}
325
+ hits = idx.search(query, sources, tag_list)
326
+ for hit in hits:
327
+ address = ctx.remember(hit.row)
328
+ fwd = ctx.lv.forward(address)
329
+ target, via = address, None
330
+ if fwd.redirected and eligible(fwd.node):
331
+ target, via = fwd.node, address
332
+ group = groups.setdefault(target, {"score": hit.score, "coverage": hit.coverage, "phrases": hit.phrases, "via": []})
333
+ group["score"] = max(group["score"], hit.score)
334
+ group["coverage"] = max(group["coverage"], hit.coverage)
335
+ group["phrases"] = max(group["phrases"], hit.phrases)
336
+ if via and via not in group["via"]:
337
+ group["via"].append(via)
338
+
339
+ ordered = sorted(groups.items(), key=lambda item: result_order(item[0], item[1], ctx.row(item[0])))
340
+ total = len(ordered)
341
+ results = [
342
+ _preview(ctx, address, group, max_neighbors)
343
+ for address, group in ordered[offset : offset + k]
344
+ ]
345
+ return {
346
+ "query": query,
347
+ "results": results,
348
+ "total_matches": total,
349
+ "offset": offset,
350
+ "k": k,
351
+ "truncated": offset + k < total,
352
+ "next_offset": _page(total, offset, k),
353
+ # About the query, not the results: words no card in scope contains (changes nothing above).
354
+ "unmatched_terms": idx.unmatched_terms(query, hits),
355
+ }
356
+
357
+
358
+ def result_order(address: str, group: dict[str, Any], row) -> tuple:
359
+ """Order of search results: BM25 score (the best of a redirected group), precedence, address."""
360
+ return (-group["score"], -row["precedence"], address)
361
+
362
+
363
+ def _preview(ctx: _Ctx, address: str, group: dict[str, Any], max_neighbors: int) -> dict[str, Any]:
364
+ row = ctx.row(address)
365
+ via = group["via"]
366
+ summary, cut = truncate(_collapse(row["preview"]), SUMMARY_PREVIEW_CHARS)
367
+ out: dict[str, Any] = {
368
+ "address": address,
369
+ "role": row["role"],
370
+ "precedence": row["precedence"],
371
+ "mtime_ns": row["mtime_ns"],
372
+ "score": round(float(group["score"]), 6),
373
+ "coverage": round(float(group["coverage"]), 3),
374
+ "title": row["title"],
375
+ "tags": json.loads(row["tags"]),
376
+ "summary": summary,
377
+ }
378
+ if cut:
379
+ out["summary_truncated"] = True
380
+ if row["load"] == LOAD_ALWAYS:
381
+ out["load"] = LOAD_ALWAYS
382
+ out.update(ctx.status(address))
383
+ fwd = ctx.lv.forward(address)
384
+ if fwd.redirected:
385
+ out["resolves_to"] = fwd.node
386
+ if via:
387
+ out["matched_via_superseded"] = via
388
+ bypassed = ctx.bypassed_replacements(*via)
389
+ if bypassed:
390
+ out["bypassed_replacements"] = bypassed
391
+ for kind in _LINEAGE_KINDS:
392
+ targets = ctx.lineage_targets(address, kind)
393
+ if targets:
394
+ out[kind] = targets
395
+ # Same notices as read(), over the whole extends closure: a superseded or deleted baseline of
396
+ # a baseline undermines this card too.
397
+ baselines, missing = _baseline_closure(ctx, address)
398
+ replaced = []
399
+ for base in baselines:
400
+ for r in ctx.lv.all_superseders(base):
401
+ if r not in replaced:
402
+ replaced.append(r)
403
+ if replaced:
404
+ out["baseline_superseded_by"] = replaced
405
+ if missing:
406
+ out["missing_baselines"] = missing
407
+ neighbors = _neighbors(ctx, address)
408
+ out["neighbors"] = [ctx.brief(a) | {"direction": d} for a, d in neighbors[:max_neighbors]]
409
+ out["total_neighbors"] = len(neighbors)
410
+ out["neighbors_truncated"] = len(neighbors) > max_neighbors
411
+ return out
412
+
413
+
414
+ def _neighbors(ctx: _Ctx, address: str) -> list[tuple[str, str]]:
415
+ seen: dict[str, str] = {}
416
+ for link in ctx.outbound(address):
417
+ if link["kind"] == "wikilink" and link["resolved"]:
418
+ target = format_address(link["dst_mount"], link["dst_path"])
419
+ if target != address:
420
+ seen.setdefault(target, "out")
421
+ mount, path = _split(address)
422
+ for r in ctx.idx.inbound("wikilink", mount, path):
423
+ src = format_address(r["src_mount"], r["src_path"])
424
+ seen.setdefault(src, "in")
425
+ return list(seen.items())
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # read()
430
+ # ---------------------------------------------------------------------------
431
+
432
+
433
+ def _select(ctx: _Ctx, address: str) -> tuple[str, list[str]]:
434
+ """Step 1 of section 4.2: initial node selection (+ shadows for unqualified paths)."""
435
+ alias, path = parse_address(address)
436
+ idx = ctx.idx
437
+ if alias is not None:
438
+ if ctx.table.get(alias) is None:
439
+ raise NotFoundError(f"unknown mount {alias}", address=address)
440
+ row = idx.card(alias, path)
441
+ if row is None:
442
+ rows = idx.cards_with_alias(path, mount=alias)
443
+ row = rows[0] if rows else None
444
+ if row is None:
445
+ raise NotFoundError(f"no card {path!r} in {alias}", address=address)
446
+ return ctx.remember(row), []
447
+ rows = idx.cards_at_path(path)
448
+ if not rows:
449
+ rows = idx.cards_with_alias(path)
450
+ if not rows:
451
+ raise NotFoundError(f"no card {path!r} in any mount", address=address)
452
+ return ctx.remember(rows[0]), []
453
+ selected = ctx.remember(rows[0])
454
+ # Same-path cards connected to the selection by a direct extends/supersedes edge (either
455
+ # direction) are lineage, not shadows.
456
+ linked = set()
457
+ for kind in _LINEAGE_KINDS:
458
+ linked.update(ctx.lineage_targets(selected, kind))
459
+ linked.update(format_address(r["src_mount"], r["src_path"]) for r in idx.inbound(kind, *_split(selected)))
460
+ shadows = [other for other in (ctx.remember(r) for r in rows[1:]) if other not in linked]
461
+ return selected, shadows
462
+
463
+
464
+ def _baseline_closure(ctx: _Ctx, start: str) -> tuple[list[str], list[dict[str, str]]]:
465
+ order: list[str] = []
466
+ missing: list[dict[str, str]] = []
467
+ seen = {start}
468
+
469
+ def visit(address: str, depth: int) -> None:
470
+ if depth > 64:
471
+ return
472
+ for link in ctx.outbound(address):
473
+ if link["kind"] != "extends":
474
+ continue
475
+ if not link["resolved"]:
476
+ missing.append({"source": address, "ref": link["raw"]})
477
+ continue
478
+ base = format_address(link["dst_mount"], link["dst_path"])
479
+ if base in seen:
480
+ continue
481
+ seen.add(base)
482
+ visit(base, depth + 1)
483
+ order.append(base)
484
+
485
+ visit(start, 0)
486
+ return order, missing
487
+
488
+
489
+ def read(
490
+ ws: "Workspace",
491
+ address: str,
492
+ follow_supersedes: FollowSpec = True,
493
+ max_backlinks: int = 10,
494
+ backlinks_offset: int = 0,
495
+ max_extensions: int = 5,
496
+ extensions_offset: int = 0,
497
+ ) -> dict[str, Any]:
498
+ _nonneg(
499
+ max_backlinks=max_backlinks,
500
+ backlinks_offset=backlinks_offset,
501
+ max_extensions=max_extensions,
502
+ extensions_offset=extensions_offset,
503
+ )
504
+ with ws._reading() as table:
505
+ ctx = _Ctx(ws, table, follow_supersedes)
506
+ return resolve(ctx, address, max_backlinks, backlinks_offset, max_extensions, extensions_offset)
507
+
508
+
509
+ def resolve(
510
+ ctx: _Ctx,
511
+ address: str,
512
+ max_backlinks: int = 10,
513
+ backlinks_offset: int = 0,
514
+ max_extensions: int = 5,
515
+ extensions_offset: int = 0,
516
+ bump_telemetry: bool = True,
517
+ ) -> dict[str, Any]:
518
+ """The body of ``read()`` for a caller that already holds the workspace lock."""
519
+ idx = ctx.idx
520
+ selected, shadows = _select(ctx, address)
521
+
522
+ # Step 2: transitive supersession forwarding (filtered by trust policy).
523
+ fwd = ctx.lv.forward(selected)
524
+ node = fwd.node
525
+ result = ctx.card_payload(node)
526
+ result["requested"] = address
527
+ result.update(ctx.status(node))
528
+ if fwd.redirected:
529
+ result["redirected_from"] = selected
530
+ result["lineage_path"] = fwd.path
531
+ bypassed = ctx.bypassed_replacements(selected)
532
+ if bypassed:
533
+ result["bypassed_replacements"] = bypassed
534
+ if shadows:
535
+ result["shadows"] = shadows
536
+
537
+ # Extensions of superseded nodes are never silently rebased onto the replacement.
538
+ if fwd.redirected:
539
+ unrebased = []
540
+ for old in sorted(fwd.superseded):
541
+ for r in idx.inbound("extends", *_split(old)):
542
+ ext = format_address(r["src_mount"], r["src_path"])
543
+ if ext == node or node in ctx.lineage_targets(ext, "extends"):
544
+ continue
545
+ unrebased.append({"address": ext, "extends": old})
546
+ if unrebased:
547
+ result["unrebased_extensions"] = unrebased
548
+
549
+ # Step 3: extension composition.
550
+ baselines, missing = _baseline_closure(ctx, node)
551
+ if baselines:
552
+ composed = []
553
+ replaced: list[str] = []
554
+ for base in baselines:
555
+ payload = ctx.card_payload(base)
556
+ direct = ctx.lv.all_superseders(base)
557
+ if direct:
558
+ payload["superseded_by"] = direct
559
+ replaced += [r for r in direct if r not in replaced]
560
+ composed.append(payload)
561
+ result["baselines"] = composed
562
+ if replaced:
563
+ result["baseline_superseded_by"] = replaced
564
+ if missing:
565
+ result["missing_baselines"] = missing
566
+
567
+ ext_rows = idx.inbound("extends", *_split(node), recency=True)
568
+ if ext_rows:
569
+ page = ext_rows[extensions_offset : extensions_offset + max_extensions]
570
+ result["extensions"] = [ctx.card_payload(format_address(r["src_mount"], r["src_path"])) for r in page]
571
+ result["total_extensions"] = len(ext_rows)
572
+ result["extensions_next_offset"] = _page(len(ext_rows), extensions_offset, max_extensions)
573
+
574
+ # Outbound wikilinks + paginated backlinks.
575
+ links = [
576
+ {"ref": l["raw"], "address": format_address(l["dst_mount"], l["dst_path"]) if l["resolved"] else None}
577
+ for l in ctx.outbound(node)
578
+ if l["kind"] == "wikilink"
579
+ ]
580
+ if links:
581
+ result["links"] = links
582
+ back_rows = idx.inbound("wikilink", *_split(node))
583
+ result["backlinks"] = [
584
+ {"address": a, "title": (ctx.row(a)["title"] if ctx.row(a) is not None else None)}
585
+ for a in (format_address(r["src_mount"], r["src_path"]) for r in back_rows[backlinks_offset : backlinks_offset + max_backlinks])
586
+ ]
587
+ result["total_backlinks"] = len(back_rows)
588
+ result["backlinks_next_offset"] = _page(len(back_rows), backlinks_offset, max_backlinks)
589
+
590
+ telemetry = idx.bump_read(result["mount"], result["path"]) if bump_telemetry else None
591
+ if telemetry is not None:
592
+ result["read_count"], result["last_read_at"] = telemetry
593
+ return result
@@ -0,0 +1 @@
1
+ """Filesystem persistence, mount configuration, locking and durable history."""
@@ -0,0 +1,14 @@
1
+ """Read card text and raw-byte revisions from disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model.card import sha256_bytes
6
+
7
+
8
+ def read_card_file(file) -> tuple[str | None, str | None]:
9
+ """Return ``(text, sha256_of_raw_bytes)`` or ``(None, None)`` when the file is absent."""
10
+ try:
11
+ data = file.read_bytes()
12
+ except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
13
+ return None, None
14
+ return data.decode("utf-8", errors="replace"), sha256_bytes(data)