diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index a30d8bb..a5c3d07 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -11,13 +11,19 @@
"name": "sage",
"source": "./",
"description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking",
- "version": "1.0.0",
+ "version": "1.0.1",
"author": {
"name": "0-BSCode"
},
"category": "productivity",
"homepage": "https://github.com/0-BSCode/sage",
- "tags": ["learning", "spaced-repetition", "flashcards", "mastery-tracking", "socratic-tutoring"]
+ "tags": [
+ "learning",
+ "spaced-repetition",
+ "flashcards",
+ "mastery-tracking",
+ "socratic-tutoring"
+ ]
}
]
}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 157cdbd..fae76c8 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "sage",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking",
"author": {
"name": "0-BSCode",
diff --git a/README.md b/README.md
index 7018f51..e290e2f 100644
--- a/README.md
+++ b/README.md
@@ -155,7 +155,7 @@ These agents are backed by ~30 Python CLI tools handling SRS scheduling, card ma
What Claude Code plan do I need?
-Sage uses Opus and spawns multiple subagents per session. The **Max plan** is recommended. Pro plan users will likely hit rate limits mid-session, which interrupts the teaching flow. Token consumption is tracked per session in your journal entries.
+Sage uses Opus and spawns multiple subagents per session. The **Max plan** is recommended. Pro plan users will likely hit rate limits mid-session, which interrupts the teaching flow. Session duration is recorded in your journal entries.
diff --git a/agents/artifact-clerk.md b/agents/artifact-clerk.md
index 0e3ca09..6954212 100644
--- a/agents/artifact-clerk.md
+++ b/agents/artifact-clerk.md
@@ -176,21 +176,10 @@ Session Data:
- Session number: [N]
- Date: [YYYY-MM-DD]
- Session type: [Quick/Deep/Spaced Review]
-- Metrics file: [path, e.g. "/tmp/session-metrics-frontend-maintainability.txt" — if provided]
-- Duration: [pre-formatted by coach, e.g. "42m15s"]
-- Context: [pre-formatted by coach, e.g. "12% of 1M (120000 tokens)"]
-- Conversation: [pre-formatted by coach, e.g. "📥 26000 in / 📤 79700 out"]
-- Subagents: [pre-formatted by coach — full per-invocation breakdown with timestamps, written verbatim]
-- Grand total: [pre-formatted by coach, e.g. "2409835"]
+- Duration: [wall time, e.g. "42m15s" — from session_duration.py via wrapup, if provided]
- Resumed from: [previous savepoint or "Fresh start"]
-**Metrics file passthrough (preferred):** If the coach provides a metrics file path (e.g., `Metrics file: /tmp/session-metrics-.txt`), read that file with the Read tool and write its contents verbatim into the Session Metrics section of the journal entry. Do NOT reformat, summarize, or drop any fields. The file content replaces Duration/Context/Conversation/Subagents/Grand total fields — do not also write inline metrics if the file is available.
-
-**Metrics validation (fallback):** If metrics are provided inline (no file path), validate before writing:
-1. Every subagent line MUST contain all four fields: `in:`, `out:`, `cache-create:`, `cache-read:`
-2. Every agent group MUST have per-invocation timestamp lines indented below it
-3. The aggregate line MUST include `fresh` keyword and all four fields
-If any field is missing, read `/logs/subagent-tokens.jsonl` directly, filter entries by session date, and reconstruct the correct format. Emit WARN: "Metrics were incomplete — reconstructed from JSONL log."
+**Duration handling:** Write the provided `Duration` value into the journal entry's `**Duration:**` field. If no duration was provided (e.g. wrapup could not resolve the session transcript), ask the learner for the session wall time. Do NOT reconstruct from token logs — session token tracking has been removed.
What Was Covered:
[bullet list from coach]
@@ -280,11 +269,7 @@ weak_spots:
**Focus:** [topics covered]
**Session Type:** [Quick / Deep / Spaced Review]
-**Duration:** [wall time]
-**Context:** [from metrics file or coach — e.g. "12% of 1M (120000 tokens)"]
-**Conversation:** [from metrics file or coach — e.g. "📥 26000 in / 📤 79700 out"]
-**Subagents:** [from metrics file or coach — full per-invocation breakdown with all four fields: in, out, cache-create, cache-read]
-**Grand total:** [from metrics file or coach — e.g. "2409835"]
+**Duration:** [wall time — from session_duration.py via wrapup]
**Resumed from:** [Session N-1 savepoint / Fresh start]
### What Was Covered
@@ -603,7 +588,7 @@ Run these checks and collect results:
6. **Capstone-to-knowledge-map:** If `capstone.md` exists, check the Technical Requirements table. Verify that every concept listed there still has status >= `solid` in `knowledge-map.md`. If any concept has regressed below `solid`, WARN: "Capstone requirement [concept] has regressed to [status] — consider a review session or running `/capstone` to review the spec."
-7. **Session metrics provided:** If `Duration:`, `Context:`, `Conversation:`, or `Grand total:` is missing, empty, or contains the word "approximate", emit a WARN: "Session metrics incomplete — check that session-tokens.sh ran successfully."
+7. **Session duration provided:** If `Duration:` is missing or empty, emit a WARN: "Session duration missing — check that session_duration.py resolved the transcript, or ask the learner for wall time."
8. **Card type coverage:** Parse the tags of each new card and count the distribution by type. Report it in the confirmation report as `Cards by type: fact, why, process, discrimination, transfer, reverse, error`. If more than 70% of new cards in this session share a single type, emit a WARN: "Card type distribution skewed toward (/). Consider whether other cognitive operations on this material are being tested."
@@ -710,23 +695,22 @@ Path: /learning/
## Operation: `patch-metrics`
-**Purpose:** Append session token metrics to the latest journal entry after all post-checkpoint work is complete.
+**Purpose:** Patch the session duration into the latest journal entry after wrapup computes it (wrapup runs after the checkpoint that wrote the entry, so the duration arrives late).
**Input format:**
```
Operation: patch-metrics
Path: /learning/
-Metrics file: /tmp/session-metrics-.txt
+Duration:
```
**Steps:**
-1. Read the metrics file at the path provided.
-2. Find the latest `journal/session-NN.md` file (highest NN).
-3. Append a `### Token Metrics` section to the end of that file with the metrics file contents verbatim. Do NOT reformat, summarize, or edit the metrics — the file is the source of truth.
-4. If the metrics file doesn't exist or is empty, report "No metrics file found" and skip.
+1. Find the latest `journal/session-NN.md` file (highest NN).
+2. Set that entry's `**Duration:**` field to the provided value (replacing any placeholder or empty value).
+3. If no `Duration` was provided, report "No duration provided" and skip.
-**Output:** Confirmation of what was appended and to which journal entry.
+**Output:** Confirmation of the duration patched and to which journal entry.
---
diff --git a/docs/ref-session-end.md b/docs/ref-session-end.md
index 74633f3..321beba 100644
--- a/docs/ref-session-end.md
+++ b/docs/ref-session-end.md
@@ -52,17 +52,18 @@ python3 "$SAGE_ROOT/tools/session_wrapup.py" "$SAGE_ROOT" "" ": `.
| Reset verification | PostToolUse (Agent) | `scripts/reset-verification.sh` | Resets counter when verification-gate agent is called. Creates counter file on first call. |
| Checkpoint guard | PreToolUse (Agent) | `scripts/checkpoint-guard.sh` | Guards checkpoint calls. |
| Enforce cross-refs | Stop | `scripts/enforce-cross-refs.sh` | Blocks session end if knowledge maps were modified but cross-refs/ wasn't updated. |
-| Track subagent | SubagentStop | `scripts/track-subagent.sh` | Tracks subagent token consumption. |
## Known Issues
diff --git a/hooks/hooks.json b/hooks/hooks.json
index 9687b7e..dd7b41c 100644
--- a/hooks/hooks.json
+++ b/hooks/hooks.json
@@ -46,17 +46,6 @@
}
]
}
- ],
- "SubagentStop": [
- {
- "matcher": "",
- "hooks": [
- {
- "type": "command",
- "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/track-subagent.sh"
- }
- ]
- }
]
}
}
diff --git a/hooks/scripts/track-subagent.sh b/hooks/scripts/track-subagent.sh
deleted file mode 100755
index e2321f8..0000000
--- a/hooks/scripts/track-subagent.sh
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/bin/bash
-# SubagentStop hook: extracts token usage from a subagent's transcript
-# JSONL and appends to /logs/subagent-tokens.jsonl.
-# Learning root is read from /tmp/.sage-learning-root (set by SessionStart hook).
-# Falls back to $HOME/.claude/logs/ if learning root is unavailable.
-
-set -euo pipefail
-
-INPUT=$(cat)
-
-AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // "unknown"')
-AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // "unknown"')
-TRANSCRIPT=$(echo "$INPUT" | jq -r '.agent_transcript_path // ""')
-SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
-
-if [[ -z "$TRANSCRIPT" || ! -f "$TRANSCRIPT" ]]; then
- exit 0
-fi
-
-# Route logs to learning root (from sage config) or global fallback
-SAGE_LEARNING_ROOT=$(cat /tmp/.sage-learning-root 2>/dev/null || true)
-if [[ -n "$SAGE_LEARNING_ROOT" && -d "$SAGE_LEARNING_ROOT" ]]; then
- LOG_DIR="$SAGE_LEARNING_ROOT/logs"
-else
- LOG_DIR="$HOME/.claude/logs"
-fi
-mkdir -p "$LOG_DIR"
-LOG_FILE="$LOG_DIR/subagent-tokens.jsonl"
-
-USAGE=$(jq -s '
- [ .[] | select(.type == "assistant") | .message.usage // empty ]
- | if length == 0 then null
- else {
- input_tokens: (map(.input_tokens // 0) | add),
- output_tokens: (map(.output_tokens // 0) | add),
- cache_creation_tokens: (map(.cache_creation_input_tokens // 0) | add),
- cache_read_tokens: (map(.cache_read_input_tokens // 0) | add)
- }
- end
-' "$TRANSCRIPT")
-
-if [[ "$USAGE" == "null" ]]; then
- exit 0
-fi
-
-echo "$USAGE" | jq -c \
- --arg agent_type "$AGENT_TYPE" \
- --arg agent_id "$AGENT_ID" \
- --arg session_id "$SESSION_ID" \
- --arg timestamp "$(date -Iseconds)" \
- '. + {agent_type: $agent_type, agent_id: $agent_id, session_id: $session_id, timestamp: $timestamp}' \
- >> "$LOG_FILE"
diff --git a/tests/conftest.py b/tests/conftest.py
index 99f7f8b..8693af0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,6 @@
if p not in sys.path:
sys.path.insert(0, p)
-# Top-level tools/ for session_metrics, mcp_server
+# Top-level tools/ for session_duration, mcp_server
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
diff --git a/tests/test_session_duration.py b/tests/test_session_duration.py
new file mode 100644
index 0000000..edaffcd
--- /dev/null
+++ b/tests/test_session_duration.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""Tests for session_duration.py — current-sitting wall time from a transcript.
+
+Covers:
+- current_sitting counts only the last sitting when a >30min gap exists
+- current_sitting returns the full span when there is no big gap
+- fmt_duration formatting (s / m / h)
+- project_dir encodes every non-alphanumeric char as '-' (slash AND dot)
+- find_transcript prefers session_id, falls back to most-recent .jsonl
+- run() end-to-end against a fixture transcript
+- missing transcript -> run() None and CLI non-zero exit
+- lines without timestamps are ignored
+"""
+
+import datetime
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
+import session_duration as sd # noqa: E402
+
+TOOL = Path(__file__).resolve().parent.parent / "tools" / "session_duration.py"
+
+
+def _dt(iso):
+ return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
+
+
+class TestCurrentSitting(unittest.TestCase):
+ def test_last_sitting_only_after_big_gap(self):
+ stamps = [
+ _dt("2026-01-01T00:00:00Z"),
+ _dt("2026-01-01T00:05:00Z"),
+ # >30min gap here
+ _dt("2026-01-01T01:05:00Z"),
+ _dt("2026-01-01T01:20:00Z"),
+ _dt("2026-01-01T01:30:00Z"),
+ ]
+ start, end = sd.current_sitting(stamps)
+ self.assertEqual(start, _dt("2026-01-01T01:05:00Z"))
+ self.assertEqual(end, _dt("2026-01-01T01:30:00Z"))
+
+ def test_no_gap_returns_full_span(self):
+ stamps = [
+ _dt("2026-01-01T00:00:00Z"),
+ _dt("2026-01-01T00:10:00Z"),
+ _dt("2026-01-01T00:20:00Z"),
+ ]
+ start, end = sd.current_sitting(stamps)
+ self.assertEqual(start, stamps[0])
+ self.assertEqual(end, stamps[-1])
+
+ def test_empty_returns_none(self):
+ self.assertIsNone(sd.current_sitting([]))
+
+
+class TestFmtDuration(unittest.TestCase):
+ def test_seconds(self):
+ self.assertEqual(sd.fmt_duration(45_000), "45s")
+
+ def test_minutes(self):
+ self.assertEqual(sd.fmt_duration(25 * 60 * 1000), "25m00s")
+
+ def test_hours(self):
+ self.assertEqual(sd.fmt_duration((2 * 3600 + 5 * 60 + 3) * 1000), "2h05m03s")
+
+
+class TestProjectDir(unittest.TestCase):
+ def test_encodes_slash_and_dot(self):
+ pdir = sd.project_dir("/home/u/.claude/commands")
+ self.assertTrue(pdir.endswith("-home-u--claude-commands"))
+
+ def test_preserves_case_and_hyphen(self):
+ pdir = sd.project_dir("/home/b/Documents/sage-plugin")
+ self.assertTrue(pdir.endswith("-home-b-Documents-sage-plugin"))
+
+
+class TestTranscriptLookupAndRun(unittest.TestCase):
+ def setUp(self):
+ self.home = tempfile.mkdtemp()
+ self._orig_home = os.environ.get("HOME")
+ os.environ["HOME"] = self.home
+ self.cwd = "/fake/project"
+ self.pdir = sd.project_dir(self.cwd)
+ os.makedirs(self.pdir, exist_ok=True)
+
+ def tearDown(self):
+ if self._orig_home is not None:
+ os.environ["HOME"] = self._orig_home
+ else:
+ del os.environ["HOME"]
+ import shutil
+
+ shutil.rmtree(self.home, ignore_errors=True)
+
+ def _write_transcript(self, name, timestamps, extra_noise=True):
+ path = os.path.join(self.pdir, name)
+ with open(path, "w") as f:
+ if extra_noise:
+ # metadata lines without a timestamp — must be ignored
+ f.write(json.dumps({"type": "summary", "leafUuid": "x"}) + "\n")
+ for ts in timestamps:
+ f.write(json.dumps({"type": "assistant", "timestamp": ts}) + "\n")
+ return path
+
+ def test_find_prefers_session_id(self):
+ self._write_transcript("aaa.jsonl", ["2026-01-01T00:00:00Z"])
+ self._write_transcript("bbb.jsonl", ["2026-01-02T00:00:00Z"])
+ found = sd.find_transcript(session_id="aaa", cwd=self.cwd)
+ self.assertTrue(found.endswith("aaa.jsonl"))
+
+ def test_find_falls_back_to_most_recent(self):
+ old = self._write_transcript("old.jsonl", ["2026-01-01T00:00:00Z"])
+ new = self._write_transcript("new.jsonl", ["2026-01-02T00:00:00Z"])
+ os.utime(old, (1_000_000, 1_000_000))
+ os.utime(new, (2_000_000, 2_000_000))
+ found = sd.find_transcript(session_id="", cwd=self.cwd)
+ self.assertTrue(found.endswith("new.jsonl"))
+
+ def test_run_end_to_end_last_sitting(self):
+ self._write_transcript(
+ "s.jsonl",
+ [
+ "2026-01-01T00:00:00Z",
+ "2026-01-01T00:05:00Z",
+ # >30min gap
+ "2026-01-01T01:05:00Z",
+ "2026-01-01T01:30:00Z",
+ ],
+ )
+ self.assertEqual(sd.run(session_id="s", cwd=self.cwd), "25m00s")
+
+ def test_run_missing_transcript_returns_none(self):
+ self.assertIsNone(sd.run(session_id="nope", cwd="/no/such/project"))
+
+ def test_end_is_last_timestamp_not_now(self):
+ # A single sitting entirely in the distant past. If the end were derived
+ # from datetime.now() instead of stamps[-1], the duration would be years,
+ # not the 10-minute transcript span. Pins the deliberate stamps[-1] choice.
+ self._write_transcript(
+ "past.jsonl",
+ ["2020-01-01T00:00:00Z", "2020-01-01T00:10:00Z"],
+ )
+ self.assertEqual(sd.run(session_id="past", cwd=self.cwd), "10m00s")
+
+
+class TestCLI(unittest.TestCase):
+ def test_missing_transcript_exits_nonzero(self):
+ env = dict(os.environ)
+ env["HOME"] = tempfile.mkdtemp()
+ proc = subprocess.run(
+ [sys.executable, str(TOOL), "does-not-exist"],
+ capture_output=True,
+ text=True,
+ cwd="/tmp",
+ env=env,
+ )
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertEqual(proc.stdout.strip(), "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_session_metrics.py b/tests/test_session_metrics.py
deleted file mode 100644
index f020621..0000000
--- a/tests/test_session_metrics.py
+++ /dev/null
@@ -1,496 +0,0 @@
-#!/usr/bin/env python3
-"""Regression-safety tests for tools/session_metrics.py CLI contract.
-
-Tests invoke the tool via subprocess — no internal imports.
-Fixture files live in /tmp with unique suffixes to avoid collisions.
-"""
-
-import json
-import os
-import shutil
-import subprocess
-import tempfile
-import unittest
-import uuid
-
-TOOL_PATH = os.path.join(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "tools",
- "session_metrics.py",
-)
-
-
-class TestSessionMetricsBasicRun(unittest.TestCase):
- """Test basic CLI invocation with a valid metrics file."""
-
- def setUp(self):
- self.session_suffix = uuid.uuid4().hex[:8]
- self.session_id = f"test-{self.session_suffix}"
- self.metrics_file = f"/tmp/claude-session-metrics-{self.session_id}.json"
- self.metrics_data = {
- "input_tokens": 5000,
- "output_tokens": 2000,
- "context_window_size": 200000,
- "used_percentage": 45,
- "duration_ms": 125000,
- }
- with open(self.metrics_file, "w") as f:
- json.dump(self.metrics_data, f)
-
- def tearDown(self):
- if os.path.exists(self.metrics_file):
- os.remove(self.metrics_file)
-
- def test_exits_zero_on_valid_input(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertEqual(result.returncode, 0)
-
- def test_prints_session_token_summary_header(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertIn("Session Token Summary", result.stdout)
-
- def test_prints_main_session_label(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertIn("Main session:", result.stdout)
-
- def test_prints_duration(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- # 125000ms = 2m05s
- self.assertIn("2m05s", result.stdout)
-
- def test_prints_context_window_info(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertIn("200.0K", result.stdout)
- self.assertIn("45%", result.stdout)
-
- @unittest.skip("conversation tokens disabled — see session_metrics.py L181 TODO")
- def test_prints_conversation_tokens(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertIn("5000", result.stdout)
- self.assertIn("2000", result.stdout)
-
- def test_prints_grand_total(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- # main_ctx = 200000 * 45 // 100 = 90000, no subagents => grand_total = 90000
- self.assertIn("Grand total:", result.stdout)
- self.assertIn("90000", result.stdout)
-
-
-class TestSessionMetricsWithTopicSlug(unittest.TestCase):
- """Test that topic_slug causes a metrics file to be written."""
-
- def setUp(self):
- self.session_suffix = uuid.uuid4().hex[:8]
- self.session_id = f"test-{self.session_suffix}"
- self.topic_slug = f"test-topic-{self.session_suffix}"
- self.metrics_file = f"/tmp/claude-session-metrics-{self.session_id}.json"
- self.output_file = f"/tmp/session-metrics-{self.topic_slug}.txt"
- self.metrics_data = {
- "input_tokens": 1000,
- "output_tokens": 500,
- "context_window_size": 100000,
- "used_percentage": 20,
- "duration_ms": 60000,
- }
- with open(self.metrics_file, "w") as f:
- json.dump(self.metrics_data, f)
-
- def tearDown(self):
- for path in [self.metrics_file, self.output_file]:
- if os.path.exists(path):
- os.remove(path)
-
- def test_exits_zero(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id, self.topic_slug],
- capture_output=True,
- text=True,
- )
- self.assertEqual(result.returncode, 0)
-
- def test_creates_metrics_output_file(self):
- subprocess.run(
- ["python3", TOOL_PATH, self.session_id, self.topic_slug],
- capture_output=True,
- text=True,
- )
- self.assertTrue(
- os.path.exists(self.output_file),
- f"Expected output file {self.output_file} to exist",
- )
-
- def test_output_file_contains_duration(self):
- subprocess.run(
- ["python3", TOOL_PATH, self.session_id, self.topic_slug],
- capture_output=True,
- text=True,
- )
- with open(self.output_file) as f:
- content = f.read()
- # 60000ms = 1m00s
- self.assertIn("1m00s", content)
-
- def test_output_file_contains_context_info(self):
- subprocess.run(
- ["python3", TOOL_PATH, self.session_id, self.topic_slug],
- capture_output=True,
- text=True,
- )
- with open(self.output_file) as f:
- content = f.read()
- self.assertIn("20%", content)
- self.assertIn("100.0K", content)
-
- def test_output_file_contains_grand_total(self):
- subprocess.run(
- ["python3", TOOL_PATH, self.session_id, self.topic_slug],
- capture_output=True,
- text=True,
- )
- with open(self.output_file) as f:
- content = f.read()
- # main_ctx = 100000 * 20 // 100 = 20000
- self.assertIn("Grand total: 20000", content)
-
- def test_stdout_mentions_metrics_file_written(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id, self.topic_slug],
- capture_output=True,
- text=True,
- )
- self.assertIn("Metrics file written", result.stdout)
- self.assertIn(self.output_file, result.stdout)
-
-
-class TestSessionMetricsWithSubagentLogs(unittest.TestCase):
- """Test subagent log aggregation via cwd-based discovery."""
-
- def setUp(self):
- self.session_suffix = uuid.uuid4().hex[:8]
- self.session_id = f"test-{self.session_suffix}"
- self.metrics_file = f"/tmp/claude-session-metrics-{self.session_id}.json"
- self.metrics_data = {
- "input_tokens": 3000,
- "output_tokens": 1500,
- "context_window_size": 200000,
- "used_percentage": 50,
- "duration_ms": 90000,
- }
- with open(self.metrics_file, "w") as f:
- json.dump(self.metrics_data, f)
-
- # Create a temp directory with learning/logs/subagent-tokens.jsonl
- self.tmpdir = tempfile.mkdtemp()
- self.log_dir = os.path.join(self.tmpdir, "learning", "logs")
- os.makedirs(self.log_dir)
- self.log_file = os.path.join(self.log_dir, "subagent-tokens.jsonl")
-
- self.subagent_entries = [
- {
- "agent_type": "quiz-master",
- "session_id": self.session_id,
- "input_tokens": 800,
- "output_tokens": 400,
- "cache_creation_tokens": 100,
- "cache_read_tokens": 50,
- "timestamp": "2026-06-21T10:00:00Z",
- },
- ]
- with open(self.log_file, "w") as f:
- for entry in self.subagent_entries:
- f.write(json.dumps(entry) + "\n")
-
- def tearDown(self):
- if os.path.exists(self.metrics_file):
- os.remove(self.metrics_file)
- shutil.rmtree(self.tmpdir, ignore_errors=True)
-
- def test_aggregates_subagent_tokens(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- self.assertEqual(result.returncode, 0)
- self.assertIn("Subagents:", result.stdout)
- self.assertIn("quiz-master", result.stdout)
-
- def test_shows_subagent_fresh_total(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- # fresh = input(800) + output(400) + cache_creation(100) = 1300
- self.assertIn("Subagent total:", result.stdout)
- self.assertIn("1300", result.stdout)
-
- def test_grand_total_includes_subagents(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- # main_ctx = 200000 * 50 // 100 = 100000; sub_total = 1300; grand = 101300
- self.assertIn("Grand total:", result.stdout)
- self.assertIn("101300", result.stdout)
-
- def test_shows_invocation_detail(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- self.assertIn("2026-06-21T10:00:00Z", result.stdout)
-
-
-class TestSessionMetricsMissingFile(unittest.TestCase):
- """Test behavior when no metrics file exists."""
-
- FALLBACK = "/tmp/claude-session-metrics.json"
-
- def setUp(self):
- self.session_suffix = uuid.uuid4().hex[:8]
- self.session_id = f"missing-{self.session_suffix}"
- # Ensure no files exist for this session
- candidate = f"/tmp/claude-session-metrics-{self.session_id}.json"
- if os.path.exists(candidate):
- os.remove(candidate)
- # Temporarily move the fallback file if it exists so it doesn't interfere
- self._fallback_backup = None
- if os.path.exists(self.FALLBACK):
- self._fallback_backup = self.FALLBACK + f".bak-{self.session_suffix}"
- os.rename(self.FALLBACK, self._fallback_backup)
-
- def tearDown(self):
- # Restore the fallback file if we moved it
- if self._fallback_backup and os.path.exists(self._fallback_backup):
- os.rename(self._fallback_backup, self.FALLBACK)
-
- def test_exits_nonzero(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertNotEqual(result.returncode, 0)
-
- def test_prints_error_to_stderr(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertIn("No session metrics found", result.stderr)
-
-
-class TestSessionMetricsZeroTokens(unittest.TestCase):
- """Test graceful handling of a metrics file with zero tokens."""
-
- def setUp(self):
- self.session_suffix = uuid.uuid4().hex[:8]
- self.session_id = f"zero-{self.session_suffix}"
- self.metrics_file = f"/tmp/claude-session-metrics-{self.session_id}.json"
- self.metrics_data = {
- "input_tokens": 0,
- "output_tokens": 0,
- "context_window_size": 0,
- "used_percentage": 0,
- "duration_ms": 0,
- }
- with open(self.metrics_file, "w") as f:
- json.dump(self.metrics_data, f)
-
- def tearDown(self):
- if os.path.exists(self.metrics_file):
- os.remove(self.metrics_file)
-
- def test_exits_zero(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertEqual(result.returncode, 0)
-
- def test_prints_summary_with_zeros(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertIn("Session Token Summary", result.stdout)
- self.assertIn("Grand total:", result.stdout)
- # Grand total should be 0 (ctx_size=0, no subagents)
- self.assertIn("Grand total: 0", result.stdout)
-
- def test_zero_duration_not_shown(self):
- """Duration line should not appear when duration_ms is 0."""
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- )
- self.assertNotIn("Duration:", result.stdout)
-
-
-class TestSessionMetricsMultipleSubagentTypes(unittest.TestCase):
- """Test grouping of multiple subagent types."""
-
- def setUp(self):
- self.session_suffix = uuid.uuid4().hex[:8]
- self.session_id = f"multi-{self.session_suffix}"
- self.metrics_file = f"/tmp/claude-session-metrics-{self.session_id}.json"
- self.metrics_data = {
- "input_tokens": 2000,
- "output_tokens": 1000,
- "context_window_size": 200000,
- "used_percentage": 30,
- "duration_ms": 60000,
- }
- with open(self.metrics_file, "w") as f:
- json.dump(self.metrics_data, f)
-
- self.tmpdir = tempfile.mkdtemp()
- self.log_dir = os.path.join(self.tmpdir, "learning", "logs")
- os.makedirs(self.log_dir)
- self.log_file = os.path.join(self.log_dir, "subagent-tokens.jsonl")
-
- entries = [
- {
- "agent_type": "quiz-master",
- "session_id": self.session_id,
- "input_tokens": 500,
- "output_tokens": 200,
- "cache_creation_tokens": 50,
- "cache_read_tokens": 10,
- "timestamp": "2026-06-21T10:00:00Z",
- },
- {
- "agent_type": "quiz-master",
- "session_id": self.session_id,
- "input_tokens": 600,
- "output_tokens": 300,
- "cache_creation_tokens": 60,
- "cache_read_tokens": 20,
- "timestamp": "2026-06-21T10:05:00Z",
- },
- {
- "agent_type": "artifact-clerk",
- "session_id": self.session_id,
- "input_tokens": 1000,
- "output_tokens": 500,
- "cache_creation_tokens": 200,
- "cache_read_tokens": 100,
- "timestamp": "2026-06-21T10:10:00Z",
- },
- {
- "agent_type": "other-agent",
- "session_id": "different-session",
- "input_tokens": 9999,
- "output_tokens": 9999,
- "cache_creation_tokens": 0,
- "cache_read_tokens": 0,
- "timestamp": "2026-06-21T10:15:00Z",
- },
- ]
- with open(self.log_file, "w") as f:
- for entry in entries:
- f.write(json.dumps(entry) + "\n")
-
- def tearDown(self):
- if os.path.exists(self.metrics_file):
- os.remove(self.metrics_file)
- shutil.rmtree(self.tmpdir, ignore_errors=True)
-
- def test_shows_both_agent_types(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- self.assertIn("quiz-master", result.stdout)
- self.assertIn("artifact-clerk", result.stdout)
-
- def test_groups_quiz_master_calls(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- # quiz-master should show x2 calls
- self.assertIn("x2", result.stdout)
-
- def test_excludes_other_session_entries(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- # other-agent belongs to a different session_id
- self.assertNotIn("other-agent", result.stdout)
- self.assertNotIn("9999", result.stdout)
-
- def test_subagent_total_sums_correctly(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- # quiz-master: fresh = (500+200+50) + (600+300+60) = 750 + 960 = 1710
- # artifact-clerk: fresh = 1000+500+200 = 1700
- # total = 1710 + 1700 = 3410
- self.assertIn("3410", result.stdout)
-
- def test_sorted_by_fresh_descending(self):
- result = subprocess.run(
- ["python3", TOOL_PATH, self.session_id],
- capture_output=True,
- text=True,
- cwd=self.tmpdir,
- )
- # quiz-master has 1710 fresh, artifact-clerk has 1700 fresh
- # quiz-master should appear first
- qm_pos = result.stdout.index("quiz-master")
- ac_pos = result.stdout.index("artifact-clerk")
- self.assertLess(qm_pos, ac_pos, "quiz-master should appear before artifact-clerk (sorted by fresh desc)")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_track_subagent.py b/tests/test_track_subagent.py
deleted file mode 100644
index 13a87d4..0000000
--- a/tests/test_track_subagent.py
+++ /dev/null
@@ -1,242 +0,0 @@
-#!/usr/bin/env python3
-"""Regression-safety tests for track-subagent.sh hook."""
-
-import json
-import os
-import shutil
-import subprocess
-import tempfile
-import unittest
-import uuid
-from pathlib import Path
-
-SCRIPT = str(
- Path(__file__).resolve().parent.parent
- / "hooks"
- / "scripts"
- / "track-subagent.sh"
-)
-
-
-class TestTrackSubagent(unittest.TestCase):
- """Test the SubagentStop track-subagent hook via subprocess."""
-
- LEARNING_ROOT_FILE = "/tmp/.sage-learning-root"
-
- def setUp(self):
- self.session_id = f"test-{uuid.uuid4().hex[:8]}"
- self.tmpdir = tempfile.mkdtemp()
- self.transcript_path = os.path.join(self.tmpdir, "transcript.jsonl")
- # Fallback log dir under a controlled HOME
- self.fake_home = tempfile.mkdtemp()
- # Save and clear real learning root file to isolate tests
- self._saved_learning_root = None
- if os.path.exists(self.LEARNING_ROOT_FILE):
- with open(self.LEARNING_ROOT_FILE) as f:
- self._saved_learning_root = f.read()
-
- def tearDown(self):
- shutil.rmtree(self.tmpdir, ignore_errors=True)
- shutil.rmtree(self.fake_home, ignore_errors=True)
- # Restore learning root file
- if self._saved_learning_root is not None:
- with open(self.LEARNING_ROOT_FILE, "w") as f:
- f.write(self._saved_learning_root)
- elif os.path.exists(self.LEARNING_ROOT_FILE):
- os.remove(self.LEARNING_ROOT_FILE)
-
- def _set_learning_root(self, path: str):
- with open(self.LEARNING_ROOT_FILE, "w") as f:
- f.write(path)
-
- def _clear_learning_root(self):
- if os.path.exists(self.LEARNING_ROOT_FILE):
- os.remove(self.LEARNING_ROOT_FILE)
-
- def _write_transcript(self, entries: list[dict]):
- """Write JSONL transcript file."""
- with open(self.transcript_path, "w") as f:
- for entry in entries:
- f.write(json.dumps(entry) + "\n")
-
- def _sample_transcript(self) -> list[dict]:
- """A minimal transcript with token usage."""
- return [
- {
- "type": "human",
- "message": {"content": "Hello"},
- },
- {
- "type": "assistant",
- "message": {
- "content": "Hi there",
- "usage": {
- "input_tokens": 100,
- "output_tokens": 50,
- "cache_creation_input_tokens": 10,
- "cache_read_input_tokens": 5,
- },
- },
- },
- {
- "type": "assistant",
- "message": {
- "content": "Done",
- "usage": {
- "input_tokens": 200,
- "output_tokens": 80,
- "cache_creation_input_tokens": 20,
- "cache_read_input_tokens": 15,
- },
- },
- },
- ]
-
- def _run(
- self, input_json: dict, env_extra: dict | None = None
- ) -> subprocess.CompletedProcess:
- env = os.environ.copy()
- env["HOME"] = self.fake_home
- if env_extra:
- env.update(env_extra)
- return subprocess.run(
- ["bash", SCRIPT],
- input=json.dumps(input_json),
- capture_output=True,
- text=True,
- timeout=10,
- env=env,
- )
-
- def _base_input(
- self,
- agent_type: str = "verification-gate",
- transcript: str | None = None,
- ) -> dict:
- return {
- "agent_type": agent_type,
- "agent_id": f"agent-{self.session_id}",
- "agent_transcript_path": transcript or self.transcript_path,
- "session_id": self.session_id,
- }
-
- # ------------------------------------------------------------------
- # 1. Writes token data to learning/logs/ when it exists
- # ------------------------------------------------------------------
- def test_writes_to_learning_root_logs(self):
- learning_root = os.path.join(self.tmpdir, "learning-root")
- os.makedirs(learning_root)
- self._set_learning_root(learning_root)
-
- self._write_transcript(self._sample_transcript())
-
- inp = self._base_input()
- self._run(inp)
-
- log_file = os.path.join(learning_root, "logs", "subagent-tokens.jsonl")
- self.assertTrue(os.path.exists(log_file), "Log file should be created")
-
- with open(log_file) as f:
- lines = f.readlines()
- self.assertEqual(len(lines), 1)
-
- data = json.loads(lines[0])
- self.assertEqual(data["input_tokens"], 300)
- self.assertEqual(data["output_tokens"], 130)
- self.assertEqual(data["cache_creation_tokens"], 30)
- self.assertEqual(data["cache_read_tokens"], 20)
- self.assertEqual(data["agent_type"], "verification-gate")
- self.assertEqual(data["session_id"], self.session_id)
- self.assertIn("timestamp", data)
-
- # ------------------------------------------------------------------
- # 2. Falls back to global logs when no learning directory
- # ------------------------------------------------------------------
- def test_falls_back_to_global_logs(self):
- self._clear_learning_root()
- self._write_transcript(self._sample_transcript())
-
- inp = self._base_input()
- self._run(inp)
-
- log_file = os.path.join(self.fake_home, ".claude", "logs", "subagent-tokens.jsonl")
- self.assertTrue(os.path.exists(log_file), "Fallback log should be created")
-
- with open(log_file) as f:
- lines = f.readlines()
- self.assertEqual(len(lines), 1)
- data = json.loads(lines[0])
- self.assertEqual(data["input_tokens"], 300)
-
- # ------------------------------------------------------------------
- # 3. Handles missing transcript gracefully (exits 0)
- # ------------------------------------------------------------------
- def test_missing_transcript_exits_zero(self):
- inp = self._base_input(transcript="/tmp/nonexistent-transcript-file.jsonl")
- result = self._run(inp)
- self.assertEqual(result.returncode, 0)
-
- # ------------------------------------------------------------------
- # 4. Handles empty transcript path gracefully (exits 0)
- # ------------------------------------------------------------------
- def test_empty_transcript_path_exits_zero(self):
- inp = self._base_input(transcript="")
- result = self._run(inp)
- self.assertEqual(result.returncode, 0)
-
- # ------------------------------------------------------------------
- # 5. Appends (not overwrites) on repeated calls
- # ------------------------------------------------------------------
- def test_appends_on_repeated_calls(self):
- learning_root = os.path.join(self.tmpdir, "learning-root")
- os.makedirs(learning_root)
- self._set_learning_root(learning_root)
-
- self._write_transcript(self._sample_transcript())
-
- inp = self._base_input()
-
- self._run(inp)
- self._run(inp)
-
- log_file = os.path.join(learning_root, "logs", "subagent-tokens.jsonl")
- with open(log_file) as f:
- lines = f.readlines()
- self.assertEqual(len(lines), 2)
-
- # ------------------------------------------------------------------
- # 6. Handles transcript with no assistant messages
- # ------------------------------------------------------------------
- def test_transcript_no_assistant_messages(self):
- self._write_transcript([
- {"type": "human", "message": {"content": "Hello"}},
- ])
-
- learning_root = os.path.join(self.tmpdir, "learning-root")
- os.makedirs(learning_root)
- self._set_learning_root(learning_root)
-
- inp = self._base_input()
- result = self._run(inp)
-
- self.assertEqual(result.returncode, 0)
-
- # ------------------------------------------------------------------
- # 7. CLAUDE_PROJECT_DIR set but no learning/ subdir -- uses fallback
- # ------------------------------------------------------------------
- def test_no_learning_root_uses_fallback(self):
- self._clear_learning_root()
-
- self._write_transcript(self._sample_transcript())
-
- inp = self._base_input()
- self._run(inp)
-
- fallback_log = os.path.join(
- self.fake_home, ".claude", "logs", "subagent-tokens.jsonl"
- )
- self.assertTrue(os.path.exists(fallback_log))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tools/session_duration.py b/tools/session_duration.py
new file mode 100644
index 0000000..43c8296
--- /dev/null
+++ b/tools/session_duration.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Current-sitting wall time for a Sage session, from its transcript.
+
+Reads the Claude Code transcript JSONL at
+``~/.claude/projects//.jsonl`` and computes the duration of
+the CURRENT sitting: the last timestamp minus the first timestamp after the most
+recent gap > SITTING_GAP_SECONDS. Compact/resume keeps the same session id and
+appends to the same file, so a naive first->last would span days across sittings.
+
+The is the working directory with every non-alphanumeric character
+replaced by '-' (Claude Code's encoding — e.g. ``/home/u/.claude`` -> ``-home-u--claude``).
+
+Usage:
+ python3 session_duration.py [session_id]
+
+If session_id is given, that transcript is used; otherwise the most-recently
+modified .jsonl in the project's transcript folder is used.
+
+Prints the formatted duration (e.g. "42m15s") to stdout on success.
+Exits non-zero with no stdout if no transcript or timestamps can be resolved.
+
+Zero external dependencies — Python 3.8+ stdlib only.
+"""
+
+import datetime
+import glob
+import json
+import os
+import re
+import sys
+
+SITTING_GAP_SECONDS = 30 * 60 # a quiet gap longer than this starts a new sitting
+
+
+def project_dir(cwd=None):
+ cwd = cwd if cwd is not None else os.getcwd()
+ slug = re.sub(r"[^a-zA-Z0-9]", "-", cwd)
+ return os.path.join(os.path.expanduser("~"), ".claude", "projects", slug)
+
+
+def find_transcript(session_id="", cwd=None):
+ pdir = project_dir(cwd)
+ if session_id:
+ candidate = os.path.join(pdir, f"{session_id}.jsonl")
+ if os.path.isfile(candidate):
+ return candidate
+ files = glob.glob(os.path.join(pdir, "*.jsonl"))
+ if not files:
+ return None
+ return max(files, key=os.path.getmtime)
+
+
+def parse_timestamps(path):
+ stamps = []
+ with open(path) as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ ts = entry.get("timestamp")
+ if not ts:
+ continue
+ try:
+ stamps.append(datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")))
+ except ValueError:
+ continue
+ stamps.sort()
+ return stamps
+
+
+def current_sitting(stamps, gap_seconds=SITTING_GAP_SECONDS):
+ """Return (start, end) of the last sitting, or None if there are no stamps.
+
+ The sitting starts at the first timestamp following the most recent gap
+ longer than gap_seconds (or the very first timestamp if no such gap exists).
+ """
+ if not stamps:
+ return None
+ start = stamps[0]
+ for a, b in zip(stamps, stamps[1:]):
+ if (b - a).total_seconds() > gap_seconds:
+ start = b
+ return start, stamps[-1]
+
+
+def fmt_duration(ms):
+ total_sec = ms // 1000
+ hours = total_sec // 3600
+ mins = (total_sec % 3600) // 60
+ secs = total_sec % 60
+ if hours > 0:
+ return f"{hours}h{mins:02d}m{secs:02d}s"
+ elif mins > 0:
+ return f"{mins}m{secs:02d}s"
+ return f"{secs}s"
+
+
+def run(session_id="", cwd=None):
+ path = find_transcript(session_id, cwd)
+ if not path:
+ return None
+ sitting = current_sitting(parse_timestamps(path))
+ if not sitting:
+ return None
+ start, end = sitting
+ ms = int((end - start).total_seconds() * 1000)
+ return fmt_duration(ms)
+
+
+def main():
+ session_id = sys.argv[1] if len(sys.argv) > 1 else ""
+ duration = run(session_id=session_id)
+ if duration is None:
+ print("No transcript or timestamps found", file=sys.stderr)
+ sys.exit(1)
+ print(duration)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/session_metrics.py b/tools/session_metrics.py
deleted file mode 100644
index 4921098..0000000
--- a/tools/session_metrics.py
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/usr/bin/env python3
-"""Aggregates token consumption for a session (main + subagents).
-
-Python port of utils/session-tokens.sh for MCP server integration.
-"""
-
-import json
-import os
-import re
-import sys
-
-
-def find_metrics_file(session_id=""):
- if session_id:
- candidate = f"/tmp/claude-session-metrics-{session_id}.json"
- if os.path.isfile(candidate):
- return candidate
- fallback = "/tmp/claude-session-metrics.json"
- if os.path.isfile(fallback):
- return fallback
- return None
-
-
-def find_subagent_log(learning_path="", project_dir=""):
- sage_root = ""
- try:
- with open("/tmp/.sage-learning-root") as f:
- sage_root = f.read().strip()
- except OSError:
- pass
- candidates = [sage_root, learning_path, project_dir, os.environ.get("CLAUDE_PROJECT_DIR", ""), os.getcwd()]
- for d in candidates:
- if not d:
- continue
- path = os.path.join(d, "logs", "subagent-tokens.jsonl")
- if os.path.isfile(path):
- return path
- alt = os.path.join(d, "learning", "logs", "subagent-tokens.jsonl")
- if os.path.isfile(alt):
- return alt
- fallback = os.path.join(os.path.expanduser("~"), ".claude", "logs", "subagent-tokens.jsonl")
- if os.path.isfile(fallback):
- return fallback
- return None
-
-
-def parse_subagent_log(path, session_id=""):
- entries = []
- seen = set()
- with open(path) as f:
- for line in f:
- line = line.strip()
- if not line:
- continue
- try:
- entry = json.loads(line)
- except json.JSONDecodeError:
- continue
- if session_id and entry.get("session_id") != session_id:
- continue
- dedup_key = (entry.get("agent_type", ""), entry.get("timestamp", ""),
- entry.get("input_tokens", 0), entry.get("output_tokens", 0))
- if dedup_key in seen:
- continue
- seen.add(dedup_key)
- entry["fresh"] = (
- entry.get("input_tokens", 0)
- + entry.get("output_tokens", 0)
- + entry.get("cache_creation_tokens", 0)
- )
- entries.append(entry)
- return entries
-
-
-def group_by_agent(entries):
- groups = {}
- for e in entries:
- agent_type = e.get("agent_type", "unknown")
- if agent_type not in groups:
- groups[agent_type] = []
- groups[agent_type].append(e)
-
- result = []
- for agent_type, items in groups.items():
- group = {
- "agent_type": agent_type,
- "calls": len(items),
- "input": sum(i.get("input_tokens", 0) for i in items),
- "output": sum(i.get("output_tokens", 0) for i in items),
- "cache_create": sum(i.get("cache_creation_tokens", 0) for i in items),
- "cache_read": sum(i.get("cache_read_tokens", 0) for i in items),
- "fresh": sum(i.get("fresh", 0) for i in items),
- "invocations": [
- {
- "timestamp": i.get("timestamp", ""),
- "fresh": i.get("fresh", 0),
- "input": i.get("input_tokens", 0),
- "output": i.get("output_tokens", 0),
- "cache_create": i.get("cache_creation_tokens", 0),
- "cache_read": i.get("cache_read_tokens", 0),
- }
- for i in items
- ],
- }
- result.append(group)
- result.sort(key=lambda g: -g["fresh"])
- return result
-
-
-def get_agent_model(agent_type, agents_dir=""):
- if not agents_dir:
- agents_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "agents")
- agent_file = os.path.join(agents_dir, f"{agent_type}.md")
- if not os.path.isfile(agent_file):
- return "opus"
- try:
- with open(agent_file) as f:
- content = f.read(500)
- match = re.search(r"^model:\s*(.+)$", content, re.MULTILINE)
- if match:
- return match.group(1).strip()
- except Exception:
- pass
- return "opus"
-
-
-def fmt_tokens(t):
- if t >= 1_000_000:
- return f"{t / 1_000_000:.1f}M"
- elif t >= 1_000:
- return f"{t / 1_000:.1f}K"
- return str(t)
-
-
-def fmt_duration(ms):
- total_sec = ms // 1000
- hours = total_sec // 3600
- mins = (total_sec % 3600) // 60
- secs = total_sec % 60
- if hours > 0:
- return f"{hours}h{mins:02d}m{secs:02d}s"
- elif mins > 0:
- return f"{mins}m{secs:02d}s"
- return f"{secs}s"
-
-
-def run(session_id="", topic_slug="", learning_path=""):
- metrics_file = find_metrics_file(session_id)
- if not metrics_file:
- return {"error": "No session metrics found"}
-
- with open(metrics_file) as f:
- main = json.load(f)
-
- main_in = main.get("input_tokens", 0)
- # output_tokens from claude-session-metrics.json is unreliable — it appears
- # to be overwritten per-turn rather than accumulated, and the per-turn values
- # are too low to be correct. Do not use for cost tracking or reporting.
- main_out = main.get("output_tokens", 0)
- ctx_size = main.get("context_window_size", 0)
- used_pct = main.get("used_percentage", 0)
- duration_ms = main.get("duration_ms", 0)
- main_ctx = (ctx_size * used_pct // 100) if ctx_size > 0 and used_pct > 0 else 0
-
- sub_log = find_subagent_log(learning_path)
- sub_total = 0
- sub_groups = []
-
- if sub_log:
- entries = parse_subagent_log(sub_log, session_id)
- sub_groups = group_by_agent(entries)
- sub_total = sum(g["fresh"] for g in sub_groups)
-
- grand_total = main_ctx + sub_total
-
- lines = ["=== Session Token Summary ===", ""]
- lines.append("Main session:")
- if duration_ms > 0:
- lines.append(f" Duration: {fmt_duration(duration_ms)}")
- lines.append(f" Context window: {fmt_tokens(ctx_size)} ({used_pct}% used = {main_ctx} tokens)")
- # TODO: get accurate per-session input/output token totals when platform supports it
- # lines.append(f" Conversation: 📥 {main_in} in / 📤 {main_out} out")
- lines.append("")
-
- if sub_groups:
- lines.append("Subagents:")
- for g in sub_groups:
- model = get_agent_model(g["agent_type"])
- lines.append(
- f" {g['agent_type']}/{model} (x{g['calls']}): {g['fresh']} fresh "
- f"(in:{g['input']} out:{g['output']} cache-create:{g['cache_create']} cache-read:{g['cache_read']})"
- )
- for inv in g["invocations"]:
- lines.append(
- f" [{inv['timestamp']}] {inv['fresh']} fresh "
- f"(in:{inv['input']} out:{inv['output']} cache-create:{inv['cache_create']} cache-read:{inv['cache_read']})"
- )
- lines.append(" ─────────────────")
- lines.append(f" Subagent total: {sub_total} fresh")
- lines.append("")
-
- lines.append(f"Grand total: {grand_total}")
-
- summary = "\n".join(lines)
-
- if topic_slug:
- metrics_out = f"/tmp/session-metrics-{topic_slug}.txt"
- clerk_lines = []
- clerk_lines.append(f"- Duration: {fmt_duration(duration_ms)}")
- clerk_lines.append(f"- Context: {used_pct}% of {fmt_tokens(ctx_size)} ({main_ctx} tokens)")
- # TODO: get accurate per-session input/output token totals when platform supports it
- # clerk_lines.append(f"- Conversation: 📥 {main_in} in / 📤 {main_out} out")
- if sub_groups:
- clerk_lines.append(f"- Subagents: {sub_total} fresh")
- for g in sub_groups:
- model = get_agent_model(g["agent_type"])
- clerk_lines.append(
- f" - {g['agent_type']}/{model} (x{g['calls']}): {g['fresh']} fresh "
- f"(in:{g['input']} out:{g['output']} cache-create:{g['cache_create']} cache-read:{g['cache_read']})"
- )
- for inv in g["invocations"]:
- clerk_lines.append(
- f" - [{inv['timestamp']}] {inv['fresh']} fresh "
- f"(in:{inv['input']} out:{inv['output']} cache-create:{inv['cache_create']} cache-read:{inv['cache_read']})"
- )
- else:
- clerk_lines.append("- Subagents: 0 fresh")
- clerk_lines.append(f"- Grand total: {grand_total}")
-
- with open(metrics_out, "w") as f:
- f.write("\n".join(clerk_lines) + "\n")
- summary += f"\n\nMetrics file written: {metrics_out}"
-
- return {"summary": summary, "grand_total": grand_total, "metrics_file": f"/tmp/session-metrics-{topic_slug}.txt" if topic_slug else None}
-
-
-def main():
- session_id = sys.argv[1] if len(sys.argv) > 1 else ""
- topic_slug = sys.argv[2] if len(sys.argv) > 2 else ""
- result = run(session_id=session_id, topic_slug=topic_slug)
- if "error" in result:
- print(result["error"], file=sys.stderr)
- sys.exit(1)
- print(result["summary"])
-
-
-if __name__ == "__main__":
- main()
diff --git a/tools/session_wrapup.py b/tools/session_wrapup.py
index 1fbc7e8..8991a83 100644
--- a/tools/session_wrapup.py
+++ b/tools/session_wrapup.py
@@ -4,7 +4,7 @@
Bundles post-checkpoint scripts into a single invocation:
1. coach_metrics.py snapshot
2. coach_reflector.py evaluate
- 3. session_metrics.py (runs last to capture full token consumption)
+ 3. session_duration.py (current-sitting wall time from the transcript)
Each step catches failures independently — partial results are returned.
@@ -39,6 +39,9 @@ def run_script(cmd, label):
def run(sage_root, topic_path, topic_slug, session_id=""):
+ # TODO: remove topic_slug — dead since session token metrics were removed (it only
+ # named the /tmp metrics file). Removal is a CLI change: also update argv parsing,
+ # both usage strings, and the caller in docs/ref-session-end.md.
errors = []
coach_metrics_flags = []
insight_updates = []
@@ -73,24 +76,21 @@ def run(sage_root, topic_path, topic_slug, session_id=""):
else:
insights_ok = True
- # 3. Session metrics (last — captures full token consumption)
- metrics_cmd = [
- "python3", os.path.join(sage_root, "tools", "session_metrics.py"),
+ # 3. Session duration (current-sitting wall time from the transcript)
+ duration_cmd = [
+ "python3", os.path.join(sage_root, "tools", "session_duration.py"),
]
if session_id:
- metrics_cmd.append(session_id)
- else:
- metrics_cmd.append("")
- metrics_cmd.append(topic_slug)
+ duration_cmd.append(session_id)
- metrics_ok, metrics_out = run_script(metrics_cmd, "session_metrics")
- metrics_file = f"/tmp/session-metrics-{topic_slug}.txt" if metrics_ok else None
- if not metrics_ok:
- errors.append(f"session_metrics: {metrics_out}")
+ duration_ok, duration_out = run_script(duration_cmd, "session_duration")
+ duration = duration_out if duration_ok and duration_out else None
+ if not duration_ok:
+ # Non-blocking: duration is best-effort. The clerk falls back to asking the learner.
+ errors.append(f"session_duration: {duration_out}")
return {
- "metrics_file": metrics_file,
- "metrics_ok": metrics_ok,
+ "duration": duration,
"coach_metrics_ok": coach_metrics_ok,
"coach_metrics_flags": coach_metrics_flags,
"insights_ok": insights_ok,