From e7955a9683ae74fc9f41498edc1395a5f2efa66c Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 17 Oct 2026 02:42:33 -0700 Subject: [PATCH 1/6] fix(log): subagent briefs are agent_prompt; --since alone; -C context A subagent's task prompt is user-role text written by the parent agent. Inline (isSidechain) or in a subagents/ file it was classified user_prompt, so metrics.user_prompts ran a fifth to a half high. It is now kind agent_prompt; insight already excluded it. --since without --until was rejected; "since X" now runs until now in log and insight, and the reconstructed insight command drops the empty --until. log -C/--context N keeps N records on each side of every --kind/--match hit from the same transcript, flagged context: true. Matches and aggregates still count matches only. Co-Authored-By: Claude Opus 5.5 --- internal/cmd/insight.go | 5 +- internal/cmd/log.go | 30 ++++++-- internal/sessionlog/sessionlog.go | 78 +++++++++++++++++---- internal/sessionlog/sessionlog_test.go | 95 ++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 20 deletions(-) diff --git a/internal/cmd/insight.go b/internal/cmd/insight.go index 2db2e8d..402804c 100644 --- a/internal/cmd/insight.go +++ b/internal/cmd/insight.go @@ -193,7 +193,10 @@ func insightCommand(cmd *cobra.Command, args []string) string { parts := []string{"ccx insight"} parts = append(parts, args...) if insightSince != "" || insightUntil != "" { - parts = append(parts, "--since", insightSince, "--until", insightUntil) + parts = append(parts, "--since", insightSince) + if insightUntil != "" { + parts = append(parts, "--until", insightUntil) + } } else { parts = append(parts, "--scope", insightScope) } diff --git a/internal/cmd/log.go b/internal/cmd/log.go index bdf7bb7..5a9112d 100644 --- a/internal/cmd/log.go +++ b/internal/cmd/log.go @@ -28,7 +28,9 @@ for scoped insight: records inside the window plus session overlap metadata. assistant_message, tool_call, tool_result, reasoning, ...); --match keeps records whose raw transcript line contains a phrase (-w for whole words). Together they turn the firehose into a timeline: -every human prompt today, across every session, in order. +every human prompt today, across every session, in order. -C N adds +N records on each side of every match from the same transcript, so +an interrupt shows what the agent was doing and what came next. Examples: ccx log --scope yesterday --tz +8 --all --json @@ -36,7 +38,8 @@ Examples: ccx log --scope week --provider cx --json ccx log /path/to/repo --scope yesterday --json --raw ccx log --scope today --all --kind user_prompt # the humans in the loop, today - ccx log --scope month --all --match semantica -w # when a term came up, in a window`, + ccx log --scope month --all --match semantica -w # when a term came up, in a window + ccx log --since 2026-09-01 --all --kind interrupt,tool_denied -C 2 # what each Esc cut off`, Args: cobra.MaximumNArgs(1), RunE: runLog, } @@ -54,6 +57,7 @@ var ( logKinds string logMatch string logWord bool + logContext int logLocation *time.Location ) @@ -70,6 +74,7 @@ func init() { logCmd.Flags().StringVar(&logKinds, "kind", "", "keep only these record kinds (comma-separated, e.g. user_prompt,assistant_message)") logCmd.Flags().StringVar(&logMatch, "match", "", "keep only records whose raw transcript line contains this phrase") logCmd.Flags().BoolVarP(&logWord, "word", "w", false, "--match whole words only") + logCmd.Flags().IntVarP(&logContext, "context", "C", 0, "with --kind/--match, also keep N records before and after each match (same transcript)") } func runLog(cmd *cobra.Command, args []string) error { @@ -104,6 +109,12 @@ func runLog(cmd *cobra.Command, args []string) error { if strings.TrimSpace(logKinds) != "" { kinds = strings.Split(logKinds, ",") } + if logContext < 0 { + return fmt.Errorf("--context must be 0 or more") + } + if logContext > 0 && len(kinds) == 0 && strings.TrimSpace(logMatch) == "" { + return fmt.Errorf("--context needs --kind or --match: it widens a match, and without a filter every record already shows") + } var match func(string) bool if strings.TrimSpace(logMatch) != "" { if err := matchValueError(logMatch); err != nil { @@ -128,6 +139,7 @@ func runLog(cmd *cobra.Command, args []string) error { Now: time.Now().In(loc), Kinds: kinds, Match: match, + Context: logContext, }) if err != nil { return err @@ -213,8 +225,12 @@ func logWindow(loc *time.Location) (time.Time, time.Time, string, string, error) if err != nil { return time.Time{}, time.Time{}, "", "", fmt.Errorf("invalid --until: %w", err) } - if start.IsZero() || end.IsZero() { - return time.Time{}, time.Time{}, "", "", fmt.Errorf("--since and --until are both required when --scope is not used") + if start.IsZero() { + return time.Time{}, time.Time{}, "", "", fmt.Errorf("--until needs --since (or use --scope)") + } + if end.IsZero() { + // "since X" means until now; the common question has no end date. + end = time.Now().In(loc) } if !start.Before(end) { return time.Time{}, time.Time{}, "", "", fmt.Errorf("--since must be before --until") @@ -267,10 +283,14 @@ func printLogTable(bundle *sessionlog.Bundle) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "WHEN\tSRC\tKIND\tSESSION\tPROJECT\tTEXT") for _, record := range bundle.Records { + kind := record.Kind + if record.Context { + kind = " ctx " + kind // a neighbor of a match, indented under it + } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", record.Timestamp.In(loc).Format("Jan 02 15:04"), providerTag(record.Provider), - record.Kind, + kind, truncateID(record.SessionID, 8), truncateDisplay(record.Project, 20), truncateDisplay(record.Text, 96)) diff --git a/internal/sessionlog/sessionlog.go b/internal/sessionlog/sessionlog.go index f5aa7a9..bad8f6c 100644 --- a/internal/sessionlog/sessionlog.go +++ b/internal/sessionlog/sessionlog.go @@ -46,6 +46,11 @@ type Options struct { // records list (and the aggregates built from it) narrow. Kinds []string Match func(rawLine string) bool + // Context keeps up to N records on each side of every match, from + // the same source file, flagged Record.Context — grep -C. A match + // alone ("turn aborted: interrupted") does not say what the agent + // was doing or what the human said next. Ignored without a filter. + Context int } type Bundle struct { @@ -150,6 +155,8 @@ type Record struct { ParentUUID string `json:"parent_uuid,omitempty"` IsSidechain bool `json:"is_sidechain,omitempty"` IsSubagent bool `json:"is_subagent,omitempty"` + // Context marks a neighbor kept by Options.Context, not a match. + Context bool `json:"context,omitempty"` // Tool and Path are set on tool_call records: the tool name (Bash, // Edit, exec_command, ...) and the workspace path it targeted when // the call named one. Text carries "Tool: argument" so a consumer @@ -258,6 +265,8 @@ func Collect(sources []Source, opts Options) (*Bundle, error) { return scans[i].Records[0].Timestamp.Before(scans[j].Records[0].Timestamp) }) + filtered := len(opts.Kinds) > 0 || opts.Match != nil + keep := recordFilter(opts.Kinds) for _, scan := range scans { session := SessionSlice{ ID: scan.SessionID, @@ -275,7 +284,14 @@ func Collect(sources []Source, opts Options) (*Bundle, error) { Relation: relationFor(scan.Start, scan.End, opts.Start, opts.End), } bundle.Sessions = append(bundle.Sessions, session) - bundle.Records = append(bundle.Records, scan.Records...) + records := scan.Records + if filtered { + // Per file, before the global sort: context is the + // neighbors in this transcript, not whatever another + // session logged in the same second. + records = selectRecords(records, keep, opts.Context) + } + bundle.Records = append(bundle.Records, records...) } sort.SliceStable(bundle.Records, func(i, j int) bool { if bundle.Records[i].Timestamp.Equal(bundle.Records[j].Timestamp) { @@ -286,12 +302,17 @@ func Collect(sources []Source, opts Options) (*Bundle, error) { } return bundle.Records[i].Timestamp.Before(bundle.Records[j].Timestamp) }) - filtered := len(opts.Kinds) > 0 || opts.Match != nil - if filtered { - bundle.Records = filterRecords(bundle.Records, opts.Kinds) + matches := bundle.Records + if filtered && opts.Context > 0 { + matches = make([]Record, 0, len(bundle.Records)) + for _, r := range bundle.Records { + if !r.Context { + matches = append(matches, r) + } + } } - matched := len(bundle.Records) - bundle.Days, bundle.Providers, bundle.Workspaces = aggregateRecords(bundle.Records, now.Location()) + matched := len(matches) + bundle.Days, bundle.Providers, bundle.Workspaces = aggregateRecords(matches, now.Location()) if opts.Limit > 0 && len(bundle.Records) > opts.Limit { bundle.Records = bundle.Records[:opts.Limit] } @@ -306,25 +327,48 @@ func Collect(sources []Source, opts Options) (*Bundle, error) { return bundle, nil } -// filterRecords keeps records that passed Match and are of one of the -// kinds (kind demotion has already run, so "user_prompt" means the +// recordFilter reports whether a record passed Match and is of one of +// the kinds (kind demotion has already run, so "user_prompt" means the // visible conversation on every provider). -func filterRecords(records []Record, kinds []string) []Record { +func recordFilter(kinds []string) func(Record) bool { want := make(map[string]bool, len(kinds)) for _, k := range kinds { if k = strings.TrimSpace(k); k != "" { want[k] = true } } - out := records[:0] - for _, r := range records { - if !r.matched { + return func(r Record) bool { + return r.matched && (len(want) == 0 || want[r.Kind]) + } +} + +// selectRecords keeps the records of one file that pass keep, plus up +// to context neighbors on each side marked Context. Records stay in +// file order and each appears once: the after-window stops at the next +// match, so overlapping windows merge and a match is never a neighbor. +func selectRecords(records []Record, keep func(Record) bool, context int) []Record { + var out []Record + next := 0 // first index not yet emitted + for i, r := range records { + if !keep(r) { continue } - if len(want) > 0 && !want[r.Kind] { - continue + for j := max(i-context, next); j < i; j++ { + c := records[j] + c.Context = true + out = append(out, c) } out = append(out, r) + next = i + 1 + for j := next; j <= i+context && j < len(records); j++ { + if keep(records[j]) { + break // the loop reaches it as a match + } + c := records[j] + c.Context = true + out = append(out, c) + next = j + 1 + } } return out } @@ -493,6 +537,12 @@ func scanFile(source Source, filePath string, opts Options) (*fileScan, error) { if strings.Contains(filePath, string(filepath.Separator)+"subagents"+string(filepath.Separator)) { record.IsSubagent = true } + if record.Kind == "user_prompt" && (record.IsSidechain || record.IsSubagent) { + // A subagent's brief is user-role, but the parent agent + // wrote it. Counted as a prompt it inflated "the humans in + // the loop" by a fifth to a half, month after month. + record.Kind = "agent_prompt" + } if opts.IncludeRaw { record.RawJSON = json.RawMessage([]byte(line)) } diff --git a/internal/sessionlog/sessionlog_test.go b/internal/sessionlog/sessionlog_test.go index b321f22..8556e3d 100644 --- a/internal/sessionlog/sessionlog_test.go +++ b/internal/sessionlog/sessionlog_test.go @@ -1,6 +1,7 @@ package sessionlog import ( + "fmt" "os" "path/filepath" "strings" @@ -304,6 +305,46 @@ func TestCollectClaudeUserRoleNoiseIsNotAPrompt(t *testing.T) { } } +// A subagent's brief is user-role text the parent agent wrote. Inline +// (isSidechain, older Claude Code) or in a subagents/ file (newer), it +// is an agent_prompt, never a user_prompt — including when the parent +// transcript is gone and only the subagent file survives. +func TestCollectSubagentBriefIsNotAHumanPrompt(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, ".claude") + projDir := filepath.Join(home, "projects", "-tmp-repo") + orphanDir := filepath.Join(projDir, "gone-parent", "subagents") + if err := os.MkdirAll(orphanDir, 0755); err != nil { + t.Fatal(err) + } + writeLines(t, filepath.Join(projDir, "s1.jsonl"), + `{"type":"user","uuid":"h1","sessionId":"s1","cwd":"/tmp/repo","timestamp":"2026-08-18T10:00:00Z","message":{"role":"user","content":"the human asks"}}`, + `{"type":"user","uuid":"a1","sessionId":"s1","cwd":"/tmp/repo","isSidechain":true,"timestamp":"2026-08-18T10:00:01Z","message":{"role":"user","content":"Search the repo for X and report"}}`, + `{"type":"assistant","uuid":"a2","sessionId":"s1","cwd":"/tmp/repo","isSidechain":true,"timestamp":"2026-08-18T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"found it"}]}}`, + ) + writeLines(t, filepath.Join(orphanDir, "agent-abc123.jsonl"), + `{"type":"user","uuid":"o1","sessionId":"gone-parent","cwd":"/tmp/repo","timestamp":"2026-08-18T11:00:00Z","message":{"role":"user","content":"Warmup"}}`, + ) + bundle, err := Collect([]Source{{Provider: "claude-code", Home: home}}, Options{Start: mustParseTime(t, "2026-08-18T00:00:00Z"), End: mustParseTime(t, "2026-08-19T00:00:00Z")}) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"h1": "user_prompt", "a1": "agent_prompt", "a2": "assistant_message", "o1": "agent_prompt"} + for _, r := range bundle.Records { + if want[r.UUID] != r.Kind { + t.Errorf("%s: kind %q, want %q", r.UUID, r.Kind, want[r.UUID]) + } + } + if len(bundle.Records) != 4 || bundle.Metrics.UserPrompts != 1 { + t.Fatalf("records %d, user prompts %d, want 4 and 1", len(bundle.Records), bundle.Metrics.UserPrompts) + } + for _, day := range bundle.Days { + if day.UserPrompts != 1 { + t.Fatalf("day rollup counts agent briefs as prompts: %+v", day) + } + } +} + func writeLines(t *testing.T, path string, lines ...string) { t.Helper() content := "" @@ -465,3 +506,57 @@ func TestCollectTruncatedIsMeasuredAgainstMatchedNotTotal(t *testing.T) { t.Fatalf("limit below matched must report truncated: %+v", m) } } + +func TestCollectContextKeepsNeighborsFromTheSameTranscript(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, ".claude") + projectDir := filepath.Join(home, "projects", "-tmp-workspace") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + writeLines(t, filepath.Join(projectDir, "s.jsonl"), + `{"type":"user","sessionId":"s","cwd":"/tmp/workspace","timestamp":"2026-05-21T09:00:00+08:00","message":{"role":"user","content":"fix the build"}}`, + `{"type":"assistant","sessionId":"s","cwd":"/tmp/workspace","timestamp":"2026-05-21T09:01:00+08:00","message":{"role":"assistant","content":[{"type":"text","text":"rewriting the Makefile"}]}}`, + `{"type":"assistant","sessionId":"s","cwd":"/tmp/workspace","timestamp":"2026-05-21T09:02:00+08:00","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"rm -rf build"}}]}}`, + `{"type":"user","sessionId":"s","cwd":"/tmp/workspace","timestamp":"2026-05-21T09:03:00+08:00","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"The user doesn't want to proceed with this tool use. The tool use was rejected"}]}}`, + `{"type":"user","sessionId":"s","cwd":"/tmp/workspace","timestamp":"2026-05-21T09:04:00+08:00","message":{"role":"user","content":"no, only the target"}}`, + `{"type":"user","sessionId":"s","cwd":"/tmp/workspace","timestamp":"2026-05-21T09:05:00+08:00","message":{"role":"user","content":"and run make"}}`, + ) + opts := Options{ + Start: mustParseTime(t, "2026-05-21T00:00:00+08:00"), End: mustParseTime(t, "2026-05-22T00:00:00+08:00"), TimeZone: "+08:00", + Kinds: []string{"tool_denied"}, Context: 1, + } + bundle, err := Collect([]Source{{Provider: "claude-code", Home: home}}, opts) + if err != nil { + t.Fatal(err) + } + var got []string + for _, r := range bundle.Records { + got = append(got, fmt.Sprintf("%s:%v", r.Kind, r.Context)) + } + if want := "tool_call:true tool_denied:false user_prompt:true"; strings.Join(got, " ") != want { + t.Fatalf("records = %v, want %s", got, want) + } + if m := bundle.Metrics; m.RecordsMatched != 1 || m.RecordsReturned != 3 || m.Truncated { + t.Fatalf("context neighbors must not count as matches: %+v", m) + } + if len(bundle.Providers) != 1 || bundle.Providers[0].Records != 1 { + t.Fatalf("aggregates must count matches only: %+v", bundle.Providers) + } + + // Adjacent matches share one window; nothing is emitted twice and + // a match is never demoted to a neighbor. + opts.Kinds = []string{"user_prompt"} + opts.Context = 2 + bundle, err = Collect([]Source{{Provider: "claude-code", Home: home}}, opts) + if err != nil { + t.Fatal(err) + } + got = got[:0] + for _, r := range bundle.Records { + got = append(got, fmt.Sprintf("%d:%v", r.Line, r.Context)) + } + if want := "1:false 2:true 3:true 4:true 5:false 6:false"; strings.Join(got, " ") != want { + t.Fatalf("records = %v, want %s", got, want) + } +} From d2fc4f9206134bb1e4d158a2903c216fd040084f Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 17 Oct 2026 02:42:34 -0700 Subject: [PATCH 2/6] feat(me): the human's profile from prompt history ccx me reads only what the human typed (Claude Code and Codex history.jsonl) and counts it as ccx.me.v1: tempo, months, steering (slash commands, /rewind edits, resends, workspace switches) and workspaces. --term name=regex trends any phrase by month, so ccx ships no word list. History outlives transcript cleanup and reads in seconds. A day runs 05:00 to 05:00 so a late session stays on the day it began. decodePromptLine keeps the absolute workspace path beside its basename. Co-Authored-By: Claude Opus 5.5 --- internal/cmd/me.go | 328 ++++++++++++++ internal/cmd/me_test.go | 101 +++++ internal/cmd/search_history.go | 4 +- internal/me/profile.go | 802 +++++++++++++++++++++++++++++++++ internal/me/profile_test.go | 213 +++++++++ 5 files changed, 1447 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/me.go create mode 100644 internal/cmd/me_test.go create mode 100644 internal/me/profile.go create mode 100644 internal/me/profile_test.go diff --git a/internal/cmd/me.go b/internal/cmd/me.go new file mode 100644 index 0000000..95f4cd1 --- /dev/null +++ b/internal/cmd/me.go @@ -0,0 +1,328 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/thevibeworks/ccx/internal/config" + "github.com/thevibeworks/ccx/internal/insight" + "github.com/thevibeworks/ccx/internal/me" +) + +var meCmd = &cobra.Command{ + Use: "me", + Short: "What the prompt record says about the human at the keyboard", + Long: `What the prompt record says about the human at the keyboard. + +Every other command describes what the agent did. This one reads only +what you typed: when you work, how you steer, how often you take a +prompt back, how many workspaces you hold at once. It counts; it does +not judge, and no model is involved. + +The source is the prompt history (~/.claude/history.jsonl, +~/.codex/history.jsonl), not the session files: history outlives +session cleanup, so it is the only record that reaches back past the +session horizon. Codex history records no workspace, so workspace +figures cover Claude Code prompts only. + +A day runs 05:00 to 05:00, so a night session stays with the day it +began; a day end past 24:00 is after midnight. + +--term counts prompts matching a regex (case-insensitive), in total +and per month, so any habit of speech can be trended without ccx +shipping a word list. + +Examples: + ccx me --tz +8 # everything on record + ccx me --since 2026-06-01 --tz +8 # from a date until now + ccx me --term think=ultrathink --term again='\bagain\b|\bstill\b' + ccx me --json # ccx.me.v1, for skills`, + Args: cobra.NoArgs, + RunE: runMe, +} + +var ( + meTZ string + meSince string + meUntil string + meJSON bool + meProvider string + meTop int + meTerms []string +) + +func init() { + meCmd.Flags().StringVar(&meTZ, "tz", "local", "timezone: IANA name, UTC, local, or offset like +8") + meCmd.Flags().StringVar(&meSince, "since", "", "start timestamp or date (RFC3339 or YYYY-MM-DD in --tz); default: first prompt") + meCmd.Flags().StringVar(&meUntil, "until", "", "exclusive end timestamp or date; default: now") + meCmd.Flags().BoolVar(&meJSON, "json", false, "output as JSON (ccx.me.v1)") + meCmd.Flags().StringVarP(&meProvider, "provider", "p", "", "filter by provider: cc, cx, all") + meCmd.Flags().IntVarP(&meTop, "limit", "n", 10, "rows in each ranked list") + meCmd.Flags().StringArrayVar(&meTerms, "term", nil, "count prompts matching a regex, as name=regex (repeatable)") + rootCmd.AddCommand(meCmd) +} + +func runMe(cmd *cobra.Command, args []string) error { + loc, err := insight.LoadLocation(meTZ) + if err != nil { + return fmt.Errorf("invalid --tz %q: %w", meTZ, err) + } + since, err := parseLogTime(meSince, loc) + if err != nil { + return fmt.Errorf("invalid --since: %w", err) + } + until, err := parseLogTime(meUntil, loc) + if err != nil { + return fmt.Errorf("invalid --until: %w", err) + } + if !since.IsZero() && !until.IsZero() && !since.Before(until) { + return fmt.Errorf("--since must be before --until") + } + terms, err := parseMeTerms(meTerms) + if err != nil { + return err + } + providerFilter := config.NormalizeProvider(meProvider) + if !validLogProvider(providerFilter) { + return fmt.Errorf("invalid --provider %q (want cc, cx, claude-code, codex, or all)", meProvider) + } + + settings := config.Load() + prompts, sources := readPromptHistory(promptHistoryFiles(settings.ClaudeHome, settings.CodexHome), providerFilter, loc) + profile := me.Build(prompts, me.Options{Location: loc, Since: since, Until: until, Terms: terms, Top: meTop}) + profile.Sources = sources + if profile.Span.Prompts == 0 { + return fmt.Errorf("no prompts on record in that window (history files: %s)", strings.Join(sourcePaths(sources), ", ")) + } + + if meJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(profile) + } + return printMe(profile) +} + +// parseMeTerms turns name=regex flags into case-insensitive patterns. +func parseMeTerms(raw []string) ([]me.Term, error) { + var terms []me.Term + seen := map[string]bool{} + for _, r := range raw { + name, pattern, ok := strings.Cut(r, "=") + name = strings.TrimSpace(name) + if !ok || name == "" || pattern == "" { + return nil, fmt.Errorf("invalid --term %q (want name=regex)", r) + } + if seen[name] { + return nil, fmt.Errorf("--term %q given twice", name) + } + seen[name] = true + re, err := regexp.Compile("(?i)" + pattern) + if err != nil { + return nil, fmt.Errorf("invalid --term %q: %w", r, err) + } + terms = append(terms, me.Term{Name: name, Pattern: re}) + } + return terms, nil +} + +// readPromptHistory loads every prompt from the history files. A +// missing file is normal (the provider was never used) and still +// reported as a source with zero prompts. +func readPromptHistory(files map[string]string, providerFilter string, loc *time.Location) ([]me.Prompt, []me.Source) { + var prompts []me.Prompt + var sources []me.Source + for _, provider := range []string{"claude-code", "codex"} { + path, ok := files[provider] + if !ok || (providerFilter != "" && providerFilter != provider) { + continue + } + src := me.Source{Provider: provider, Path: path} + var first, last time.Time + file, err := os.Open(path) + if err != nil { + sources = append(sources, src) + continue + } + reader := bufio.NewReaderSize(file, 64*1024) + for { + line, err := reader.ReadString('\n') + if rec, ok := decodePromptLine(provider, line); ok { + prompts = append(prompts, me.Prompt{Time: rec.time, Provider: provider, Workspace: rec.workspace, SessionID: rec.SessionID, Text: rec.text}) + src.Prompts++ + if !rec.time.IsZero() { + if first.IsZero() || rec.time.Before(first) { + first = rec.time + } + if rec.time.After(last) { + last = rec.time + } + } + } + if err != nil { + if err != io.EOF { + fmt.Fprintf(os.Stderr, "warning: read error in %s: %v\n", filepath.Base(path), err) + } + break + } + } + file.Close() + if !first.IsZero() { + src.First, src.Last = first.In(loc).Format(time.RFC3339), last.In(loc).Format(time.RFC3339) + } + sources = append(sources, src) + } + return prompts, sources +} + +func sourcePaths(sources []me.Source) []string { + paths := make([]string, 0, len(sources)) + for _, s := range sources { + paths = append(paths, s.Path) + } + return paths +} + +func printMe(p me.Profile) error { + providers := make([]string, 0, len(p.Span.Providers)) + for _, c := range p.Span.Providers { + providers = append(providers, fmt.Sprintf("%s %d", c.Key, c.Count)) + } + fmt.Printf("ccx me · %s to %s · %s\n", dayOf(p.Span.First), dayOf(p.Span.Last), p.TimeZone) + fmt.Printf("prompts %d (%s) · active %d of %d days · longest streak %d days from %s\n", + p.Span.Prompts, strings.Join(providers, ", "), p.Span.ActiveDays, p.Span.CalendarDays, + p.Tempo.LongestStreak.Days, p.Tempo.LongestStreak.Start) + + fmt.Printf("\nTEMPO (a day runs %02d:00 to %02d:00)\n", p.Tempo.DayStartHour, p.Tempo.DayStartHour) + fmt.Printf(" weekdays %.1f prompts/day over %d days · weekends %.1f over %d\n", + p.Tempo.WeekdayPerDay, p.Tempo.WeekdayActiveDays, p.Tempo.WeekendPerDay, p.Tempo.WeekendActiveDays) + fmt.Printf(" median day starts %s · ends %s\n", p.Tempo.MedianDayStart, p.Tempo.MedianDayEnd) + if len(p.Tempo.Breaks) > 0 { + parts := make([]string, 0, len(p.Tempo.Breaks)) + for _, b := range p.Tempo.Breaks { + parts = append(parts, fmt.Sprintf("%d after %s", b.Days, b.After)) + } + fmt.Printf(" breaks of 2+ idle days: %s\n", strings.Join(parts, " · ")) + } else { + fmt.Println(" breaks of 2+ idle days: none") + } + peak := 0 + for _, n := range p.Tempo.Hours { + if n > peak { + peak = n + } + } + for h, n := range p.Tempo.Hours { + fmt.Printf(" %02d %6d %s\n", h, n, strings.Repeat("#", bar(n, peak, 48))) + } + + termNames := make([]string, 0, len(p.Terms)) + for _, t := range p.Terms { + termNames = append(termNames, t.Name) + } + fmt.Println("\nMONTHS (CHARS = median prose length; CJK/ASK = share of prose; CMD/LATE = share of prompts; LATE = 23:00 to day roll)") + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + header := "MONTH\tPROMPTS\tDAYS\tSPACES\tNEW\tPER DAY\tCHARS\tCJK\tASK\tCMD\tLATE\tENDS" + for _, name := range termNames { + header += "\t" + strings.ToUpper(name) + } + fmt.Fprintln(w, header) + for _, m := range p.Months { + row := fmt.Sprintf("%s\t%d\t%d\t%d\t%d\t%.1f\t%d\t%s\t%s\t%s\t%s\t%s", + m.Key, m.Prompts, m.ActiveDays, m.Workspaces, m.NewWorkspaces, m.MedianWorkspacesPerDay, m.MedianChars, + pct(m.CJKShare), pct(m.QuestionShare), pct(m.CommandShare), pct(m.LateShare), m.MedianDayEnd) + for _, name := range termNames { + row += fmt.Sprintf("\t%s", pct(termShare(m.Terms[name], m.Prompts))) + } + fmt.Fprintln(w, row) + } + if err := w.Flush(); err != nil { + return err + } + + fmt.Println("\nSTEERING") + fmt.Printf(" commands %s\n", countLine(p.Commands)) + fmt.Printf(" rewinds %d · median %ds after the prompt · %d of %d are followed by the same prompt again (%d longer, %d shorter)\n", + p.Rewinds.Count, p.Rewinds.MedianSecondsBefore, p.Rewinds.Resent, p.Rewinds.Paired, p.Rewinds.ResentLonger, p.Rewinds.ResentShorter) + fmt.Printf(" resends %d of %d substantive prompts repeat one from the last hour (%s)\n", + p.Resends.Resent, p.Resends.Substantive, pct(p.Resends.Share)) + fmt.Printf(" openers %s\n", countLine(p.Openers)) + if len(p.Repeats) > 0 { + fmt.Println(" typed again and again") + for _, c := range p.Repeats { + fmt.Printf(" %5d %s\n", c.Count, truncateDisplay(cleanDisplayText(c.Key), 72)) + } + } + + ws := p.Workspaces + fmt.Println("\nWORKSPACES (Claude Code only; Codex history records none)") + fmt.Printf(" %d total · prompted on 1 day: %d · 2-3: %d · 4-7: %d · 8-30: %d · 30+: %d\n", + ws.Total, ws.Lifespans.OneDay, ws.Lifespans.TwoToThree, ws.Lifespans.FourToSeven, ws.Lifespans.EightToThirty, ws.Lifespans.OverThirty) + fmt.Printf(" %s of prompts sent within 10 min of the previous one land in a different workspace (%d of %d)\n", + pct(ws.Switches.Share), ws.Switches.Switched, ws.Switches.Pairs) + w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, " PROMPTS\tDAYS\tFIRST\tLAST\tWORKSPACE") + for _, r := range ws.Top { + fmt.Fprintf(w, " %d\t%d\t%s\t%s\t%s\n", r.Prompts, r.ActiveDays, r.First, r.Last, shortPath(r.Path)) + } + if err := w.Flush(); err != nil { + return err + } + + if len(p.Terms) > 0 { + fmt.Println("\nTERMS") + terms := append([]me.TermTotal(nil), p.Terms...) + sort.SliceStable(terms, func(i, j int) bool { return terms[i].Prompts > terms[j].Prompts }) + for _, t := range terms { + fmt.Printf(" %-14s %6d prompts (%s) /%s/\n", t.Name, t.Prompts, pct(t.Share), strings.TrimPrefix(t.Pattern, "(?i)")) + } + } + return nil +} + +func dayOf(rfc3339 string) string { + if len(rfc3339) >= 10 { + return rfc3339[:10] + } + return rfc3339 +} + +func pct(share float64) string { + return fmt.Sprintf("%.1f%%", share*100) +} + +func termShare(n, total int) float64 { + if total == 0 { + return 0 + } + return float64(n) / float64(total) +} + +func bar(n, peak, width int) int { + if peak == 0 { + return 0 + } + return n * width / peak +} + +func countLine(counts []me.Count) string { + if len(counts) == 0 { + return "-" + } + parts := make([]string, 0, len(counts)) + for _, c := range counts { + parts = append(parts, fmt.Sprintf("%s %d", c.Key, c.Count)) + } + return strings.Join(parts, " · ") +} diff --git a/internal/cmd/me_test.go b/internal/cmd/me_test.go new file mode 100644 index 0000000..3f0fc85 --- /dev/null +++ b/internal/cmd/me_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestReadPromptHistoryKeepsWorkspaceAndReportsSources(t *testing.T) { + dir := t.TempDir() + claude := filepath.Join(dir, "claude-history.jsonl") + codex := filepath.Join(dir, "codex-history.jsonl") + writeFile(t, claude, `{"display":"/rewind","project":"/w/src/deva","sessionId":"a1","timestamp":1790000000000} +not json at all +{"display":"","project":"/w/src/deva","timestamp":1790000001000} +{"display":"let's continue","project":"/w/src/deva","sessionId":"a1","timestamp":1790000060000}`) + writeFile(t, codex, `{"session_id":"c1","ts":1790000100,"text":"review the diff"}`+"\n") + + loc := time.FixedZone("+08", 8*3600) + files := map[string]string{"claude-code": claude, "codex": codex, "missing": filepath.Join(dir, "nope")} + prompts, sources := readPromptHistory(files, "", loc) + + if len(prompts) != 3 { + t.Fatalf("prompts = %d, want 3 (bad and empty lines dropped, last line without newline kept)", len(prompts)) + } + if p := prompts[0]; p.Workspace != "/w/src/deva" || p.Provider != "claude-code" || p.SessionID != "a1" { + t.Fatalf("claude prompt = %+v", p) + } + if p := prompts[2]; p.Workspace != "" || p.Provider != "codex" || !p.Time.Equal(time.Unix(1790000100, 0)) { + t.Fatalf("codex prompt = %+v", p) + } + if len(sources) != 2 || sources[0].Prompts != 2 || sources[1].Prompts != 1 { + t.Fatalf("sources = %+v", sources) + } + if sources[0].First == "" || sources[0].First == sources[0].Last { + t.Fatalf("source window = %q..%q", sources[0].First, sources[0].Last) + } + + only, sources := readPromptHistory(files, "codex", loc) + if len(only) != 1 || len(sources) != 1 { + t.Fatalf("provider filter: %d prompts, %d sources", len(only), len(sources)) + } + + // A provider that was never used is a source with nothing in it. + none, sources := readPromptHistory(map[string]string{"codex": filepath.Join(dir, "absent.jsonl")}, "", loc) + if len(none) != 0 || len(sources) != 1 || sources[0].Prompts != 0 { + t.Fatalf("absent history: %d prompts, sources %+v", len(none), sources) + } +} + +func TestParseMeTerms(t *testing.T) { + terms, err := parseMeTerms([]string{"think=ultrathink", `again=\bagain\b|still`}) + if err != nil || len(terms) != 2 { + t.Fatalf("terms = %+v, err = %v", terms, err) + } + if !terms[0].Pattern.MatchString("be ULTRATHINK") { + t.Errorf("terms match case-insensitively") + } + // The value may itself contain '=': only the first one splits. + if eq, err := parseMeTerms([]string{"eq=a=b"}); err != nil || !eq[0].Pattern.MatchString("A=B") { + t.Errorf("name=regex splits on the first '=': %+v, %v", eq, err) + } + for _, bad := range [][]string{{"noequals"}, {"=x"}, {"x="}, {"x=("}, {"x=a", "x=b"}} { + if _, err := parseMeTerms(bad); err == nil { + t.Errorf("parseMeTerms(%q) should fail", bad) + } + } +} + +func TestLogWindowSinceAloneRunsUntilNow(t *testing.T) { + defer func(scope, since, until string) { logScope, logSince, logUntil = scope, since, until }(logScope, logSince, logUntil) + loc := time.FixedZone("+08", 8*3600) + + logScope, logSince, logUntil = "", "2026-01-01", "" + before := time.Now() + start, end, name, _, err := logWindow(loc) + if err != nil { + t.Fatalf("--since alone: %v", err) + } + if name != "custom" || !start.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, loc)) || end.Before(before) { + t.Fatalf("window = %s..%s (%s)", start, end, name) + } + // The table prints the window end as-is: it must be in --tz, not + // in the host's zone. + if end.Location() != loc { + t.Fatalf("window end is in %s, want the requested zone", end.Location()) + } + + logSince, logUntil = "", "2026-01-01" + if _, _, _, _, err := logWindow(loc); err == nil { + t.Fatalf("--until alone has no start and must fail") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cmd/search_history.go b/internal/cmd/search_history.go index ea4842b..fe85d88 100644 --- a/internal/cmd/search_history.go +++ b/internal/cmd/search_history.go @@ -107,7 +107,8 @@ func scanPromptHistoryFile(provider, path string, m textMatcher, knownSessions m type promptLine struct { SessionID string - project string + project string // workspace basename + workspace string // absolute path; claude-code only time time.Time text string } @@ -131,6 +132,7 @@ func decodePromptLine(provider, line string) (promptLine, bool) { out := promptLine{SessionID: rec.SessionID, text: rec.Display} if rec.Project != "" { out.project = filepath.Base(rec.Project) + out.workspace = rec.Project } if rec.Timestamp > 0 { out.time = time.UnixMilli(rec.Timestamp).UTC() diff --git a/internal/me/profile.go b/internal/me/profile.go new file mode 100644 index 0000000..d84cbd5 --- /dev/null +++ b/internal/me/profile.go @@ -0,0 +1,802 @@ +// Package me profiles the human at the keyboard from the prompt record. +// +// Sessions describe what the agent did; the prompts describe the person +// driving it. Everything here is counted from the prompts alone, with no +// model in the loop and no judgment: when they work, how they steer, +// how often they take a prompt back, how many workspaces they hold at +// once. Interpretation belongs to the reader. +package me + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" + "unicode" +) + +// Kind is the JSON schema tag of a Profile. +const Kind = "ccx.me.v1" + +// DayStartHour is where a working day rolls over. A prompt at 00:40 +// belongs to the day that began the previous morning, so a night +// session is not split in two and does not fake an extra active day. +const DayStartHour = 5 + +const ( + repeatMaxRunes = 60 // a "repeat" is a short prompt typed again + substantiveRunes = 30 // shorter prompts are acks, not resends + similarityRunes = 400 // compare prompts on their opening + rewindLookBack = time.Hour // the prompt a /rewind takes back + rewindLookAhead = 30 * time.Minute // the prompt typed in its place + resendWindow = time.Hour + switchWindow = 10 * time.Minute + resentThreshold = 0.6 + resendThreshold = 0.85 + minTrigramsToJoin = 8 +) + +// Prompt is one thing the human typed. +type Prompt struct { + Time time.Time + Provider string + Workspace string // absolute path; empty when the source records none + SessionID string + Text string +} + +// Term is a named pattern to count across prompts (--term name=regex). +type Term struct { + Name string + Pattern *regexp.Regexp +} + +// Options bound and shape a profile. +type Options struct { + Location *time.Location + Since time.Time // zero = from the first prompt + Until time.Time // zero = to the last prompt (exclusive otherwise) + Terms []Term + Top int // rows in each ranked list (0 = 10) +} + +type Profile struct { + Kind string `json:"kind"` + TimeZone string `json:"time_zone"` + Sources []Source `json:"sources,omitempty"` + Span Span `json:"span"` + Tempo Tempo `json:"tempo"` + Months []Month `json:"months"` + Commands []Count `json:"commands"` + Repeats []Count `json:"repeats"` + Openers []Count `json:"openers"` + Rewinds Rewinds `json:"rewinds"` + Resends Resends `json:"resends"` + Workspaces Workspaces `json:"workspaces"` + Terms []TermTotal `json:"terms,omitempty"` +} + +// Source is one file the prompts were read from. +type Source struct { + Provider string `json:"provider"` + Path string `json:"path"` + Prompts int `json:"prompts"` + First string `json:"first,omitempty"` + Last string `json:"last,omitempty"` +} + +type Span struct { + First string `json:"first"` + Last string `json:"last"` + Prompts int `json:"prompts"` + Undated int `json:"undated,omitempty"` // dropped: no timestamp + CalendarDays int `json:"calendar_days"` + ActiveDays int `json:"active_days"` + Providers []Count `json:"providers"` +} + +type Count struct { + Key string `json:"key"` + Count int `json:"count"` +} + +type Tempo struct { + DayStartHour int `json:"day_start_hour"` + Hours [24]int `json:"hours"` // by clock hour + WeekdayActiveDays int `json:"weekday_active_days"` + WeekendActiveDays int `json:"weekend_active_days"` + WeekdayPerDay float64 `json:"weekday_prompts_per_day"` + WeekendPerDay float64 `json:"weekend_prompts_per_day"` + MedianDayStart string `json:"median_day_start"` // first prompt of a day + MedianDayEnd string `json:"median_day_end"` // last; past 24:00 is after midnight + LongestStreak Streak `json:"longest_streak"` + Breaks []Break `json:"breaks"` // runs of 2+ idle days, longest first +} + +type Streak struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Days int `json:"days"` +} + +type Break struct { + After string `json:"after"` // last active day before the gap + Back string `json:"back"` // first active day after it + Days int `json:"days"` // idle days in between +} + +type Month struct { + Key string `json:"key"` + Prompts int `json:"prompts"` + ActiveDays int `json:"active_days"` + Workspaces int `json:"workspaces"` + NewWorkspaces int `json:"new_workspaces"` + MedianWorkspacesPerDay float64 `json:"median_workspaces_per_day"` + MedianChars int `json:"median_chars"` + CJKShare float64 `json:"cjk_share"` + CommandShare float64 `json:"command_share"` + QuestionShare float64 `json:"question_share"` + LateShare float64 `json:"late_share"` // 23:00 up to the day roll + MedianDayEnd string `json:"median_day_end"` + Terms map[string]int `json:"terms,omitempty"` +} + +// Rewinds reads /rewind as "I take that prompt back". Resent counts the +// rewinds followed by the same prompt again, edited. +type Rewinds struct { + Count int `json:"count"` + Paired int `json:"paired"` // a prompt before and one after + Resent int `json:"resent"` + ResentLonger int `json:"resent_longer"` + ResentShorter int `json:"resent_shorter"` + MedianSecondsBefore int `json:"median_seconds_before"` // prompt -> rewind +} + +// Resends are prompts sent again, nearly verbatim, within the hour. +type Resends struct { + Substantive int `json:"substantive"` + Resent int `json:"resent"` + Share float64 `json:"share"` +} + +type Workspaces struct { + Total int `json:"total"` + Lifespans Lifespans `json:"lifespans"` + Switches Switches `json:"switches"` + Top []WorkspaceRow `json:"top"` +} + +// Lifespans buckets workspaces by how many days they were prompted on. +type Lifespans struct { + OneDay int `json:"one_day"` + TwoToThree int `json:"two_to_three"` + FourToSeven int `json:"four_to_seven"` + EightToThirty int `json:"eight_to_thirty"` + OverThirty int `json:"over_thirty"` +} + +// Switches counts prompt pairs less than ten minutes apart, and how +// many of them land in a different workspace than the one before. +type Switches struct { + Pairs int `json:"pairs"` + Switched int `json:"switched"` + Share float64 `json:"share"` +} + +type WorkspaceRow struct { + Path string `json:"path"` + Prompts int `json:"prompts"` + First string `json:"first"` + Last string `json:"last"` + ActiveDays int `json:"active_days"` +} + +type TermTotal struct { + Name string `json:"name"` + Pattern string `json:"pattern"` + Prompts int `json:"prompts"` + Share float64 `json:"share"` +} + +const dayLayout = "2006-01-02" + +// item is a prompt with its derived fields, computed once. +type item struct { + Prompt + local time.Time + day time.Time // working day (midnight of the day it began) + command string // "/rewind"; empty for prose + stream string // workspace, else the session +} + +// Build profiles the prompts. Order of the input does not matter. +func Build(prompts []Prompt, opts Options) Profile { + loc := opts.Location + if loc == nil { + loc = time.Local + } + top := opts.Top + if top <= 0 { + top = 10 + } + + p := Profile{Kind: Kind, TimeZone: loc.String()} + p.Tempo.DayStartHour = DayStartHour + + items := make([]item, 0, len(prompts)) + for _, pr := range prompts { + if strings.TrimSpace(pr.Text) == "" { + continue + } + if pr.Time.IsZero() { + p.Span.Undated++ + continue + } + if !opts.Since.IsZero() && pr.Time.Before(opts.Since) { + continue + } + if !opts.Until.IsZero() && !pr.Time.Before(opts.Until) { + continue + } + local := pr.Time.In(loc) + it := item{Prompt: pr, local: local, day: workingDay(local), command: slashCommand(pr.Text)} + it.stream = pr.Workspace + if it.stream == "" { + it.stream = "session:" + pr.SessionID + } + items = append(items, it) + } + sort.SliceStable(items, func(i, j int) bool { return items[i].Time.Before(items[j].Time) }) + if len(items) == 0 { + p.Months, p.Commands, p.Repeats, p.Openers = []Month{}, []Count{}, []Count{}, []Count{} + p.Tempo.Breaks, p.Span.Providers, p.Workspaces.Top = []Break{}, []Count{}, []WorkspaceRow{} + return p + } + + p.Span = span(items, p.Span.Undated) + p.Tempo = tempo(items, top) + p.Months = months(items, opts.Terms) + p.Commands = topCounts(countBy(items, func(it item) string { return it.command }), top) + p.Repeats = repeats(items, top) + p.Openers = topCounts(countBy(items, opener), top) + p.Rewinds = rewinds(items) + p.Resends = resends(items) + p.Workspaces = workspaces(items, top) + for _, t := range opts.Terms { + n := 0 + for _, it := range items { + if t.Pattern.MatchString(it.Text) { + n++ + } + } + p.Terms = append(p.Terms, TermTotal{Name: t.Name, Pattern: t.Pattern.String(), Prompts: n, Share: share(n, len(items))}) + } + return p +} + +func workingDay(local time.Time) time.Time { + d := local.Add(-DayStartHour * time.Hour) + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, local.Location()) +} + +// slashCommand returns the command of a prompt that is one ("/model +// opus" -> "/model"). A pasted absolute path is not a command. +func slashCommand(text string) string { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, "/") { + return "" + } + token := text + if i := strings.IndexFunc(text, unicode.IsSpace); i >= 0 { + token = text[:i] + } + if len(token) < 2 || len(token) > 40 || strings.Contains(token[1:], "/") { + return "" + } + for _, r := range token[1:] { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != ':' { + return "" + } + } + return strings.ToLower(token) +} + +func span(items []item, undated int) Span { + first, last := items[0], items[len(items)-1] + days := map[time.Time]bool{} + for _, it := range items { + days[it.day] = true + } + return Span{ + First: first.local.Format(time.RFC3339), + Last: last.local.Format(time.RFC3339), + Prompts: len(items), + Undated: undated, + CalendarDays: daysBetween(first.day, last.day) + 1, + ActiveDays: len(days), + Providers: topCounts(countBy(items, func(it item) string { return it.Provider }), 0), + } +} + +func tempo(items []item, top int) Tempo { + t := Tempo{DayStartHour: DayStartHour, Breaks: []Break{}} + firstOf, lastOf := map[time.Time]time.Time{}, map[time.Time]time.Time{} + var weekdayPrompts, weekendPrompts int + for _, it := range items { + t.Hours[it.local.Hour()]++ + if _, ok := firstOf[it.day]; !ok { + firstOf[it.day] = it.local + } + lastOf[it.day] = it.local + if isWeekend(it.day) { + weekendPrompts++ + } else { + weekdayPrompts++ + } + } + + days := make([]time.Time, 0, len(firstOf)) + var starts, ends []int + for d := range firstOf { + days = append(days, d) + starts = append(starts, minutesInto(d, firstOf[d])) + ends = append(ends, minutesInto(d, lastOf[d])) + if isWeekend(d) { + t.WeekendActiveDays++ + } else { + t.WeekdayActiveDays++ + } + } + sort.Slice(days, func(i, j int) bool { return days[i].Before(days[j]) }) + t.WeekdayPerDay = ratio(weekdayPrompts, t.WeekdayActiveDays) + t.WeekendPerDay = ratio(weekendPrompts, t.WeekendActiveDays) + t.MedianDayStart = clock(medianInt(starts)) + t.MedianDayEnd = clock(medianInt(ends)) + + runStart, run := days[0], 1 + best := Streak{Start: days[0].Format(dayLayout), End: days[0].Format(dayLayout), Days: 1} + for i := 1; i < len(days); i++ { + gap := daysBetween(days[i-1], days[i]) + if gap == 1 { + run++ + } else { + runStart, run = days[i], 1 + if gap > 2 { + t.Breaks = append(t.Breaks, Break{After: days[i-1].Format(dayLayout), Back: days[i].Format(dayLayout), Days: gap - 1}) + } + } + if run > best.Days { + best = Streak{Start: runStart.Format(dayLayout), End: days[i].Format(dayLayout), Days: run} + } + } + t.LongestStreak = best + sort.SliceStable(t.Breaks, func(i, j int) bool { return t.Breaks[i].Days > t.Breaks[j].Days }) + if len(t.Breaks) > top { + t.Breaks = t.Breaks[:top] + } + return t +} + +func months(items []item, terms []Term) []Month { + type acc struct { + m Month + days map[time.Time]map[string]bool // day -> workspaces touched + lastOf map[time.Time]time.Time + workspaces map[string]bool + chars []int + prose int + cjk, ask int + cmds, late int + newWorkspace int + } + seen := map[string]bool{} + byKey := map[string]*acc{} + var keys []string + for _, it := range items { + key := it.day.Format("2006-01") + a := byKey[key] + if a == nil { + a = &acc{m: Month{Key: key}, days: map[time.Time]map[string]bool{}, lastOf: map[time.Time]time.Time{}, workspaces: map[string]bool{}} + if len(terms) > 0 { + a.m.Terms = map[string]int{} + } + byKey[key] = a + keys = append(keys, key) + } + a.m.Prompts++ + if a.days[it.day] == nil { + a.days[it.day] = map[string]bool{} + } + a.lastOf[it.day] = it.local + if it.Workspace != "" { + a.days[it.day][it.Workspace] = true + a.workspaces[it.Workspace] = true + if !seen[it.Workspace] { + seen[it.Workspace] = true + a.newWorkspace++ + } + } + if h := it.local.Hour(); h >= 23 || h < DayStartHour { + a.late++ + } + if it.command != "" { + a.cmds++ + } else { + a.prose++ + a.chars = append(a.chars, len([]rune(it.Text))) + if hasHan(it.Text) { + a.cjk++ + } + if strings.ContainsAny(it.Text, "??") { + a.ask++ + } + } + for _, t := range terms { + if t.Pattern.MatchString(it.Text) { + a.m.Terms[t.Name]++ + } + } + } + sort.Strings(keys) + out := make([]Month, 0, len(keys)) + for _, key := range keys { + a := byKey[key] + var perDay, ends []int + for d, ws := range a.days { + if len(ws) > 0 { + perDay = append(perDay, len(ws)) + } + ends = append(ends, minutesInto(d, a.lastOf[d])) + } + a.m.ActiveDays = len(a.days) + a.m.Workspaces = len(a.workspaces) + a.m.NewWorkspaces = a.newWorkspace + a.m.MedianWorkspacesPerDay = medianFloat(perDay) + a.m.MedianChars = medianInt(a.chars) + a.m.CJKShare = share(a.cjk, a.prose) + a.m.QuestionShare = share(a.ask, a.prose) + a.m.CommandShare = share(a.cmds, a.m.Prompts) + a.m.LateShare = share(a.late, a.m.Prompts) + a.m.MedianDayEnd = clock(medianInt(ends)) + out = append(out, a.m) + } + return out +} + +func repeats(items []item, top int) []Count { + counts := map[string]int{} + for _, it := range items { + if it.command != "" { + continue + } + text := strings.ToLower(strings.Join(strings.Fields(it.Text), " ")) + if n := len([]rune(text)); n == 0 || n > repeatMaxRunes { + continue + } + counts[text]++ + } + for k, n := range counts { + if n < 2 { + delete(counts, k) + } + } + return topCounts(counts, top) +} + +// opener is the first word of a prose prompt: how a request begins. +func opener(it item) string { + if it.command != "" { + return "" + } + fields := strings.Fields(strings.ToLower(it.Text)) + if len(fields) == 0 { + return "" + } + word := strings.TrimFunc(fields[0], func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + if n := len([]rune(word)); n == 0 || n > 24 { + return "" + } + return word +} + +func rewinds(items []item) Rewinds { + var r Rewinds + var before []int + for _, stream := range streams(items) { + for i, it := range stream { + if it.command != "/rewind" { + continue + } + r.Count++ + prev := nearestProse(stream, i, -1, 6, rewindLookBack) + next := nearestProse(stream, i, +1, 3, rewindLookAhead) + if prev != nil { + before = append(before, int(it.Time.Sub(prev.Time).Seconds())) + } + if prev == nil || next == nil { + continue + } + r.Paired++ + if similarity(prev.Text, next.Text) < resentThreshold { + continue + } + r.Resent++ + switch a, b := len([]rune(prev.Text)), len([]rune(next.Text)); { + case b > a: + r.ResentLonger++ + case b < a: + r.ResentShorter++ + } + } + } + r.MedianSecondsBefore = medianInt(before) + return r +} + +// nearestProse walks from a rewind to the closest non-command prompt in +// one direction, within a bounded number of steps and a time window. +func nearestProse(stream []item, at, dir, steps int, window time.Duration) *item { + for n, i := 0, at+dir; n < steps && i >= 0 && i < len(stream); n, i = n+1, i+dir { + gap := stream[i].Time.Sub(stream[at].Time) + if gap < 0 { + gap = -gap + } + if gap > window { + return nil + } + if stream[i].command == "" { + return &stream[i] + } + } + return nil +} + +func resends(items []item) Resends { + var r Resends + for _, stream := range streams(items) { + var recent []item // substantive prose only + for _, it := range stream { + if it.command != "" || len([]rune(it.Text)) < substantiveRunes { + continue + } + r.Substantive++ + for i := len(recent) - 1; i >= 0 && i >= len(recent)-4; i-- { + if it.Time.Sub(recent[i].Time) > resendWindow { + break + } + if similarity(recent[i].Text, it.Text) >= resendThreshold && lengthsClose(recent[i].Text, it.Text) { + r.Resent++ + break + } + } + recent = append(recent, it) + } + } + r.Share = share(r.Resent, r.Substantive) + return r +} + +func workspaces(items []item, top int) Workspaces { + type acc struct { + row WorkspaceRow + days map[time.Time]bool + } + byPath := map[string]*acc{} + w := Workspaces{Top: []WorkspaceRow{}} + var prev *item + for i := range items { + it := &items[i] + if it.Workspace == "" { + continue + } + a := byPath[it.Workspace] + if a == nil { + a = &acc{row: WorkspaceRow{Path: it.Workspace, First: it.day.Format(dayLayout)}, days: map[time.Time]bool{}} + byPath[it.Workspace] = a + } + a.row.Prompts++ + a.row.Last = it.day.Format(dayLayout) + a.days[it.day] = true + if prev != nil && it.Time.Sub(prev.Time) < switchWindow { + w.Switches.Pairs++ + if prev.Workspace != it.Workspace { + w.Switches.Switched++ + } + } + prev = it + } + w.Total = len(byPath) + w.Switches.Share = share(w.Switches.Switched, w.Switches.Pairs) + for _, a := range byPath { + a.row.ActiveDays = len(a.days) + switch n := a.row.ActiveDays; { + case n == 1: + w.Lifespans.OneDay++ + case n <= 3: + w.Lifespans.TwoToThree++ + case n <= 7: + w.Lifespans.FourToSeven++ + case n <= 30: + w.Lifespans.EightToThirty++ + default: + w.Lifespans.OverThirty++ + } + w.Top = append(w.Top, a.row) + } + sort.Slice(w.Top, func(i, j int) bool { + if w.Top[i].Prompts != w.Top[j].Prompts { + return w.Top[i].Prompts > w.Top[j].Prompts + } + return w.Top[i].Path < w.Top[j].Path + }) + if len(w.Top) > top { + w.Top = w.Top[:top] + } + return w +} + +// streams splits the time-ordered prompts into one sequence per +// workspace (or per session when the source records no workspace), in +// a stable order. +func streams(items []item) [][]item { + index := map[string]int{} + var out [][]item + for _, it := range items { + i, ok := index[it.stream] + if !ok { + i = len(out) + index[it.stream] = i + out = append(out, nil) + } + out[i] = append(out[i], it) + } + return out +} + +// similarity is how much of the shorter prompt's opening survives in +// the other: rune-trigram containment, so it reads the same for an +// appended tail, a prepended line, and unspaced CJK. 1 = contained. +func similarity(a, b string) float64 { + ta, tb := trigrams(a), trigrams(b) + if len(ta) > len(tb) { + ta, tb = tb, ta + } + if len(ta) < minTrigramsToJoin { + if normalize(a) == normalize(b) { + return 1 + } + return 0 + } + shared := 0 + for g := range ta { + if tb[g] { + shared++ + } + } + return float64(shared) / float64(len(ta)) +} + +func normalize(s string) string { + return strings.ToLower(strings.Join(strings.Fields(s), " ")) +} + +func trigrams(s string) map[string]bool { + runes := []rune(normalize(s)) + if len(runes) > similarityRunes { + runes = runes[:similarityRunes] + } + out := map[string]bool{} + for i := 0; i+3 <= len(runes); i++ { + out[string(runes[i:i+3])] = true + } + return out +} + +func lengthsClose(a, b string) bool { + la, lb := len([]rune(a)), len([]rune(b)) + if la > lb { + la, lb = lb, la + } + return lb <= 2*la+40 +} + +func hasHan(s string) bool { + for _, r := range s { + if unicode.Is(unicode.Han, r) { + return true + } + } + return false +} + +func isWeekend(day time.Time) bool { + wd := day.Weekday() + return wd == time.Saturday || wd == time.Sunday +} + +// daysBetween counts calendar days from a to b (both at midnight); +// rounding absorbs DST days that are 23 or 25 hours long. +func daysBetween(a, b time.Time) int { + return int(b.Sub(a).Hours()/24 + 0.5) +} + +// minutesInto is the distance from a working day's midnight to t, so a +// prompt after midnight reads past 24:00 instead of wrapping to 00:xx. +func minutesInto(day, t time.Time) int { + return int(t.Sub(day).Minutes()) +} + +func clock(minutes int) string { + return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) +} + +func countBy(items []item, key func(item) string) map[string]int { + counts := map[string]int{} + for _, it := range items { + if k := key(it); k != "" { + counts[k]++ + } + } + return counts +} + +// topCounts ranks by count, ties by key, so output is deterministic. +func topCounts(counts map[string]int, top int) []Count { + out := make([]Count, 0, len(counts)) + for k, n := range counts { + out = append(out, Count{Key: k, Count: n}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Key < out[j].Key + }) + if top > 0 && len(out) > top { + out = out[:top] + } + return out +} + +func medianInt(values []int) int { + if len(values) == 0 { + return 0 + } + sorted := append([]int(nil), values...) + sort.Ints(sorted) + mid := len(sorted) / 2 + if len(sorted)%2 == 1 { + return sorted[mid] + } + return (sorted[mid-1] + sorted[mid]) / 2 +} + +func medianFloat(values []int) float64 { + if len(values) == 0 { + return 0 + } + sorted := append([]int(nil), values...) + sort.Ints(sorted) + mid := len(sorted) / 2 + if len(sorted)%2 == 1 { + return float64(sorted[mid]) + } + return float64(sorted[mid-1]+sorted[mid]) / 2 +} + +func share(part, whole int) float64 { + if whole == 0 { + return 0 + } + return float64(int(float64(part)/float64(whole)*1000+0.5)) / 1000 +} + +func ratio(part, whole int) float64 { + if whole == 0 { + return 0 + } + return float64(int(float64(part)/float64(whole)*10+0.5)) / 10 +} diff --git a/internal/me/profile_test.go b/internal/me/profile_test.go new file mode 100644 index 0000000..230a821 --- /dev/null +++ b/internal/me/profile_test.go @@ -0,0 +1,213 @@ +package me + +import ( + "regexp" + "testing" + "time" +) + +var utc8 = time.FixedZone("+08", 8*3600) + +func at(day, clock string) time.Time { + t, err := time.ParseInLocation("2006-01-02 15:04:05", day+" "+clock, utc8) + if err != nil { + panic(err) + } + return t +} + +func prompt(day, clock, workspace, text string) Prompt { + return Prompt{Time: at(day, clock), Provider: "claude-code", Workspace: workspace, SessionID: "s1", Text: text} +} + +func build(prompts ...Prompt) Profile { + return Build(prompts, Options{Location: utc8}) +} + +func TestNightPromptStaysWithTheDayItBegan(t *testing.T) { + p := build( + prompt("2026-03-02", "10:00:00", "/w/a", "start the day with a real request here"), + prompt("2026-03-03", "00:40:00", "/w/a", "still the same working day after midnight"), + ) + if p.Span.ActiveDays != 1 { + t.Fatalf("active days = %d, want 1 (00:40 belongs to the day before)", p.Span.ActiveDays) + } + if p.Tempo.MedianDayStart != "10:00" || p.Tempo.MedianDayEnd != "24:40" { + t.Fatalf("day = %s..%s, want 10:00..24:40", p.Tempo.MedianDayStart, p.Tempo.MedianDayEnd) + } + if p.Tempo.Hours[0] != 1 || p.Tempo.Hours[10] != 1 { + t.Fatalf("hours are by the clock: %v", p.Tempo.Hours) + } + if got := p.Months[0].LateShare; got != 0.5 { + t.Fatalf("late share = %v, want 0.5", got) + } +} + +func TestStreakAndBreaks(t *testing.T) { + var prompts []Prompt + for _, day := range []string{"2026-01-05", "2026-01-06", "2026-01-07", "2026-01-09", "2026-01-13", "2026-01-14"} { + prompts = append(prompts, prompt(day, "11:00:00", "/w/a", "a prompt long enough to be prose on "+day)) + } + p := build(prompts...) + if s := p.Tempo.LongestStreak; s.Days != 3 || s.Start != "2026-01-05" || s.End != "2026-01-07" { + t.Fatalf("streak = %+v, want 3 days from 01-05", s) + } + // 01-08 alone is one idle day: not a break. 01-10..12 is three. + if len(p.Tempo.Breaks) != 1 || p.Tempo.Breaks[0] != (Break{After: "2026-01-09", Back: "2026-01-13", Days: 3}) { + t.Fatalf("breaks = %+v", p.Tempo.Breaks) + } + if p.Span.CalendarDays != 10 || p.Span.ActiveDays != 6 { + t.Fatalf("days = %d active of %d", p.Span.ActiveDays, p.Span.CalendarDays) + } +} + +func TestSlashCommandIsNotAPath(t *testing.T) { + cases := map[string]string{ + "/rewind": "/rewind", + "/model claude-opus-4-6[1m]": "/model", + "/ralph-loop:ralph-loop \"go\"": "/ralph-loop:ralph-loop", + "/Users/eric/wrk/file.go broken": "", + "/": "", + "//model": "", + "not a command": "", + } + for text, want := range cases { + if got := slashCommand(text); got != want { + t.Errorf("slashCommand(%q) = %q, want %q", text, got, want) + } + } +} + +func TestRewindFollowedByTheSamePromptLonger(t *testing.T) { + first := "review the pull request and merge it if the tests are green" + p := build( + prompt("2026-04-01", "10:00:00", "/w/a", first), + prompt("2026-04-01", "10:01:30", "/w/a", "/rewind"), + prompt("2026-04-01", "10:02:00", "/w/a", first+"; then cut a patch release"), + // A rewind into a different request is paired but not a resend. + prompt("2026-04-01", "11:00:00", "/w/a", "now look at the flaky integration test in the ci run"), + prompt("2026-04-01", "11:03:00", "/w/a", "/rewind"), + prompt("2026-04-01", "11:04:00", "/w/a", "let us write the changelog entry for this release instead"), + // A rewind in another workspace must not pair with /w/a prompts. + prompt("2026-04-01", "11:05:00", "/w/b", "/rewind"), + ) + r := p.Rewinds + if r.Count != 3 || r.Paired != 2 || r.Resent != 1 || r.ResentLonger != 1 || r.ResentShorter != 0 { + t.Fatalf("rewinds = %+v", r) + } + if r.MedianSecondsBefore != 135 { // 90s and 180s + t.Fatalf("median seconds before rewind = %d, want 135", r.MedianSecondsBefore) + } +} + +func TestResendWithinTheHour(t *testing.T) { + text := "generate the quarterly usage report and verify every number against the log" + p := build( + prompt("2026-04-02", "10:00:00", "/w/a", text), + prompt("2026-04-02", "10:20:00", "/w/a", text), + prompt("2026-04-02", "13:00:00", "/w/a", text), // past the hour: not a resend + prompt("2026-04-02", "13:01:00", "/w/b", text), // other workspace: not a resend + prompt("2026-04-02", "13:02:00", "/w/a", "ok go"), // too short to count + prompt("2026-04-02", "13:03:00", "/w/a", "/model"), // commands never count + ) + if r := p.Resends; r.Substantive != 4 || r.Resent != 1 || r.Share != 0.25 { + t.Fatalf("resends = %+v", r) + } +} + +func TestSimilarityHandlesPrependAndCJK(t *testing.T) { + base := "read the devlog spec and include the checklist in a new page" + if s := similarity(base, "it is Jan 27 today; "+base); s < 0.9 { + t.Errorf("prepended line: similarity %v, want >= 0.9", s) + } + zh := "我觉得我们需要把我们的方案文档重写一下然后转成文档" + if s := similarity(zh, zh+"文件名不要写客户版"); s < 0.9 { + t.Errorf("cjk append: similarity %v, want >= 0.9", s) + } + if s := similarity(base, "deploy the worker and check the live version id"); s > 0.3 { + t.Errorf("unrelated: similarity %v, want low", s) + } + if similarity("go", "go") != 1 || similarity("go", "no") != 0 { + t.Errorf("short prompts compare by equality") + } +} + +func TestWorkspacesLifespansAndSwitches(t *testing.T) { + p := build( + prompt("2026-05-01", "10:00:00", "/w/a", "first prompt in workspace a for the day"), + prompt("2026-05-01", "10:05:00", "/w/b", "jump to workspace b within ten minutes"), + prompt("2026-05-01", "10:08:00", "/w/b", "stay in workspace b within ten minutes"), + prompt("2026-05-01", "12:00:00", "/w/a", "back to a, but hours later: not a pair"), + prompt("2026-05-02", "10:00:00", "/w/a", "second day in workspace a"), + Prompt{Time: at("2026-05-02", "10:01:00"), Provider: "codex", SessionID: "cx1", Text: "codex prompt carries no workspace"}, + ) + ws := p.Workspaces + if ws.Total != 2 || ws.Lifespans.OneDay != 1 || ws.Lifespans.TwoToThree != 1 { + t.Fatalf("workspaces = %+v", ws) + } + if ws.Switches.Pairs != 2 || ws.Switches.Switched != 1 || ws.Switches.Share != 0.5 { + t.Fatalf("switches = %+v", ws.Switches) + } + if top := ws.Top[0]; top.Path != "/w/a" || top.Prompts != 3 || top.ActiveDays != 2 || top.First != "2026-05-01" || top.Last != "2026-05-02" { + t.Fatalf("top workspace = %+v", top) + } + m := p.Months[0] + if m.Workspaces != 2 || m.NewWorkspaces != 2 || m.MedianWorkspacesPerDay != 1.5 { + t.Fatalf("month workspaces = %+v", m) + } + if len(p.Span.Providers) != 2 || p.Span.Providers[0] != (Count{Key: "claude-code", Count: 5}) { + t.Fatalf("providers = %+v", p.Span.Providers) + } +} + +func TestMonthSharesRepeatsOpenersAndTerms(t *testing.T) { + prompts := []Prompt{ + prompt("2026-06-01", "10:00:00", "/w/a", "let's continue"), + prompt("2026-06-01", "10:10:00", "/w/a", "Let's continue"), + prompt("2026-06-01", "10:20:00", "/w/a", "why is this failing again?"), + prompt("2026-06-01", "10:30:00", "/w/a", "我觉得这个方案可以"), + prompt("2026-06-01", "10:40:00", "/w/a", "/compact"), + prompt("2026-07-01", "10:00:00", "/w/c", "good, again please"), + } + p := Build(prompts, Options{Location: utc8, Terms: []Term{{Name: "again", Pattern: regexp.MustCompile(`(?i)\bagain\b`)}}}) + if len(p.Months) != 2 { + t.Fatalf("months = %d, want 2", len(p.Months)) + } + june := p.Months[0] + if june.Key != "2026-06" || june.Prompts != 5 || june.CommandShare != 0.2 || june.CJKShare != 0.25 || june.QuestionShare != 0.25 { + t.Fatalf("june = %+v", june) + } + if june.Terms["again"] != 1 || p.Months[1].Terms["again"] != 1 || p.Terms[0].Prompts != 2 { + t.Fatalf("terms: june %v july %v total %+v", june.Terms, p.Months[1].Terms, p.Terms) + } + if p.Months[1].NewWorkspaces != 1 { + t.Fatalf("july new workspaces = %d, want 1", p.Months[1].NewWorkspaces) + } + if len(p.Repeats) != 1 || p.Repeats[0] != (Count{Key: "let's continue", Count: 2}) { + t.Fatalf("repeats = %+v", p.Repeats) + } + if p.Openers[0] != (Count{Key: "let's", Count: 2}) { + t.Fatalf("openers = %+v", p.Openers) + } + if len(p.Commands) != 1 || p.Commands[0] != (Count{Key: "/compact", Count: 1}) { + t.Fatalf("commands = %+v", p.Commands) + } +} + +func TestWindowAndUndated(t *testing.T) { + prompts := []Prompt{ + prompt("2026-01-01", "10:00:00", "/w/a", "before the window"), + prompt("2026-02-01", "10:00:00", "/w/a", "inside the window"), + prompt("2026-03-01", "10:00:00", "/w/a", "at the exclusive end"), + {Provider: "codex", Text: "no timestamp at all"}, + prompt("2026-02-02", "10:00:00", "/w/a", " "), + } + p := Build(prompts, Options{Location: utc8, Since: at("2026-01-15", "00:00:00"), Until: at("2026-03-01", "10:00:00")}) + if p.Span.Prompts != 1 || p.Span.Undated != 1 { + t.Fatalf("span = %+v", p.Span) + } + empty := Build(nil, Options{Location: utc8}) + if empty.Span.Prompts != 0 || empty.Months == nil || empty.Tempo.Breaks == nil { + t.Fatalf("empty profile must carry empty lists, not nulls: %+v", empty) + } +} From 0c0cb0eb8b9068d17af7f169c88daf808e5d07cd Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 17 Oct 2026 02:42:34 -0700 Subject: [PATCH 3/6] feat(doctor): report transcript retention and coverage gaps Claude Code deletes transcripts older than cleanupPeriodDays (default 30) and never touches history.jsonl, so a store can lose months without one error. doctor reports the retention setting, warning on the default and on 0, and compares the sessions named in prompt history with the transcripts on disk per provider and month, listing months where more than a tenth of the prompts have no transcript. Codex archived_sessions count as on disk. No transcript is read. Co-Authored-By: Claude Opus 5.5 --- internal/cmd/coverage.go | 194 ++++++++++++++++++++++++++++++++++ internal/cmd/coverage_test.go | 107 +++++++++++++++++++ internal/cmd/doctor.go | 29 ++++- 3 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/coverage.go create mode 100644 internal/cmd/coverage_test.go diff --git a/internal/cmd/coverage.go b/internal/cmd/coverage.go new file mode 100644 index 0000000..cffd0db --- /dev/null +++ b/internal/cmd/coverage.go @@ -0,0 +1,194 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/thevibeworks/ccx/internal/me" +) + +// Transcript coverage: does the session store still hold what the +// prompt history says happened? +// +// The two records have different lifetimes. Claude Code deletes +// transcripts older than cleanupPeriodDays (default 30, by file mtime) +// and never touches history.jsonl, so a store can lose months without a +// single error: every command simply returns fewer sessions. History +// names the session each prompt was typed into, which makes the loss +// countable without reading a transcript. + +const ( + coverageMinPrompts = 20 // smaller months are noise, not a gap + coverageFloor = 0.9 // below this share on disk, a month is a gap + defaultCleanupDays = 30 +) + +// coverageRow is one provider-month of prompt history, split by +// whether the transcript of the prompt's session is on disk. +type coverageRow struct { + Provider string + Month string + Prompts int // prompts that name a session + PromptsOnDisk int + Sessions int + SessionsOnDisk int + Unattributed int // prompts with no session id: cannot be checked +} + +func (r coverageRow) gap() bool { + return r.Prompts >= coverageMinPrompts && float64(r.PromptsOnDisk) < coverageFloor*float64(r.Prompts) +} + +// transcriptCoverage buckets prompts by provider and month and checks +// each named session against the ids of the transcripts on disk. +func transcriptCoverage(prompts []me.Prompt, onDisk map[string]bool, loc *time.Location) []coverageRow { + type acc struct { + row coverageRow + sessions map[string]bool + } + byKey := map[string]*acc{} + for _, p := range prompts { + if p.Time.IsZero() { + continue + } + month := p.Time.In(loc).Format("2006-01") + key := p.Provider + "\x00" + month + a := byKey[key] + if a == nil { + a = &acc{row: coverageRow{Provider: p.Provider, Month: month}, sessions: map[string]bool{}} + byKey[key] = a + } + id := strings.ToLower(p.SessionID) + if id == "" { + a.row.Unattributed++ + continue + } + a.row.Prompts++ + if onDisk[id] { + a.row.PromptsOnDisk++ + } + if !a.sessions[id] { + a.sessions[id] = true + a.row.Sessions++ + if onDisk[id] { + a.row.SessionsOnDisk++ + } + } + } + rows := make([]coverageRow, 0, len(byKey)) + for _, a := range byKey { + rows = append(rows, a.row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Provider != rows[j].Provider { + return rows[i].Provider < rows[j].Provider + } + return rows[i].Month < rows[j].Month + }) + return rows +} + +// transcriptIDs lists the session ids that have a transcript on disk. +// Claude Code names the file after the session; Codex ends the rollout +// file name with it, in sessions/ and in archived_sessions/. +func transcriptIDs(claudeHome, codexHome string) map[string]bool { + ids := map[string]bool{} + if claudeHome != "" { + matches, _ := filepath.Glob(filepath.Join(claudeHome, "projects", "*", "*.jsonl")) + for _, m := range matches { + ids[strings.ToLower(strings.TrimSuffix(filepath.Base(m), ".jsonl"))] = true + } + } + if codexHome != "" { + for _, dir := range []string{"sessions", "archived_sessions"} { + _ = filepath.WalkDir(filepath.Join(codexHome, dir), func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil // a missing or unreadable dir holds no transcripts + } + name := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())) + if len(name) >= 36 { + ids[strings.ToLower(name[len(name)-36:])] = true + } + return nil + }) + } + } + return ids +} + +// claudeRetention reads cleanupPeriodDays from Claude Code's user +// settings. set is false when the file or the key is absent, which +// means the 30-day default is in force. +func claudeRetention(claudeHome string) (days int, set bool, err error) { + data, err := os.ReadFile(filepath.Join(claudeHome, "settings.json")) + if err != nil { + if os.IsNotExist(err) { + return defaultCleanupDays, false, nil + } + return 0, false, err + } + var settings struct { + CleanupPeriodDays *int `json:"cleanupPeriodDays"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return 0, false, err + } + if settings.CleanupPeriodDays == nil { + return defaultCleanupDays, false, nil + } + return *settings.CleanupPeriodDays, true, nil +} + +// reportCoverage prints the gaps and returns one warning per provider +// that has any. Nothing is printed when the store matches history. +func reportCoverage(rows []coverageRow) []string { + var warnings []string + for _, provider := range []string{"claude-code", "codex"} { + var gaps []coverageRow + var unattributed, missingPrompts, missingSessions int + var first, last string + for _, r := range rows { + if r.Provider != provider { + continue + } + unattributed += r.Unattributed + if !r.gap() { + continue + } + gaps = append(gaps, r) + missingPrompts += r.Prompts - r.PromptsOnDisk + missingSessions += r.Sessions - r.SessionsOnDisk + if first == "" { + first = r.Month + } + last = r.Month + } + if len(gaps) == 0 { + fmt.Printf("[OK] %s transcripts: every month of prompt history is on disk\n", provider) + } else { + fmt.Printf("[!!] %s transcripts: prompt history names sessions that are not on disk\n", provider) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.AlignRight) + fmt.Fprintln(w, " MONTH\tPROMPTS\tON DISK\tSESSIONS\tON DISK\t") + for _, r := range gaps { + fmt.Fprintf(w, " %s\t%d\t%d\t%d\t%d\t\n", r.Month, r.Prompts, r.PromptsOnDisk, r.Sessions, r.SessionsOnDisk) + } + w.Flush() + warnings = append(warnings, fmt.Sprintf( + "%s: %d prompts in %d sessions (%s to %s) have no transcript on disk. "+ + "What was typed survives in prompt history (ccx me, ccx search --content); what the agent did does not. "+ + "A backup restores it: rsync -a --ignore-existing /projects/ /projects/", + provider, missingPrompts, missingSessions, first, last)) + } + if unattributed > 0 { + fmt.Printf(" %d %s prompts predate session ids in history and cannot be checked\n", unattributed, provider) + } + } + return warnings +} diff --git a/internal/cmd/coverage_test.go b/internal/cmd/coverage_test.go new file mode 100644 index 0000000..ebb70a5 --- /dev/null +++ b/internal/cmd/coverage_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/thevibeworks/ccx/internal/me" +) + +func TestTranscriptCoverageCountsWhatFellOutOfTheStore(t *testing.T) { + jan := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + may := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) + var prompts []me.Prompt + add := func(n int, at time.Time, provider, session string) { + for i := 0; i < n; i++ { + prompts = append(prompts, me.Prompt{Time: at, Provider: provider, SessionID: session, Text: "x"}) + } + } + add(30, jan, "claude-code", "GONE-1") // ids compare case-insensitively + add(10, jan, "claude-code", "kept-1") + add(5, jan, "claude-code", "") // older history rows name no session + add(40, may, "claude-code", "kept-2") + add(1, may, "claude-code", "never-written") // a lone /model: under the floor + add(25, jan, "codex", "cx-kept") + + rows := transcriptCoverage(prompts, map[string]bool{"kept-1": true, "kept-2": true, "cx-kept": true}, time.UTC) + if len(rows) != 3 { + t.Fatalf("rows = %+v", rows) + } + janRow := rows[0] + want := coverageRow{Provider: "claude-code", Month: "2026-01", Prompts: 40, PromptsOnDisk: 10, Sessions: 2, SessionsOnDisk: 1, Unattributed: 5} + if janRow != want { + t.Fatalf("january = %+v, want %+v", janRow, want) + } + if !janRow.gap() { + t.Errorf("30 of 40 prompts without a transcript is a gap") + } + if mayRow := rows[1]; mayRow.gap() || mayRow.Prompts != 41 || mayRow.PromptsOnDisk != 40 { + t.Errorf("one command-only session must not flag the month: %+v", mayRow) + } + if cx := rows[2]; cx.Provider != "codex" || cx.gap() { + t.Errorf("codex = %+v", cx) + } + if small := (coverageRow{Prompts: 5, PromptsOnDisk: 0}); small.gap() { + t.Errorf("a month with a handful of prompts is noise, not a gap") + } +} + +func TestTranscriptIDsFromBothHomes(t *testing.T) { + root := t.TempDir() + claude := filepath.Join(root, "claude") + codex := filepath.Join(root, "codex") + for path, body := range map[string]string{ + filepath.Join(claude, "projects", "-w-a", "AAAA1111-0000-4000-8000-000000000001.jsonl"): "{}", + filepath.Join(claude, "projects", "-w-a", "gone-parent", "subagents", "agent-x.jsonl"): "{}", + filepath.Join(codex, "sessions", "2026", "01", "02", "rollout-2026-01-02T03-04-05-019ba178-0e7d-7380-a58c-1d64d653c655.jsonl"): "{}", + filepath.Join(codex, "archived_sessions", "rollout-2026-08-30T23-16-51-01a05676-a62e-7311-8bf9-db685e3cef67.jsonl"): "{}", + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, path, body) + } + ids := transcriptIDs(claude, codex) + for _, id := range []string{ + "aaaa1111-0000-4000-8000-000000000001", + "019ba178-0e7d-7380-a58c-1d64d653c655", + "01a05676-a62e-7311-8bf9-db685e3cef67", // archived is still on disk + } { + if !ids[id] { + t.Errorf("missing id %s in %v", id, ids) + } + } + // A surviving subagent file is not the parent's transcript. + if ids["gone-parent"] || len(ids) != 3 { + t.Errorf("ids = %v", ids) + } + if got := transcriptIDs(filepath.Join(root, "absent"), filepath.Join(root, "absent")); len(got) != 0 { + t.Errorf("absent homes hold no transcripts: %v", got) + } +} + +func TestClaudeRetention(t *testing.T) { + home := t.TempDir() + if days, set, err := claudeRetention(home); err != nil || set || days != 30 { + t.Fatalf("no settings file: days=%d set=%v err=%v, want the 30-day default", days, set, err) + } + settings := filepath.Join(home, "settings.json") + writeFile(t, settings, `{"model":"opus"}`) + if days, set, _ := claudeRetention(home); set || days != 30 { + t.Fatalf("key absent: days=%d set=%v", days, set) + } + writeFile(t, settings, `{"cleanupPeriodDays": 3650}`) + if days, set, _ := claudeRetention(home); !set || days != 3650 { + t.Fatalf("days=%d set=%v, want 3650 set", days, set) + } + writeFile(t, settings, `{"cleanupPeriodDays": 0}`) + if days, set, _ := claudeRetention(home); !set || days != 0 { + t.Fatalf("an explicit 0 is set, and means no transcripts: days=%d set=%v", days, set) + } + writeFile(t, settings, `{not json`) + if _, _, err := claudeRetention(home); err == nil { + t.Fatalf("unreadable settings must not read as the default") + } +} diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index b237ae0..57c1665 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -15,8 +16,14 @@ import ( var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Validate environment and configuration", - Long: `Check that ccx is properly configured and can access session sources.`, - RunE: runDoctor, + Long: `Check that ccx is properly configured and can access session sources. + +Also checks that the record is whole: Claude Code's transcript +retention (cleanupPeriodDays; the default deletes after 30 days), and +whether every session named in prompt history still has a transcript +on disk. A month that fell out of the store is listed with its prompt +and session counts.`, + RunE: runDoctor, } func runDoctor(cmd *cobra.Command, args []string) error { @@ -53,6 +60,18 @@ func runDoctor(cmd *cobra.Command, args []string) error { if _, err := os.Stat(claudeHome + "/settings.json"); err == nil { fmt.Println("[OK] Claude Code settings.json: found") } + switch days, set, err := claudeRetention(claudeHome); { + case err != nil: + warnings = append(warnings, fmt.Sprintf("Claude Code settings.json unreadable, retention unknown: %v", err)) + case !set: + warnings = append(warnings, fmt.Sprintf( + "Claude Code retention is the default: transcripts older than %d days (file mtime) are deleted. Set \"cleanupPeriodDays\" in %s to keep them", + days, filepath.Join(claudeHome, "settings.json"))) + case days == 0: + warnings = append(warnings, "Claude Code cleanupPeriodDays is 0: no transcripts are written at all") + default: + fmt.Printf("[OK] Claude Code retention: cleanupPeriodDays=%d (user settings)\n", days) + } } else { warnings = append(warnings, fmt.Sprintf("Claude Code home not found: %s", claudeHome)) } @@ -95,6 +114,12 @@ func runDoctor(cmd *cobra.Command, args []string) error { warnings = append(warnings, fmt.Sprintf("Grok home not found: %s", grokHome)) } + // Does the store still hold what prompt history says happened? + prompts, _ := readPromptHistory(promptHistoryFiles(claudeHome, codexHome), "", time.Local) + if len(prompts) > 0 { + warnings = append(warnings, reportCoverage(transcriptCoverage(prompts, transcriptIDs(claudeHome, codexHome), time.Local))...) + } + fmt.Println() for _, w := range warnings { From 8747c6594361b85cdc6c369f3cf444c682e469e9 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 17 Oct 2026 02:42:34 -0700 Subject: [PATCH 4/6] docs: me, doctor coverage, log -C and --since in README, schema, skill Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ README.md | 2 ++ docs/schema.md | 15 ++++++++++++++- llms.txt | 2 +- skills/ccx/SKILL.md | 32 ++++++++++++++++++++++++++++---- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fbf53..6fb7d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added +- **`ccx me`: what the prompt record says about the human at the keyboard.** Every other command describes the agent; this one reads only what you typed and counts it (`ccx.me.v1`, no model, no judgment): working hours and median day start/end, the longest streak and every break of 2+ idle days, and per month the prompt length, CJK / question / command / late-night shares, workspaces touched per day and new ones started. Steering is measured too: slash commands, `/rewind` read as "I take that back" (how many seconds after the prompt, and how often the same prompt comes back edited — longer or shorter), near-verbatim resends within the hour, and how often consecutive prompts change workspace. `--term name=regex` trends any habit of speech by month, so ccx ships no word list. The source is prompt history, not session files: history outlives session cleanup, so a 14-month profile of 21k prompts takes 2 s where a session scan takes 100 s and can only see the transcripts still on disk (on one store, January: 1,953 prompts on record, 332 with a transcript). A day runs 05:00-05:00, so a night session is not split into two days. (dogfood 2026-09-30) +- **`log -C/--context N` shows what a match interrupted.** With `--kind` or `--match`, keeps N records on each side of every match from the same transcript, flagged `"context": true` (text view: `ctx `); `records_matched` and the aggregates still count matches only. An interrupt alone reads "turn aborted: interrupted"; with `-C 2` it shows what the agent was doing and what the human typed next. (dogfood 2026-09-30) +- **`ccx doctor` checks that the record is whole.** Two records of the same work have different lifetimes: Claude Code deletes transcripts older than `cleanupPeriodDays` (default 30, by file mtime) and never touches `history.jsonl`, so a store can lose months without one error — every command just returns fewer sessions. `doctor` now reports the retention setting (warning when it is the default, or 0) and compares the sessions named in prompt history against the transcripts on disk, per provider and month, listing each month where more than a tenth of the prompts have no transcript (`2026-01 1953 prompts, 332 on disk; 77 sessions, 7 on disk`). Codex is checked too, including `archived_sessions/`. 0.2 s; no transcript is read. (dogfood 2026-09-30: a five-month-old hole nobody could see from inside ccx) + +### Fixed +- **`log --kind user_prompt` counted subagent briefs as human prompts.** A subagent's task prompt is user-role text written by the parent agent; inline (`isSidechain`) or in a `subagents/` file it was classified `user_prompt`, so "the humans in the loop" and `metrics.user_prompts` / `days[].user_prompts` ran a fifth to a half high (3,079 of 16,829 on one store; in a month whose parent transcripts were gone, the surviving subagent files made up most of the "prompts"). They are now kind `agent_prompt`. `insight` already excluded them and is unchanged. +- `log --since X` without `--until` was rejected ("both required"); "since X" now means until now, in `log` and `insight`, and the reconstructed `insight` command no longer prints an empty `--until`. + ## [0.17.0] - 2026-09-14 ### Added diff --git a/README.md b/README.md index d6b210e..276d19e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ ccx reads session files from `~/.claude/`, `~/.codex/`, and `~/.grok/` and gives - **Context trace** — `ccx trace` emits evidence for Context Folding - **Time-sliced logs** — `ccx log` cuts through long-running session JSONL by timestamp - **Window digest** — `ccx insight` answers "what was worked on this week, everywhere": per session the human's prompts, the agent's final answer, edits, commits, interventions and cost (with coverage), rolled up by workspace, day, provider and model; JSON for skills, HTML cockpit for humans +- **The human's profile** — `ccx me` reads only what you typed (prompt history, which outlives session cleanup): working hours, streaks and breaks, rewinds and resends, workspaces held per day, any phrase trended by month with `--term` - **Provider filter** — `--provider cc`, `cx`, or `gx` on any command - **Date filter** — `--after 2026-03-01 --before 2026-04-01` - **Keyboard shortcuts** — `j/k` scroll, `/` search, `z` fold, `r` refresh, `d` theme @@ -85,6 +86,7 @@ ccx log --scope yesterday --tz +8 --all --json # Time-sliced log evidence ccx log --scope today --all --kind user_prompt # Every human prompt today, across sessions ccx insight --scope week --all --json # Per-session digest of the week: prompts, answers, edits, cost ccx insight --scope month --all # Same digest as an HTML cockpit (ccx web -> /insights) +ccx me --tz +8 # How you work: hours, streaks, rewinds, workspaces, by month ccx search "auth bug" # Search across sessions + memory ccx search --content -w --sort first goose # Whole-word content hits, earliest first ccx search --content -w --hits goose # Every mention, quoted + anchored (time, session, message id) diff --git a/docs/schema.md b/docs/schema.md index 90c7b85..d89e64b 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -16,8 +16,9 @@ they do not know. | `ccx.turn.v1` | `ccx trace --turn N` | One turn with full step evidence, plus the sidechain entries that turn references, plus warnings. | | `ccx.trace.v2` | `ccx trace --full` | Complete evidence bundle: all turns/steps, sidechains, git correlation, workspace context, `related` sessions, stats, warnings. Large. | | `ccx.related.v1` | `ccx related --json`, `GET /api/related//` | The anchor session's connections to the other sessions of its workspace: `related[]` of `{session_id, provider, summary, start, end, strength, relations[]}`, plus `total`/`shown`. Each relation is `{kind, count?, paths?, evidence[], truncated?}`; evidence items are `{session_id, message_id, time, path?, quote?}`. Kinds: `forked_from`/`fork_of`, `mentions`/`mentioned_by`, `handoff_from`/`handoff_to`, `builds_on`/`built_on_by`, `overlaps`, `previous`/`next`. Strength is `strong`/`medium`/`weak`. | -| `ccx.log.v1` | `ccx log --json` | Time-scoped records across sessions with pre-computed `days[]` / `providers[]` / `workspaces[]` aggregates. Record `kind` is provider-normalized: `user_prompt` and `assistant_message` are the visible conversation only (Claude command markers/echoes/notifications are `command`/`command_output`/`notification`, injected meta is `meta`, compaction carriers `compact_summary`; Codex 0.147 raw `response_item` messages are `model_input`/`model_output` and duplicated legacy events `legacy_message`). `tool_call` records carry `tool`, `path` (when the call named one) and a `Tool: argument` `text`. With `--kind`/`--match`, `metrics.records` stays scope-wide, `metrics.records_matched` reports the narrowed count before any limit, and `truncated` is measured against the matched count. | +| `ccx.log.v1` | `ccx log --json` | Time-scoped records across sessions with pre-computed `days[]` / `providers[]` / `workspaces[]` aggregates. Record `kind` is provider-normalized: `user_prompt` and `assistant_message` are the visible conversation only (a subagent's brief — user-role text on a sidechain or in a `subagents/` file, written by the parent agent — is `agent_prompt`; Claude command markers/echoes/notifications are `command`/`command_output`/`notification`, injected meta is `meta`, compaction carriers `compact_summary`; Codex 0.147 raw `response_item` messages are `model_input`/`model_output` and duplicated legacy events `legacy_message`). `tool_call` records carry `tool`, `path` (when the call named one) and a `Tool: argument` `text`. With `--kind`/`--match`, `metrics.records` stays scope-wide, `metrics.records_matched` reports the narrowed count before any limit, and `truncated` is measured against the matched count. `-C N` adds up to N neighbors per match from the same transcript, flagged `"context": true`; they are returned but never counted in `records_matched` or the aggregates. | | `ccx.insight.v1` | `ccx insight --json` | The window digest: `sessions[]` (one per logical session — a Claude session and its subagent files fold together) with `prompts[]` (`{time, line, text}`, first 6 / last 2, `prompts_omitted`), `final_answer`, `edits` + `edited_paths[]` (capped, `edited_paths_truncated`), `commits`, `interrupts`, `denials`, `tokens`, `cost_usd`, `unpriced_tokens`, `cost_status`, `model`, `summary`, `relation`; rollups `workspaces[]` (with `session_ids[]`), `days[]`, `providers[]`, `models[]`; `metrics` including `cost_coverage` (priced tokens / all tokens), `priced_sessions`, `unpriced_sessions`. `records[]` only with `--records`. Cost and tokens are whole-session figures from the provider's accounting, never sliced to the window. | +| `ccx.me.v1` | `ccx me --json` | The human's profile, counted from prompt history alone (no session files, no model): `sources[]` (the history files read), `span` (first/last, prompts, `calendar_days`, `active_days`, `providers[]`), `tempo` (`hours[24]` by clock hour, weekday/weekend prompts per active day, `median_day_start`/`median_day_end`, `longest_streak`, `breaks[]` of 2+ idle days), `months[]` (prompts, active days, workspaces, `new_workspaces`, `median_workspaces_per_day`, `median_chars`, `cjk_share`/`question_share` over prose, `command_share`/`late_share` over all prompts, `median_day_end`, `terms{}`), `commands[]`, `repeats[]` (short prompts typed at least twice), `openers[]` (first word), `rewinds` (`count`, `paired`, `resent`, `resent_longer`/`resent_shorter`, `median_seconds_before`), `resends`, `workspaces` (`total`, `lifespans` by active days, `switches`, `top[]`), `terms[]` (one per `--term`). Shares are fractions 0-1. A day runs 05:00-05:00 (`tempo.day_start_hour`), so clock strings past `24:00` are after midnight. Workspace figures cover Claude Code only: Codex history records no workspace. | ## Versioning policy @@ -76,6 +77,18 @@ correction/sentiment/importance flags — v1 shipped keyword-based user pushback. Interpretation belongs to the consumer reading `user_text` with comprehension. +### Two records of the human + +Session files and prompt history are different records with different +lifetimes. `log` and `insight` read session files. Claude Code deletes +those after `cleanupPeriodDays` (default 30, by file mtime) and never +touches `history.jsonl`; on one store that ran on the default until May, +January has 1,953 prompts on record and a transcript for 332 of them. +`me` reads history only, so its counts are whole where the +session-derived ones have holes. `ccx doctor` lists the holes. Do not +reconcile the two records by adding them; a prompt in a surviving +session is in both. + ### Evidence budgets Text fields are bounded (user text 2000 runes, narration 1200) with diff --git a/llms.txt b/llms.txt index 356d052..f54849b 100644 --- a/llms.txt +++ b/llms.txt @@ -17,7 +17,7 @@ schema reference before consuming it programmatically. ## Reference -- [JSON schema contract](https://raw.githubusercontent.com/thevibeworks/ccx/main/docs/schema.md): all emitted kinds (ccx.outline.v1, ccx.turn.v1, ccx.trace.v2, ccx.related.v1, ccx.log.v1, ccx.insight.v1), versioning policy, semantics consumers must know +- [JSON schema contract](https://raw.githubusercontent.com/thevibeworks/ccx/main/docs/schema.md): all emitted kinds (ccx.outline.v1, ccx.turn.v1, ccx.trace.v2, ccx.related.v1, ccx.log.v1, ccx.insight.v1, ccx.me.v1), versioning policy, semantics consumers must know - [Claude Code / Codex session format reference](https://raw.githubusercontent.com/thevibeworks/ccx/main/docs/devlog/2026-04-02-session-format-reference.org): the on-disk JSONL formats ccx parses, observed from real sessions - [Grok session format reference](https://raw.githubusercontent.com/thevibeworks/ccx/main/docs/devlog/2026-07-15-grok-session-format.org): ~/.grok layout, chat_history/updates/summary shapes, mapping onto ccx's model diff --git a/skills/ccx/SKILL.md b/skills/ccx/SKILL.md index 37d4587..f21ebc3 100644 --- a/skills/ccx/SKILL.md +++ b/skills/ccx/SKILL.md @@ -42,16 +42,20 @@ ccx │ └── --json # Every relation with evidence (ccx.related.v1) ├── log [project] # Slice raw session logs by time scope │ └── --scope today|yesterday|week|month|quarter|year -│ └── --since / --until TIME # RFC3339 or YYYY-MM-DD +│ └── --since / --until TIME # RFC3339 or YYYY-MM-DD (--since alone = until now) │ └── --tz ZONE # IANA timezone, UTC, local, or offset like +8 │ └── --json --raw # Evidence bundle, optional raw JSONL -│ └── --kind K1,K2 # Only these record kinds (user_prompt, assistant_message, tool_call, ...) +│ └── --kind K1,K2 # Only these record kinds (user_prompt, agent_prompt, assistant_message, tool_call, ...) │ └── --match PHRASE [-w] # Only records whose raw line contains the phrase ├── insight [project] # Digest of every session in a window (ccx.insight.v1) │ └── --scope --tz --since --until --all │ └── --json # sessions[] with prompts/final answer/edits/commits/cost + workspaces[]/days[]/models[] │ └── --records [-n N] # Add the raw log records (ccx log's payload) │ └── -o FILE # HTML evidence cockpit (default: $XDG_DATA_HOME/ccx/insights/, browsable at /insights) +├── me # What the prompt record says about the human (ccx.me.v1) +│ └── --since / --until --tz # Window (default: everything on record, until now) +│ └── --term NAME=REGEX # Count prompts matching a regex, total and per month (repeatable) +│ └── --json # tempo, months[], commands, rewinds, resends, workspaces ├── web # Start web UI │ └── --port --host --no-open │ └── --project [path] --session ID --latest # Deep-link into a view @@ -63,7 +67,7 @@ ccx ├── skills # Manage bundled agent skills │ └── install --scope user|project # Install skills matching this binary ├── config # Show / init config -└── doctor # Check setup +└── doctor # Check setup, transcript retention, and months where history has prompts but no transcript ``` ## Quick Start @@ -78,10 +82,11 @@ ccx export -f html # Export to HTML ccx sessions --scope yesterday --tz +8 --all --json # Session containers by end time ccx log --scope yesterday --tz +8 --all --json ccx log --scope today --all --kind user_prompt # The humans in the loop: every prompt today, all sessions, in order -ccx log --scope today --all --kind interrupt,tool_denied # Where humans stopped or refused the agent +ccx log --scope today --all --kind interrupt,tool_denied -C 2 # Where humans stopped the agent, with what it was doing and what came next ccx log --scope month --all --match deadman -w # When a term came up inside a window (raw-line match) ccx insight --scope week --all --json # What was worked on, everywhere: per-session prompts, answers, edits, cost ccx insight --scope month --all # Same digest as an HTML cockpit in the insights dir +ccx me --tz +8 # How the human works: hours, streaks, rewinds, workspaces, by month ccx trace # Outline of the latest workspace session ccx trace abc123 --turn 5 # Full evidence for one turn ccx related abc123 # Sessions connected to this one: fork, handoff, mentions, shared files @@ -137,6 +142,25 @@ for reconstructing sessions. Without `--json` the same digest is rendered as a self-contained HTML cockpit (facts only; the judgment layer is the ccx-recap skill). +## Me: the human at the keyboard + +`ccx me --tz +8` profiles the person, not the agent, from what they +typed: working hours and day start/end, streaks and breaks, per-month +prompt length / language / command share / late share, slash commands, +`/rewind` behaviour (how soon, and whether the same prompt comes back +edited), resends, workspaces held per day and how long each lived. +`--term name=regex` trends any habit of speech by month. It counts and +does not judge; interpretation is the reader's. + +The source is the prompt history, which outlives session cleanup — so +it answers year-long questions in seconds where `log`/`insight` see +only the transcripts still on disk. Before trusting a session-derived +count over a long window, run `ccx doctor`: it lists the months where +history names sessions whose transcript is gone. Use `me` for "how do I work", and +`insight` for "what was worked on". Codex history records no +workspace, so workspace figures are Claude Code only. A day runs +05:00 to 05:00; a day end past 24:00 is after midnight. + ## Multi-Provider ```bash From c06947c47f03e9ef6105c63e681eae10678f1d88 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 17 Oct 2026 02:44:02 -0700 Subject: [PATCH 5/6] fix(doctor): the Codex restore hint names sessions/, not projects/ Codex keeps transcripts under /sessions/; the hint told Codex users to rsync a projects/ directory that does not exist there. Co-Authored-By: Claude Opus 5.5 --- internal/cmd/coverage.go | 9 +++++++-- internal/cmd/coverage_test.go | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/cmd/coverage.go b/internal/cmd/coverage.go index cffd0db..c2428c6 100644 --- a/internal/cmd/coverage.go +++ b/internal/cmd/coverage.go @@ -180,11 +180,16 @@ func reportCoverage(rows []coverageRow) []string { fmt.Fprintf(w, " %s\t%d\t%d\t%d\t%d\t\n", r.Month, r.Prompts, r.PromptsOnDisk, r.Sessions, r.SessionsOnDisk) } w.Flush() + // Claude Code keeps transcripts under projects/, Codex under sessions/. + dir := "projects" + if provider == "codex" { + dir = "sessions" + } warnings = append(warnings, fmt.Sprintf( "%s: %d prompts in %d sessions (%s to %s) have no transcript on disk. "+ "What was typed survives in prompt history (ccx me, ccx search --content); what the agent did does not. "+ - "A backup restores it: rsync -a --ignore-existing /projects/ /projects/", - provider, missingPrompts, missingSessions, first, last)) + "A backup restores it: rsync -a --ignore-existing /%s/ /%s/", + provider, missingPrompts, missingSessions, first, last, dir, dir)) } if unattributed > 0 { fmt.Printf(" %d %s prompts predate session ids in history and cannot be checked\n", unattributed, provider) diff --git a/internal/cmd/coverage_test.go b/internal/cmd/coverage_test.go index ebb70a5..12a1752 100644 --- a/internal/cmd/coverage_test.go +++ b/internal/cmd/coverage_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "time" @@ -48,6 +49,26 @@ func TestTranscriptCoverageCountsWhatFellOutOfTheStore(t *testing.T) { } } +func TestReportCoverageNamesEachProvidersTranscriptDir(t *testing.T) { + rows := []coverageRow{ + {Provider: "claude-code", Month: "2026-01", Prompts: 40, PromptsOnDisk: 0, Sessions: 2}, + {Provider: "codex", Month: "2026-01", Prompts: 40, PromptsOnDisk: 0, Sessions: 2}, + } + stdout := os.Stdout + os.Stdout, _ = os.Open(os.DevNull) + warnings := reportCoverage(rows) + os.Stdout = stdout + if len(warnings) != 2 { + t.Fatalf("warnings = %q", warnings) + } + if !strings.Contains(warnings[0], "/projects/ /projects/") { + t.Errorf("claude-code hint = %q", warnings[0]) + } + if !strings.Contains(warnings[1], "/sessions/ /sessions/") { + t.Errorf("codex keeps transcripts under sessions/, hint = %q", warnings[1]) + } +} + func TestTranscriptIDsFromBothHomes(t *testing.T) { root := t.TempDir() claude := filepath.Join(root, "claude") From 2f33aeb7dfb22dbd5da9c8b9d588b5d30cee6c00 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 17 Oct 2026 02:47:31 -0700 Subject: [PATCH 6/6] docs(skills): carry the one-line ccx and ccx-recap descriptions The installed copies were tightened on 2026-09-12 and the repo never got the edit, so `ccx skills install` reverted them. Bodies unchanged. Co-Authored-By: Claude Opus 5.5 --- skills/ccx-recap/SKILL.md | 10 +--------- skills/ccx/SKILL.md | 8 +------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/skills/ccx-recap/SKILL.md b/skills/ccx-recap/SKILL.md index d545654..76aa945 100644 --- a/skills/ccx-recap/SKILL.md +++ b/skills/ccx-recap/SKILL.md @@ -1,14 +1,6 @@ --- name: ccx-recap -description: > - What did the agent actually do? Recap a coding-agent session or a - time window from ccx evidence: the story, the decisions, what was - verified vs merely claimed, mistakes and cost. Use after stepping - away from an autonomous run, at end of day, or whenever the human - asks "what happened", "what did it do", "recap", "summarize the - session", or "what did I work on today/this week". Routing: this - skill answers "what happened"; for "what went wrong / what should - change", use ccx-retro. +description: "Recap what a coding-agent session or time window actually did from ccx evidence: story, decisions, verified vs merely claimed, mistakes, cost. Use for 'what happened', 'what did it do', 'recap', 'summarize the session', 'what did I work on today/this week'. For 'what went wrong', use ccx-retro." --- # ccx-recap diff --git a/skills/ccx/SKILL.md b/skills/ccx/SKILL.md index f21ebc3..bd3cc7d 100644 --- a/skills/ccx/SKILL.md +++ b/skills/ccx/SKILL.md @@ -1,12 +1,6 @@ --- name: ccx -description: > - Session viewer for Claude Code, Codex, and Grok. Use this skill when working - with agent session files, browsing conversation history, exporting - transcripts, inspecting memory/instruction files, or analyzing AI - interactions. Triggers on: session management, conversation export, - transcript viewing, memory inspection, Claude Code debugging, Codex session - viewing, Grok session viewing. +description: "Session viewer for Claude Code, Codex, and Grok: browse conversation history, export transcripts, inspect memory and instruction files, analyze AI interactions. Triggers: session management, conversation export, transcript viewing, memory inspection, agent session debugging." --- # ccx - Session Viewer for Agent CLIs