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,8 @@
1
+ """Katalyst context plugin: the right context from the taxonomy for an agent working on a
2
+ node, over MCP (``taxonomy_context_map``, ``taxonomy_context``), REST (``/ext/context``) and
3
+ the CLI (``kata tax context``).
4
+ """
5
+
6
+ from katalyst_plugin_context.plugin import ContextPlugin
7
+
8
+ __all__ = ["ContextPlugin"]
File without changes
@@ -0,0 +1,126 @@
1
+ """The graph one call walks, read from the profile's backends through the port.
2
+
3
+ Each backend is read once (``load_documents`` and ``list_relationships``, the two calls every
4
+ backend type implements, through the profile's permission gate) and the corpus is narrowed by
5
+ the profile's filters (:func:`katalyst_taxonomy.services.filters.visible_nodes`). An edge is
6
+ kept only when both its ends are visible. A reference into another backend is stored as
7
+ ``backend:fqtn`` and keyed that way, so an edge held by one backend can reach a node of
8
+ another.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable
14
+ from typing import Any, cast
15
+
16
+ from katalyst_engine.model.node import Node
17
+ from katalyst_taxonomy.engine.accessors import (
18
+ get_extensions_map,
19
+ get_markdown_body,
20
+ get_node_backend,
21
+ )
22
+ from katalyst_taxonomy.engine.identity import split_backend_prefix
23
+ from katalyst_taxonomy.ports.backend import TaxonomyBackend
24
+
25
+ from katalyst_plugin_context.domain.graph import Edge, Graph, GraphNode
26
+
27
+ __all__ = ["build_graph", "graph_from"]
28
+
29
+
30
+ def _graph_node(doc: Node, backend: str, key: str) -> GraphNode:
31
+ spec = {k: v for k, v in doc.spec.items() if not str(k).startswith("_")}
32
+ return GraphNode(
33
+ key=key,
34
+ kind=doc.kind,
35
+ name=doc.name,
36
+ fqn=doc.fqn or doc.name,
37
+ backend=backend,
38
+ spec=spec,
39
+ description=doc.description or str(spec.get("description") or ""),
40
+ body=get_markdown_body(doc),
41
+ extensions=cast("dict[str, Any]", get_extensions_map(doc)),
42
+ )
43
+
44
+
45
+ def graph_from(
46
+ documents: Iterable[tuple[str, Node]],
47
+ relationships: Iterable[tuple[str, str, str, str]],
48
+ *,
49
+ hidden: Iterable[tuple[str, str]] = (),
50
+ ) -> Graph:
51
+ """A graph over *documents* (``(holder backend, node)``) and *relationships*
52
+ (``(holder backend, source fqtn, rel, target fqtn)``).
53
+
54
+ An edge to an address in *hidden* (``(backend, fqtn)`` of nodes the profile's filters
55
+ hide) is left out silently. Any other edge that reaches no node is counted as dropped.
56
+ Nothing about a hidden node is counted.
57
+ """
58
+ hidden_addresses = set(hidden)
59
+ by_address: dict[tuple[str, str], list[str]] = {}
60
+ nodes: list[GraphNode] = []
61
+ pending: list[tuple[str, str, Node]] = []
62
+ for holder, doc in documents:
63
+ backend = get_node_backend(doc) or holder
64
+ fqn = doc.fqn or doc.name
65
+ pending.append((backend, fqn, doc))
66
+ by_address.setdefault((backend, fqn), []).append(doc.kind)
67
+ for backend, fqn, doc in pending:
68
+ shared = len(by_address[(backend, fqn)]) > 1
69
+ key = f"{backend}:{fqn}#{doc.kind}" if shared else f"{backend}:{fqn}"
70
+ nodes.append(_graph_node(doc, backend, key))
71
+
72
+ def key_of(holder: str, address: str) -> str | None:
73
+ prefix, rest = split_backend_prefix(address)
74
+ backend = prefix or holder
75
+ kinds = by_address.get((backend, rest))
76
+ if kinds is None or len(kinds) != 1:
77
+ return None
78
+ return f"{backend}:{rest}"
79
+
80
+ edges: list[Edge] = []
81
+ dropped = 0
82
+ for holder, source, rel, target in relationships:
83
+ source_key, target_key = key_of(holder, source), key_of(holder, target)
84
+ if source_key is None or target_key is None:
85
+ ends = (_address(holder, source), _address(holder, target))
86
+ if not any(end in hidden_addresses for end in ends):
87
+ dropped += 1
88
+ continue
89
+ edges.append(Edge(rel=rel, source=source_key, target=target_key))
90
+ return Graph(nodes, edges, dropped=dropped)
91
+
92
+
93
+ def _address(holder: str, value: str) -> tuple[str, str]:
94
+ prefix, rest = split_backend_prefix(value)
95
+ return (prefix or holder, rest)
96
+
97
+
98
+ def build_graph(
99
+ sources: Iterable[tuple[str, TaxonomyBackend]], *, profile: str | None = None
100
+ ) -> Graph:
101
+ """Read every backend in *sources* once and build the graph the profile may see.
102
+
103
+ Raises:
104
+ PermissionDeniedError: If the profile may not read a backend (as every read does).
105
+ """
106
+ from katalyst_taxonomy.services.filters import visible_nodes
107
+
108
+ loaded: list[tuple[str, Node]] = []
109
+ relationships: list[tuple[str, str, str, str]] = []
110
+ for name, backend in sources:
111
+ loaded.extend((name, doc) for doc in backend.load_documents())
112
+ relationships.extend(
113
+ (name, rel.source_fqtn, rel.rel_type, rel.target_fqtn)
114
+ for rel in backend.list_relationships()
115
+ )
116
+ visible = {id(doc) for doc in visible_nodes(None, [doc for _, doc in loaded], profile=profile)}
117
+ hidden = (
118
+ (get_node_backend(doc) or holder, doc.fqn or doc.name)
119
+ for holder, doc in loaded
120
+ if id(doc) not in visible
121
+ )
122
+ return graph_from(
123
+ ((holder, doc) for holder, doc in loaded if id(doc) in visible),
124
+ relationships,
125
+ hidden=list(hidden),
126
+ )
@@ -0,0 +1,139 @@
1
+ # pyright: reportUnusedFunction=false
2
+ """``kata tax context NODE`` — what an agent working on a node is given, from the shell."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import click
10
+
11
+ if TYPE_CHECKING:
12
+ from katalyst_taxonomy_cli.context import CliContext
13
+
14
+ from katalyst_plugin_context.service import ContextService
15
+
16
+
17
+ def _service(ctx: CliContext, backend: str | None) -> ContextService:
18
+ from katalyst_taxonomy.config.backends import load_backends
19
+ from katalyst_taxonomy.config.routing import BackendAddressError
20
+ from katalyst_taxonomy.engine.bridge import get_default_registry
21
+
22
+ from katalyst_plugin_context.composition import service_for
23
+
24
+ try:
25
+ backends = load_backends(None, profile=ctx.profile)
26
+ sources = backends.read_set(backend)
27
+ except (BackendAddressError, ValueError) as exc:
28
+ raise click.ClickException(str(exc)) from exc
29
+ return service_for(get_default_registry(), sources, profile=backends.profile)
30
+
31
+
32
+ def _emit(payload: Any, fmt: str, markdown: str | None = None) -> None:
33
+ if fmt == "json":
34
+ click.echo(json.dumps(payload, indent=2, default=str))
35
+ else:
36
+ click.echo(markdown if markdown is not None else json.dumps(payload, indent=2), nl=False)
37
+
38
+
39
+ def create_command() -> click.Command:
40
+ """Entry point for ``katalyst_taxonomy.cli``: contributes ``kata tax context``."""
41
+
42
+ @click.command("context")
43
+ @click.argument("node")
44
+ @click.option(
45
+ "--map", "show_map", is_flag=True, help="Show the map: relationships, views, options."
46
+ )
47
+ @click.option(
48
+ "--policy",
49
+ "show_policy",
50
+ is_flag=True,
51
+ help="Show the effective policy for NODE: layers, limits and notes.",
52
+ )
53
+ @click.option(
54
+ "--path",
55
+ "paths",
56
+ multiple=True,
57
+ metavar="REL[:in|out[:KIND,...]][/...]",
58
+ help="A path to walk; repeatable.",
59
+ )
60
+ @click.option("--view", "views", multiple=True, metavar="NAME", help="A view; repeatable.")
61
+ @click.option("--depth", type=int, default=None, help="Repetitions of a single-step path.")
62
+ @click.option(
63
+ "--detail",
64
+ type=click.Choice(["refs", "summary", "full"]),
65
+ default=None,
66
+ help="Item detail.",
67
+ )
68
+ @click.option("--budget", type=int, default=None, metavar="TOKENS", help="Approximate tokens.")
69
+ @click.option("--inherit/--no-inherit", default=None, help="Also walk from NODE's ancestors.")
70
+ @click.option("--backend", default=None, help="Read only this backend.")
71
+ @click.option(
72
+ "--format",
73
+ "fmt",
74
+ type=click.Choice(["markdown", "json"]),
75
+ default="markdown",
76
+ show_default=True,
77
+ )
78
+ @click.pass_obj
79
+ def context(
80
+ ctx: CliContext,
81
+ node: str,
82
+ show_map: bool,
83
+ show_policy: bool,
84
+ paths: tuple[str, ...],
85
+ views: tuple[str, ...],
86
+ depth: int | None,
87
+ detail: str | None,
88
+ budget: int | None,
89
+ inherit: bool | None,
90
+ backend: str | None,
91
+ fmt: str,
92
+ ) -> None:
93
+ """What the taxonomy holds around NODE, and why each item is relevant.
94
+
95
+ NODE is a qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
96
+ Without --map or --policy, prints the views NODE's kind always includes, or the
97
+ whole neighbourhood at depth 1 if it declares none; with --path or --view, exactly
98
+ those. Policy from the schema, the node and the profile can only narrow a request.
99
+
100
+ Examples:
101
+
102
+ kata tax context repo:shop.api.web --map
103
+
104
+ kata tax context repo:shop.api.web --view some-view --budget 8000
105
+
106
+ kata tax context repo:shop.api.web --path 'SOME_REL:in' --detail full --format json
107
+ """
108
+ from katalyst_plugin_context.domain.context import ContextError, ContextRequest
109
+
110
+ try:
111
+ service = _service(ctx, backend)
112
+ if show_policy:
113
+ _emit(service.explain(node), "json")
114
+ return
115
+ if show_map:
116
+ payload = service.map(node)
117
+ _emit(
118
+ payload, fmt, service.markdown_for_map(payload) if fmt == "markdown" else None
119
+ )
120
+ return
121
+ request = ContextRequest(
122
+ paths=paths or None,
123
+ views=views or None,
124
+ depth=depth,
125
+ detail=detail,
126
+ budget=budget,
127
+ inherit=inherit,
128
+ format=fmt,
129
+ )
130
+ payload = service.query(node, request)
131
+ _emit(
132
+ payload,
133
+ fmt,
134
+ service.markdown_for_result(node, payload) if fmt == "markdown" else None,
135
+ )
136
+ except ContextError as exc:
137
+ raise click.ClickException(exc.message) from exc
138
+
139
+ return context
@@ -0,0 +1,49 @@
1
+ """Composition: a :class:`ContextService` for one call, from each host's own objects.
2
+
3
+ Every host reads the same way: the profile's backends (through its permission gate), its
4
+ filters, the schema registry, and the plugin's config for the active profile, read **per
5
+ call** from the current configuration, so a Settings save or a profile switch takes effect
6
+ on the next call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Mapping
12
+ from typing import Any
13
+
14
+ from katalyst_engine.schema.registry import SchemaRegistry
15
+ from katalyst_taxonomy.ports.backend import TaxonomyBackend
16
+
17
+ from katalyst_plugin_context.adapters.runtime_graph import build_graph
18
+ from katalyst_plugin_context.service import NAMESPACE, ContextService
19
+
20
+ __all__ = ["plugin_config", "service_for"]
21
+
22
+
23
+ def plugin_config(profile: str | None) -> tuple[Mapping[str, Any], str | None]:
24
+ """This plugin's ``spec.plugins[].config`` and the active profile's name.
25
+
26
+ ``({}, profile)`` when no configuration resolves (nothing configured yet): with nothing
27
+ declared, nothing narrows.
28
+ """
29
+ from katalyst_taxonomy.config.backends import resolve_effective_config
30
+ from katalyst_taxonomy.config.plugins import selection_from_config
31
+
32
+ try:
33
+ effective = resolve_effective_config(None, profile=profile)
34
+ except Exception: # noqa: BLE001 - no config is a normal first-run state
35
+ return {}, profile
36
+ selection = selection_from_config(effective.config, effective.selection.profile)
37
+ return selection.config_for(NAMESPACE), effective.selection.name or profile
38
+
39
+
40
+ def service_for(
41
+ registry: SchemaRegistry,
42
+ sources: Iterable[tuple[str, TaxonomyBackend]],
43
+ *,
44
+ profile: str | None,
45
+ ) -> ContextService:
46
+ """The service over what *sources* hold, under the profile named *profile*."""
47
+ config, active = plugin_config(profile)
48
+ graph = build_graph(sources, profile=active)
49
+ return ContextService(registry, graph, config=config, profile=active)
File without changes