katalyst-plugin-context 0.1.1a1__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.
@@ -0,0 +1,319 @@
1
+ """The context service: the policy for a node, and its map and context, on one call's graph.
2
+
3
+ It reads each layer of policy from where it is declared (the wing's and kind's
4
+ ``plugin_settings["context"]``, the node's ``spec.extensions.context``, the plugin's config),
5
+ checks each against the plugin's schema, folds them, and asks the domain for the payload.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import re
12
+ from collections.abc import Mapping, Sequence
13
+ from typing import Any, cast
14
+
15
+ from katalyst_engine.schema.definition import VALUE_FLAG_BLOCKING
16
+ from katalyst_engine.schema.registry import SchemaRegistry
17
+ from katalyst_taxonomy.engine.relationships import CHILD_OF
18
+
19
+ from katalyst_plugin_context.domain.context import (
20
+ ContextError,
21
+ ContextRequest,
22
+ build_map,
23
+ build_result,
24
+ json_size,
25
+ )
26
+ from katalyst_plugin_context.domain.graph import Graph, GraphNode
27
+ from katalyst_plugin_context.domain.markdown import item_size, render_map, render_result
28
+ from katalyst_plugin_context.domain.paths import PathSyntaxError, parse_path
29
+ from katalyst_plugin_context.domain.policy import Layer, Policy, fold
30
+ from katalyst_plugin_context.domain.settings import ItemHints, View, item_hints, views_from
31
+ from katalyst_plugin_context.schema import (
32
+ CONFIG_SCHEMA,
33
+ NODE_SETTINGS_SCHEMA,
34
+ SETTINGS_SCHEMA,
35
+ check,
36
+ )
37
+
38
+ __all__ = ["NAMESPACE", "ContextService", "RegistrySchema"]
39
+
40
+ #: The plugin's name, and so the key of its settings sections everywhere.
41
+ NAMESPACE = "context"
42
+
43
+ FORMATS = ("json", "markdown")
44
+
45
+
46
+ class RegistrySchema:
47
+ """What the schema registry says about kinds and relationships, for the domain."""
48
+
49
+ def __init__(self, registry: SchemaRegistry) -> None:
50
+ self._registry = registry
51
+ definition = registry.get_relationship("child_of")
52
+ self.parent_rel = definition.graph_label if definition is not None else CHILD_OF
53
+ self._hints: dict[str, ItemHints] = {}
54
+
55
+ def sections(self, kind: str) -> tuple[Mapping[str, Any] | None, Mapping[str, Any] | None, str]:
56
+ """The wing's and the kind's context sections, and the wing's name."""
57
+ definition = self._registry.get_node_definition(kind)
58
+ if definition is None:
59
+ return None, None, ""
60
+ wing = self._registry.get_wing_definition(definition.wing) if definition.wing else None
61
+ return (
62
+ wing.plugin_settings.get(NAMESPACE) if wing is not None else None,
63
+ definition.plugin_settings.get(NAMESPACE),
64
+ definition.wing,
65
+ )
66
+
67
+ def kind_label(self, kind: str) -> str:
68
+ return self._registry.display_label_for(kind) if kind in self._registry else kind
69
+
70
+ def hints(self, kind: str) -> ItemHints:
71
+ if kind not in self._hints:
72
+ wing, own, _ = self.sections(kind)
73
+ self._hints[kind] = item_hints(wing, own)
74
+ return self._hints[kind]
75
+
76
+ def declared_fields(self, kind: str) -> Sequence[tuple[str, str]]:
77
+ definition = self._registry.get_node_definition(kind)
78
+ return [(f.name, f.field_type) for f in definition.fields] if definition else []
79
+
80
+ def blocking(self, kind: str, spec: Mapping[str, Any]) -> list[dict[str, Any]]:
81
+ definition = self._registry.get_node_definition(kind)
82
+ if definition is None:
83
+ return []
84
+ found: list[dict[str, Any]] = []
85
+ for field in definition.fields:
86
+ if not any(meta.flags for meta in field.value_meta):
87
+ continue
88
+ # A node that doesn't say holds the field's default, which may block.
89
+ value = spec.get(field.name, field.default)
90
+ if not isinstance(value, str) or value not in field.allowed_values:
91
+ continue
92
+ meta = field.meta_for(value)
93
+ found.append(
94
+ {
95
+ "field": field.name,
96
+ "value": value,
97
+ "blocking": meta is not None and VALUE_FLAG_BLOCKING in meta.flags,
98
+ }
99
+ )
100
+ return found
101
+
102
+ def relationship_description(self, rel: str) -> str:
103
+ definition = self._registry.get_relationship_by_graph_label(rel)
104
+ return definition.description if definition is not None else ""
105
+
106
+ def carried(self, kind: str) -> Sequence[tuple[str, frozenset[str]]]:
107
+ from katalyst_taxonomy.engine.relationships import carriers_of
108
+
109
+ return [(c.rel_type, c.target_kinds) for c in carriers_of(self._registry, kind)]
110
+
111
+
112
+ def _glob(pattern: str) -> re.Pattern[str]:
113
+ """An fqtn glob: ``*`` is one segment, ``**`` any number."""
114
+ out = ""
115
+ i = 0
116
+ while i < len(pattern):
117
+ if pattern.startswith("**", i):
118
+ out += ".*"
119
+ i += 2
120
+ elif pattern[i] == "*":
121
+ out += "[^.]*"
122
+ i += 1
123
+ else:
124
+ out += re.escape(pattern[i])
125
+ i += 1
126
+ return re.compile(f"^{out}$")
127
+
128
+
129
+ def _matches(match: Mapping[str, Any], node: GraphNode, wing: str) -> bool:
130
+ """Whether a profile rule's ``match`` selects *node*; every declared criterion must hold."""
131
+
132
+ def listed(key: str) -> list[str]:
133
+ return [str(v) for v in cast("list[object]", match.get(key) or [])]
134
+
135
+ if listed("backend") and node.backend not in listed("backend"):
136
+ return False
137
+ if listed("kind") and node.kind not in listed("kind"):
138
+ return False
139
+ if listed("wing") and wing not in listed("wing"):
140
+ return False
141
+ if listed("name") and not any(fnmatch.fnmatchcase(node.name, p) for p in listed("name")):
142
+ return False
143
+ if listed("fqtn") and not any(
144
+ _glob(p).match(node.key if ":" in p else node.fqn) for p in listed("fqtn")
145
+ ):
146
+ return False
147
+ labels = cast("Mapping[str, str]", match.get("labels") or {})
148
+ node_labels = cast("Mapping[str, Any]", node.spec.get("labels") or {})
149
+ return all(str(node_labels.get(k)) == v for k, v in labels.items())
150
+
151
+
152
+ class ContextService:
153
+ """Map, context and policy for the nodes of one call's graph."""
154
+
155
+ def __init__(
156
+ self,
157
+ registry: SchemaRegistry,
158
+ graph: Graph,
159
+ *,
160
+ config: Mapping[str, Any] | None = None,
161
+ profile: str | None = None,
162
+ ) -> None:
163
+ self._registry = registry
164
+ self._schema = RegistrySchema(registry)
165
+ self._graph = graph
166
+ self._config = dict(config or {})
167
+ self._profile = profile
168
+ check(CONFIG_SCHEMA, self._config, where=f"spec.plugins[{NAMESPACE}].config")
169
+
170
+ # ── addressing ──────────────────────────────────────────────────────
171
+
172
+ def resolve(self, address: str) -> str:
173
+ """The graph key *address* names: ``backend:fqtn``, or an FQTN one backend holds.
174
+
175
+ Raises:
176
+ ContextError: ``NOT_FOUND`` (with the qualified names a bare name may mean)
177
+ or ``INVALID_INPUT`` (an FQTN several backends hold).
178
+ """
179
+ text, _, kind = address.strip().partition("#")
180
+ nodes = self._graph.nodes.values()
181
+
182
+ def selected(node: GraphNode) -> bool:
183
+ return not kind or node.kind == kind
184
+
185
+ if ":" in text:
186
+ found = [n.key for n in nodes if f"{n.backend}:{n.fqn}" == text and selected(n)]
187
+ else:
188
+ found = [n.key for n in nodes if n.fqn == text and selected(n)]
189
+ if len(found) == 1:
190
+ return found[0]
191
+ if len(found) > 1:
192
+ raise ContextError(
193
+ "INVALID_INPUT",
194
+ f"{address!r} names more than one node; use one of: {', '.join(sorted(found))}",
195
+ {"candidates": sorted(found)},
196
+ )
197
+ leaf = text.rsplit(":", 1)[-1].rsplit(".", 1)[-1]
198
+ meant = sorted(n.key for n in nodes if n.name == leaf and selected(n))
199
+ hint = (
200
+ f"; a node is named by its qualified name: use {' or '.join(map(repr, meant[:10]))}"
201
+ if meant
202
+ else ""
203
+ )
204
+ raise ContextError(
205
+ "NOT_FOUND", f"Node {address!r} not found{hint}", {"node": address, "candidates": meant}
206
+ )
207
+
208
+ # ── policy ──────────────────────────────────────────────────────────
209
+
210
+ def policy_for(self, key: str) -> tuple[Policy, dict[str, View]]:
211
+ """The effective policy for the node at *key*, and the views its kind declares.
212
+
213
+ Raises:
214
+ ContextError: ``VALIDATION_ERROR`` if a layer breaks the plugin's schema.
215
+ """
216
+ node = self._graph.nodes[key]
217
+ wing_section, kind_section, wing = self._schema.sections(node.kind)
218
+ layers: list[Layer] = []
219
+ for label, section in ((f"wing:{wing}", wing_section), (f"kind:{node.kind}", kind_section)):
220
+ if section is None:
221
+ continue
222
+ check(SETTINGS_SCHEMA, section, where=f"{label} plugin_settings.{NAMESPACE}")
223
+ layers.append(_layer(label, section))
224
+ own = node.extensions.get(NAMESPACE)
225
+ if own is not None:
226
+ check(NODE_SETTINGS_SCHEMA, own, where=f"{key} spec.extensions.{NAMESPACE}")
227
+ layers.append(_layer(f"node:{key}", cast("Mapping[str, Any]", own)))
228
+ layers.extend(self._profile_layers(node, wing))
229
+ try:
230
+ views = views_from(
231
+ wing_section,
232
+ kind_section,
233
+ wing_label=f"wing:{wing}",
234
+ kind_label=f"kind:{node.kind}",
235
+ )
236
+ policy = fold(layers, views=sorted(views))
237
+ except PathSyntaxError as exc:
238
+ raise ContextError("VALIDATION_ERROR", str(exc)) from exc
239
+ return policy, views
240
+
241
+ def _profile_layers(self, node: GraphNode, wing: str) -> list[Layer]:
242
+ config = self._config
243
+ layers: list[Layer] = []
244
+ if config.get("policy") or config.get("defaults"):
245
+ layers.append(_layer("plugin config", config))
246
+ profiles = cast("Mapping[str, Mapping[str, Any]]", config.get("profiles") or {})
247
+ section = profiles.get(self._profile or "") if self._profile else None
248
+ if section is None:
249
+ return layers
250
+ label = f"profile:{self._profile}"
251
+ layers.append(_layer(label, section))
252
+ for index, rule in enumerate(cast("list[Mapping[str, Any]]", section.get("rules") or [])):
253
+ if _matches(cast("Mapping[str, Any]", rule.get("match") or {}), node, wing):
254
+ layers.append(_layer(f"{label}#rules[{index}]", rule))
255
+ return layers
256
+
257
+ # ── payloads ────────────────────────────────────────────────────────
258
+
259
+ def map(self, address: str) -> dict[str, Any]:
260
+ """Everything around the node at *address*: relationships, inheritance, views, options."""
261
+ key = self.resolve(address)
262
+ policy, views = self.policy_for(key)
263
+ return build_map(self._graph, self._schema, key, policy, views)
264
+
265
+ def query(self, address: str, request: ContextRequest) -> dict[str, Any]:
266
+ """The items *request* asks for around the node at *address*."""
267
+ if request.format not in FORMATS:
268
+ raise ContextError(
269
+ "INVALID_INPUT", f"format must be json or markdown; got {request.format!r}"
270
+ )
271
+ for text in request.paths or ():
272
+ try:
273
+ parse_path(text)
274
+ except PathSyntaxError as exc:
275
+ raise ContextError("INVALID_INPUT", str(exc), {"path": text}) from exc
276
+ key = self.resolve(address)
277
+ policy, views = self.policy_for(key)
278
+ size = item_size if request.format == "markdown" else json_size
279
+ return build_result(self._graph, self._schema, key, policy, views, request, size_of=size)
280
+
281
+ def explain(self, address: str) -> dict[str, Any]:
282
+ """The effective policy for the node at *address*: layers, limits and notes."""
283
+ key = self.resolve(address)
284
+ policy, views = self.policy_for(key)
285
+ return {
286
+ "node": key,
287
+ "effective": policy.describe(),
288
+ "views": {
289
+ name: {
290
+ "paths": view.path_texts,
291
+ "declaredBy": view.declared_by,
292
+ "title": view.title,
293
+ }
294
+ for name, view in sorted(views.items())
295
+ },
296
+ }
297
+
298
+ def markdown_for_map(self, payload: Mapping[str, Any]) -> str:
299
+ return render_map(payload)
300
+
301
+ def markdown_for_result(self, address: str, payload: Mapping[str, Any]) -> str:
302
+ key = self.resolve(address)
303
+ _, views = self.policy_for(key)
304
+ ancestors = [
305
+ {"qualifiedName": a, "kind": self._graph.nodes[a].kind}
306
+ for a in self._graph.ancestors(key, self._schema.parent_rel)
307
+ ]
308
+ declared = {
309
+ name: {"title": v.title, "description": v.description} for name, v in views.items()
310
+ }
311
+ return render_result(payload, views=declared, ancestors=ancestors)
312
+
313
+
314
+ def _layer(label: str, section: Mapping[str, Any]) -> Layer:
315
+ return Layer(
316
+ label=label,
317
+ policy=cast("Mapping[str, Any]", section.get("policy") or {}),
318
+ defaults=cast("Mapping[str, Any]", section.get("defaults") or {}),
319
+ )
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: katalyst-plugin-context
3
+ Version: 0.1.1a1
4
+ Summary: Katalyst context plugin — the right context from the taxonomy for an agent working on a node
5
+ Author: eSimplicity
6
+ License: Proprietary
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: jsonschema>=4
10
+ Requires-Dist: katalyst-engine<6,>=5
11
+ Requires-Dist: katalyst-taxonomy-cli<3,>=2
12
+ Requires-Dist: katalyst-taxonomy-mcp<5,>=4
13
+ Requires-Dist: katalyst-taxonomy<6,>=5
@@ -0,0 +1,23 @@
1
+ katalyst_plugin_context/__init__.py,sha256=L3hMpS4HTRZa1wuRAOLcmhuwTMkcGuKhwS8gM12zMZQ,306
2
+ katalyst_plugin_context/cli.py,sha256=ZULcaiaKuKmkXhtGjv-mnK_k8GF4EmiFvaubtzoojzY,4889
3
+ katalyst_plugin_context/composition.py,sha256=FdlswZYtQzNwAHbjlQ-J1DQM7IGFyUM6sujAgRnGa4w,1980
4
+ katalyst_plugin_context/lint.py,sha256=ag7sr-QNcgRg6-npC8pVD9f9idySzlFvSR_dxQWsIdw,6258
5
+ katalyst_plugin_context/mcp_tools.py,sha256=L8ITnVb4Yzxk8MCCBwe5llvNIbcDyBJU5lD1dosrjZQ,6035
6
+ katalyst_plugin_context/plugin.py,sha256=yrw7bT0X4TCJRMVJlxJXShCQnp7DKSJeZqEZalVXXZk,2432
7
+ katalyst_plugin_context/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ katalyst_plugin_context/rest.py,sha256=5GcLdfEaY7ntbwevt6op1b82pDJGvAOMlGCGLclgXTs,5898
9
+ katalyst_plugin_context/schema.py,sha256=vI_tfLmVtuioCv_KgXmiqKSx1sa2wLvcrYjunFBNLsk,6755
10
+ katalyst_plugin_context/service.py,sha256=sQttBS95ZmfXeTwOVOWe517piofwo9Y5Z2DcEnCOAMU,13375
11
+ katalyst_plugin_context/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ katalyst_plugin_context/adapters/runtime_graph.py,sha256=gOqCsC_zs1glLcMhZEhCZOsG1-uomhDqDnYzmXjuCcU,4847
13
+ katalyst_plugin_context/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ katalyst_plugin_context/domain/context.py,sha256=xe-7ASwIsnagrC53o4QxtJeOOU2rvMNQ3CItLkfbbIQ,27411
15
+ katalyst_plugin_context/domain/graph.py,sha256=bknXgiof25-2j4I2GPsIv-u0s76LqzHajsXCU5utYPA,3425
16
+ katalyst_plugin_context/domain/markdown.py,sha256=KbHhg8VWQuVEU7t1LT26Ie6cSEJfhkJCiP-o8XGaaBw,8779
17
+ katalyst_plugin_context/domain/paths.py,sha256=VhVHEkPdfi-gdD4SmnjSX3ybUddM0Lw64zprR9u9CJ4,3830
18
+ katalyst_plugin_context/domain/policy.py,sha256=8yLiYrhBSoIs3YKTTgDb5MjfF3dXWh-MirNJ65QfR7Q,13771
19
+ katalyst_plugin_context/domain/settings.py,sha256=PH-t52j3s-X0bOl5vcc9ZMqvxW2CTUa0nyqW-hrnens,3153
20
+ katalyst_plugin_context-0.1.1a1.dist-info/METADATA,sha256=qf13Dtxc0hOySDIRYUm-k8bdhe95HjTlVFpGTkHB5VI,455
21
+ katalyst_plugin_context-0.1.1a1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
22
+ katalyst_plugin_context-0.1.1a1.dist-info/entry_points.txt,sha256=0kZT_Rlbx8YcvLb2L_Heo4JPILmpERHySD-op6LDvpc,154
23
+ katalyst_plugin_context-0.1.1a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [katalyst_taxonomy.cli]
2
+ context = katalyst_plugin_context.cli:create_command
3
+
4
+ [katalyst_taxonomy.plugins]
5
+ context = katalyst_plugin_context:ContextPlugin