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.
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/PKG-INFO +1 -1
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/pyproject.toml +1 -1
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/cli.py +3 -2
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/context.py +147 -36
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/markdown.py +21 -15
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/policy.py +8 -0
- katalyst_plugin_context-0.2.1a3/src/katalyst_plugin_context/lint.py +339 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/mcp_tools.py +7 -5
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/service.py +2 -4
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/test_context.py +121 -7
- katalyst_plugin_context-0.2.1a3/tests/test_lint.py +135 -0
- katalyst_plugin_context-0.1.1a2/src/katalyst_plugin_context/lint.py +0 -149
- katalyst_plugin_context-0.1.1a2/tests/test_lint.py +0 -71
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/.gitignore +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/__init__.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/adapters/runtime_graph.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/composition.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/__init__.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/graph.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/paths.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/domain/settings.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/plugin.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/py.typed +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/rest.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/src/katalyst_plugin_context/schema.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/conftest.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/context_support.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a3}/tests/test_surfaces.py +0 -0
|
@@ -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,
|
|
98
|
-
|
|
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
|
-
# ──
|
|
303
|
+
# ── the budget's measure ─────────────────────────────────────────────────
|
|
299
304
|
|
|
300
305
|
|
|
301
306
|
SizeOf = Callable[[dict[str, Any]], int]
|
|
302
307
|
|
|
303
308
|
|
|
304
|
-
def
|
|
305
|
-
return
|
|
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=
|
|
360
|
-
inherit=
|
|
404
|
+
detail=detail,
|
|
405
|
+
inherit=walks_up,
|
|
361
406
|
repeat=1,
|
|
362
407
|
)
|
|
363
408
|
)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
665
|
+
fits = True
|
|
557
666
|
break
|
|
558
|
-
if not
|
|
559
|
-
cut =
|
|
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
|
|
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":
|
|
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()
|
|
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
|
-
|
|
5
|
-
(:func
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
139
|
-
|
|
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 =
|
|
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
|
)
|