Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ ccvault stats
| `stats` | Show archive statistics, including reclaimable space |
| `vacuum` | Reclaim dead space in the database file (see [Storage reclaim](#storage-reclaim)) |
| `list-projects` | List all indexed projects |
| `list-sessions` | List sessions (optionally filtered by project) |
| `list-sessions` | List top-level sessions (optionally filtered by project; `--include-subagents` / `--subagents-of <id>` expand subagent transcripts) |
| `show [session-id]` | Display a specific session |
| `export [session-id]` | Export a session to markdown |
| `build-cache` | Build Parquet analytics cache |
Expand Down
67 changes: 60 additions & 7 deletions cmd/ccvault/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,12 @@ Supports Gmail-like query syntax:
p = &models.Project{Path: r.ProjectPath}
}
fmt.Printf(" Project: %s\n", projectref.Inline(p))
// Search is never filtered, so hits land inside subagent
// transcripts that no default listing shows. Naming the parent
// is how the reader places the hit in a conversation.
if r.ParentSessionID != "" {
fmt.Printf(" Subagent of: %s\n", r.ParentSessionID)
}
if r.Model != "" {
fmt.Printf(" Model: %s\n", r.Model)
}
Expand Down Expand Up @@ -969,13 +975,40 @@ var listProjectsCmd = &cobra.Command{
},
}

// subagentScopeFromFlags maps the list-sessions flags onto a listing scope.
// The default hides subagent sessions — on a real machine they outnumber
// top-level sessions roughly 2.6:1 — while every row still reports how many
// it stands for. --subagents-of beats --include-subagents because asking for
// one parent's children is the more specific request.
func subagentScopeFromFlags(includeSubagents bool, subagentsOf string) (db.SubagentScope, string) {
switch {
case subagentsOf != "":
return db.SubagentsOf, subagentsOf
case includeSubagents:
return db.SubagentsIncluded, ""
default:
return db.SubagentsHidden, ""
}
}

// formatSubagentCount renders the SUBS column. Zero reads as "-" rather than
// "0" so the rows that dispatched work stand out in a long list.
func formatSubagentCount(n int) string {
if n == 0 {
return "-"
}
return fmt.Sprintf("%d", n)
}

var listSessionsCmd = &cobra.Command{
Use: "list-sessions",
Short: "List sessions",
RunE: func(cmd *cobra.Command, args []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
projectFilter, _ := cmd.Flags().GetString("project")
limit, _ := cmd.Flags().GetInt("limit")
includeSubagents, _ := cmd.Flags().GetBool("include-subagents")
subagentsOf, _ := cmd.Flags().GetString("subagents-of")

cfg, err := loadConfig(cmd)
if err != nil {
Expand Down Expand Up @@ -1022,7 +1055,13 @@ var listSessionsCmd = &cobra.Command{
}
}

sessions, err := database.GetSessions(projectID, limit)
scope, parentSessionID := subagentScopeFromFlags(includeSubagents, subagentsOf)
sessions, err := database.QuerySessions(db.SessionQuery{
ProjectID: projectID,
Limit: limit,
Scope: scope,
ParentSessionID: parentSessionID,
})
if err != nil {
return fmt.Errorf("get sessions: %w", err)
}
Expand Down Expand Up @@ -1058,11 +1097,11 @@ var listSessionsCmd = &cobra.Command{
byPath = projectref.ProjectsByPath(allProjects)
}
if showProject {
fmt.Printf("%-38s %-25s %16s %6s %10s %s\n", "SESSION ID", "PROJECT", "STARTED", "TURNS", "TOKENS", "MODEL")
fmt.Println(strings.Repeat("-", 125))
fmt.Printf("%-38s %-25s %16s %6s %10s %5s %s\n", "SESSION ID", "PROJECT", "STARTED", "TURNS", "TOKENS", "SUBS", "MODEL")
fmt.Println(strings.Repeat("-", 131))
} else {
fmt.Printf("%-38s %16s %6s %10s %s\n", "SESSION ID", "STARTED", "TURNS", "TOKENS", "MODEL")
fmt.Println(strings.Repeat("-", 100))
fmt.Printf("%-38s %16s %6s %10s %5s %s\n", "SESSION ID", "STARTED", "TURNS", "TOKENS", "SUBS", "MODEL")
fmt.Println(strings.Repeat("-", 106))
}
for _, s := range sessions {
model := s.Model
Expand All @@ -1076,20 +1115,22 @@ var listSessionsCmd = &cobra.Command{
// compact.Truncate so multibyte adapter labels don't get
// byte-sliced (e.g. Cyrillic "Иванов-project").
project := compact.Truncate(projectref.LabelFromPath(s.ProjectPath, byPath), 23).Text
fmt.Printf("%-38s %-25s %16s %6d %10s %s\n",
fmt.Printf("%-38s %-25s %16s %6d %10s %5s %s\n",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
s.ID,
project,
s.StartedAt.Format("2006-01-02 15:04"),
s.TurnCount,
formatTokens(tokens),
formatSubagentCount(s.SubagentCount),
model,
)
} else {
fmt.Printf("%-38s %16s %6d %10s %s\n",
fmt.Printf("%-38s %16s %6d %10s %5s %s\n",
s.ID,
s.StartedAt.Format("2006-01-02 15:04"),
s.TurnCount,
formatTokens(tokens),
formatSubagentCount(s.SubagentCount),
model,
)
}
Expand Down Expand Up @@ -1146,6 +1187,16 @@ var showCmd = &cobra.Command{
fmt.Printf("Model: %s\n", session.Model)
fmt.Printf("Started: %s\n", session.StartedAt.Format("2006-01-02 15:04:05"))
fmt.Printf("Turns: %d\n", len(turns))
// The way a reader discovers that a session dispatched work into
// transcripts the default listing doesn't show, and the way back up
// from one of those transcripts to the session that started it.
if session.ParentSessionID != "" {
fmt.Printf("Subagent of: %s\n", session.ParentSessionID)
}
if session.SubagentCount > 0 {
fmt.Printf("Subagents: %d (ccvault list-sessions --subagents-of %s)\n",
session.SubagentCount, session.ID)
}
fmt.Println(strings.Repeat("=", 60))
fmt.Println()

Expand Down Expand Up @@ -1456,6 +1507,8 @@ func init() {
listSessionsCmd.Flags().Bool("json", false, "Output as JSON")
listSessionsCmd.Flags().String("project", "", "Filter by project")
listSessionsCmd.Flags().Int("limit", 50, "Maximum number of results")
listSessionsCmd.Flags().Bool("include-subagents", false, "List subagent sessions alongside top-level ones")
listSessionsCmd.Flags().String("subagents-of", "", "List only the subagent sessions dispatched by this session id")

// Show flags
showCmd.Flags().Bool("json", false, "Output as JSON")
Expand Down
54 changes: 54 additions & 0 deletions cmd/ccvault/subagents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// ABOUTME: Tests the list-sessions subagent flags and the SUBS column rendering.
// ABOUTME: The default hides subagent rows; the count is what keeps that honest.

package main

import (
"testing"

"github.com/2389-research/ccvault/internal/db"
)

func TestSubagentScopeFromFlags(t *testing.T) {
cases := []struct {
name string
include bool
of string
wantScope db.SubagentScope
wantID string
}{
{name: "default hides them", wantScope: db.SubagentsHidden},
{name: "include flattens", include: true, wantScope: db.SubagentsIncluded},
{name: "subagents-of narrows", of: "parent-a", wantScope: db.SubagentsOf, wantID: "parent-a"},
{
// Asking for one parent's children is more specific than asking
// for everything, so it wins.
name: "subagents-of wins over include",
include: true,
of: "parent-a",
wantScope: db.SubagentsOf,
wantID: "parent-a",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
scope, id := subagentScopeFromFlags(tc.include, tc.of)
if scope != tc.wantScope {
t.Errorf("scope = %v, want %v", scope, tc.wantScope)
}
if id != tc.wantID {
t.Errorf("parent id = %q, want %q", id, tc.wantID)
}
})
}
}

func TestFormatSubagentCount(t *testing.T) {
cases := map[int]string{0: "-", 1: "1", 72: "72"}
for in, want := range cases {
if got := formatSubagentCount(in); got != want {
t.Errorf("formatSubagentCount(%d) = %q, want %q", in, got, want)
}
}
}
91 changes: 91 additions & 0 deletions internal/analytics/subagents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// ABOUTME: Tests that analytics counts subagent sessions rather than filtering them.
// ABOUTME: The hidden-by-default listing rule is a listing rule, not an accounting rule.

package analytics

import (
"testing"
"time"

"github.com/2389-research/ccvault/internal/db"
)

// TestAnalyticsCountsSubagentSessions is the loud half of this change: a
// subagent transcript's tokens are additive, not duplicated — parent
// transcripts contain no sidechain lines at all — so counting them raises the
// totals. On the author's archive the 63 parents with ingested subagents hold
// 2,905,646 tokens and their subagents another 832,723, about 29% more.
func TestAnalyticsCountsSubagentSessions(t *testing.T) {
database, err := db.Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = database.Close() })

now := time.Now().UTC()
seedSession(t, database, now, "/tmp/proj-a", "proj-a", "parent-a",
"claude-opus-4", "claude-code", 100, 50, 0)
seedSubagentSession(t, database, now.Add(time.Minute), "/tmp/proj-a", "claude-code:parent-a:agent-a1",
"parent-a", 1000, 500)

// The summary the TUI and MCP read is driven by these totals.
count, turns, tokens, err := database.GetSessionStats()
if err != nil {
t.Fatalf("session stats: %v", err)
}
if count != 2 {
t.Errorf("session count = %d, want 2 — analytics must count subagent sessions", count)
}
if turns != 4 {
t.Errorf("turn count = %d, want 4", turns)
}
if tokens != 1650 {
t.Errorf("token total = %d, want 1650 (150 parent + 1500 subagent)", tokens)
}

// And the parquet export, which backs the DuckDB analytics views.
cacheDir := t.TempDir()
if err := NewExporter(database, cacheDir).Export(); err != nil {
t.Fatalf("export: %v", err)
}
analyzer, err := NewAnalyzer(cacheDir)
if err != nil {
t.Fatalf("new analyzer: %v", err)
}
t.Cleanup(func() { _ = analyzer.Close() })

summary, err := analyzer.GetSummary()
if err != nil {
t.Fatalf("get summary: %v", err)
}
if summary.TotalSessions != 2 {
t.Errorf("parquet TotalSessions = %d, want 2", summary.TotalSessions)
}
if summary.TotalTokens != 1650 {
t.Errorf("parquet TotalTokens = %d, want 1650", summary.TotalTokens)
}
}

// seedSubagentSession inserts a session row linked to a parent, in the shape
// the claude-code adapter now mints.
func seedSubagentSession(t *testing.T, d *db.DB, when time.Time, projectPath, sessionID, parentID string, inputTok, outputTok int64) {
t.Helper()

proj, err := d.GetProjectByPath(projectPath)
if err != nil {
t.Fatalf("get project: %v", err)
}
if proj == nil {
t.Fatalf("project %s must exist before its subagent session", projectPath)
}

_, err = d.Exec(`INSERT INTO sessions
(id, project_id, model, git_branch, started_at, ended_at, turn_count, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, source_file, source, parent_session_id)
VALUES (?, ?, 'claude-opus-4', 'main', ?, ?, 2, ?, ?, 0, 0, ?, 'claude-code', ?)`,
sessionID, proj.ID, when, when, inputTok, outputTok,
"/tmp/parent-a/subagents/agent-a1.jsonl", parentID)
if err != nil {
t.Fatalf("insert subagent session: %v", err)
}
}
52 changes: 52 additions & 0 deletions internal/db/migrations/007_add_parent_session_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- ABOUTME: Adds sessions.parent_session_id so a subagent transcript can be its own row.
-- ABOUTME: Backfills the link for subagent rows that only ever carried it inside their composite id.

-- A subagent transcript (<project>/<session-uuid>/subagents/agent-*.jsonl) is
-- its own session row, identified by a minted composite id:
--
-- <source>:<parent-uuid>:agent-<agentId>
--
-- The relationship lives in this column rather than only inside that string,
-- so listings can filter on it and a parent can count its children. NULL means
-- top-level, which is what every pre-existing row is.
ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id);

-- Both directions are hot: "is this row top-level?" on every default listing,
-- and "how many children does this parent have?" for subagent_count.
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)
WHERE parent_session_id IS NOT NULL;

-- Backfill. The nanoclaw adapter has always minted composite ids for the
-- sidechain files it ingests (133 rows on the author's archive) and has always
-- computed a parent_session_id into its Metadata — but there was no column, so
-- sync dropped it and every one of those rows was orphaned: reachable by id,
-- unlinked to its parent. The id is the only surviving record of the link, and
-- it is enough: the parent id is the composite id minus its last ':' segment.
--
-- substr/rtrim/replace is the SQLite spelling of "index of the last colon":
-- replace(id, ':', '') is the id with colons removed, so rtrim(id, <that>)
-- strips trailing non-colon characters and leaves a string ending at the last
-- colon. Its length is that colon's position.
--
-- Scope, deliberately narrow:
-- * source_file under a subagents/ directory — the on-disk fact that makes a
-- row a sidechain, independent of how its id happens to be spelled.
-- * id of the <prefix>:<parent>:agent-<id> shape, so a two-segment id never
-- gets truncated to its bare prefix.
-- * parent_session_id IS NULL, so this can only ever fill a blank.
-- * NOT 'claude-code:%' — claude-code subagent rows are minted by the code
-- that ships with this migration and always write the column directly, and
-- their parent id is the bare uuid (no source prefix), so the
-- strip-last-segment rule would produce a parent that does not exist.
-- No such row can predate this migration: the scanner skipped them all.
--
-- Rows whose computed parent is not itself in the archive still get the link.
-- It is the truth about where the transcript came from, and the default
-- listing deliberately keeps showing a subagent whose parent is absent so that
-- hiding never turns into losing.
UPDATE sessions
SET parent_session_id = substr(id, 1, length(rtrim(id, replace(id, ':', ''))) - 1)
WHERE parent_session_id IS NULL
AND source_file LIKE '%/subagents/%'
AND id LIKE '%:%:agent-%'
AND id NOT LIKE 'claude-code:%';
Loading
Loading