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,97 @@
1
+ """The relationship graph one call walks: visible nodes and the edges between them.
2
+
3
+ Built once per call from what the profile's backends hold
4
+ (:mod:`katalyst_plugin_context.adapters.runtime_graph`) and walked in memory. Every node is
5
+ keyed by its qualified name, ``backend:fqtn``. The graph holds only nodes the profile may see,
6
+ and only edges whose both ends it may see, so a walk can never pass through a hidden node.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from collections.abc import Iterable, Iterator, Mapping
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from katalyst_plugin_context.domain.paths import IN, OUT
17
+
18
+ __all__ = ["Edge", "Graph", "GraphNode", "Neighbour"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class GraphNode:
23
+ """One visible node."""
24
+
25
+ key: str
26
+ """``backend:fqtn``: the node's qualified name."""
27
+ kind: str
28
+ name: str
29
+ fqn: str
30
+ backend: str
31
+ spec: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]())
32
+ description: str = ""
33
+ body: str | None = None
34
+ extensions: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]())
35
+ """The node's ``spec.extensions``: each plugin's section."""
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Edge:
40
+ """A relationship between two visible nodes."""
41
+
42
+ rel: str
43
+ source: str
44
+ target: str
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class Neighbour:
49
+ """An edge seen from one of its ends."""
50
+
51
+ rel: str
52
+ direction: str
53
+ other: str
54
+
55
+
56
+ class Graph:
57
+ """Visible nodes and the edges between them, indexed both ways."""
58
+
59
+ def __init__(
60
+ self, nodes: Iterable[GraphNode], edges: Iterable[Edge], *, dropped: int = 0
61
+ ) -> None:
62
+ self.nodes: dict[str, GraphNode] = {node.key: node for node in nodes}
63
+ self._out: dict[str, list[Edge]] = defaultdict(list)
64
+ self._in: dict[str, list[Edge]] = defaultdict(list)
65
+ seen: set[Edge] = set()
66
+ self.dropped = dropped
67
+ """Edges left out because an end is not a visible node (hidden, or not read)."""
68
+ for edge in edges:
69
+ if edge.source not in self.nodes or edge.target not in self.nodes:
70
+ self.dropped += 1
71
+ continue
72
+ if edge in seen:
73
+ continue
74
+ seen.add(edge)
75
+ self._out[edge.source].append(edge)
76
+ self._in[edge.target].append(edge)
77
+
78
+ def __contains__(self, key: object) -> bool:
79
+ return key in self.nodes
80
+
81
+ def neighbours(self, key: str) -> Iterator[Neighbour]:
82
+ """Every edge at *key*, outgoing first, in a stable order."""
83
+ for edge in sorted(self._out.get(key, ()), key=lambda e: (e.rel, e.target)):
84
+ yield Neighbour(edge.rel, OUT, edge.target)
85
+ for edge in sorted(self._in.get(key, ()), key=lambda e: (e.rel, e.source)):
86
+ yield Neighbour(edge.rel, IN, edge.source)
87
+
88
+ def ancestors(self, key: str, parent_rel: str) -> tuple[str, ...]:
89
+ """*key*'s ancestors, nearest first, along *parent_rel* (outgoing), stopping at a cycle."""
90
+ chain: list[str] = []
91
+ current = key
92
+ while True:
93
+ parents = sorted(e.target for e in self._out.get(current, ()) if e.rel == parent_rel)
94
+ if not parents or parents[0] in chain or parents[0] == key:
95
+ return tuple(chain)
96
+ current = parents[0]
97
+ chain.append(current)
@@ -0,0 +1,201 @@
1
+ """Agent-ready markdown for a context result or map, built from the same payload as JSON.
2
+
3
+ The two formats always agree because the markdown is rendered from the result object.
4
+ Item blocks are also what the budget measures when the markdown format is asked for
5
+ (:func:`item_size`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, Sequence
11
+ from typing import Any, cast
12
+
13
+ from katalyst_plugin_context.domain.context import estimate_tokens
14
+
15
+ __all__ = ["item_block", "item_size", "render_map", "render_result"]
16
+
17
+
18
+ def _blocking_marker(item: Mapping[str, Any]) -> str:
19
+ blocking = item.get("blocking")
20
+ entries = (
21
+ cast("list[Mapping[str, Any]]", blocking)
22
+ if isinstance(blocking, list)
23
+ else ([cast("Mapping[str, Any]", blocking)] if isinstance(blocking, Mapping) else [])
24
+ )
25
+ if not entries:
26
+ return ""
27
+ if any(e.get("blocking") for e in entries):
28
+ return "⛔ blocking · "
29
+ return "_advisory_ · "
30
+
31
+
32
+ def item_block(item: Mapping[str, Any]) -> str:
33
+ """One item as a markdown list entry."""
34
+ head = f"- {_blocking_marker(item)}**{item['title']}** `{item['qualifiedName']}`"
35
+ fields = cast("Mapping[str, Any]", item.get("fields") or {})
36
+ shown = [f"{k}: {v}" for k, v in fields.items() if not isinstance(v, (list, dict))]
37
+ lines = [head + (" · " + " · ".join(shown) if shown else "")]
38
+ description = item.get("description")
39
+ if description:
40
+ length = int(item.get("descriptionLength") or 0)
41
+ more = (
42
+ f" _({length:,} chars; detail=full for all)_"
43
+ if item.get("detail") != "full" and length > len(str(description))
44
+ else ""
45
+ )
46
+ lines.append(f" {str(description).strip()}{more}")
47
+ for why in cast("list[Mapping[str, Any]]", item.get("why") or []):
48
+ lines.append(f" why: {why['text']}")
49
+ if item.get("detail") == "full":
50
+ spec = cast("Mapping[str, Any]", item.get("spec") or {})
51
+ extra = {k: v for k, v in spec.items() if k not in fields}
52
+ for key, value in extra.items():
53
+ lines.append(f" {key}: {value}")
54
+ body = item.get("body")
55
+ if body:
56
+ lines.append("")
57
+ lines.extend(f" {line}" for line in str(body).strip().splitlines())
58
+ return "\n".join(lines)
59
+
60
+
61
+ def item_size(item: dict[str, Any]) -> int:
62
+ """The item's markdown block, in estimated tokens."""
63
+ return estimate_tokens(item_block(item))
64
+
65
+
66
+ def _heading(node: Mapping[str, Any]) -> list[str]:
67
+ lines = [f"# Context: {node['kind']} `{node['qualifiedName']}`"]
68
+ if node.get("description"):
69
+ lines.append(str(node["description"]))
70
+ return lines
71
+
72
+
73
+ def _ancestors(ancestors: Sequence[Mapping[str, Any]]) -> list[str]:
74
+ if not ancestors:
75
+ return []
76
+ chain = " ← ".join(f"{a['kind']} `{a['qualifiedName']}`" for a in ancestors)
77
+ return [f"Ancestors: {chain}"]
78
+
79
+
80
+ def render_result(
81
+ result: Mapping[str, Any],
82
+ *,
83
+ views: Mapping[str, Mapping[str, Any]] | None = None,
84
+ ancestors: Sequence[Mapping[str, Any]] = (),
85
+ level: int = 1,
86
+ ) -> str:
87
+ """A context result as markdown: grouped by view (then kind), then by asked path."""
88
+ lines: list[str] = []
89
+ if level == 1:
90
+ lines += _heading(cast("Mapping[str, Any]", result["node"]))
91
+ lines += _ancestors(ancestors)
92
+ items = cast("list[Mapping[str, Any]]", result.get("items") or [])
93
+ groups: dict[tuple[str | None, str], list[Mapping[str, Any]]] = {}
94
+ for item in items:
95
+ key = (item.get("view"), str(item.get("path")) if not item.get("view") else "")
96
+ groups.setdefault(key, []).append(item)
97
+ h = "#" * (level + 1)
98
+ for (view, path), members in groups.items():
99
+ lines.append("")
100
+ if view:
101
+ declared = (views or {}).get(view, {})
102
+ title = declared.get("title") or view
103
+ lines.append(f"{h} {title} _(view: {view})_")
104
+ if declared.get("description"):
105
+ lines.append(str(declared["description"]))
106
+ else:
107
+ lines.append(f"{h} `{path}`")
108
+ by_kind: dict[str, list[Mapping[str, Any]]] = {}
109
+ for item in members:
110
+ by_kind.setdefault(str(item["kind"]), []).append(item)
111
+ for kind, kind_items in by_kind.items():
112
+ lines.append(f"{h}# {kind}")
113
+ lines.extend(item_block(item) for item in kind_items)
114
+ if not items:
115
+ lines.append("")
116
+ lines.append("_Nothing here._")
117
+ truncated = cast("Mapping[str, Any] | None", result.get("truncated"))
118
+ if truncated:
119
+ lines.append("")
120
+ lines.append(
121
+ f"{h} Cut by the budget ({truncated['budget']:,} tokens; {truncated['used']:,} used)"
122
+ )
123
+ for group in cast("list[Mapping[str, Any]]", truncated.get("cut") or []):
124
+ names = ", ".join(f"`{n}`" for n in cast("list[str]", group["qualifiedNames"]))
125
+ where = f"view {group['view']}, " if group.get("view") else ""
126
+ lines.append(f"- {where}`{group['path']}`: {group['count']} cut — {names}")
127
+ downgraded = cast("list[Any]", truncated.get("downgraded") or [])
128
+ if downgraded:
129
+ lines.append(f"- {len(downgraded)} shown with less detail than asked")
130
+ hint = cast("Mapping[str, Any]", truncated.get("hint") or {})
131
+ if hint.get("budget"):
132
+ lines.append(f"More: ask again with budget {hint['budget']}.")
133
+ policy = cast("Mapping[str, Any]", result.get("policy") or {})
134
+ lines.extend(_policy_lines(policy))
135
+ return "\n".join(lines).strip() + "\n"
136
+
137
+
138
+ def _policy_lines(policy: Mapping[str, Any]) -> list[str]:
139
+ said: list[str] = []
140
+ for key in ("notes", "clamped", "refused"):
141
+ for entry in cast("list[Mapping[str, Any]]", policy.get(key) or []):
142
+ text = entry.get("text") or (
143
+ f"{entry.get('control')}: asked {entry.get('requested')}, got "
144
+ f"{entry.get('effective')} ({entry.get('limitedBy')})"
145
+ )
146
+ said.append(f"- {text}")
147
+ layers = " → ".join(cast("list[str]", policy.get("layers") or []))
148
+ if not said:
149
+ return ["", f"_Policy: {layers}; nothing narrowed._"] if layers else []
150
+ return ["", f"_Policy: {layers}_", *said]
151
+
152
+
153
+ def render_map(context_map: Mapping[str, Any]) -> str:
154
+ """A context map as markdown."""
155
+ lines = _heading(cast("Mapping[str, Any]", context_map["node"]))
156
+ lines += _ancestors(cast("list[Mapping[str, Any]]", context_map.get("ancestors") or []))
157
+ lines.append("")
158
+ lines.append("## Relationships")
159
+ rows = cast("list[Mapping[str, Any]]", context_map.get("relationships") or [])
160
+ if not rows:
161
+ lines.append("_None._")
162
+ for row in rows:
163
+ kinds = ", ".join(f"{k} ×{n}" for k, n in cast("Mapping[str, int]", row["kinds"]).items())
164
+ origin = "" if row["from"] == "self" else f" — inherited from `{row['from']}`"
165
+ lines.append(f"- `{row['path']}` → {kinds}{origin}")
166
+ hidden = cast("list[Mapping[str, Any]]", context_map.get("hiddenByPolicy") or [])
167
+ if hidden:
168
+ lines.append(
169
+ "- hidden by policy: " + ", ".join(f"`{h['path']}` ({h['limitedBy']})" for h in hidden)
170
+ )
171
+ view_rows = cast("list[Mapping[str, Any]]", context_map.get("views") or [])
172
+ if view_rows:
173
+ lines.append("")
174
+ lines.append("## Views")
175
+ for view in view_rows:
176
+ flag = " (always included)" if view.get("always") else ""
177
+ lines.append(f"- **{view['name']}**{flag}: {view['title']} — {view['count']} item(s)")
178
+ options = cast("Mapping[str, Any]", context_map.get("options") or {})
179
+ lines.append("")
180
+ lines.append("## Options")
181
+ steps = cast("list[str]", options.get("steps") or [])
182
+ lines.append(f"- steps: {', '.join(f'`{s}`' for s in steps) or 'none'}")
183
+ lines.append(
184
+ f"- max depth {options.get('maxDepth')}; detail {', '.join(options.get('detail') or [])}; "
185
+ f"budget {cast('Mapping[str, Any]', options.get('budget') or {}).get('default')} "
186
+ f"(max {cast('Mapping[str, Any]', options.get('budget') or {}).get('max')}); "
187
+ f"inheritance {'on' if options.get('inheritance') else 'off'}"
188
+ )
189
+ always = cast("Mapping[str, Any] | None", context_map.get("always"))
190
+ if always:
191
+ lines.append("")
192
+ lines.append("## Always included")
193
+ declared = {str(v["name"]): v for v in view_rows}
194
+ lines.append(render_result(always, views=declared, level=2).rstrip())
195
+ else:
196
+ lines.extend(_policy_lines(cast("Mapping[str, Any]", context_map.get("policy") or {})))
197
+ nxt = cast("list[str]", context_map.get("next") or [])
198
+ if nxt:
199
+ lines.append("")
200
+ lines.append("Next: " + "; ".join(f"`{n}`" for n in nxt))
201
+ return "\n".join(lines).strip() + "\n"
@@ -0,0 +1,114 @@
1
+ """Steps, paths and patterns: how a walk through the relationship graph is written.
2
+
3
+ A **step** follows one relationship type in one direction, optionally only to some kinds:
4
+ ``REL[:DIR[:KIND[,KIND...]]]``. ``REL`` is a graph label (``SOME_REL``) or ``*``. ``DIR`` is
5
+ ``out`` (the node is the source) or ``in`` (the node is the target), and empty or omitted
6
+ means both. ``KIND`` restricts the node at the far end. A **path** is steps joined by
7
+ ``/``, walked in order. A **pattern** is a single step used as a filter in a policy's
8
+ ``paths.allow`` / ``paths.deny``.
9
+
10
+ The strings are data a bundle, a profile or an agent writes. Nothing here knows any
11
+ relationship or kind.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ __all__ = [
20
+ "DIRECTIONS",
21
+ "IN",
22
+ "OUT",
23
+ "STEP_RE",
24
+ "PathSyntaxError",
25
+ "Step",
26
+ "parse_path",
27
+ "parse_step",
28
+ "path_text",
29
+ ]
30
+
31
+ OUT = "out"
32
+ IN = "in"
33
+ DIRECTIONS = (OUT, IN)
34
+
35
+ #: One step. Mirrored by the ``step`` definition of the plugin's JSON Schemas.
36
+ STEP_RE = r"(\*|[A-Z][A-Z0-9_]*)(:(in|out)?(:[a-z][a-z0-9_]*(,[a-z][a-z0-9_]*)*)?)?"
37
+ _STEP = re.compile(rf"^{STEP_RE}$")
38
+
39
+
40
+ class PathSyntaxError(ValueError):
41
+ """A step or path that does not follow ``REL[:in|out[:KIND,...]]``, joined by ``/``."""
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Step:
46
+ """One relationship type, one direction (or both), optionally only to some kinds."""
47
+
48
+ rel: str
49
+ direction: str | None = None
50
+ kinds: frozenset[str] = frozenset()
51
+
52
+ def matches(self, rel: str, direction: str, kind: str) -> bool:
53
+ """Whether traversing *rel* in *direction* to a node of *kind* is this step."""
54
+ return (
55
+ self.rel in ("*", rel)
56
+ and (self.direction is None or self.direction == direction)
57
+ and (not self.kinds or kind in self.kinds)
58
+ )
59
+
60
+ def overlaps(self, other: Step) -> bool:
61
+ """Whether some traversal matches both steps."""
62
+ return (
63
+ (self.rel == "*" or other.rel == "*" or self.rel == other.rel)
64
+ and (
65
+ self.direction is None
66
+ or other.direction is None
67
+ or self.direction == other.direction
68
+ )
69
+ and (not self.kinds or not other.kinds or bool(self.kinds & other.kinds))
70
+ )
71
+
72
+ @property
73
+ def text(self) -> str:
74
+ """The step written back: ``REL``, ``REL:out``, ``REL::kind`` or ``REL:in:a,b``."""
75
+ if self.kinds:
76
+ return f"{self.rel}:{self.direction or ''}:{','.join(sorted(self.kinds))}"
77
+ if self.direction:
78
+ return f"{self.rel}:{self.direction}"
79
+ return self.rel
80
+
81
+
82
+ def parse_step(text: str) -> Step:
83
+ """Parse ``REL[:DIR[:KIND,...]]``.
84
+
85
+ Raises:
86
+ PathSyntaxError: If *text* is not a step.
87
+ """
88
+ if not isinstance(text, str) or not _STEP.match(text.strip()): # pyright: ignore[reportUnnecessaryIsInstance]
89
+ raise PathSyntaxError(
90
+ f"{text!r} is not a step: write REL[:in|out[:kind,...]], e.g. 'SOME_REL:out'"
91
+ )
92
+ rel, _, rest = text.strip().partition(":")
93
+ direction, _, kinds = rest.partition(":")
94
+ return Step(
95
+ rel=rel,
96
+ direction=direction or None,
97
+ kinds=frozenset(k for k in kinds.split(",") if k),
98
+ )
99
+
100
+
101
+ def parse_path(text: str) -> tuple[Step, ...]:
102
+ """Parse steps joined by ``/``.
103
+
104
+ Raises:
105
+ PathSyntaxError: If any part is not a step.
106
+ """
107
+ if not isinstance(text, str) or not text.strip(): # pyright: ignore[reportUnnecessaryIsInstance]
108
+ raise PathSyntaxError(f"{text!r} is not a path: write steps joined by '/'")
109
+ return tuple(parse_step(part) for part in text.strip().split("/"))
110
+
111
+
112
+ def path_text(steps: tuple[Step, ...]) -> str:
113
+ """A path written back."""
114
+ return "/".join(step.text for step in steps)