diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d8118a..1a0556d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,23 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
+## [0.54.12] — 2026-05-14
+
+### Fixed
+- **Usage processor now extracts user-typed slash invocations.** Claude Code
+ records `/foo` and `/plugin:name` slash commands as
+ `/foo` XML tags embedded in user message
+ content; the previous `^\s*/` regex in `iter_events` only matched
+ raw `/foo` prefixes, which never appear in real session jsonls. Result on
+ production: `usage_events.command_name` and
+ `usage_session_summary.slash_commands` stayed NULL/0 for every actually-typed
+ slash invocation (`/clear`, `/exit`, `/plugin`, `/model`, plugin commands of
+ the form `/plugin:name`). Replaced with a `` tag scan;
+ `USAGE_PROCESSOR_VERSION` bumps 2 → 3. Operators wanting to rewrite
+ historical rows under the new logic call `POST /api/admin/usage/reprocess`
+ (CLI: `agnes admin telemetry reprocess`). Implicit Skill tool_use
+ extraction (LLM-decided invocations) is unchanged.
+
## [0.54.11] — 2026-05-14
### Changed
diff --git a/pyproject.toml b/pyproject.toml
index f10b50a..7c4e367 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "agnes-the-ai-analyst"
-version = "0.54.11"
+version = "0.54.12"
description = "Agnes — AI Data Analyst platform for AI analytical systems"
requires-python = ">=3.11,<3.14"
license = "MIT"
diff --git a/services/session_processors/usage_lib.py b/services/session_processors/usage_lib.py
index 7a3cbe0..5265217 100644
--- a/services/session_processors/usage_lib.py
+++ b/services/session_processors/usage_lib.py
@@ -40,7 +40,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Iterator
-USAGE_PROCESSOR_VERSION = 2
+USAGE_PROCESSOR_VERSION = 3
BUILTIN_TOOLS = frozenset({
"Bash", "Read", "Edit", "Write", "Grep", "Glob", "TodoWrite",
@@ -48,8 +48,13 @@ BUILTIN_TOOLS = frozenset({
"LS", # also built-in
})
-# Slash commands: "/something" or "/namespace:something" at start of user text
-SLASH_RE = re.compile(r"^\s*/([A-Za-z][\w:-]*)")
+# Claude Code wraps user-typed slash invocations as
+# / inside the user message content
+# (raw "/foo" plain text never reaches the jsonl). Tag may sit anywhere
+# in the text — typically after a sibling — so we
+# search rather than anchor at start. Name pattern matches both flat
+# commands (`clear`, `exit`) and plugin-prefixed ones (`plugin:name`).
+COMMAND_NAME_RE = re.compile(r"/([A-Za-z][\w:-]*)")
# Event types to skip entirely
_SKIP_TYPES = frozenset({
@@ -125,7 +130,9 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
- Skill tool → also extracts skill_name
- Task/Agent tools → event_type='subagent'
- mcp__* tools → event_type='mcp_call'
- - User messages starting with '/' → event_type='slash_command'
+ - User messages containing a /foo tag
+ (Claude Code's wire format for user-typed slash invocations)
+ → event_type='slash_command', command_name='foo'
Skips: system, summary, file-history-snapshot, queue-operation, progress.
"""
@@ -191,7 +198,11 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
)
elif turn_type == "user":
- # Slash-command detection from text content
+ # Slash-invocation detection: scan user message content for
+ # /foo tags. content is normally a
+ # plain string on slash-invocation turns; tolerate the
+ # list-of-blocks shape too in case Claude Code's wire format
+ # shifts to structured content later.
if isinstance(content, str):
text_parts = [content]
elif isinstance(content, list):
@@ -206,8 +217,7 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
for text in text_parts:
if not text:
continue
- m = SLASH_RE.match(text)
- if m:
+ for name in COMMAND_NAME_RE.findall(text):
yield ParsedEvent(
event_uuid=event_uuid,
parent_uuid=parent_uuid,
@@ -216,7 +226,7 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
tool_name=None,
skill_name=None,
subagent_type=None,
- command_name=m.group(1),
+ command_name=name,
is_error=False,
model=None,
cwd=cwd,
diff --git a/tests/fixtures/sessions/usage/mixed.jsonl b/tests/fixtures/sessions/usage/mixed.jsonl
index 43a3b14..5c997c8 100644
--- a/tests/fixtures/sessions/usage/mixed.jsonl
+++ b/tests/fixtures/sessions/usage/mixed.jsonl
@@ -1,4 +1,4 @@
-{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "/compound:debug start debugging"}}
+{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "compound:debug\n/compound:debug\nstart debugging"}}
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_bash_m", "name": "Bash", "input": {"command": "ls", "description": "List"}}]}}
{"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:02.000Z", "cwd": "/workspace", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_bash_m", "is_error": false, "content": [{"type": "text", "text": "file.txt"}]}]}}
{"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-mixed", "timestamp": "2026-05-12T15:00:03.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_skill_m", "name": "Skill", "input": {"skill": "my-skill", "args": ""}}]}}
diff --git a/tests/fixtures/sessions/usage/skill_curated.jsonl b/tests/fixtures/sessions/usage/skill_curated.jsonl
index 56165ee..a84d097 100644
--- a/tests/fixtures/sessions/usage/skill_curated.jsonl
+++ b/tests/fixtures/sessions/usage/skill_curated.jsonl
@@ -1,4 +1,4 @@
-{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "/my-skill do something"}}
+{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "my-skill\n/my-skill\ndo something"}}
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_skill_1", "name": "Skill", "input": {"skill": "my-skill", "args": "do something"}}]}}
{"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:02.000Z", "cwd": "/workspace", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_skill_1", "is_error": false, "content": [{"type": "text", "text": "Skill executed successfully"}]}]}}
{"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-skill-curated", "timestamp": "2026-05-12T10:00:03.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "Done."}]}}
diff --git a/tests/fixtures/sessions/usage/slash_command.jsonl b/tests/fixtures/sessions/usage/slash_command.jsonl
index cdd7934..6ff5588 100644
--- a/tests/fixtures/sessions/usage/slash_command.jsonl
+++ b/tests/fixtures/sessions/usage/slash_command.jsonl
@@ -1,4 +1,4 @@
-{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "/compound:debug some args here"}}
+{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "compound:debug\n/compound:debug\nsome args here"}}
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "Debugging..."}]}}
{"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:05.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "Thanks"}}
{"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-slash-cmd", "timestamp": "2026-05-12T12:00:06.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "You are welcome."}]}}
diff --git a/tests/test_session_processor_usage.py b/tests/test_session_processor_usage.py
index 3368b09..4fe8080 100644
--- a/tests/test_session_processor_usage.py
+++ b/tests/test_session_processor_usage.py
@@ -164,17 +164,6 @@ class TestCuratedSkill:
s = _summary(conn, "skill_curated.jsonl")
assert s["skill_invocations"] == 1
- def test_slash_command_also_extracted(self, tmp_path, monkeypatch):
- """skill_curated fixture has a /my-skill slash command in user message."""
- conn = _fresh_db(tmp_path, monkeypatch)
- _seed_attribution(conn)
- _process("skill_curated.jsonl", conn)
- evts = _events(conn)
- types = [e["event_type"] for e in evts]
- # Both tool_use (Skill) and slash_command should be present
- assert "tool_use" in types
- assert "slash_command" in types
-
class TestFleaSkill:
def test_flea_attribution(self, tmp_path, monkeypatch):
@@ -384,3 +373,64 @@ class TestMultiToolTurnDedup:
"SELECT COUNT(*) FROM usage_events WHERE session_id='sess-multi'"
).fetchone()[0]
assert n == 2, f"expected 2 events (one per tu_xxx), got {n}"
+
+
+class TestCommandNameTagExtraction:
+ """Slash invocations arrive as /foo embedded in
+ user message content (Claude Code's wire format). Unit-test iter_events
+ against synthetic turns so a future shape shift doesn't silently regress."""
+
+ @staticmethod
+ def _user_turn(content):
+ return {
+ "type": "user",
+ "uuid": "u1",
+ "parentUuid": None,
+ "sessionId": "sess-cn",
+ "timestamp": "2026-05-14T10:00:00.000Z",
+ "cwd": "/workspace",
+ "message": {"role": "user", "content": content},
+ }
+
+ def test_extracts_command_name_from_string_content(self):
+ from services.session_processors.usage_lib import iter_events
+ turn = self._user_turn(
+ "/clear\n"
+ )
+ events = list(iter_events([turn]))
+ assert len(events) == 1
+ assert events[0].event_type == "slash_command"
+ assert events[0].command_name == "clear"
+
+ def test_extracts_command_name_from_text_block(self):
+ """Defensive: same regex behavior when content arrives as a list-of-blocks
+ instead of a plain string, in case Claude Code's wire format shifts."""
+ from services.session_processors.usage_lib import iter_events
+ turn = self._user_turn(
+ [{"type": "text", "text": "/plugin:name"}]
+ )
+ events = list(iter_events([turn]))
+ assert len(events) == 1
+ assert events[0].command_name == "plugin:name"
+
+ def test_command_name_not_at_start_still_matches(self):
+ """Real Claude Code prepends a sibling before the
+ tag — regex must search, not anchor at start."""
+ from services.session_processors.usage_lib import iter_events
+ turn = self._user_turn(
+ "foo\n"
+ "/foo\n"
+ "some arg"
+ )
+ events = list(iter_events([turn]))
+ assert len(events) == 1
+ assert events[0].command_name == "foo"
+
+ def test_plain_text_without_tag_does_not_match(self):
+ """A user message that happens to contain '/foo' as prose, but no
+ tag, must NOT yield a slash_command event — that's the
+ whole point of switching from the old `^\\s*/` regex."""
+ from services.session_processors.usage_lib import iter_events
+ turn = self._user_turn("Hello world, see /not-a-command-just-prose for context.")
+ events = list(iter_events([turn]))
+ assert events == []