katalyst-plugin-context 0.1.1a2__tar.gz → 0.2.1a4__tar.gz

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 (29) hide show
  1. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/PKG-INFO +1 -1
  2. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/pyproject.toml +1 -1
  3. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/cli.py +3 -2
  4. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/context.py +159 -39
  5. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/markdown.py +21 -15
  6. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/policy.py +8 -0
  7. katalyst_plugin_context-0.2.1a4/src/katalyst_plugin_context/lint.py +339 -0
  8. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/mcp_tools.py +7 -5
  9. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/service.py +2 -4
  10. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/test_context.py +158 -7
  11. katalyst_plugin_context-0.2.1a4/tests/test_lint.py +135 -0
  12. katalyst_plugin_context-0.1.1a2/src/katalyst_plugin_context/lint.py +0 -149
  13. katalyst_plugin_context-0.1.1a2/tests/test_lint.py +0 -71
  14. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/.gitignore +0 -0
  15. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/__init__.py +0 -0
  16. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
  17. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/adapters/runtime_graph.py +0 -0
  18. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/composition.py +0 -0
  19. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/__init__.py +0 -0
  20. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/graph.py +0 -0
  21. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/paths.py +0 -0
  22. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/settings.py +0 -0
  23. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/plugin.py +0 -0
  24. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/py.typed +0 -0
  25. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/rest.py +0 -0
  26. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/schema.py +0 -0
  27. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/conftest.py +0 -0
  28. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/context_support.py +0 -0
  29. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/test_surfaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: katalyst-plugin-context
3
- Version: 0.1.1a2
3
+ Version: 0.2.1a4
4
4
  Summary: Katalyst context plugin — the right context from the taxonomy for an agent working on a node
5
5
  Author: eSimplicity
6
6
  License: Proprietary
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-plugin-context"
3
- version = "0.1.1a2"
3
+ version = "0.2.1a4"
4
4
  description = "Katalyst context plugin — the right context from the taxonomy for an agent working on a node"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.12"
@@ -94,8 +94,9 @@ def create_command() -> click.Command:
94
94
 
95
95
  NODE is a qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
96
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.
97
+ whole neighbourhood at depth 1 if it declares none; with --path or --view, those
98
+ and the always-included views. Policy from the schema, the node and the profile can
99
+ only narrow a request, and only policy can take an always-included view out.
99
100
 
100
101
  Examples:
101
102
 
@@ -12,7 +12,7 @@ import json
12
12
  import math
13
13
  from collections.abc import Callable, Mapping, Sequence
14
14
  from dataclasses import dataclass, field
15
- from typing import Any, Protocol
15
+ from typing import Any, Protocol, cast
16
16
 
17
17
  from katalyst_plugin_context.domain.graph import Graph, GraphNode
18
18
  from katalyst_plugin_context.domain.paths import OUT, Step, parse_path, path_text
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "Schema",
26
26
  "build_map",
27
27
  "build_result",
28
+ "content_size",
28
29
  "estimate_tokens",
29
30
  ]
30
31
 
@@ -119,6 +120,7 @@ def _walk(
119
120
  policy: Policy,
120
121
  *,
121
122
  exclude: frozenset[str],
123
+ open_to_named: frozenset[str] = frozenset(),
122
124
  inherited: bool,
123
125
  parent_rel: str,
124
126
  collect_every_level: bool,
@@ -126,8 +128,10 @@ def _walk(
126
128
  """Every node reached from *start* by following *steps*, with the hops that reached it.
127
129
 
128
130
  A node never appears twice on one branch (cycles stop). *exclude* (the focus node and
129
- its ancestors) is never reached. From an ancestor, its structural parent edges are
130
- never followed: they would only reach siblings.
131
+ its ancestors) is never reached, except that a step which names its relationship
132
+ (not ``*``) may reach the nodes of *open_to_named*: asking for ``CHILD_OF:out`` is
133
+ asking for the ancestors. From an ancestor, its structural parent edges are never
134
+ followed: they would only reach siblings.
131
135
  """
132
136
  frontier: list[tuple[str, tuple[_Hop, ...]]] = [(start, ())]
133
137
  arrivals: list[_Arrival] = []
@@ -139,7 +143,11 @@ def _walk(
139
143
  if inherited and index == 0 and neighbour.rel == parent_rel:
140
144
  continue
141
145
  other = graph.nodes[neighbour.other]
142
- if neighbour.other in exclude or neighbour.other in on_branch:
146
+ if neighbour.other in on_branch:
147
+ continue
148
+ if neighbour.other in exclude and not (
149
+ step.rel != "*" and neighbour.other in open_to_named
150
+ ):
143
151
  continue
144
152
  if not step.matches(neighbour.rel, neighbour.direction, other.kind):
145
153
  continue
@@ -290,19 +298,50 @@ def _item_payload(
290
298
  return payload
291
299
 
292
300
 
301
+ def _higher(a: str, b: str) -> str:
302
+ return max(a, b, key=DETAIL_LEVELS.index)
303
+
304
+
293
305
  def _lower(level: str) -> list[str]:
294
306
  """*level* and every level below it, highest first."""
295
307
  return list(reversed(DETAIL_LEVELS[: DETAIL_LEVELS.index(level) + 1]))
296
308
 
297
309
 
298
- # ── markdown sizes (shared with the renderer) ───────────────────────────
310
+ # ── the budget's measure ─────────────────────────────────────────────────
299
311
 
300
312
 
301
313
  SizeOf = Callable[[dict[str, Any]], int]
302
314
 
303
315
 
304
- def json_size(item: dict[str, Any]) -> int:
305
- return estimate_tokens(json.dumps(item, separators=(",", ":"), default=str))
316
+ def _text(value: object) -> str:
317
+ return value if isinstance(value, str) else json.dumps(value, default=str, ensure_ascii=False)
318
+
319
+
320
+ def content_size(item: Mapping[str, Any]) -> int:
321
+ """The item's content in estimated tokens, whatever format the answer is given in.
322
+
323
+ Counts what a reader reads: the qualified name, kind and title, each ``why``, each
324
+ blocking value, the description, the fields, and at ``full`` the spec and the body.
325
+ No keys, punctuation or markup, so a JSON and a markdown answer hold the same items
326
+ for the same budget.
327
+ """
328
+ parts: list[str] = [str(item["qualifiedName"]), str(item["kind"]), str(item["title"])]
329
+ parts.extend(str(why["text"]) for why in item.get("why") or ())
330
+ blocking = item.get("blocking")
331
+ entries = cast(
332
+ "list[Mapping[str, Any]]",
333
+ blocking if isinstance(blocking, list) else [blocking] if blocking else [],
334
+ )
335
+ parts.extend(f"{entry['field']}: {entry['value']}" for entry in entries)
336
+ if item.get("description"):
337
+ parts.append(str(item["description"]))
338
+ fields: Mapping[str, Any] = item.get("fields") or {}
339
+ parts.extend(f"{name}: {_text(value)}" for name, value in fields.items())
340
+ spec: Mapping[str, Any] = item.get("spec") or {}
341
+ parts.extend(f"{name}: {_text(value)}" for name, value in spec.items() if name not in fields)
342
+ if item.get("body"):
343
+ parts.append(str(item["body"]))
344
+ return estimate_tokens("\n".join(parts))
306
345
 
307
346
 
308
347
  # ── the result ───────────────────────────────────────────────────────────
@@ -319,10 +358,20 @@ def _sources(
319
358
  inherit: bool,
320
359
  refused: list[dict[str, Any]],
321
360
  notes: list[dict[str, Any]],
361
+ dropped: list[dict[str, Any]],
322
362
  ) -> list[_Source]:
363
+ """What to walk: the always-included views first, then what the request names.
364
+
365
+ An always-included view the request does not name keeps its own detail and
366
+ inheritance (clamped by policy): the request's ``detail`` and ``inherit`` apply to what
367
+ it names. A bare request names the always-included views.
368
+ """
323
369
  sources: list[_Source] = []
324
370
 
325
- def add_view(view: View, section: int) -> None:
371
+ def add_view(view: View, section: int, *, detail: str, walks_up: bool) -> str | None:
372
+ """Add *view*'s paths; the layer that left it none, or ``None``."""
373
+ limited: str | None = None
374
+ kept = False
326
375
  for index, steps in enumerate(view.paths):
327
376
  hidden = next(
328
377
  (by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
@@ -336,6 +385,7 @@ def _sources(
336
385
  "text": f"view {view.name!r} path {path_text(steps)!r} is hidden by {hidden}",
337
386
  }
338
387
  )
388
+ limited = limited or hidden
339
389
  continue
340
390
  if len(steps) > policy.max_depth:
341
391
  notes.append(
@@ -349,29 +399,58 @@ def _sources(
349
399
  )
350
400
  steps = steps[: policy.max_depth]
351
401
  if not steps:
402
+ limited = limited or policy.max_depth_by
352
403
  continue
404
+ kept = True
353
405
  sources.append(
354
406
  _Source(
355
407
  section=section,
356
408
  view=view,
357
409
  steps=steps,
358
410
  path_index=index,
359
- detail=detail_for(request.detail or view.detail),
360
- inherit=view.inherit and inherit,
411
+ detail=detail,
412
+ inherit=walks_up,
361
413
  repeat=1,
362
414
  )
363
415
  )
364
-
365
- if request.names_nothing:
366
- for name in sorted(always, key=lambda n: -views[n].priority):
367
- add_view(views[name], 0)
368
- if not sources and not always:
416
+ return None if kept else limited
417
+
418
+ bare = request.names_nothing
419
+ named = set(request.views or ())
420
+ default_inherit, _ = policy.clamp_inherit(None)
421
+ for name in sorted(always, key=lambda n: -views[n].priority):
422
+ view = views[name]
423
+ asked = bare or name in named
424
+ limited = add_view(
425
+ view,
426
+ 0,
427
+ detail=detail_for(request.detail or view.detail) if asked else detail_for(view.detail),
428
+ walks_up=view.inherit and (inherit if asked else default_inherit),
429
+ )
430
+ if limited is not None:
431
+ dropped.append(
432
+ {
433
+ "view": name,
434
+ "limitedBy": limited,
435
+ "text": f"always-included view {name!r} has no path left by {limited}",
436
+ }
437
+ )
438
+ if bare:
439
+ if not always:
369
440
  sources.append(
370
441
  _Source(3, None, (Step("*"),), 0, detail_for(request.detail), inherit, depth)
371
442
  )
372
443
  return sources
373
444
  for name in sorted(request.views or (), key=lambda n: -views[n].priority):
374
- add_view(views[name], 1)
445
+ if name in always:
446
+ continue
447
+ view = views[name]
448
+ add_view(
449
+ view,
450
+ 1,
451
+ detail=detail_for(request.detail or view.detail),
452
+ walks_up=view.inherit and inherit,
453
+ )
375
454
  for index, text in enumerate(request.paths or ()):
376
455
  steps = parse_path(text)
377
456
  hidden = next(
@@ -413,12 +492,20 @@ def _collect(
413
492
  ancestors: Sequence[str],
414
493
  policy: Policy,
415
494
  sources: Sequence[_Source],
495
+ *,
496
+ inherit: bool,
416
497
  ) -> tuple[dict[str, _Entry], list[dict[str, Any]]]:
498
+ """Every item *sources* reach, at its best rank, and each view's and path's members.
499
+
500
+ The ancestors are never items of a source that inherits, or of any source when the
501
+ call inherits, unless a step names the relationship that reaches them; otherwise
502
+ (inheritance off) they can be.
503
+ """
417
504
  entries: dict[str, _Entry] = {}
418
- exclude = frozenset({focus, *ancestors})
419
505
  group_members: dict[tuple[str | None, str], set[str]] = {}
420
506
  for source in sources:
421
507
  starts = [focus, *(ancestors if source.inherit else ())]
508
+ exclude = frozenset({focus, *(ancestors if source.inherit or inherit else ())})
422
509
  steps = source.steps * source.repeat if source.repeat > 1 else source.steps
423
510
  text = path_text(source.steps)
424
511
  group = group_members.setdefault((source.view.name if source.view else None, text), set())
@@ -429,6 +516,7 @@ def _collect(
429
516
  steps,
430
517
  policy,
431
518
  exclude=exclude,
519
+ open_to_named=frozenset(ancestors),
432
520
  inherited=start != focus,
433
521
  parent_rel=schema.parent_rel,
434
522
  collect_every_level=source.repeat > 1,
@@ -456,6 +544,8 @@ def _collect(
456
544
  )
457
545
  group.add(node.key)
458
546
  entry = entries.get(node.key)
547
+ # The best-ranked source places the item; the highest detail asked shows it.
548
+ detail = _higher(entry.detail, source.detail) if entry else source.detail
459
549
  if entry is None or rank < entry.rank:
460
550
  previous = entry.whys if entry is not None else []
461
551
  entry = _Entry(
@@ -463,13 +553,14 @@ def _collect(
463
553
  rank=rank,
464
554
  view=source.view.name if source.view else None,
465
555
  path=text,
466
- detail=source.detail,
556
+ detail=detail,
467
557
  inherited_from=None if start == focus else start,
468
558
  depth=len(arrival.hops),
469
559
  whys=previous,
470
560
  blocking=blocking,
471
561
  )
472
562
  entries[node.key] = entry
563
+ entry.detail = detail
473
564
  if not any(w[1].hops == arrival.hops for w in entry.whys):
474
565
  entry.whys.append((rank, arrival, text))
475
566
  groups = [
@@ -479,6 +570,10 @@ def _collect(
479
570
  return entries, groups
480
571
 
481
572
 
573
+ def _blocks(entry: _Entry) -> bool:
574
+ return any(b.get("blocking") for b in entry.blocking)
575
+
576
+
482
577
  def build_result(
483
578
  graph: Graph,
484
579
  schema: Schema,
@@ -487,9 +582,16 @@ def build_result(
487
582
  views: Mapping[str, View],
488
583
  request: ContextRequest,
489
584
  *,
490
- size_of: SizeOf = json_size,
585
+ size_of: SizeOf = content_size,
586
+ include_always: bool = True,
491
587
  ) -> dict[str, Any]:
492
- """The items *request* asks for around *focus*, budgeted, with why each is here."""
588
+ """The items *request* asks for around *focus*, budgeted, with why each is here.
589
+
590
+ The always-included views are in every answer, whatever the request names; policy
591
+ alone can take them out. The budget places blocking items first, then the rest, each
592
+ in rank order (so always-included items first): no blocking item is cut while a
593
+ non-blocking one stays. *include_always* is off only for the map's per-view counts.
594
+ """
493
595
  unknown = [name for name in request.views or () if name not in views]
494
596
  if unknown:
495
597
  raise ContextError(
@@ -520,8 +622,20 @@ def build_result(
520
622
  requested_detail_note.append(note)
521
623
  return level
522
624
 
523
- ancestors = graph.ancestors(focus, schema.parent_rel) if inherit else ()
524
- always = [name for name in policy.always or () if name in views]
625
+ always = [name for name in policy.always or () if name in views] if include_always else []
626
+ dropped_always: list[dict[str, Any]] = (
627
+ [
628
+ {
629
+ "view": name,
630
+ "limitedBy": by,
631
+ "text": f"{by} takes {name!r} out of the always-included views",
632
+ }
633
+ for name, by in policy.always_dropped
634
+ if name in views
635
+ ]
636
+ if include_always
637
+ else []
638
+ )
525
639
  sources = _sources(
526
640
  request,
527
641
  policy,
@@ -532,39 +646,43 @@ def build_result(
532
646
  inherit=inherit,
533
647
  refused=refused,
534
648
  notes=notes,
649
+ dropped=dropped_always,
535
650
  )
536
651
  clamped.extend(requested_detail_note)
537
- entries, groups = _collect(graph, schema, focus, ancestors, policy, sources)
652
+ walks_up = inherit or any(s.inherit for s in sources)
653
+ ancestors = graph.ancestors(focus, schema.parent_rel) if walks_up else ()
654
+ entries, groups = _collect(graph, schema, focus, ancestors, policy, sources, inherit=inherit)
538
655
  ordered = sorted(entries.values(), key=lambda e: e.rank)
656
+ placing = sorted(ordered, key=lambda e: (not _blocks(e), e.rank))
539
657
 
540
- items: list[dict[str, Any]] = []
658
+ placed: dict[str, dict[str, Any]] = {}
541
659
  downgraded: list[dict[str, Any]] = []
542
660
  cut: list[_Entry] = []
543
661
  used = 0
544
- for position, entry in enumerate(ordered):
545
- placed = False
662
+ for position, entry in enumerate(placing):
663
+ fits = False
546
664
  for level in _lower(entry.detail):
547
665
  payload = _item_payload(entry, level, graph, schema, focus)
548
666
  cost = size_of(payload)
549
667
  if used + cost <= budget:
550
- items.append(payload)
668
+ placed[entry.node.key] = payload
551
669
  used += cost
552
670
  if level != entry.detail:
553
671
  downgraded.append(
554
672
  {"qualifiedName": entry.node.key, "from": entry.detail, "to": level}
555
673
  )
556
- placed = True
674
+ fits = True
557
675
  break
558
- if not placed:
559
- cut = ordered[position:]
676
+ if not fits:
677
+ cut = sorted(placing[position:], key=lambda e: e.rank)
560
678
  break
679
+ items = [placed[e.node.key] for e in ordered if e.node.key in placed]
561
680
 
562
- returned = {item["qualifiedName"] for item in items}
563
681
  for group in groups:
564
682
  group["returned"] = sum(
565
683
  1
566
684
  for e in entries.values()
567
- if e.node.key in returned and (e.view, e.path) == (group["view"], group["path"])
685
+ if e.node.key in placed and (e.view, e.path) == (group["view"], group["path"])
568
686
  )
569
687
  truncated: dict[str, Any] | None = None
570
688
  if cut or downgraded:
@@ -586,13 +704,16 @@ def build_result(
586
704
  for (view, path), names in by_group.items()
587
705
  ],
588
706
  "cutTotal": len(cut),
707
+ "cutBlocking": sum(1 for e in cut if _blocks(e)),
589
708
  "hint": {"budget": min(max(whole, budget + 1), policy.budget_max)},
590
709
  }
710
+ included = list(dict.fromkeys(s.view.name for s in sources if s.view is not None))
591
711
  return {
592
712
  "node": _node_payload(graph, schema, focus),
593
713
  "request": request.as_payload(),
594
714
  "effective": {
595
- "views": list(dict.fromkeys(s.view.name for s in sources if s.view is not None)),
715
+ "views": included,
716
+ "always": [name for name in always if name in included],
596
717
  "paths": [path_text(s.steps) for s in sources if s.view is None],
597
718
  "depth": depth,
598
719
  "detail": detail_for(request.detail) if request.detail else None,
@@ -603,7 +724,7 @@ def build_result(
603
724
  "groups": groups,
604
725
  "truncated": truncated,
605
726
  "used": used,
606
- "dropped": {"unresolved": graph.dropped},
727
+ "dropped": {"unresolved": graph.dropped, "always": dropped_always},
607
728
  "policy": {
608
729
  "layers": list(policy.layers),
609
730
  "notes": notes,
@@ -633,8 +754,6 @@ def build_map(
633
754
  focus: str,
634
755
  policy: Policy,
635
756
  views: Mapping[str, View],
636
- *,
637
- size_of: SizeOf = json_size,
638
757
  ) -> dict[str, Any]:
639
758
  """Every relationship around *focus*, what it inherits, its views and the options."""
640
759
  ancestors = graph.ancestors(focus, schema.parent_rel) if policy.inheritance else ()
@@ -686,6 +805,7 @@ def build_map(
686
805
 
687
806
  view_rows: list[dict[str, Any]] = []
688
807
  always = [name for name in policy.always or () if name in views]
808
+ dropped_by = dict(policy.always_dropped)
689
809
  for name, view in sorted(views.items(), key=lambda kv: (-kv[1].priority, kv[0])):
690
810
  counted = build_result(
691
811
  graph,
@@ -695,6 +815,7 @@ def build_map(
695
815
  views,
696
816
  ContextRequest(views=(name,), detail=REFS, budget=policy.budget_max),
697
817
  size_of=lambda _item: 0,
818
+ include_always=False,
698
819
  )
699
820
  view_rows.append(
700
821
  {
@@ -705,13 +826,12 @@ def build_map(
705
826
  "inherit": view.inherit,
706
827
  "detail": view.detail,
707
828
  "always": name in always,
829
+ "droppedBy": dropped_by.get(name),
708
830
  "count": len(counted["items"]),
709
831
  }
710
832
  )
711
833
  always_result = (
712
- build_result(graph, schema, focus, policy, views, ContextRequest(), size_of=size_of)
713
- if always
714
- else None
834
+ build_result(graph, schema, focus, policy, views, ContextRequest()) if always else None
715
835
  )
716
836
  address = focus
717
837
  suggestions = [
@@ -1,8 +1,8 @@
1
1
  """Agent-ready markdown for a context result or map, built from the same payload as JSON.
2
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`).
3
+ The two formats always agree because the markdown is rendered from the result object,
4
+ and the budget measures an item's content, not either format
5
+ (:func:`~katalyst_plugin_context.domain.context.content_size`).
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -10,9 +10,7 @@ from __future__ import annotations
10
10
  from collections.abc import Mapping, Sequence
11
11
  from typing import Any, cast
12
12
 
13
- from katalyst_plugin_context.domain.context import estimate_tokens
14
-
15
- __all__ = ["item_block", "item_size", "render_map", "render_result"]
13
+ __all__ = ["item_block", "render_map", "render_result"]
16
14
 
17
15
 
18
16
  def _blocking_marker(item: Mapping[str, Any]) -> str:
@@ -58,11 +56,6 @@ def item_block(item: Mapping[str, Any]) -> str:
58
56
  return "\n".join(lines)
59
57
 
60
58
 
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
59
  def _heading(node: Mapping[str, Any]) -> list[str]:
67
60
  lines = [f"# Context: {node['kind']} `{node['qualifiedName']}`"]
68
61
  if node.get("description"):
@@ -124,6 +117,8 @@ def render_result(
124
117
  names = ", ".join(f"`{n}`" for n in cast("list[str]", group["qualifiedNames"]))
125
118
  where = f"view {group['view']}, " if group.get("view") else ""
126
119
  lines.append(f"- {where}`{group['path']}`: {group['count']} cut — {names}")
120
+ if truncated.get("cutBlocking"):
121
+ lines.append(f"- ⛔ {truncated['cutBlocking']} of them blocking")
127
122
  downgraded = cast("list[Any]", truncated.get("downgraded") or [])
128
123
  if downgraded:
129
124
  lines.append(f"- {len(downgraded)} shown with less detail than asked")
@@ -131,12 +126,17 @@ def render_result(
131
126
  if hint.get("budget"):
132
127
  lines.append(f"More: ask again with budget {hint['budget']}.")
133
128
  policy = cast("Mapping[str, Any]", result.get("policy") or {})
134
- lines.extend(_policy_lines(policy))
129
+ dropped = cast("Mapping[str, Any]", result.get("dropped") or {})
130
+ lines.extend(
131
+ _policy_lines(policy, cast("list[Mapping[str, Any]]", dropped.get("always") or []))
132
+ )
135
133
  return "\n".join(lines).strip() + "\n"
136
134
 
137
135
 
138
- def _policy_lines(policy: Mapping[str, Any]) -> list[str]:
139
- said: list[str] = []
136
+ def _policy_lines(
137
+ policy: Mapping[str, Any], dropped_always: Sequence[Mapping[str, Any]] = ()
138
+ ) -> list[str]:
139
+ said: list[str] = [f"- {entry['text']}" for entry in dropped_always]
140
140
  for key in ("notes", "clamped", "refused"):
141
141
  for entry in cast("list[Mapping[str, Any]]", policy.get(key) or []):
142
142
  text = entry.get("text") or (
@@ -173,7 +173,13 @@ def render_map(context_map: Mapping[str, Any]) -> str:
173
173
  lines.append("")
174
174
  lines.append("## Views")
175
175
  for view in view_rows:
176
- flag = " (always included)" if view.get("always") else ""
176
+ flag = (
177
+ " (always included)"
178
+ if view.get("always")
179
+ else f" (taken out of always-included by {view['droppedBy']})"
180
+ if view.get("droppedBy")
181
+ else ""
182
+ )
177
183
  lines.append(f"- **{view['name']}**{flag}: {view['title']} — {view['count']} item(s)")
178
184
  options = cast("Mapping[str, Any]", context_map.get("options") or {})
179
185
  lines.append("")
@@ -14,6 +14,9 @@ Each control narrows in its own way:
14
14
  ``always_include`` the first declaring layer sets it; later layers intersect
15
15
  ================= ===========================================================
16
16
 
17
+ A view a later layer takes out of ``always_include`` is recorded (:attr:`Policy.always_dropped`)
18
+ so an answer can say which layer excluded it: the operator's control, not a widening.
19
+
17
20
  A layer that tries to widen is not an error. Its declaration has no effect, and the
18
21
  effective policy records a note saying which layer limited it. Defaults (``depth``,
19
22
  ``detail``, ``budget``, ``inherit``) are not limits: a later layer's default replaces an
@@ -73,6 +76,8 @@ class Policy:
73
76
  inheritance_by: str = BUILTIN
74
77
  always: tuple[str, ...] | None = None
75
78
  always_by: str = ""
79
+ always_dropped: list[tuple[str, str]] = field(default_factory=lambda: list[tuple[str, str]]())
80
+ """Views an earlier layer always included and a later one took out, with that layer."""
76
81
  default_depth: int = _BUILTIN_DEPTH
77
82
  default_detail: str | None = None
78
83
  default_budget: int = _BUILTIN_BUDGET
@@ -160,6 +165,7 @@ class Policy:
160
165
  },
161
166
  "inheritance": self.inheritance,
162
167
  "alwaysInclude": list(self.always or ()),
168
+ "alwaysDropped": [{"view": v, "limitedBy": by} for v, by in self.always_dropped],
163
169
  "notes": list(self.notes),
164
170
  }
165
171
 
@@ -327,6 +333,8 @@ def _fold_limits(policy: Policy, layer: Layer, declared_views: set[str]) -> None
327
333
  )
328
334
  )
329
335
  kept = tuple(n for n in names if n in declared_views)
336
+ if policy.always is not None:
337
+ policy.always_dropped.extend((n, layer.label) for n in policy.always if n not in kept)
330
338
  policy.always = (
331
339
  kept if policy.always is None else tuple(n for n in policy.always if n in kept)
332
340
  )