rememberstack 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 (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,673 @@
1
+ """The P3 corpus filesystem builder (e0 §6, D40/D49): the navigable tree.
2
+
3
+ A real directory tree, rebuilt whole and published as an immutable snapshot
4
+ with a pointer swap — the same rebuild-first discipline as P2 (D7). It holds
5
+ no truth: every file is generated from Postgres plus the artifacts, and the
6
+ tree is discardable.
7
+
8
+ The tree exists to make navigation cheaper than search. An agent reads ONE
9
+ `_index.md` and learns what every file in that directory is about (each
10
+ member row carries the document's PageIndex root summary, already stored by
11
+ the structure stage), so navigation cost is O(index files read), not
12
+ O(documents opened). Every level also carries `llms.txt` — orientation
13
+ before contents.
14
+
15
+ **The two-tier path contract (F6) is the load-bearing rule.**
16
+
17
+ - *Tier 1 — stable, ID-addressed leaves that never move across rebuilds*:
18
+ `entities/<type>/<entity_id>/` and `documents/<doc_id>/`. Lineage-anchored
19
+ (D55), so a living document's canonical path survives its versions. These
20
+ are the durable targets agents and K pages may store.
21
+ - *Tier 2 — view paths* (`by-source/…`, `by-topic/…`), freely reorganizable
22
+ as the corpus grows; every view stub carries its canonical Tier-1 path in
23
+ frontmatter, so a moved stub is never a lost document.
24
+
25
+ Fully deterministic, zero LLM: a directory-level synthesis is a K page's
26
+ job (a second uncited understanding layer would drift), so `_index.md`
27
+ LINKS K and never competes with it.
28
+ """
29
+
30
+ from collections.abc import Iterable
31
+ from datetime import datetime
32
+ from datetime import UTC
33
+ import hashlib
34
+ import json
35
+ from pathlib import PurePosixPath
36
+ from typing import Final
37
+ from uuid import UUID
38
+
39
+ from pydantic import Field
40
+ from pydantic_settings import BaseSettings
41
+ from pydantic_settings import SettingsConfigDict
42
+
43
+ from rememberstack.model import ObjectKey
44
+ from rememberstack.ports.object_store import ObjectStorePort
45
+ from rememberstack.spine.projection import ProjectionCatalog
46
+
47
+ P3_BUILDER_VERSION: Final = "p3-corpusfs-2026.07"
48
+ """The builder's component version (D12)."""
49
+
50
+ INDEX_FILE: Final = "_index.md"
51
+ MANIFEST_FILE: Final = "llms.txt"
52
+
53
+ _MAX_SLUG_CHARS: Final = 60
54
+ """Cap on any generated path component — filesystem limits are real."""
55
+
56
+ _MAX_TOPIC_DEPTH: Final = 6
57
+ """How deep a placement hint may nest: hints are inputs, not commitments."""
58
+
59
+ _MAX_SHARD_DEPTH: Final = 4
60
+ """How far prefix sharding deepens before accepting a wide leaf."""
61
+
62
+
63
+ class CorpusFsSettings(BaseSettings):
64
+ """The P3 builder's knobs (starting points to measure, D22)."""
65
+
66
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_P3_")
67
+
68
+ snapshot_prefix: str = Field(default="corpusfs/snapshots")
69
+ facets: tuple[str, ...] = ("by-source", "by-time", "by-topic")
70
+ """The declared facet skeleton (e0 §6 rule 1: the top level is
71
+ CONFIGURED, never emergent — facets are stable, their interiors
72
+ reorganize). Every declared facet gets orientation even when empty."""
73
+ shard_threshold: int = Field(default=150, ge=2)
74
+ """Above this many entries a directory shards deterministically — an
75
+ unbounded directory is unbrowsable for an agent and slow to list."""
76
+
77
+
78
+ class CorpusFsBuilder:
79
+ """Build, validate, publish one corpus-filesystem snapshot."""
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ catalog: ProjectionCatalog,
85
+ snapshot_store: ObjectStorePort,
86
+ settings: CorpusFsSettings | None = None,
87
+ ) -> None:
88
+ """Bind the builder to the spine and the corpusfs bucket."""
89
+ self._catalog = catalog
90
+ self._snapshot_store = snapshot_store
91
+ self._settings = settings or CorpusFsSettings()
92
+
93
+ def build(
94
+ self, *, deployment_id: UUID, version: str | None = None
95
+ ) -> dict[str, object]:
96
+ """Rebuild the whole tree, publish it, swap the pointer."""
97
+ version = version or datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S%f")
98
+ prefix = f"{self._settings.snapshot_prefix}/{deployment_id}/{version}"
99
+ snapshot_id = self._catalog.open_snapshot(
100
+ deployment_id=deployment_id,
101
+ plane="P3_corpusfs",
102
+ version=version,
103
+ store_prefix=prefix,
104
+ )
105
+ try:
106
+ files = self._render(deployment_id=deployment_id)
107
+ for path, content in sorted(files.items()):
108
+ self._snapshot_store.write_bytes(
109
+ key=ObjectKey(f"{prefix}/{path}"), content=content.encode("utf-8")
110
+ )
111
+ manifest = {
112
+ "version": version,
113
+ "files": {
114
+ path: hashlib.sha256(content.encode("utf-8")).hexdigest()
115
+ for path, content in files.items()
116
+ },
117
+ }
118
+ self._snapshot_store.write_bytes(
119
+ key=ObjectKey(f"{prefix}/MANIFEST.json"),
120
+ content=json.dumps(manifest).encode("utf-8"),
121
+ )
122
+ except Exception as error:
123
+ self._catalog.mark_failed(
124
+ snapshot_id=snapshot_id,
125
+ validation={"gate": "exception", "error": str(error)[:500]},
126
+ )
127
+ raise
128
+ published = self._catalog.publish(
129
+ deployment_id=deployment_id,
130
+ snapshot_id=snapshot_id,
131
+ plane="P3_corpusfs",
132
+ row_counts={"files": len(files)},
133
+ validation={"gate": "passed", "builder": P3_BUILDER_VERSION},
134
+ built_from_watermark=None,
135
+ )
136
+ return {
137
+ "snapshot_id": snapshot_id,
138
+ "version": version,
139
+ "files": len(files),
140
+ "published": published,
141
+ }
142
+
143
+ def _render(self, *, deployment_id: UUID) -> dict[str, str]:
144
+ """Render every file of the tree, keyed by its snapshot-relative path."""
145
+ with self._catalog.corpus_export(deployment_id=deployment_id) as export:
146
+ documents = export.documents()
147
+ entities = export.entities()
148
+ links = export.entity_document_links()
149
+ by_entity: dict[UUID, list[UUID]] = {}
150
+ for link in links:
151
+ by_entity.setdefault(UUID(str(link["entity_id"])), []).append(
152
+ UUID(str(link["doc_id"]))
153
+ )
154
+ documents_by_id = {UUID(str(doc["doc_id"])): doc for doc in documents}
155
+
156
+ files: dict[str, str] = {}
157
+ # ── Tier 1: canonical, ID-addressed leaves (never move) ──────────
158
+ for document in documents:
159
+ path = _document_path(doc_id=UUID(str(document["doc_id"])))
160
+ files[f"{path}/{INDEX_FILE}"] = _document_stub(
161
+ document=document, canonical_path=path
162
+ )
163
+ for entity in entities:
164
+ entity_id = UUID(str(entity["entity_id"]))
165
+ path = _entity_path(entity_id=entity_id, entity_type=str(entity["type"]))
166
+ files[f"{path}/{INDEX_FILE}"] = _entity_index(
167
+ entity=entity,
168
+ documents=[
169
+ documents_by_id[doc_id]
170
+ for doc_id in by_entity.get(entity_id, [])
171
+ if doc_id in documents_by_id
172
+ ],
173
+ )
174
+ # ── Tier 2: view subtrees (reorganizable; stubs carry Tier 1) ────
175
+ views: dict[str, list[dict[str, object]]] = {}
176
+ for document in documents:
177
+ for view_path in _view_paths(document=document):
178
+ views.setdefault(view_path, []).append(document)
179
+ directories: dict[str, list[dict[str, object]]] = {}
180
+ for view_path, members in views.items():
181
+ for directory, shard_members in _shard_tree(
182
+ directory=view_path,
183
+ members=members,
184
+ threshold=self._settings.shard_threshold,
185
+ ).items():
186
+ directories[directory] = shard_members
187
+ for directory, members in directories.items():
188
+ for document in members:
189
+ files[f"{directory}/{_stub_name(document=document)}"] = _document_stub(
190
+ document=document,
191
+ canonical_path=_document_path(doc_id=UUID(str(document["doc_id"]))),
192
+ view_path=directory,
193
+ )
194
+ # ── every level gets orientation, including intermediates ────────
195
+ files.update(
196
+ _level_indexes(
197
+ documents=documents,
198
+ entities=entities,
199
+ directories=directories,
200
+ facets=self._settings.facets,
201
+ )
202
+ )
203
+ return files
204
+
205
+
206
+ def _document_path(*, doc_id: UUID) -> str:
207
+ """The canonical Tier-1 path: lineage-anchored, stable across versions."""
208
+ return f"documents/{doc_id}"
209
+
210
+
211
+ def _entity_path(*, entity_id: UUID, entity_type: str) -> str:
212
+ """The canonical Tier-1 path for one entity."""
213
+ return f"entities/{_slug(entity_type)}/{entity_id}"
214
+
215
+
216
+ def _view_paths(*, document: dict[str, object]) -> tuple[str, ...]:
217
+ """The Tier-2 views one document appears in — one stub per view.
218
+
219
+ Views come from the configured facet skeleton (the top level is
220
+ configured, never emergent) plus the document's placement hint. A
221
+ document with no hint still lands in the source and time views, so the
222
+ tree is never partially navigable.
223
+ """
224
+ paths = [f"by-source/{_slug(str(document['source_kind']))}"]
225
+ stamp = document.get("source_modified_at") or document.get("published_at")
226
+ if isinstance(stamp, datetime):
227
+ paths.append(f"by-time/{stamp.year:04d}/{stamp.month:02d}")
228
+ placement = document.get("placement_path")
229
+ if isinstance(placement, str) and placement.strip("/"):
230
+ parts = [_slug(part) for part in placement.strip("/").split("/")]
231
+ paths.append("by-topic/" + "/".join(parts[:_MAX_TOPIC_DEPTH]))
232
+
233
+ return tuple(paths)
234
+
235
+
236
+ def _shard_tree(
237
+ *, directory: str, members: list[dict[str, object]], threshold: int
238
+ ) -> dict[str, list[dict[str, object]]]:
239
+ """Split an oversized directory until every leaf fits the threshold.
240
+
241
+ Bounded fan-out means BOUNDED: a single-character split leaves a hot
242
+ initial ("Report …" × 500k) as unbrowsable as before (Codex review), so
243
+ the prefix deepens until each bucket fits — or until the names stop
244
+ distinguishing, which stops the recursion rather than looping.
245
+ Deterministic by name, so a rebuild puts a document in the same shard.
246
+ """
247
+ ordered = sorted(members, key=_stub_name_of)
248
+ return _shard_level(
249
+ directory=directory, members=ordered, threshold=threshold, depth=1
250
+ )
251
+
252
+
253
+ def _shard_level(
254
+ *, directory: str, members: list[dict[str, object]], threshold: int, depth: int
255
+ ) -> dict[str, list[dict[str, object]]]:
256
+ """One level of the shard recursion."""
257
+ if len(members) <= threshold or depth > _MAX_SHARD_DEPTH:
258
+ return {directory: members}
259
+ buckets: dict[str, list[dict[str, object]]] = {}
260
+ for document in members:
261
+ prefix = _stub_name_of(document)[:depth].lower()
262
+ key = prefix if prefix.isalnum() else "other"
263
+ buckets.setdefault(key, []).append(document)
264
+ if len(buckets) == 1: # the prefix does not distinguish: stop here
265
+ return {directory: members}
266
+ result: dict[str, list[dict[str, object]]] = {}
267
+ for bucket, bucket_members in buckets.items():
268
+ result.update(
269
+ _shard_level(
270
+ directory=f"{directory}/{bucket}",
271
+ members=bucket_members,
272
+ threshold=threshold,
273
+ depth=depth + 1,
274
+ )
275
+ )
276
+ return result
277
+
278
+
279
+ def _stub_name_of(document: dict[str, object]) -> str:
280
+ """Sort key / shard key for one member."""
281
+ return _stub_name(document=document)
282
+
283
+
284
+ def _stub_name(*, document: dict[str, object]) -> str:
285
+ """The view stub's filename: readable, deterministic, collision-FREE.
286
+
287
+ The FULL document id rides the name (Codex review: a truncated id
288
+ collides at corpus scale, silently overwriting one stub while the
289
+ member table still lists two documents).
290
+ """
291
+ return f"{_slug(str(document.get('title') or 'untitled'))}-{document['doc_id']}.md"
292
+
293
+
294
+ def _document_stub(
295
+ *, document: dict[str, object], canonical_path: str, view_path: str | None = None
296
+ ) -> str:
297
+ """One generated stub: orientation + canonical path + artifact pointer.
298
+
299
+ `grep -r` over stubs is content-ish lookup with zero API calls, so the
300
+ title, summary, and pointers are IN the file — and every view stub
301
+ names its Tier-1 canonical path, so a reorganized view never loses the
302
+ document. The stub also carries the explicit `raw_uri` (D51): raw is
303
+ off the browse path, never unreachable — following this pointer is how
304
+ a multimodal harness or a re-OCR session opens the original, and that
305
+ read is audited.
306
+ """
307
+ summary = _one_line(str(document.get("root_summary") or ""))
308
+ frontmatter: dict[str, str] = {
309
+ "doc_id": str(document["doc_id"]),
310
+ "canonical_path": canonical_path,
311
+ "version_id": str(document.get("version_id") or ""),
312
+ "content_hash": str(document.get("content_hash") or ""),
313
+ "artifact_uri": str(document.get("markdown_uri") or ""),
314
+ "raw_uri": str(document.get("raw_uri") or ""),
315
+ "mime": str(document.get("mime") or ""),
316
+ "source_kind": str(document.get("source_kind") or ""),
317
+ "source_ref": str(document.get("source_ref") or ""),
318
+ }
319
+ if view_path is not None:
320
+ frontmatter["view_path"] = view_path
321
+ lines = ["---"]
322
+ # values are JSON-escaped: a source ref carrying a newline must never
323
+ # inject or override a frontmatter field such as canonical_path
324
+ lines.extend(f"{key}: {json.dumps(value)}" for key, value in frontmatter.items())
325
+ lines.extend(
326
+ [
327
+ "---",
328
+ "",
329
+ f"# {_one_line(str(document.get('title') or 'Untitled document'))}",
330
+ "",
331
+ ]
332
+ )
333
+ if summary:
334
+ lines.extend([summary, ""])
335
+ lines.append(f"- Canonical path: `{canonical_path}/`")
336
+ lines.append(f"- Full text: `{document.get('markdown_uri') or '(not converted)'}`")
337
+ if document.get("raw_uri"):
338
+ lines.append(
339
+ f"- Original (off the browse path; audited): `{document['raw_uri']}`"
340
+ )
341
+ lines.append("")
342
+ return "\n".join(lines)
343
+
344
+
345
+ def _entity_index(
346
+ *, entity: dict[str, object], documents: list[dict[str, object]]
347
+ ) -> str:
348
+ """An entity's Tier-1 page: profile plus the documents evidencing it."""
349
+ entity_id = UUID(str(entity["entity_id"]))
350
+ canonical = _entity_path(entity_id=entity_id, entity_type=str(entity["type"]))
351
+ lines = [
352
+ "---",
353
+ f"entity_id: {json.dumps(str(entity_id))}",
354
+ f"type: {json.dumps(str(entity['type']))}",
355
+ f"canonical_path: {json.dumps(canonical)}",
356
+ "---",
357
+ "",
358
+ f"# {_one_line(str(entity['canonical_name']))}",
359
+ "",
360
+ f"{entity['type']} · {entity.get('mention_count') or 0} mention(s)"
361
+ f" · graph degree {entity.get('graph_degree') or 0}",
362
+ "",
363
+ ]
364
+ profile = _one_line(str(entity.get("profile_summary") or ""))
365
+ if profile:
366
+ lines.extend([profile, ""])
367
+ lines.extend(["## Documents mentioning this entity", ""])
368
+ lines.extend(_member_table(members=documents, base=canonical))
369
+ lines.append("")
370
+ return "\n".join(lines)
371
+
372
+
373
+ def _directory_index(
374
+ *, directory: str, members: list[dict[str, object]], children: list[str]
375
+ ) -> str:
376
+ """The member table: every file's one-line meaning, from Postgres.
377
+
378
+ This is the load-bearing property of the tree — one read tells an agent
379
+ what everything here is about. Deterministic by contract: no LLM call
380
+ lives inside the projection builder (a directory-level synthesis is a K
381
+ page's job, e0 §6).
382
+ """
383
+ sources = sorted({str(member["source_kind"]) for member in members})
384
+ dated = sorted(
385
+ stamp
386
+ for stamp in (
387
+ member.get("source_modified_at") or member.get("published_at")
388
+ for member in members
389
+ )
390
+ if isinstance(stamp, datetime)
391
+ )
392
+ span = f" · {dated[0].date()}–{dated[-1].date()}" if dated else ""
393
+ headline = f"{len(members)} document(s) directly here"
394
+ if children:
395
+ headline += f", {len(children)} subdirectory/subdirectories"
396
+ if sources:
397
+ headline += f" · sources: {', '.join(sources)}"
398
+ lines = [f"# {directory}", "", headline + span, ""]
399
+ if children:
400
+ lines.extend(["## Subdirectories", ""])
401
+ lines.extend(
402
+ f"- [`{PurePosixPath(child).name}/`]"
403
+ f"({PurePosixPath(child).name}/{INDEX_FILE})"
404
+ for child in sorted(children)
405
+ )
406
+ lines.append("")
407
+ lines.extend(["## Contents", ""])
408
+ lines.extend(_member_table(members=members, base=directory))
409
+ parent = str(PurePosixPath(directory).parent)
410
+ parent_link = INDEX_FILE if parent in {".", ""} else f"{parent}/{INDEX_FILE}"
411
+ lines.extend(
412
+ [
413
+ "",
414
+ "## Navigation",
415
+ "",
416
+ f"- Parent: `{parent_link}`",
417
+ "- Canonical (never-moving) paths are in the table above and in"
418
+ " each stub's `canonical_path` frontmatter.",
419
+ "",
420
+ ]
421
+ )
422
+ return "\n".join(lines)
423
+
424
+
425
+ def _member_table(*, members: Iterable[dict[str, object]], base: str) -> list[str]:
426
+ """One row per child carrying its root summary AND its canonical link.
427
+
428
+ The canonical column is what makes the table navigable from anywhere:
429
+ an entity page lists documents that do not live in its directory, so a
430
+ bare filename would be a dead end (Codex review).
431
+ """
432
+ rows = [
433
+ "| File | What it is | Canonical | Source | Date |",
434
+ "|---|---|---|---|---|",
435
+ ]
436
+ for member in sorted(members, key=_stub_name_of):
437
+ summary = _one_line(str(member.get("root_summary") or ""))[:160]
438
+ stamp = member.get("source_modified_at") or member.get("published_at")
439
+ date = stamp.date().isoformat() if isinstance(stamp, datetime) else "—"
440
+ canonical = _document_path(doc_id=UUID(str(member["doc_id"])))
441
+ link = f"{_relative(base=base, target=canonical)}/{INDEX_FILE}"
442
+ rows.append(
443
+ f"| `{_stub_name(document=member)}` | {summary or '—'} |"
444
+ f" [`{canonical}/`]({link}) |"
445
+ f" {member.get('source_kind') or '—'} | {date} |"
446
+ )
447
+ if len(rows) == 2:
448
+ rows.append("| — | (empty) | — | — | — |")
449
+ return rows
450
+
451
+
452
+ def _directory_manifest(
453
+ *, directory: str, members: list[dict[str, object]], children: list[str]
454
+ ) -> str:
455
+ """`llms.txt`: orientation before contents (the navigation-manifest pattern)."""
456
+ lines = [
457
+ f"# {directory}",
458
+ "",
459
+ f"> {len(members)} document(s) here, {len(children)} subdirectory/"
460
+ f"subdirectories. Read {INDEX_FILE} for the member table — every"
461
+ " file's one-line meaning — before opening any file.",
462
+ "",
463
+ ]
464
+ if children:
465
+ lines.extend(["## Subdirectories", ""])
466
+ lines.extend(f"- {PurePosixPath(child).name}/" for child in sorted(children))
467
+ lines.append("")
468
+ if members:
469
+ lines.extend(["## Files", ""])
470
+ lines.extend(
471
+ f"- [{_stub_name(document=member)}]({_stub_name(document=member)}):"
472
+ f" {_one_line(str(member.get('root_summary') or ''))[:120] or 'no summary'}"
473
+ for member in sorted(members, key=_stub_name_of)
474
+ )
475
+ lines.append("")
476
+ return "\n".join(lines)
477
+
478
+
479
+ def _level_indexes(
480
+ *,
481
+ documents: tuple[dict[str, object], ...],
482
+ entities: tuple[dict[str, object], ...],
483
+ directories: dict[str, list[dict[str, object]]],
484
+ facets: tuple[str, ...],
485
+ ) -> dict[str, str]:
486
+ """Orientation for EVERY level: leaves, intermediates, facets, root.
487
+
488
+ e0 §6's contract is "each level carries a generated `_index.md` /
489
+ `llms.txt`" — an intermediate directory named as a parent but missing
490
+ its own index is a dead end in the navigation ladder (Codex review).
491
+ The CONFIGURED facet skeleton is emitted whether or not documents
492
+ landed in it, so the top level never depends on what happens to be
493
+ ingested (e0 §6 rule 1).
494
+ """
495
+ rendered: dict[str, str] = {}
496
+ members_by_directory: dict[str, list[dict[str, object]]] = dict(directories)
497
+ children_by_directory: dict[str, set[str]] = {}
498
+ for directory in list(directories):
499
+ current = PurePosixPath(directory)
500
+ while True:
501
+ parent = str(current.parent)
502
+ if parent in {".", ""}:
503
+ break
504
+ children_by_directory.setdefault(parent, set()).add(str(current))
505
+ members_by_directory.setdefault(parent, [])
506
+ current = current.parent
507
+ for facet in facets:
508
+ members_by_directory.setdefault(facet, [])
509
+ children_by_directory.setdefault(facet, set())
510
+ for directory, members in members_by_directory.items():
511
+ children = sorted(children_by_directory.get(directory, set()))
512
+ rendered[f"{directory}/{INDEX_FILE}"] = _directory_index(
513
+ directory=directory, members=members, children=children
514
+ )
515
+ rendered[f"{directory}/{MANIFEST_FILE}"] = _directory_manifest(
516
+ directory=directory, members=members, children=children
517
+ )
518
+ rendered[f"documents/{INDEX_FILE}"] = _tier_one_documents_index(documents=documents)
519
+ rendered[f"entities/{INDEX_FILE}"] = _tier_one_entities_index(entities=entities)
520
+ rendered[MANIFEST_FILE] = _root_manifest(
521
+ documents=documents, entities=entities, facets=facets
522
+ )
523
+ rendered[INDEX_FILE] = _root_index(
524
+ documents=documents, entities=entities, facets=facets
525
+ )
526
+ return rendered
527
+
528
+
529
+ def _tier_one_documents_index(*, documents: tuple[dict[str, object], ...]) -> str:
530
+ """`documents/_index.md`: the canonical leaves, one row each."""
531
+ lines = [
532
+ "# documents",
533
+ "",
534
+ "Canonical (Tier 1) document leaves — these paths never move across"
535
+ f" rebuilds. {len(documents)} lineage(s).",
536
+ "",
537
+ ]
538
+ lines.extend(_member_table(members=documents, base="documents"))
539
+ lines.append("")
540
+ return "\n".join(lines)
541
+
542
+
543
+ def _tier_one_entities_index(*, entities: tuple[dict[str, object], ...]) -> str:
544
+ """`entities/_index.md`: the canonical entity leaves, one row each."""
545
+ lines = [
546
+ "# entities",
547
+ "",
548
+ "Canonical (Tier 1) entity leaves — these paths never move across"
549
+ f" rebuilds. {len(entities)} active entity/entities.",
550
+ "",
551
+ "| Entity | Type | Mentions | Canonical |",
552
+ "|---|---|---|---|",
553
+ ]
554
+ for entity in sorted(entities, key=lambda item: str(item["canonical_name"])):
555
+ canonical = _entity_path(
556
+ entity_id=UUID(str(entity["entity_id"])), entity_type=str(entity["type"])
557
+ )
558
+ link = f"{_relative(base='entities', target=canonical)}/{INDEX_FILE}"
559
+ lines.append(
560
+ f"| {_one_line(str(entity['canonical_name']))} | {entity['type']} |"
561
+ f" {entity.get('mention_count') or 0} | [`{canonical}/`]({link}) |"
562
+ )
563
+ if len(lines) == 6:
564
+ lines.append("| — | — | — | — |")
565
+ lines.append("")
566
+ return "\n".join(lines)
567
+
568
+
569
+ def _root_index(
570
+ *,
571
+ documents: tuple[dict[str, object], ...],
572
+ entities: tuple[dict[str, object], ...],
573
+ facets: tuple[str, ...],
574
+ ) -> str:
575
+ """The root `_index.md`: how to navigate, and the durable-path contract."""
576
+ lines = [
577
+ "# Corpus",
578
+ "",
579
+ f"{len(documents)} document(s), {len(entities)} entity/entities.",
580
+ "",
581
+ "## How to navigate",
582
+ "",
583
+ "1. `cat llms.txt` — facets and where things live.",
584
+ f"2. `cat <facet>/{INDEX_FILE}` — what kinds of things exist there.",
585
+ f"3. `cat <directory>/{INDEX_FILE}` — the member table: every file's"
586
+ " one-line meaning.",
587
+ "4. `cat <stub>.md` — orientation, canonical path, artifact pointer.",
588
+ "",
589
+ "## Facets",
590
+ "",
591
+ ]
592
+ lines.extend(
593
+ f"- [`{facet}/`]({facet}/{INDEX_FILE}) — view paths (reorganizable)"
594
+ for facet in facets
595
+ )
596
+ lines.extend(
597
+ [
598
+ f"- [`documents/`](documents/{INDEX_FILE}) — canonical, stable per lineage",
599
+ f"- [`entities/`](entities/{INDEX_FILE}) — canonical, stable per entity",
600
+ "",
601
+ "Durable paths live under `documents/` and `entities/` (Tier 1) and"
602
+ " never move; view subtrees reorganize as the corpus grows.",
603
+ "",
604
+ ]
605
+ )
606
+ return "\n".join(lines)
607
+
608
+
609
+ def _root_manifest(
610
+ *,
611
+ documents: tuple[dict[str, object], ...],
612
+ entities: tuple[dict[str, object], ...],
613
+ facets: tuple[str, ...],
614
+ ) -> str:
615
+ """The root orientation file an agent reads first."""
616
+ lines = [
617
+ "# Corpus filesystem",
618
+ "",
619
+ "> A generated, rebuildable view over the memory's documents and"
620
+ " entities. Nothing here is source of truth; every file names the"
621
+ " artifact it points at.",
622
+ "",
623
+ f"- {len(documents)} document lineage(s)",
624
+ f"- {len(entities)} active entity/entities",
625
+ "",
626
+ "## Facets",
627
+ "",
628
+ ]
629
+ lines.extend(f"- `{facet}/` (view paths — reorganizable)" for facet in facets)
630
+ lines.extend(
631
+ [
632
+ "- `documents/` (canonical, stable per lineage)",
633
+ "- `entities/` (canonical, stable per entity)",
634
+ "",
635
+ "## Contract",
636
+ "",
637
+ "Paths under `documents/` and `entities/` are stable across"
638
+ " rebuilds and safe to store. View paths may reorganize; every"
639
+ " view stub carries its canonical path in frontmatter.",
640
+ "",
641
+ "Originals live off this path: follow a stub's `raw_uri`"
642
+ " deliberately — those reads are audited.",
643
+ "",
644
+ ]
645
+ )
646
+ return "\n".join(lines)
647
+
648
+
649
+ def _relative(*, base: str, target: str) -> str:
650
+ """A relative link from one directory to another inside the tree."""
651
+ up = "/".join([".."] * len(PurePosixPath(base).parts))
652
+ return f"{up}/{target}" if up else target
653
+
654
+
655
+ def _one_line(value: str) -> str:
656
+ """Collapse whitespace so a title or summary can never break a table."""
657
+ return " ".join(value.split())
658
+
659
+
660
+ def _slug(value: str) -> str:
661
+ """A filesystem-safe, deterministic, LENGTH-CAPPED slug.
662
+
663
+ Capping matters: a 300-character title would otherwise produce a name
664
+ common filesystems reject, failing the whole publish for one verbose
665
+ (or hostile) document (Codex review).
666
+ """
667
+ cleaned = "".join(
668
+ character.lower() if character.isalnum() else "-" for character in value.strip()
669
+ )
670
+ while "--" in cleaned:
671
+ cleaned = cleaned.replace("--", "-")
672
+ capped = (cleaned.strip("-") or "untitled")[:_MAX_SLUG_CHARS]
673
+ return capped.strip("-") or "untitled"