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.
- mindslice/__init__.py +58 -0
- mindslice/__main__.py +5 -0
- mindslice/baking/__init__.py +1 -0
- mindslice/baking/archive.py +66 -0
- mindslice/baking/bake.py +299 -0
- mindslice/baking/bake_batch.py +537 -0
- mindslice/baking/slice_transfer.py +265 -0
- mindslice/errors.py +104 -0
- mindslice/interfaces/__init__.py +1 -0
- mindslice/interfaces/bake_review.py +230 -0
- mindslice/interfaces/cli.py +392 -0
- mindslice/interfaces/cli_addressing.py +52 -0
- mindslice/interfaces/doctor.py +184 -0
- mindslice/interfaces/mcp_server.py +536 -0
- mindslice/interfaces/render.py +346 -0
- mindslice/interfaces/staging_review.py +92 -0
- mindslice/model/__init__.py +1 -0
- mindslice/model/addressing.py +251 -0
- mindslice/model/card.py +641 -0
- mindslice/model/lineage.py +228 -0
- mindslice/model/text.py +19 -0
- mindslice/model/tomlfmt.py +129 -0
- mindslice/operations/__init__.py +1 -0
- mindslice/operations/audit.py +195 -0
- mindslice/operations/boot.py +183 -0
- mindslice/operations/impact.py +98 -0
- mindslice/operations/recovery.py +113 -0
- mindslice/operations/staging.py +641 -0
- mindslice/operations/status.py +137 -0
- mindslice/operations/validation.py +250 -0
- mindslice/operations/writer.py +481 -0
- mindslice/retrieval/__init__.py +1 -0
- mindslice/retrieval/index.py +634 -0
- mindslice/retrieval/lexical.py +215 -0
- mindslice/retrieval/query.py +593 -0
- mindslice/storage/__init__.py +1 -0
- mindslice/storage/card_io.py +14 -0
- mindslice/storage/config.py +192 -0
- mindslice/storage/fsutil.py +141 -0
- mindslice/storage/history.py +182 -0
- mindslice/storage/locking.py +125 -0
- mindslice/storage/snapshots.py +262 -0
- mindslice/workspace.py +385 -0
- mindslice-0.1.0.dist-info/METADATA +98 -0
- mindslice-0.1.0.dist-info/RECORD +50 -0
- mindslice-0.1.0.dist-info/WHEEL +5 -0
- mindslice-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
- 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)
|