diff --git a/hermes_cli/prompt_size.py b/hermes_cli/prompt_size.py
index 9ce06ae5a21e..30c212a429ce 100644
--- a/hermes_cli/prompt_size.py
+++ b/hermes_cli/prompt_size.py
@@ -26,6 +26,9 @@ _SKILLS_BLOCK_RE = re.compile(r".*?", re.DO
# leading spaces, so the four-space + ``- `` prefix isolates skill lines.
_SKILL_LINE_PREFIX = " - "
+# Posture-demoted categories render all visible skill names on one shared line.
+_NAMES_ONLY_LINE_RE = re.compile(r"^ .+ \[names only\]: (?P.+)$")
+
# Cap the human-readable "Skills by size" table; ``--json`` always has them all.
_SKILLS_TABLE_LIMIT = 20
@@ -114,8 +117,11 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]:
Two honest, distinct numbers per skill:
- * ``index_line_bytes`` — the skill's actual rendered line in the always-on
- index (the fixed per-call cost of *listing* the skill).
+ * ``index_line_bytes`` — the skill's attributed bytes in the always-on
+ index (the fixed per-call cost of *listing* the skill). For a compact
+ ``[names only]`` line, each name keeps its own bytes and receives an
+ even share of the category prefix and separators. The attributed bytes
+ therefore sum exactly to the shared rendered line.
* ``skill_md_bytes`` — the on-disk size of the skill's ``SKILL.md`` (the
real token cost paid only when the model loads it via ``skill_view``).
``None`` when the name can't be mapped to a file (e.g. a plugin skill
@@ -126,16 +132,15 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]:
"""
name_to_path = _skill_md_paths_by_name()
entries: List[Dict[str, Any]] = []
- for line in skills_block.splitlines():
- if not line.startswith(_SKILL_LINE_PREFIX):
- continue
- rest = line[len(_SKILL_LINE_PREFIX):]
- # ``name: desc`` — the first ``": "`` separates name from description.
- # Namespaced names (``codex:rescue``) have no space after their colon,
- # so partitioning on ``": "`` keeps the full name intact.
- name = rest.partition(": ")[0].strip()
- if not name:
- continue
+
+ def append_entry(
+ name: str,
+ *,
+ attributed_bytes: int,
+ total_bytes: int,
+ shared_bytes: int,
+ skill_count: int,
+ ) -> None:
path = name_to_path.get(name)
md_bytes: Optional[int] = None
if path is not None:
@@ -145,10 +150,56 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]:
md_bytes = None
entries.append({
"name": name,
- "index_line_bytes": _bytes(line),
+ "index_line_bytes": attributed_bytes,
+ "index_line_total_bytes": total_bytes,
+ "index_line_shared_bytes": shared_bytes,
+ "index_line_skill_count": skill_count,
"skill_md_bytes": md_bytes,
"path": str(path) if path is not None else "",
})
+
+ for line in skills_block.splitlines():
+ compact_match = _NAMES_ONLY_LINE_RE.match(line)
+ if compact_match is not None:
+ names = [
+ name.strip()
+ for name in compact_match.group("names").split(",")
+ if name.strip()
+ ]
+ if not names:
+ continue
+ total_bytes = _bytes(line)
+ name_bytes = [_bytes(name) for name in names]
+ shared_total = total_bytes - sum(name_bytes)
+ shared_base, shared_remainder = divmod(shared_total, len(names))
+ for index, name in enumerate(names):
+ shared_bytes = shared_base + (1 if index < shared_remainder else 0)
+ append_entry(
+ name,
+ attributed_bytes=name_bytes[index] + shared_bytes,
+ total_bytes=total_bytes,
+ shared_bytes=shared_bytes,
+ skill_count=len(names),
+ )
+ continue
+
+ if not line.startswith(_SKILL_LINE_PREFIX):
+ continue
+ rest = line[len(_SKILL_LINE_PREFIX):]
+ # ``name: desc`` — the first ``": "`` separates name from description.
+ # Namespaced names (``codex:rescue``) have no space after their colon,
+ # so partitioning on ``": "`` keeps the full name intact.
+ name = rest.partition(": ")[0].strip()
+ if not name:
+ continue
+ line_bytes = _bytes(line)
+ append_entry(
+ name,
+ attributed_bytes=line_bytes,
+ total_bytes=line_bytes,
+ shared_bytes=0,
+ skill_count=1,
+ )
entries.sort(key=lambda e: (-(e["skill_md_bytes"] or 0), e["name"]))
return entries
@@ -288,10 +339,10 @@ def render_breakdown(data: Dict[str, Any]) -> str:
if skills:
lines.append("")
lines.append(
- " Skills by size (SKILL.md on-disk = read cost; index line = "
- "always-on cost, largest first):"
+ " Skills by size (SKILL.md on-disk = read cost; index cost = "
+ "attributed always-on bytes, largest first):"
)
- lines.append(f" {'skill':<28} {'SKILL.md':>10} {'index line':>10}")
+ lines.append(f" {'skill':<28} {'SKILL.md':>10} {'index cost':>10}")
shown = skills[:_SKILLS_TABLE_LIMIT]
for sk in shown:
md = sk["skill_md_bytes"]
diff --git a/tests/hermes_cli/test_prompt_size.py b/tests/hermes_cli/test_prompt_size.py
index c6fb8ed1934f..f95e8c0b3cf8 100644
--- a/tests/hermes_cli/test_prompt_size.py
+++ b/tests/hermes_cli/test_prompt_size.py
@@ -236,6 +236,32 @@ def test_skills_breakdown_parses_namespaced_names():
assert [e["name"] for e in entries] == ["codex:rescue"]
+def test_skills_breakdown_attributes_demoted_category_shared_line(isolated_home):
+ """A real posture-demoted category retains every skill in the breakdown."""
+ from agent.prompt_builder import build_skills_system_prompt
+
+ _seed_skill(isolated_home, "alpha-skill", "alpha description")
+ _seed_skill(isolated_home, "beta-skill", "beta description")
+ prompt = build_skills_system_prompt(compact_categories=frozenset({"demo"}))
+ skills_match = _SKILLS_BLOCK_RE.search(prompt)
+ assert skills_match is not None
+ skills_block = skills_match.group(0)
+ shared_line = next(
+ line for line in skills_block.splitlines() if "demo [names only]" in line
+ )
+
+ entries = _compute_skills_breakdown(skills_block)
+ by_name = {entry["name"]: entry for entry in entries}
+ assert set(by_name) == {"alpha-skill", "beta-skill"}
+
+ shared_line_bytes = len(shared_line.encode("utf-8"))
+ assert sum(entry["index_line_bytes"] for entry in entries) == shared_line_bytes
+ for entry in entries:
+ assert entry["index_line_total_bytes"] == shared_line_bytes
+ assert entry["index_line_shared_bytes"] > 0
+ assert entry["index_line_skill_count"] == 2
+
+
def test_render_includes_per_component_tables(isolated_home):
"""The rendered report gains the two new sorted tables (additive)."""
_seed_skill(isolated_home, "demo-skill", "a demo skill")