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.
- rememberstack/__init__.py +9 -0
- rememberstack/adapters/__init__.py +42 -0
- rememberstack/adapters/codex_writer.py +221 -0
- rememberstack/adapters/markitdown_converter.py +42 -0
- rememberstack/adapters/openrouter.py +136 -0
- rememberstack/adapters/selfhost/__init__.py +54 -0
- rememberstack/adapters/selfhost/forget.py +66 -0
- rememberstack/adapters/selfhost/git.py +374 -0
- rememberstack/adapters/selfhost/lance.py +328 -0
- rememberstack/adapters/selfhost/minio.py +279 -0
- rememberstack/adapters/selfhost/mounts.py +249 -0
- rememberstack/adapters/selfhost/object_store.py +130 -0
- rememberstack/adapters/selfhost/projection.py +80 -0
- rememberstack/adapters/selfhost/queue.py +137 -0
- rememberstack/adapters/selfhost/telemetry.py +45 -0
- rememberstack/adapters/selfhost/watcher.py +70 -0
- rememberstack/adapters/testing/__init__.py +15 -0
- rememberstack/adapters/testing/cost_meter.py +13 -0
- rememberstack/adapters/testing/model_provider.py +83 -0
- rememberstack/adapters/testing/queue.py +43 -0
- rememberstack/adapters/testing/telemetry.py +22 -0
- rememberstack/client.py +19 -0
- rememberstack/core/__init__.py +127 -0
- rememberstack/core/blockizer.py +189 -0
- rememberstack/core/chunker.py +216 -0
- rememberstack/core/consumption_skill.py +275 -0
- rememberstack/core/conversion.py +76 -0
- rememberstack/core/core_manifest.py +598 -0
- rememberstack/core/extension_packs.py +124 -0
- rememberstack/core/forget.py +17 -0
- rememberstack/core/knowledge_authored.py +276 -0
- rememberstack/core/knowledge_compile.py +215 -0
- rememberstack/core/knowledge_fact_sheet.py +210 -0
- rememberstack/core/knowledge_hashing.py +68 -0
- rememberstack/core/knowledge_planner.py +64 -0
- rememberstack/core/knowledge_writer.py +175 -0
- rememberstack/core/ranking.py +200 -0
- rememberstack/core/recipe_linter.py +149 -0
- rememberstack/core/section_snap.py +209 -0
- rememberstack/core/storage_routing.py +27 -0
- rememberstack/eval/__init__.py +53 -0
- rememberstack/eval/consumption.py +141 -0
- rememberstack/eval/contradiction.py +184 -0
- rememberstack/eval/harness.py +136 -0
- rememberstack/eval/lifecycle.py +400 -0
- rememberstack/eval/operational_scale.py +49 -0
- rememberstack/eval/resolution.py +255 -0
- rememberstack/eval/retrieval_spikes.py +50 -0
- rememberstack/eval/skeleton.py +231 -0
- rememberstack/llm/__init__.py +1 -0
- rememberstack/model/__init__.py +589 -0
- rememberstack/model/adjudication.py +100 -0
- rememberstack/model/auth.py +27 -0
- rememberstack/model/blocks.py +30 -0
- rememberstack/model/chunks.py +190 -0
- rememberstack/model/claims.py +162 -0
- rememberstack/model/client.py +98 -0
- rememberstack/model/clustering.py +54 -0
- rememberstack/model/component_version.py +124 -0
- rememberstack/model/consumption.py +88 -0
- rememberstack/model/conversion.py +31 -0
- rememberstack/model/deployment.py +53 -0
- rememberstack/model/documents.py +168 -0
- rememberstack/model/envelope.py +513 -0
- rememberstack/model/evaluation.py +72 -0
- rememberstack/model/forget.py +143 -0
- rememberstack/model/git.py +13 -0
- rememberstack/model/knowledge.py +840 -0
- rememberstack/model/knowledge_authored.py +325 -0
- rememberstack/model/knowledge_planner.py +431 -0
- rememberstack/model/lifecycle.py +42 -0
- rememberstack/model/model_provider.py +78 -0
- rememberstack/model/mounts.py +24 -0
- rememberstack/model/object_store.py +21 -0
- rememberstack/model/operational_scale.py +59 -0
- rememberstack/model/operations.py +153 -0
- rememberstack/model/processing.py +228 -0
- rememberstack/model/queue.py +73 -0
- rememberstack/model/recipes.py +83 -0
- rememberstack/model/relations.py +79 -0
- rememberstack/model/resolution.py +83 -0
- rememberstack/model/retrieval_spikes.py +62 -0
- rememberstack/model/sections.py +120 -0
- rememberstack/model/telemetry.py +30 -0
- rememberstack/ports/__init__.py +29 -0
- rememberstack/ports/auth.py +16 -0
- rememberstack/ports/connector.py +23 -0
- rememberstack/ports/cost_meter.py +17 -0
- rememberstack/ports/forget.py +20 -0
- rememberstack/ports/git.py +20 -0
- rememberstack/ports/model_provider.py +28 -0
- rememberstack/ports/mounts.py +16 -0
- rememberstack/ports/object_store.py +27 -0
- rememberstack/ports/p1_index.py +92 -0
- rememberstack/ports/purge.py +93 -0
- rememberstack/ports/queue.py +23 -0
- rememberstack/ports/telemetry.py +21 -0
- rememberstack/profiles/__init__.py +22 -0
- rememberstack/profiles/selfhost.py +324 -0
- rememberstack/profiles/selfhost_forget.py +158 -0
- rememberstack/profiles/selfhost_operations.py +95 -0
- rememberstack/py.typed +1 -0
- rememberstack/spine/__init__.py +93 -0
- rememberstack/spine/admission.py +26 -0
- rememberstack/spine/backfill.py +168 -0
- rememberstack/spine/catalog_contract.py +742 -0
- rememberstack/spine/chunk_catalog.py +237 -0
- rememberstack/spine/claim_catalog.py +298 -0
- rememberstack/spine/clustering.py +740 -0
- rememberstack/spine/component_versions.py +208 -0
- rememberstack/spine/consumption.py +81 -0
- rememberstack/spine/deployment_bootstrap.py +445 -0
- rememberstack/spine/document_catalog.py +621 -0
- rememberstack/spine/entity_registry.py +205 -0
- rememberstack/spine/extension_packs.py +220 -0
- rememberstack/spine/fact_catalog.py +571 -0
- rememberstack/spine/forget.py +1753 -0
- rememberstack/spine/knowledge.py +5467 -0
- rememberstack/spine/lifecycle.py +1071 -0
- rememberstack/spine/migrations/__init__.py +1 -0
- rememberstack/spine/migrations/_helpers.py +153 -0
- rememberstack/spine/migrations/env.py +58 -0
- rememberstack/spine/migrations/script.py.mako +27 -0
- rememberstack/spine/migrations/versions/__init__.py +1 -0
- rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
- rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
- rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
- rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
- rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
- rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
- rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
- rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
- rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
- rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
- rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
- rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
- rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
- rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
- rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
- rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
- rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
- rememberstack/spine/observation_adjudication.py +778 -0
- rememberstack/spine/operations.py +298 -0
- rememberstack/spine/projection.py +662 -0
- rememberstack/spine/recipes.py +276 -0
- rememberstack/spine/resolver.py +763 -0
- rememberstack/spine/review.py +650 -0
- rememberstack/spine/settings.py +22 -0
- rememberstack/spine/supersession.py +510 -0
- rememberstack/spine/sync.py +128 -0
- rememberstack/spine/work_ledger.py +816 -0
- rememberstack/surfaces/__init__.py +110 -0
- rememberstack/surfaces/cli.py +447 -0
- rememberstack/surfaces/consumption_skill.py +87 -0
- rememberstack/surfaces/graph_queries.py +698 -0
- rememberstack/surfaces/http_api.py +377 -0
- rememberstack/surfaces/mcp.py +67 -0
- rememberstack/surfaces/query_engine.py +1591 -0
- rememberstack/surfaces/recipe_executor.py +185 -0
- rememberstack/surfaces/recipe_surface.py +219 -0
- rememberstack/surfaces/remote_mcp.py +133 -0
- rememberstack/surfaces/sdk.py +324 -0
- rememberstack/workers/__init__.py +155 -0
- rememberstack/workers/base.py +312 -0
- rememberstack/workers/e0.py +577 -0
- rememberstack/workers/e1.py +425 -0
- rememberstack/workers/e2.py +525 -0
- rememberstack/workers/e3.py +434 -0
- rememberstack/workers/forget.py +299 -0
- rememberstack/workers/knowledge_authored.py +146 -0
- rememberstack/workers/knowledge_driver.py +735 -0
- rememberstack/workers/knowledge_fact_sheet.py +123 -0
- rememberstack/workers/knowledge_planner.py +325 -0
- rememberstack/workers/knowledge_writer.py +393 -0
- rememberstack/workers/operations.py +42 -0
- rememberstack/workers/p1.py +234 -0
- rememberstack/workers/p2.py +513 -0
- rememberstack/workers/p2_analytics.py +276 -0
- rememberstack/workers/p3.py +673 -0
- rememberstack/workers/reconcile.py +485 -0
- rememberstack/workers/sync.py +168 -0
- rememberstack-0.1.0.dist-info/METADATA +213 -0
- rememberstack-0.1.0.dist-info/RECORD +186 -0
- rememberstack-0.1.0.dist-info/WHEEL +4 -0
- rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
- 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"
|