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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ prosa search "sqlite" --remote --last 90d
prosa --project mz-iac --agent codex

# pipe to jq
prosa --last 30d --json | jq '[.[] | {agent, project, started_at}]'
prosa --last 30d --json | jq -s '[.[] | {agent, project, started_at}]'

# show the raw of the most recent session
prosa --last 1d --json \
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/cli_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ func TestCLIEndToEnd(t *testing.T) {

var row map[string]any
require.NoError(t, json.Unmarshal([]byte(lines[0]), &row))
require.Equal(t, e2eSessionID, row["ID"])
require.Equal(t, "codex", row["Agent"])
require.Equal(t, "explain entanglement", row["FirstPrompt"])
require.Equal(t, e2eSessionID, row["id"])
require.Equal(t, "codex", row["agent"])
require.Equal(t, "explain entanglement", row["first_prompt"])
}

type cliEnv struct {
Expand Down
115 changes: 101 additions & 14 deletions internal/cli/nu.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"

Expand Down Expand Up @@ -88,13 +89,7 @@ func runNu(cmd *cobra.Command, _ []string) error {
}

if g.JSON {
enc := json.NewEncoder(os.Stdout)
for i := range sessions {
if err := enc.Encode(sessions[i]); err != nil {
return err
}
}
return nil
return emitTimelineJSON(os.Stdout, sessions)
}

if len(sessions) == 0 {
Expand Down Expand Up @@ -185,13 +180,7 @@ func runNuRemote(ctx context.Context, w Window, now time.Time) error {
sessions = append(sessions, remoteSessionToLocal(in))
}
if g.JSON {
enc := json.NewEncoder(os.Stdout)
for i := range sessions {
if err := enc.Encode(sessions[i]); err != nil {
return err
}
}
return nil
return emitTimelineJSON(os.Stdout, sessions)
}
if len(sessions) == 0 {
if interactive {
Expand Down Expand Up @@ -224,6 +213,104 @@ func runNuRemote(ctx context.Context, w Window, now time.Time) error {
})
}

type timelineSessionJSON struct {
ID string `json:"id"`
Agent string `json:"agent"`
DeviceID string `json:"device_id"`
Project string `json:"project,omitempty"`
ProjectPath string `json:"project_path,omitempty"`
ProjectRemote string `json:"project_remote,omitempty"`
ProjectMarker string `json:"project_marker,omitempty"`
StartedAt time.Time `json:"started_at"`
LastActivityAt time.Time `json:"last_activity_at"`
FirstPrompt string `json:"first_prompt,omitempty"`
Model string `json:"model,omitempty"`
RawPath string `json:"raw_path,omitempty"`
RawHash string `json:"raw_hash,omitempty"`
RawSize int64 `json:"raw_size,omitempty"`
Usage *timelineUsageJSON `json:"usage,omitempty"`
ParentID string `json:"parent_session_id,omitempty"`
Profile string `json:"profile"`
Kinds []string `json:"kinds,omitempty"`
}

type timelineUsageJSON struct {
TotalTokens int64 `json:"total_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CachedTokens int64 `json:"cached_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
}

func emitTimelineJSON(w io.Writer, sessions []session.Session) error {
enc := json.NewEncoder(w)
for i := range sessions {
if err := enc.Encode(timelineSessionPayload(sessions[i])); err != nil {
return err
}
}
return nil
}

func timelineSessionPayload(s session.Session) timelineSessionJSON {
out := timelineSessionJSON{
ID: s.ID,
Agent: s.Agent,
DeviceID: s.DeviceID,
ProjectPath: ptrText(s.ProjectPath),
ProjectRemote: ptrText(s.ProjectRemote),
ProjectMarker: ptrText(s.ProjectMarker),
StartedAt: s.StartedAt,
LastActivityAt: s.LastActivityAt,
FirstPrompt: ptrText(s.FirstPrompt),
Model: ptrText(s.Model),
RawPath: s.RawPath,
RawHash: s.RawHash,
RawSize: s.RawSize,
Usage: timelineUsagePayload(s.Usage),
ParentID: ptrText(s.ParentSessionID),
Profile: session.ProfileOrDefault(s.Profile),
}
out.Project = timelineProject(out.ProjectPath, out.ProjectRemote, out.ProjectMarker)
if len(s.Kinds) > 0 {
out.Kinds = append([]string(nil), s.Kinds...)
}
return out
}

func timelineProject(path, remote, marker string) string {
switch {
case marker != "":
return marker
case remote != "":
return remote
default:
return path
}
}

func timelineUsagePayload(u *session.TokenUsage) *timelineUsageJSON {
if u == nil {
return nil
}
return &timelineUsageJSON{
TotalTokens: u.TotalTokens,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CachedTokens: u.CachedTokens,
CacheReadTokens: u.CacheReadTokens,
CacheCreationTokens: u.CacheCreationTokens,
}
}

func ptrText(v *string) string {
if v == nil {
return ""
}
return *v
}

func remoteDeviceLabels(ctx context.Context, auth rpc.AuthFile) map[string]string {
client := rpc.Devices(auth.Server, auth.Token)
resp, err := client.List(ctx, connect.NewRequest(&prosav1.DevicesServiceListRequest{}))
Expand Down
63 changes: 61 additions & 2 deletions internal/cli/nu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
prosav1 "github.com/c3-oss/prosa/gen/go/prosa/v1"
"github.com/c3-oss/prosa/gen/go/prosa/v1/prosav1connect"
"github.com/c3-oss/prosa/internal/cli/rpc"
"github.com/c3-oss/prosa/pkg/session"
)

type fakeNuSessions struct {
Expand All @@ -38,6 +39,8 @@ func (f *fakeNuSessions) List(_ context.Context, req *connect.Request[prosav1.Li
FirstPrompt: "remote timeline work",
RawHash: "hash",
RawSize: 4,
Profile: "work",
Kinds: []string{"goal"},
}},
TotalCount: 1,
}), nil
Expand Down Expand Up @@ -113,6 +116,62 @@ func TestRunNuRemoteListsWithoutLocalStore(t *testing.T) {

var row map[string]any
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &row))
require.Equal(t, "remote-session", row["ID"])
require.Equal(t, "device-b", row["DeviceID"])
require.Equal(t, "remote-session", row["id"])
require.Equal(t, "device-b", row["device_id"])
require.Equal(t, "work", row["profile"])
require.NotContains(t, row, "ID")
require.NotContains(t, row, "DeviceID")
}

func TestTimelineSessionPayloadUsesStableJSONSchema(t *testing.T) {
projectPath := "/Users/cai/Projects/prosa"
projectRemote := "git@github.com:c3-oss/prosa.git"
projectMarker := "prosa"
firstPrompt := "fix timeline json"
model := "gpt-5-codex"
parent := "parent-session"
started := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
payload := timelineSessionPayload(session.Session{
ID: "s1",
Agent: "codex",
DeviceID: "device-a",
ProjectPath: &projectPath,
ProjectRemote: &projectRemote,
ProjectMarker: &projectMarker,
StartedAt: started,
LastActivityAt: started.Add(time.Minute),
FirstPrompt: &firstPrompt,
Model: &model,
RawPath: "/tmp/s1.jsonl",
RawHash: "hash",
RawSize: 42,
Usage: &session.TokenUsage{TotalTokens: 10, InputTokens: 7, OutputTokens: 3},
ParentSessionID: &parent,
Profile: "work",
Kinds: []string{"goal"},
})

body, err := json.Marshal(payload)
require.NoError(t, err)
var row map[string]any
require.NoError(t, json.Unmarshal(body, &row))
require.Equal(t, "s1", row["id"])
require.Equal(t, "codex", row["agent"])
require.Equal(t, "device-a", row["device_id"])
require.Equal(t, "prosa", row["project"])
require.Equal(t, projectPath, row["project_path"])
require.Equal(t, projectRemote, row["project_remote"])
require.Equal(t, projectMarker, row["project_marker"])
require.Equal(t, "2026-06-22T12:00:00Z", row["started_at"])
require.Equal(t, "fix timeline json", row["first_prompt"])
require.Equal(t, "parent-session", row["parent_session_id"])
require.Equal(t, "work", row["profile"])
require.Equal(t, []any{"goal"}, row["kinds"])
usage := row["usage"].(map[string]any)
require.Equal(t, float64(10), usage["total_tokens"])
require.Equal(t, float64(7), usage["input_tokens"])
require.Equal(t, float64(3), usage["output_tokens"])
require.NotContains(t, row, "ID")
require.NotContains(t, row, "StartedAt")
require.NotContains(t, row, "DeviceID")
}
8 changes: 8 additions & 0 deletions internal/cli/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ func remoteSessionToLocal(in *prosav1.Session) session.Session {
v := in.Model
out.Model = &v
}
if in.ParentSessionId != "" {
v := in.ParentSessionId
out.ParentSessionID = &v
}
out.Profile = session.ProfileOrDefault(in.Profile)
if len(in.Kinds) > 0 {
out.Kinds = append([]string(nil), in.Kinds...)
}
if in.Usage != nil {
out.Usage = &session.TokenUsage{
TotalTokens: in.Usage.TotalTokens,
Expand Down
Loading