data-olympus 0.3.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 (73) hide show
  1. data_olympus/__init__.py +14 -0
  2. data_olympus/_bin/_kb_detect_workspace.sh +57 -0
  3. data_olympus/_bin/_kb_enforce.py +619 -0
  4. data_olympus/_bin/_kb_fallback.py +361 -0
  5. data_olympus/_bin/kb +957 -0
  6. data_olympus/_bin/kb-enforce-hook +337 -0
  7. data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
  8. data_olympus/audit_log.py +275 -0
  9. data_olympus/audit_trailers.py +42 -0
  10. data_olympus/auth.py +139 -0
  11. data_olympus/cli/__init__.py +1 -0
  12. data_olympus/cli/import_cmd.py +115 -0
  13. data_olympus/cli/indexgen.py +60 -0
  14. data_olympus/cli/main.py +151 -0
  15. data_olympus/cli/report_cmd.py +181 -0
  16. data_olympus/config.py +261 -0
  17. data_olympus/cooccurrence.py +393 -0
  18. data_olympus/dedup.py +57 -0
  19. data_olympus/durable.py +51 -0
  20. data_olympus/embeddings.py +317 -0
  21. data_olympus/enforce_policy.py +297 -0
  22. data_olympus/format/__init__.py +16 -0
  23. data_olympus/format/document.py +39 -0
  24. data_olympus/format/frontmatter.py +35 -0
  25. data_olympus/format/lint.py +92 -0
  26. data_olympus/format/validate.py +71 -0
  27. data_olympus/git_ops.py +397 -0
  28. data_olympus/health.py +114 -0
  29. data_olympus/importer/__init__.py +13 -0
  30. data_olympus/importer/adr.py +286 -0
  31. data_olympus/importer/flat.py +170 -0
  32. data_olympus/importer/model.py +106 -0
  33. data_olympus/importer/okf.py +192 -0
  34. data_olympus/importer/run.py +416 -0
  35. data_olympus/importer/stamp.py +227 -0
  36. data_olympus/index.py +1745 -0
  37. data_olympus/markdown_parse.py +103 -0
  38. data_olympus/models.py +480 -0
  39. data_olympus/onboarding.py +131 -0
  40. data_olympus/onboarding_inflight.py +137 -0
  41. data_olympus/onboarding_playbook.py +99 -0
  42. data_olympus/pending.py +533 -0
  43. data_olympus/principals.py +168 -0
  44. data_olympus/prompts.py +35 -0
  45. data_olympus/push_queue.py +261 -0
  46. data_olympus/query_expansion.py +200 -0
  47. data_olympus/rate_limit.py +81 -0
  48. data_olympus/refresh.py +329 -0
  49. data_olympus/report.py +133 -0
  50. data_olympus/rest_api.py +845 -0
  51. data_olympus/safe_id.py +36 -0
  52. data_olympus/search_gate.py +64 -0
  53. data_olympus/search_shortcut.py +146 -0
  54. data_olympus/server.py +1115 -0
  55. data_olympus/session_metrics.py +303 -0
  56. data_olympus/setup_wizard.py +751 -0
  57. data_olympus/thin_pointer.py +20 -0
  58. data_olympus/tools_audit.py +41 -0
  59. data_olympus/tools_enforce.py +198 -0
  60. data_olympus/tools_onboarding.py +585 -0
  61. data_olympus/tools_read.py +230 -0
  62. data_olympus/tools_write.py +878 -0
  63. data_olympus/trigram.py +126 -0
  64. data_olympus/viewer/__init__.py +1 -0
  65. data_olympus/viewer/generator.py +375 -0
  66. data_olympus/worktrees.py +147 -0
  67. data_olympus/write_gate.py +382 -0
  68. data_olympus-0.3.0.dist-info/METADATA +97 -0
  69. data_olympus-0.3.0.dist-info/RECORD +73 -0
  70. data_olympus-0.3.0.dist-info/WHEEL +4 -0
  71. data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
  72. data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,317 @@
1
+ """Optional local-embedding hybrid ranking (issue #42).
2
+
3
+ Real paraphrase / synonymy handling via LOCAL embeddings, blended with BM25. No
4
+ external API is ever called at query time; a small ONNX MiniLM-class model runs
5
+ in-process. The feature is OFF by default and, when off, this module is the ONLY
6
+ place that would touch the embedding libraries, and it imports them LAZILY
7
+ (inside :func:`_load_text_embedding_cls`), so with the ``embeddings`` extra NOT
8
+ installed and ``KB_EMBEDDINGS_MODE`` unset the default product is byte-for-byte
9
+ unchanged and never imports ``fastembed`` / ``onnxruntime``.
10
+
11
+ Design (mirrors ``trigram.py`` / ``cooccurrence.py``):
12
+
13
+ - ``EMBEDDINGS_SCHEMA``: the ``doc_vectors`` table DDL, appended to the index
14
+ schema so it is created (and, via the tmp-DB build + ``os.replace``, swapped)
15
+ atomically with the primary FTS table. The table is created unconditionally
16
+ (an empty table costs nothing) but only POPULATED when the feature is enabled,
17
+ so a default build needs no embedding dependency.
18
+ - ``embeddings_enabled`` / ``embeddings_config``: env config. ``KB_EMBEDDINGS_MODE``
19
+ gates the whole feature (default off); ``KB_EMBEDDINGS_MODEL`` and
20
+ ``KB_EMBEDDINGS_WEIGHT`` tune the model and blend weight.
21
+ - ``build_embedder``: construct the local embedder, raising a LOUD, actionable
22
+ :class:`EmbeddingsUnavailableError` when the dep/model is missing rather than
23
+ silently falling back (which would hide misconfiguration).
24
+ - ``serialize_vector`` / ``deserialize_vector`` / ``cosine``: pure-Python vector
25
+ storage + similarity, so the query-time blend needs no numpy.
26
+ - ``make_hybrid_reranker``: blend NORMALISED bm25 with cosine over candidate
27
+ hits' stored vectors and re-sort. Composed as an INNER reranker under the
28
+ id/tag short-circuit in ``server.build_app`` so an exact id/tag still wins.
29
+
30
+ Ranking discipline: the hybrid reranker only RE-ORDERS the existing candidate
31
+ pool; it never drops a hit (a doc with no stored vector keeps its bm25
32
+ contribution and a neutral cosine of 0). Because it is composed as ``inner``
33
+ under the id/tag short-circuit, an exact-id or exact-tag query is unaffected.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import array
38
+ import math
39
+ import os
40
+ from dataclasses import dataclass
41
+ from typing import TYPE_CHECKING, Any
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Callable
45
+
46
+ from data_olympus.index import SearchHit
47
+
48
+
49
+ # --- config ------------------------------------------------------------------
50
+
51
+ # A small, local, ONNX MiniLM-class model. bge-small-en-v1.5 is 384-dim, emits
52
+ # L2-normalised vectors, and ships as a quantised ONNX file (~33 MB) that
53
+ # fastembed fetches once and caches; it needs no torch. A deployment can point
54
+ # ``KB_EMBEDDINGS_MODEL`` at any fastembed-supported model.
55
+ DEFAULT_MODEL_NAME = "BAAI/bge-small-en-v1.5"
56
+
57
+ # Blend weight in [0, 1]: the fraction of the final score contributed by cosine
58
+ # similarity; ``1 - weight`` is contributed by normalised bm25. 0.0 == pure
59
+ # lexical (bm25) ordering, 1.0 == pure semantic (cosine). The default leans on
60
+ # bm25 (the tuned lexical signal) while letting cosine rescue paraphrase misses.
61
+ DEFAULT_WEIGHT = 0.35
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class EmbeddingsConfig:
66
+ """Resolved embeddings configuration (model + blend weight)."""
67
+
68
+ model_name: str
69
+ weight: float
70
+
71
+
72
+ class EmbeddingsUnavailableError(RuntimeError):
73
+ """Raised when embeddings are ENABLED but the dep/model cannot be loaded.
74
+
75
+ Carries an actionable message so a misconfigured deployment fails loudly at
76
+ startup instead of silently shipping default (lexical-only) ranking.
77
+ """
78
+
79
+
80
+ def embeddings_enabled() -> bool:
81
+ """Whether local-embedding hybrid ranking is active. Default OFF.
82
+
83
+ ``KB_EMBEDDINGS_MODE=on`` (case-insensitive) enables it; any other value
84
+ (including unset) leaves it off so the zero-dependency lexical product is
85
+ unchanged and the embedding libraries are never imported.
86
+ """
87
+ return os.getenv("KB_EMBEDDINGS_MODE", "off").strip().lower() == "on"
88
+
89
+
90
+ def embeddings_config() -> EmbeddingsConfig:
91
+ """Resolve the embeddings config from env, applying defaults.
92
+
93
+ ``KB_EMBEDDINGS_WEIGHT`` must parse to a float in [0, 1]; a malformed or
94
+ out-of-range value raises ``ValueError`` rather than silently clamping, so a
95
+ misconfigured blend fails loudly (matching ``KB_STATUS_WEIGHTS`` semantics).
96
+ """
97
+ model_name = os.getenv("KB_EMBEDDINGS_MODEL", "").strip() or DEFAULT_MODEL_NAME
98
+ raw_weight = os.getenv("KB_EMBEDDINGS_WEIGHT", "").strip()
99
+ if not raw_weight:
100
+ weight = DEFAULT_WEIGHT
101
+ else:
102
+ try:
103
+ weight = float(raw_weight)
104
+ except ValueError as e:
105
+ raise ValueError(
106
+ f"KB_EMBEDDINGS_WEIGHT must be a float in [0, 1]; got {raw_weight!r}"
107
+ ) from e
108
+ if not 0.0 <= weight <= 1.0:
109
+ raise ValueError(
110
+ f"KB_EMBEDDINGS_WEIGHT must be in [0, 1]; got {weight}"
111
+ )
112
+ return EmbeddingsConfig(model_name=model_name, weight=weight)
113
+
114
+
115
+ # --- vector storage + similarity (pure Python, no numpy) ---------------------
116
+
117
+
118
+ def serialize_vector(vec: list[float]) -> bytes:
119
+ """Pack a float vector into a compact little-endian float32 blob.
120
+
121
+ float32 halves the stored size versus float64 at a precision loss well below
122
+ the ranking signal. ``array`` keeps this dependency-free; the blob round-trips
123
+ through :func:`deserialize_vector`.
124
+ """
125
+ return array.array("f", vec).tobytes()
126
+
127
+
128
+ def deserialize_vector(blob: bytes) -> list[float]:
129
+ """Unpack a float32 blob written by :func:`serialize_vector`."""
130
+ a = array.array("f")
131
+ a.frombytes(blob)
132
+ return list(a)
133
+
134
+
135
+ def cosine(a: list[float], b: list[float]) -> float:
136
+ """Cosine similarity of two equal-length vectors, in [-1, 1].
137
+
138
+ A zero-norm vector has no direction, so its similarity is defined as 0 rather
139
+ than dividing by zero. A length mismatch is a programming error and raises.
140
+ """
141
+ if len(a) != len(b):
142
+ raise ValueError(f"vector length mismatch: {len(a)} != {len(b)}")
143
+ dot = 0.0
144
+ na = 0.0
145
+ nb = 0.0
146
+ for x, y in zip(a, b, strict=True):
147
+ dot += x * y
148
+ na += x * x
149
+ nb += y * y
150
+ if na == 0.0 or nb == 0.0:
151
+ return 0.0
152
+ return dot / (math.sqrt(na) * math.sqrt(nb))
153
+
154
+
155
+ # --- embedder (lazy fastembed import) ----------------------------------------
156
+
157
+
158
+ def _load_text_embedding_cls() -> Any:
159
+ """Import and return ``fastembed.TextEmbedding``.
160
+
161
+ Isolated so it can be monkeypatched in tests and so the import stays LAZY:
162
+ it runs only from :func:`build_embedder`, which is only reached when the
163
+ feature is enabled. With the feature off this line is never executed and
164
+ ``fastembed`` is never imported.
165
+ """
166
+ from fastembed import TextEmbedding
167
+
168
+ return TextEmbedding
169
+
170
+
171
+ class Embedder:
172
+ """Thin wrapper over a local fastembed model.
173
+
174
+ Holds the loaded model and exposes ``embed_one`` (query) and ``embed_many``
175
+ (build) returning plain ``list[float]`` so the rest of the codebase stays
176
+ numpy-free. Construction is via :func:`build_embedder`.
177
+ """
178
+
179
+ def __init__(self, model: Any, *, model_name: str) -> None:
180
+ self._model = model
181
+ self.model_name = model_name
182
+
183
+ def embed_one(self, text: str) -> list[float]:
184
+ """Embed a single text into a plain float list."""
185
+ for vec in self._model.embed([text]):
186
+ return [float(x) for x in vec]
187
+ return []
188
+
189
+ def embed_many(self, texts: list[str]) -> list[list[float]]:
190
+ """Embed a batch, preserving input order."""
191
+ return [[float(x) for x in vec] for vec in self._model.embed(texts)]
192
+
193
+
194
+ def build_embedder(config: EmbeddingsConfig) -> Embedder:
195
+ """Load the local embedding model, or fail loudly.
196
+
197
+ Raises :class:`EmbeddingsUnavailableError` with an actionable message when
198
+ the ``embeddings`` extra is not installed or the model cannot be loaded
199
+ (e.g. it could not be fetched to the local cache). The caller (startup path)
200
+ surfaces this so a misconfigured deployment fails visibly rather than
201
+ silently reverting to lexical-only ranking.
202
+ """
203
+ try:
204
+ text_embedding_cls = _load_text_embedding_cls()
205
+ except ImportError as e:
206
+ raise EmbeddingsUnavailableError(
207
+ "KB_EMBEDDINGS_MODE is on but the embeddings dependency is not "
208
+ "installed. Install the extra: `uv sync --extra embeddings` (or "
209
+ "`pip install 'data-olympus[embeddings]'`), or set KB_EMBEDDINGS_MODE=off "
210
+ f"to run lexical-only. Underlying import error: {e}"
211
+ ) from e
212
+ try:
213
+ model = text_embedding_cls(model_name=config.model_name)
214
+ except Exception as e: # noqa: BLE001 - any load failure must fail loudly
215
+ raise EmbeddingsUnavailableError(
216
+ f"KB_EMBEDDINGS_MODE is on but the embedding model "
217
+ f"{config.model_name!r} could not be loaded (it is fetched once and "
218
+ f"cached; a first-run needs the model available). Set a cached "
219
+ f"KB_EMBEDDINGS_MODEL or KB_EMBEDDINGS_MODE=off. Underlying error: {e}"
220
+ ) from e
221
+ return Embedder(model, model_name=config.model_name)
222
+
223
+
224
+ # --- SQLite persistence (built inside Index.build, read at query time) -------
225
+
226
+ # One row per doc: the id (FK to docs.id) and its float32 vector blob. Created
227
+ # unconditionally in the schema (empty when the feature is off) so a default
228
+ # build needs no embedding dep; populated only when embeddings are enabled.
229
+ EMBEDDINGS_SCHEMA = """
230
+ CREATE TABLE IF NOT EXISTS doc_vectors (
231
+ id TEXT PRIMARY KEY,
232
+ vector BLOB NOT NULL
233
+ );
234
+ """
235
+
236
+
237
+ # --- hybrid reranker ---------------------------------------------------------
238
+
239
+
240
+ def _normalise_bm25(hits: list[SearchHit]) -> dict[str, float]:
241
+ """Map each hit id to a bm25 GOODNESS in [0, 1] (1 == best in this pool).
242
+
243
+ search() orders bm25 ASCENDING (lower is better), so we invert: the minimum
244
+ (best) score maps to 1.0 and the maximum (worst) to 0.0. When all scores are
245
+ equal (single hit, or a synthetic neutral pool) every hit gets 1.0, so the
246
+ blend is decided purely by cosine, and there is no divide-by-zero.
247
+ """
248
+ if not hits:
249
+ return {}
250
+ scores = [h.score for h in hits]
251
+ lo = min(scores)
252
+ hi = max(scores)
253
+ spread = hi - lo
254
+ if spread == 0.0:
255
+ return {h.id: 1.0 for h in hits}
256
+ # goodness = 1 when score == lo (best), 0 when score == hi (worst).
257
+ return {h.id: (hi - h.score) / spread for h in hits}
258
+
259
+
260
+ def make_hybrid_reranker(
261
+ *,
262
+ embed_query: Callable[[str], list[float] | None],
263
+ get_vector: Callable[[str], list[float] | None],
264
+ weight: float,
265
+ ) -> Callable[[str, list[SearchHit]], list[SearchHit]]:
266
+ """Build a reranker that blends normalised bm25 with query-doc cosine.
267
+
268
+ ``embed_query(query)`` returns the query vector (or ``None`` if it cannot be
269
+ embedded); ``get_vector(id)`` returns a stored doc vector (or ``None`` when
270
+ the doc has no vector). ``weight`` in [0, 1] is the cosine fraction of the
271
+ blended score (``1 - weight`` is bm25).
272
+
273
+ The blended score is::
274
+
275
+ blended = (1 - weight) * bm25_goodness + weight * cosine_component
276
+
277
+ where ``bm25_goodness`` is in [0, 1] (higher = better within this pool) and
278
+ ``cosine_component`` maps cosine [-1, 1] to [0, 1]. Hits are re-sorted by
279
+ blended score DESCENDING (higher = better) and the result is re-scored onto
280
+ the search()-native ascending-bm25 convention (negated blended score) so a
281
+ later stage that sorts ascending keeps the same order.
282
+
283
+ Discipline: no hit is dropped. A doc with no stored vector, or a query that
284
+ cannot be embedded, contributes a neutral cosine (the reranker degrades to
285
+ bm25 ordering for that case). The re-sort is stable for equal blended scores.
286
+ """
287
+
288
+ def reranker(query: str, hits: list[SearchHit]) -> list[SearchHit]:
289
+ if not hits:
290
+ return hits
291
+ from dataclasses import replace
292
+
293
+ qvec = embed_query(query)
294
+ bm25_goodness = _normalise_bm25(hits)
295
+
296
+ def blended(h: SearchHit) -> float:
297
+ good = bm25_goodness.get(h.id, 0.0)
298
+ cos_component = 0.0
299
+ if qvec is not None:
300
+ dvec = get_vector(h.id)
301
+ if dvec is not None and len(dvec) == len(qvec):
302
+ # Map cosine [-1, 1] -> [0, 1].
303
+ cos_component = (cosine(qvec, dvec) + 1.0) / 2.0
304
+ return (1.0 - weight) * good + weight * cos_component
305
+
306
+ scored = [(blended(h), i, h) for i, h in enumerate(hits)]
307
+ # Sort by rank_class FIRST (primaries before backfill), then blended DESC;
308
+ # ties keep incoming order via the enumerate index. The rank-class outer
309
+ # key means the cosine blend can re-order WITHIN a class but never lift a
310
+ # backfill (expansion/trigram) hit above a primary (finding (d)): the
311
+ # blend re-normalises scores, so a score-only floor would not hold.
312
+ scored.sort(key=lambda t: (t[2].rank_class, -t[0], t[1]))
313
+ # Re-score onto the ascending-bm25 convention (lower == better) so a
314
+ # downstream ascending sort agrees with this order: negate blended.
315
+ return [replace(h, score=-b) for b, _i, h in scored]
316
+
317
+ return reranker
@@ -0,0 +1,297 @@
1
+ """Policy core for enforcement: the heuristic intent classifier and the
2
+ in-memory consultation ledger. Pure and dependency-free so it is unit-testable
3
+ without a FastMCP server."""
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import fnmatch
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ import tempfile
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+
16
+ # Keyword signals that a user prompt is a governed code/architectural decision.
17
+ GOVERNED_KEYWORDS: tuple[str, ...] = (
18
+ "library", "dependency", "dependencies", "framework", "package",
19
+ "pattern", "migration", "migrate", "refactor", "architecture",
20
+ "api design", "endpoint", "schema", "auth", "authorization",
21
+ "authentication", "rls", "secret", "convention", "standard",
22
+ )
23
+
24
+ # Path globs that mark a pending file action as a governed decision. Deliberately
25
+ # narrow (dependency manifests, migrations, schema, container/build config) so
26
+ # that ordinary source edits flow without a consult; the prompt-level classifier
27
+ # carries the broad net.
28
+ GOVERNED_PATH_GLOBS: tuple[str, ...] = (
29
+ "pyproject.toml", "*/pyproject.toml",
30
+ "package.json", "*/package.json",
31
+ "*/requirements*.txt", "requirements*.txt",
32
+ "*/go.mod", "go.mod", "*/Cargo.toml", "Cargo.toml", "*/pom.xml",
33
+ "*/migrations/*", "*/migration/*",
34
+ "*/schema/*", "*/schema.sql", "*.sql",
35
+ "Dockerfile", "*/Dockerfile", "*/docker-compose*.yml", "docker-compose*.yml",
36
+ )
37
+
38
+ # Command fragments (matched against action_diff) that indicate a governed
39
+ # dependency/install action, so Bash/shell tool actions can be classified.
40
+ GOVERNED_COMMAND_PATTERNS: tuple[str, ...] = (
41
+ "pip install", "pip3 install", "uv add", "uv pip install", "poetry add",
42
+ "npm install", "npm i ", "yarn add", "pnpm add",
43
+ "apt install", "apt-get install", "brew install",
44
+ "go get", "cargo add", "gem install", "bundle add",
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ClassifyResult:
50
+ """Outcome of a classification: governed or not, plus the matched signals."""
51
+
52
+ is_governed_decision: bool
53
+ signals: list[str] = field(default_factory=list)
54
+
55
+
56
+ class IntentClassifier:
57
+ """Heuristic classifier. Pluggable: a future LLM-backed classifier can
58
+ implement the same ``classify`` signature without touching callers."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ keywords: tuple[str, ...] = GOVERNED_KEYWORDS,
64
+ path_globs: tuple[str, ...] = GOVERNED_PATH_GLOBS,
65
+ command_patterns: tuple[str, ...] = GOVERNED_COMMAND_PATTERNS,
66
+ ) -> None:
67
+ self._keywords = tuple(k.lower() for k in keywords)
68
+ self._path_globs = tuple(path_globs)
69
+ self._command_patterns = tuple(p.lower() for p in command_patterns)
70
+ # Pre-compile a word-boundary regex per keyword so "authored" does not
71
+ # match "auth". \b around each keyword; keywords with spaces still work.
72
+ self._keyword_res = tuple(
73
+ (kw, re.compile(rf"\b{re.escape(kw)}\b")) for kw in self._keywords
74
+ )
75
+
76
+ def classify(
77
+ self,
78
+ *,
79
+ intent: str = "",
80
+ action_path: str | None = None,
81
+ action_diff: str = "",
82
+ ) -> ClassifyResult:
83
+ signals: list[str] = []
84
+ text = f"{intent} {action_diff}".lower()
85
+ for kw, rx in self._keyword_res:
86
+ if rx.search(text):
87
+ signals.append(f"keyword:{kw}")
88
+ diff_lower = action_diff.lower()
89
+ for pat in self._command_patterns:
90
+ if pat in diff_lower:
91
+ signals.append(f"command:{pat.strip()}")
92
+ if action_path:
93
+ p = action_path.replace("\\", "/")
94
+ base = p.rsplit("/", 1)[-1]
95
+ for glob in self._path_globs:
96
+ if fnmatch.fnmatch(p, glob) or fnmatch.fnmatch(base, glob):
97
+ signals.append(f"path:{glob}")
98
+ break
99
+ return ClassifyResult(is_governed_decision=bool(signals), signals=signals)
100
+
101
+
102
+ # Consultation trigger provenance. "explicit" is a deliberate agent call to
103
+ # kb_consult (the MCP tool default, and any old client that sends no trigger).
104
+ # "prompt_hook" is an installer-driven UserPromptSubmit/BeforeAgent auto-consult:
105
+ # recorded for audit/compliance but NOT sufficient to clear the gate, because it
106
+ # fires on every user turn and would otherwise keep the ledger perpetually fresh.
107
+ EXPLICIT_TRIGGER = "explicit"
108
+ PROMPT_HOOK_TRIGGER = "prompt_hook"
109
+
110
+
111
+ @dataclass
112
+ class LedgerEntry:
113
+ """A recorded consultation for a (session, workspace) pair.
114
+
115
+ ``consulted_at`` is the last touch of ANY trigger (used for retention/TTL of
116
+ the row itself and audit). ``explicit_at`` is the last EXPLICIT consult, or
117
+ None if only prompt-hook auto-consults have been recorded. The gate clears on
118
+ ``explicit_at`` freshness so a prompt-hook auto-consult (which fires every
119
+ turn) can never satisfy the gate, while an explicit consult is not silently
120
+ downgraded by a later prompt-hook consult on the same (session, workspace)."""
121
+
122
+ consulted_at: float
123
+ rule_ids: list[str]
124
+ explicit_at: float | None = None
125
+
126
+
127
+ log = logging.getLogger("data_olympus")
128
+
129
+
130
+ class ConsultationLedger:
131
+ """Records which (session, workspace) pairs consulted and when.
132
+
133
+ With no ``path`` it is purely in-memory (the slice-1 behavior). With a
134
+ ``path`` it loads an existing JSON file on construction and rewrites it on
135
+ every ``record`` so consultations survive a server restart. A corrupt or
136
+ unreadable file degrades to empty with a logged warning and never crashes."""
137
+
138
+ def __init__(
139
+ self,
140
+ path: str | None = None,
141
+ *,
142
+ retention_sec: float = 3600.0,
143
+ max_entries: int = 50_000,
144
+ ) -> None:
145
+ self._path = path
146
+ self._entries: dict[tuple[str, str], LedgerEntry] = {}
147
+ # An entry is a TTL freshness cache: once it is older than the consult
148
+ # TTL it can never be fresh again, so we evict it on the next record().
149
+ # ``retention_sec`` should be >= the ttl_sec passed to is_fresh (the
150
+ # server threads config.consult_ttl_sec in) so a still-fresh entry is
151
+ # never dropped. ``max_entries`` is a hard belt-and-suspenders cap that
152
+ # bounds memory/disk even under a flood of unique sessions inside one
153
+ # retention window. Together they stop _entries growing without bound and
154
+ # keep the per-record file rewrite O(active window) instead of O(all
155
+ # sessions ever seen).
156
+ self._retention_sec = retention_sec
157
+ self._max_entries = max_entries
158
+ # record() mutates _entries and rewrites the whole file; once consult
159
+ # handlers are offloaded to the anyio threadpool these can run
160
+ # concurrently. Without this lock, _save()'s iteration over _entries can
161
+ # race a concurrent insert (RuntimeError / dropped entries). Public
162
+ # methods take the lock; _save()/_evict() do not (called only while held).
163
+ self._lock = threading.Lock()
164
+ if path:
165
+ self._load()
166
+ # A ledger persisted by the previous unbounded implementation can be
167
+ # arbitrarily large; cap it on load so an oversized /state/ledger.json
168
+ # is not held in memory until the first record() prunes it. TTL
169
+ # eviction still runs on the first record() (it needs a caller "now").
170
+ self._enforce_cap()
171
+
172
+ def _load(self) -> None:
173
+ if not self._path or not os.path.exists(self._path):
174
+ return
175
+ try:
176
+ with open(self._path, encoding="utf-8") as f:
177
+ rows = json.load(f)
178
+ for row in rows:
179
+ key = (row["session_id"], row["workspace"])
180
+ # Back-compat: a ledger persisted before the trigger split has no
181
+ # explicit_at. Treat those legacy rows as explicit (they were all
182
+ # gate-clearing under the old policy), so a server upgrade does not
183
+ # spuriously re-block a session that already consulted.
184
+ consulted_at = float(row["consulted_at"])
185
+ explicit_at = row.get("explicit_at", consulted_at)
186
+ self._entries[key] = LedgerEntry(
187
+ consulted_at=consulted_at,
188
+ rule_ids=list(row.get("rule_ids", [])),
189
+ explicit_at=None if explicit_at is None else float(explicit_at),
190
+ )
191
+ except Exception as exc: # noqa: BLE001 - corrupt file -> empty, never crash
192
+ log.warning("consultation ledger at %s unreadable, starting empty: %s",
193
+ self._path, exc)
194
+ self._entries = {}
195
+
196
+ def _save(self) -> None:
197
+ if not self._path:
198
+ return
199
+ rows = [
200
+ {"session_id": s, "workspace": w,
201
+ "consulted_at": e.consulted_at, "rule_ids": e.rule_ids,
202
+ "explicit_at": e.explicit_at}
203
+ for (s, w), e in self._entries.items()
204
+ ]
205
+ d = os.path.dirname(self._path) or "."
206
+ os.makedirs(d, exist_ok=True)
207
+ # Atomic write: serialize to a temp file in the same directory, then
208
+ # os.replace() over the target so a crash/full-disk mid-write cannot
209
+ # truncate or corrupt the existing ledger.
210
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".ledger-", suffix=".tmp")
211
+ try:
212
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
213
+ json.dump(rows, f)
214
+ os.replace(tmp, self._path)
215
+ except Exception:
216
+ with contextlib.suppress(OSError):
217
+ os.unlink(tmp)
218
+ raise
219
+
220
+ def _evict(self, now: float) -> None:
221
+ """Drop entries that can no longer be fresh, then enforce the hard cap.
222
+
223
+ Caller must hold ``self._lock``. Reassigns ``self._entries`` to a pruned
224
+ dict; O(n) but n is exactly what this bounds.
225
+ """
226
+ cutoff = now - self._retention_sec
227
+ self._entries = {
228
+ key: e for key, e in self._entries.items() if e.consulted_at >= cutoff
229
+ }
230
+ self._enforce_cap()
231
+
232
+ def _enforce_cap(self) -> None:
233
+ """Bound _entries to the newest ``max_entries`` by consulted_at. Clock-free
234
+ so it can run on load (before any caller "now" is available). Caller holds
235
+ the lock, except the single-threaded construction-time call."""
236
+ if len(self._entries) > self._max_entries:
237
+ newest = sorted(
238
+ self._entries.items(), key=lambda kv: kv[1].consulted_at, reverse=True
239
+ )[: self._max_entries]
240
+ self._entries = dict(newest)
241
+
242
+ def record(
243
+ self,
244
+ *,
245
+ session_id: str,
246
+ workspace: str,
247
+ rule_ids: list[str],
248
+ now: float,
249
+ trigger: str = EXPLICIT_TRIGGER,
250
+ ) -> None:
251
+ """Record a consultation. ``trigger`` is EXPLICIT_TRIGGER (a deliberate
252
+ agent consult that clears the gate) or PROMPT_HOOK_TRIGGER (an installer
253
+ auto-consult that is audited but never clears the gate).
254
+
255
+ A prompt-hook record refreshes ``consulted_at`` (row liveness/audit) but
256
+ carries forward any existing ``explicit_at`` so it cannot downgrade a
257
+ still-fresh explicit consult into a non-clearing one."""
258
+ with self._lock:
259
+ prior = self._entries.get((session_id, workspace))
260
+ if trigger == EXPLICIT_TRIGGER:
261
+ explicit_at: float | None = now
262
+ else:
263
+ explicit_at = prior.explicit_at if prior is not None else None
264
+ self._entries[(session_id, workspace)] = LedgerEntry(
265
+ consulted_at=now, rule_ids=list(rule_ids), explicit_at=explicit_at
266
+ )
267
+ self._evict(now)
268
+ self._save()
269
+
270
+ def is_fresh(
271
+ self,
272
+ *,
273
+ session_id: str,
274
+ workspace: str,
275
+ now: float,
276
+ ttl_sec: float,
277
+ require_explicit: bool = True,
278
+ ) -> bool:
279
+ """True when a fresh consult is on record for (session, workspace).
280
+
281
+ With ``require_explicit`` (the gate's default) only an explicit consult
282
+ within ``ttl_sec`` counts: a prompt-hook auto-consult never clears the
283
+ gate. With ``require_explicit=False`` any consult (explicit or prompt
284
+ hook) within the TTL counts (used where the mere fact of a recent consult,
285
+ not its provenance, matters)."""
286
+ with self._lock:
287
+ entry = self._entries.get((session_id, workspace))
288
+ if entry is None:
289
+ return False
290
+ ts = entry.explicit_at if require_explicit else entry.consulted_at
291
+ if ts is None:
292
+ return False
293
+ return (now - ts) <= ttl_sec
294
+
295
+ def get(self, *, session_id: str, workspace: str) -> LedgerEntry | None:
296
+ with self._lock:
297
+ return self._entries.get((session_id, workspace))
@@ -0,0 +1,16 @@
1
+ """Public API for the data-olympus format core."""
2
+
3
+ from .document import Document
4
+ from .frontmatter import parse_frontmatter
5
+ from .lint import discover_bundle_files, lint_bundle, lint_files
6
+ from .validate import Finding, validate_document
7
+
8
+ __all__ = [
9
+ "Document",
10
+ "Finding",
11
+ "parse_frontmatter",
12
+ "validate_document",
13
+ "lint_bundle",
14
+ "discover_bundle_files",
15
+ "lint_files",
16
+ ]
@@ -0,0 +1,39 @@
1
+ """The Document model: a parsed concept file (frontmatter + body)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .frontmatter import parse_frontmatter
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Document:
14
+ path: Path
15
+ frontmatter: dict[str, Any]
16
+ body: str
17
+
18
+ @classmethod
19
+ def load(cls, path: str | Path) -> Document:
20
+ """Load a document from disk. Callers must not mutate `frontmatter` in place."""
21
+ p = Path(path)
22
+ fm, body = parse_frontmatter(p.read_text(encoding="utf-8"))
23
+ return cls(path=p, frontmatter=fm, body=body)
24
+
25
+ @property
26
+ def id(self) -> str | None:
27
+ return self.frontmatter.get("id")
28
+
29
+ @property
30
+ def type(self) -> str | None:
31
+ return self.frontmatter.get("type")
32
+
33
+ @property
34
+ def status(self) -> str | None:
35
+ return self.frontmatter.get("status")
36
+
37
+ @property
38
+ def tier(self) -> str | None:
39
+ return self.frontmatter.get("tier")