katalyst-plugin-context 0.1.1a2__tar.gz → 0.2.1a3__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.1a3}/PKG-INFO +1 -1
  2. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/pyproject.toml +1 -1
  3. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/cli.py +3 -2
  4. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/context.py +147 -36
  5. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/markdown.py +21 -15
  6. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/policy.py +8 -0
  7. katalyst_plugin_context-0.2.1a3/src/katalyst_plugin_context/lint.py +339 -0
  8. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/mcp_tools.py +7 -5
  9. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/service.py +2 -4
  10. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/test_context.py +121 -7
  11. katalyst_plugin_context-0.2.1a3/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.1a3}/.gitignore +0 -0
  15. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/__init__.py +0 -0
  16. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
  17. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/adapters/runtime_graph.py +0 -0
  18. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/composition.py +0 -0
  19. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/__init__.py +0 -0
  20. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/graph.py +0 -0
  21. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/paths.py +0 -0
  22. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/settings.py +0 -0
  23. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/plugin.py +0 -0
  24. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/py.typed +0 -0
  25. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/rest.py +0 -0
  26. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/schema.py +0 -0
  27. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/conftest.py +0 -0
  28. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/context_support.py +0 -0
  29. {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/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.1a3
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.1a3"
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
 
@@ -290,19 +291,50 @@ def _item_payload(
290
291
  return payload
291
292
 
292
293
 
294
+ def _higher(a: str, b: str) -> str:
295
+ return max(a, b, key=DETAIL_LEVELS.index)
296
+
297
+
293
298
  def _lower(level: str) -> list[str]:
294
299
  """*level* and every level below it, highest first."""
295
300
  return list(reversed(DETAIL_LEVELS[: DETAIL_LEVELS.index(level) + 1]))
296
301
 
297
302
 
298
- # ── markdown sizes (shared with the renderer) ───────────────────────────
303
+ # ── the budget's measure ─────────────────────────────────────────────────
299
304
 
300
305
 
301
306
  SizeOf = Callable[[dict[str, Any]], int]
302
307
 
303
308
 
304
- def json_size(item: dict[str, Any]) -> int:
305
- return estimate_tokens(json.dumps(item, separators=(",", ":"), default=str))
309
+ def _text(value: object) -> str:
310
+ return value if isinstance(value, str) else json.dumps(value, default=str, ensure_ascii=False)
311
+
312
+
313
+ def content_size(item: Mapping[str, Any]) -> int:
314
+ """The item's content in estimated tokens, whatever format the answer is given in.
315
+
316
+ Counts what a reader reads: the qualified name, kind and title, each ``why``, each
317
+ blocking value, the description, the fields, and at ``full`` the spec and the body.
318
+ No keys, punctuation or markup, so a JSON and a markdown answer hold the same items
319
+ for the same budget.
320
+ """
321
+ parts: list[str] = [str(item["qualifiedName"]), str(item["kind"]), str(item["title"])]
322
+ parts.extend(str(why["text"]) for why in item.get("why") or ())
323
+ blocking = item.get("blocking")
324
+ entries = cast(
325
+ "list[Mapping[str, Any]]",
326
+ blocking if isinstance(blocking, list) else [blocking] if blocking else [],
327
+ )
328
+ parts.extend(f"{entry['field']}: {entry['value']}" for entry in entries)
329
+ if item.get("description"):
330
+ parts.append(str(item["description"]))
331
+ fields: Mapping[str, Any] = item.get("fields") or {}
332
+ parts.extend(f"{name}: {_text(value)}" for name, value in fields.items())
333
+ spec: Mapping[str, Any] = item.get("spec") or {}
334
+ parts.extend(f"{name}: {_text(value)}" for name, value in spec.items() if name not in fields)
335
+ if item.get("body"):
336
+ parts.append(str(item["body"]))
337
+ return estimate_tokens("\n".join(parts))
306
338
 
307
339
 
308
340
  # ── the result ───────────────────────────────────────────────────────────
@@ -319,10 +351,20 @@ def _sources(
319
351
  inherit: bool,
320
352
  refused: list[dict[str, Any]],
321
353
  notes: list[dict[str, Any]],
354
+ dropped: list[dict[str, Any]],
322
355
  ) -> list[_Source]:
356
+ """What to walk: the always-included views first, then what the request names.
357
+
358
+ An always-included view the request does not name keeps its own detail and
359
+ inheritance (clamped by policy): the request's ``detail`` and ``inherit`` apply to what
360
+ it names. A bare request names the always-included views.
361
+ """
323
362
  sources: list[_Source] = []
324
363
 
325
- def add_view(view: View, section: int) -> None:
364
+ def add_view(view: View, section: int, *, detail: str, walks_up: bool) -> str | None:
365
+ """Add *view*'s paths; the layer that left it none, or ``None``."""
366
+ limited: str | None = None
367
+ kept = False
326
368
  for index, steps in enumerate(view.paths):
327
369
  hidden = next(
328
370
  (by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
@@ -336,6 +378,7 @@ def _sources(
336
378
  "text": f"view {view.name!r} path {path_text(steps)!r} is hidden by {hidden}",
337
379
  }
338
380
  )
381
+ limited = limited or hidden
339
382
  continue
340
383
  if len(steps) > policy.max_depth:
341
384
  notes.append(
@@ -349,29 +392,58 @@ def _sources(
349
392
  )
350
393
  steps = steps[: policy.max_depth]
351
394
  if not steps:
395
+ limited = limited or policy.max_depth_by
352
396
  continue
397
+ kept = True
353
398
  sources.append(
354
399
  _Source(
355
400
  section=section,
356
401
  view=view,
357
402
  steps=steps,
358
403
  path_index=index,
359
- detail=detail_for(request.detail or view.detail),
360
- inherit=view.inherit and inherit,
404
+ detail=detail,
405
+ inherit=walks_up,
361
406
  repeat=1,
362
407
  )
363
408
  )
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:
409
+ return None if kept else limited
410
+
411
+ bare = request.names_nothing
412
+ named = set(request.views or ())
413
+ default_inherit, _ = policy.clamp_inherit(None)
414
+ for name in sorted(always, key=lambda n: -views[n].priority):
415
+ view = views[name]
416
+ asked = bare or name in named
417
+ limited = add_view(
418
+ view,
419
+ 0,
420
+ detail=detail_for(request.detail or view.detail) if asked else detail_for(view.detail),
421
+ walks_up=view.inherit and (inherit if asked else default_inherit),
422
+ )
423
+ if limited is not None:
424
+ dropped.append(
425
+ {
426
+ "view": name,
427
+ "limitedBy": limited,
428
+ "text": f"always-included view {name!r} has no path left by {limited}",
429
+ }
430
+ )
431
+ if bare:
432
+ if not always:
369
433
  sources.append(
370
434
  _Source(3, None, (Step("*"),), 0, detail_for(request.detail), inherit, depth)
371
435
  )
372
436
  return sources
373
437
  for name in sorted(request.views or (), key=lambda n: -views[n].priority):
374
- add_view(views[name], 1)
438
+ if name in always:
439
+ continue
440
+ view = views[name]
441
+ add_view(
442
+ view,
443
+ 1,
444
+ detail=detail_for(request.detail or view.detail),
445
+ walks_up=view.inherit and inherit,
446
+ )
375
447
  for index, text in enumerate(request.paths or ()):
376
448
  steps = parse_path(text)
377
449
  hidden = next(
@@ -413,12 +485,19 @@ def _collect(
413
485
  ancestors: Sequence[str],
414
486
  policy: Policy,
415
487
  sources: Sequence[_Source],
488
+ *,
489
+ inherit: bool,
416
490
  ) -> tuple[dict[str, _Entry], list[dict[str, Any]]]:
491
+ """Every item *sources* reach, at its best rank, and each view's and path's members.
492
+
493
+ The ancestors are never items of a source that inherits, or of any source when the
494
+ call inherits; otherwise (inheritance off) they can be.
495
+ """
417
496
  entries: dict[str, _Entry] = {}
418
- exclude = frozenset({focus, *ancestors})
419
497
  group_members: dict[tuple[str | None, str], set[str]] = {}
420
498
  for source in sources:
421
499
  starts = [focus, *(ancestors if source.inherit else ())]
500
+ exclude = frozenset({focus, *(ancestors if source.inherit or inherit else ())})
422
501
  steps = source.steps * source.repeat if source.repeat > 1 else source.steps
423
502
  text = path_text(source.steps)
424
503
  group = group_members.setdefault((source.view.name if source.view else None, text), set())
@@ -456,6 +535,8 @@ def _collect(
456
535
  )
457
536
  group.add(node.key)
458
537
  entry = entries.get(node.key)
538
+ # The best-ranked source places the item; the highest detail asked shows it.
539
+ detail = _higher(entry.detail, source.detail) if entry else source.detail
459
540
  if entry is None or rank < entry.rank:
460
541
  previous = entry.whys if entry is not None else []
461
542
  entry = _Entry(
@@ -463,13 +544,14 @@ def _collect(
463
544
  rank=rank,
464
545
  view=source.view.name if source.view else None,
465
546
  path=text,
466
- detail=source.detail,
547
+ detail=detail,
467
548
  inherited_from=None if start == focus else start,
468
549
  depth=len(arrival.hops),
469
550
  whys=previous,
470
551
  blocking=blocking,
471
552
  )
472
553
  entries[node.key] = entry
554
+ entry.detail = detail
473
555
  if not any(w[1].hops == arrival.hops for w in entry.whys):
474
556
  entry.whys.append((rank, arrival, text))
475
557
  groups = [
@@ -479,6 +561,10 @@ def _collect(
479
561
  return entries, groups
480
562
 
481
563
 
564
+ def _blocks(entry: _Entry) -> bool:
565
+ return any(b.get("blocking") for b in entry.blocking)
566
+
567
+
482
568
  def build_result(
483
569
  graph: Graph,
484
570
  schema: Schema,
@@ -487,9 +573,16 @@ def build_result(
487
573
  views: Mapping[str, View],
488
574
  request: ContextRequest,
489
575
  *,
490
- size_of: SizeOf = json_size,
576
+ size_of: SizeOf = content_size,
577
+ include_always: bool = True,
491
578
  ) -> dict[str, Any]:
492
- """The items *request* asks for around *focus*, budgeted, with why each is here."""
579
+ """The items *request* asks for around *focus*, budgeted, with why each is here.
580
+
581
+ The always-included views are in every answer, whatever the request names; policy
582
+ alone can take them out. The budget places blocking items first, then the rest, each
583
+ in rank order (so always-included items first): no blocking item is cut while a
584
+ non-blocking one stays. *include_always* is off only for the map's per-view counts.
585
+ """
493
586
  unknown = [name for name in request.views or () if name not in views]
494
587
  if unknown:
495
588
  raise ContextError(
@@ -520,8 +613,20 @@ def build_result(
520
613
  requested_detail_note.append(note)
521
614
  return level
522
615
 
523
- ancestors = graph.ancestors(focus, schema.parent_rel) if inherit else ()
524
- always = [name for name in policy.always or () if name in views]
616
+ always = [name for name in policy.always or () if name in views] if include_always else []
617
+ dropped_always: list[dict[str, Any]] = (
618
+ [
619
+ {
620
+ "view": name,
621
+ "limitedBy": by,
622
+ "text": f"{by} takes {name!r} out of the always-included views",
623
+ }
624
+ for name, by in policy.always_dropped
625
+ if name in views
626
+ ]
627
+ if include_always
628
+ else []
629
+ )
525
630
  sources = _sources(
526
631
  request,
527
632
  policy,
@@ -532,39 +637,43 @@ def build_result(
532
637
  inherit=inherit,
533
638
  refused=refused,
534
639
  notes=notes,
640
+ dropped=dropped_always,
535
641
  )
536
642
  clamped.extend(requested_detail_note)
537
- entries, groups = _collect(graph, schema, focus, ancestors, policy, sources)
643
+ walks_up = inherit or any(s.inherit for s in sources)
644
+ ancestors = graph.ancestors(focus, schema.parent_rel) if walks_up else ()
645
+ entries, groups = _collect(graph, schema, focus, ancestors, policy, sources, inherit=inherit)
538
646
  ordered = sorted(entries.values(), key=lambda e: e.rank)
647
+ placing = sorted(ordered, key=lambda e: (not _blocks(e), e.rank))
539
648
 
540
- items: list[dict[str, Any]] = []
649
+ placed: dict[str, dict[str, Any]] = {}
541
650
  downgraded: list[dict[str, Any]] = []
542
651
  cut: list[_Entry] = []
543
652
  used = 0
544
- for position, entry in enumerate(ordered):
545
- placed = False
653
+ for position, entry in enumerate(placing):
654
+ fits = False
546
655
  for level in _lower(entry.detail):
547
656
  payload = _item_payload(entry, level, graph, schema, focus)
548
657
  cost = size_of(payload)
549
658
  if used + cost <= budget:
550
- items.append(payload)
659
+ placed[entry.node.key] = payload
551
660
  used += cost
552
661
  if level != entry.detail:
553
662
  downgraded.append(
554
663
  {"qualifiedName": entry.node.key, "from": entry.detail, "to": level}
555
664
  )
556
- placed = True
665
+ fits = True
557
666
  break
558
- if not placed:
559
- cut = ordered[position:]
667
+ if not fits:
668
+ cut = sorted(placing[position:], key=lambda e: e.rank)
560
669
  break
670
+ items = [placed[e.node.key] for e in ordered if e.node.key in placed]
561
671
 
562
- returned = {item["qualifiedName"] for item in items}
563
672
  for group in groups:
564
673
  group["returned"] = sum(
565
674
  1
566
675
  for e in entries.values()
567
- if e.node.key in returned and (e.view, e.path) == (group["view"], group["path"])
676
+ if e.node.key in placed and (e.view, e.path) == (group["view"], group["path"])
568
677
  )
569
678
  truncated: dict[str, Any] | None = None
570
679
  if cut or downgraded:
@@ -586,13 +695,16 @@ def build_result(
586
695
  for (view, path), names in by_group.items()
587
696
  ],
588
697
  "cutTotal": len(cut),
698
+ "cutBlocking": sum(1 for e in cut if _blocks(e)),
589
699
  "hint": {"budget": min(max(whole, budget + 1), policy.budget_max)},
590
700
  }
701
+ included = list(dict.fromkeys(s.view.name for s in sources if s.view is not None))
591
702
  return {
592
703
  "node": _node_payload(graph, schema, focus),
593
704
  "request": request.as_payload(),
594
705
  "effective": {
595
- "views": list(dict.fromkeys(s.view.name for s in sources if s.view is not None)),
706
+ "views": included,
707
+ "always": [name for name in always if name in included],
596
708
  "paths": [path_text(s.steps) for s in sources if s.view is None],
597
709
  "depth": depth,
598
710
  "detail": detail_for(request.detail) if request.detail else None,
@@ -603,7 +715,7 @@ def build_result(
603
715
  "groups": groups,
604
716
  "truncated": truncated,
605
717
  "used": used,
606
- "dropped": {"unresolved": graph.dropped},
718
+ "dropped": {"unresolved": graph.dropped, "always": dropped_always},
607
719
  "policy": {
608
720
  "layers": list(policy.layers),
609
721
  "notes": notes,
@@ -633,8 +745,6 @@ def build_map(
633
745
  focus: str,
634
746
  policy: Policy,
635
747
  views: Mapping[str, View],
636
- *,
637
- size_of: SizeOf = json_size,
638
748
  ) -> dict[str, Any]:
639
749
  """Every relationship around *focus*, what it inherits, its views and the options."""
640
750
  ancestors = graph.ancestors(focus, schema.parent_rel) if policy.inheritance else ()
@@ -686,6 +796,7 @@ def build_map(
686
796
 
687
797
  view_rows: list[dict[str, Any]] = []
688
798
  always = [name for name in policy.always or () if name in views]
799
+ dropped_by = dict(policy.always_dropped)
689
800
  for name, view in sorted(views.items(), key=lambda kv: (-kv[1].priority, kv[0])):
690
801
  counted = build_result(
691
802
  graph,
@@ -695,6 +806,7 @@ def build_map(
695
806
  views,
696
807
  ContextRequest(views=(name,), detail=REFS, budget=policy.budget_max),
697
808
  size_of=lambda _item: 0,
809
+ include_always=False,
698
810
  )
699
811
  view_rows.append(
700
812
  {
@@ -705,13 +817,12 @@ def build_map(
705
817
  "inherit": view.inherit,
706
818
  "detail": view.detail,
707
819
  "always": name in always,
820
+ "droppedBy": dropped_by.get(name),
708
821
  "count": len(counted["items"]),
709
822
  }
710
823
  )
711
824
  always_result = (
712
- build_result(graph, schema, focus, policy, views, ContextRequest(), size_of=size_of)
713
- if always
714
- else None
825
+ build_result(graph, schema, focus, policy, views, ContextRequest()) if always else None
715
826
  )
716
827
  address = focus
717
828
  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
  )