blob: 7917c1e9aaaf3fd33aa65c26eef10ae8e57e4826 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
"github.com/apache/skywalking-ai-sessionizer/internal/config"
"github.com/apache/skywalking-ai-sessionizer/internal/parse"
"github.com/apache/skywalking-ai-sessionizer/internal/storage"
"github.com/apache/skywalking-ai-sessionizer/internal/view"
"github.com/apache/skywalking-ai-sessionizer/pkg/model"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
)
// cmdParse turns landed records into conversation structure and appends one
// round to each conversation's chain.
//
// The conversation id defaults to the session id, which is the adapter's
// documented mapping: nothing on disk reliably links two Claude Code sessions.
// An application that considers several sessions one piece of work supplies its
// own id instead.
func cmdParse(cfg *config.Config, _ config.Adapter, _ bool) error {
zoneRoot, err := cfg.ResolvedRoot()
if err != nil {
return err
}
return parseZone(zoneRoot, arg(0), cfg.Parse.MaxRoundBytes)
}
// parseZone assembles every session under zoneRoot, or the one named by want,
// writing rounds of at most maxRound bytes until each chain reaches its index.
func parseZone(zoneRoot, want string, maxRound int64) error {
z := storage.NewZone(zoneRoot)
sessions, err := sessionDirs(zoneRoot)
if err != nil {
return err
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SESSION\tROUND\tSEQ\tNODES\tRELS\tUNRES\tTALKS\tRUNS\tSTEPS\tTOOLS\tCHILDREN")
var rounds, failed int
for _, id := range sessions {
if want != "" && id != want {
continue
}
r, err := parseToIndex(z, id, maxRound, &rounds)
if err != nil {
fmt.Fprintf(os.Stderr, " %s: %v\n", id, err)
failed++
continue
}
st := r.Stats
round := "-"
if r.Changed() {
round = fmt.Sprintf("%d", r.Number)
}
fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d/%d\t%d/%d\n",
id, round, r.ThroughSeq, r.Nodes, r.Relations, r.Unresolved,
st.Talks, st.Runs, st.Steps,
st.ToolsResolved, st.ToolUses, st.SpawnsResolved, st.Spawns)
}
if err := tw.Flush(); err != nil {
return err
}
fmt.Printf("\n%d round(s) written\n", rounds)
if failed > 0 {
// A pass that could not parse some sessions must not look like a clean
// one. A watch loop or a CI step reads the exit status, not the log.
return fmt.Errorf("%d session(s) failed to parse", failed)
}
return nil
}
// parseToIndex writes rounds for one session until the chain reaches the
// index. A round is cut at the byte budget, so one pass may take several.
// It returns the last round and adds to rounds the number written.
func parseToIndex(z *storage.Zone, id string, maxRound int64, rounds *int) (*parse.Round, error) {
for {
r, err := parse.Session(z, parse.Options{Conversation: id, Session: id, MaxRoundBytes: maxRound})
if err != nil {
return nil, err
}
if r.Changed() {
*rounds++
}
if !r.Changed() || !r.More {
return r, nil
}
}
}
// printDocument writes the conversation's asz.view document to standard
// output, the same document asz view serves at /api/c/{id}/view, as JSON
// or as YAML. The page stays the page; this is the document for a pipeline
// or a diff.
func printDocument(zoneRoot, id, format string) error {
c, err := view.New(storage.NewZone(zoneRoot), nil).Load(id)
if err != nil {
return err
}
doc, err := c.Build()
if err != nil {
return err
}
var out []byte
switch format {
case "json":
out, err = json.MarshalIndent(doc, "", " ")
case "yaml":
out, err = sessionview.MarshalYAML(doc)
}
if err != nil {
return err
}
_, err = os.Stdout.Write(append(out, '\n'))
return err
}
// termsFor returns how to render a name, from the -terms flag.
//
// A reader who knows their own agent product recognises its words; a reader
// comparing two products needs one vocabulary. Neither is the right default for
// both, so the choice is theirs and nothing is lost either way.
func termsFor(mode string) func(string) string {
g := claudecode.Glossary()
switch mode {
case "native":
return g.Native
case "both":
return func(u string) string {
if n := g.Native(u); n != u {
return u + " (" + n + ")"
}
return u
}
}
return func(u string) string { return u }
}
// cmdGlossary prints what a runtime calls the things the model names.
func cmdGlossary(_ *config.Config, _ config.Adapter, _ bool) error {
g := claudecode.Glossary()
fmt.Printf("dialect %s\n\n", g.Dialect)
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "MODEL\tRUNTIME\tWHERE\tNOTE")
var derived int
for _, t := range g.Terms() {
native, where := t.Native, t.Where
if t.Derived() {
derived++
native, where = "—", "derived here"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", t.Unified, native, where, t.Note)
}
if err := tw.Flush(); err != nil {
return err
}
terms := g.Terms()
fmt.Printf("\n%d terms: %d named by the runtime, %d derived by this project\n",
len(terms), len(terms)-derived, derived)
return nil
}
// cmdConversation folds a conversation's round chain and prints the structure.
//
// The fold is the whole definition of the conversation as of the latest round.
// There is no other state, which is why this reads the chain rather than any
// cached copy of it.
func cmdConversation(cfg *config.Config, _ config.Adapter, _ bool) error {
zoneRoot, err := cfg.ResolvedRoot()
if err != nil {
return err
}
id := arg(0)
if id == "" {
return fmt.Errorf("usage: asz conversation [-json|-yaml] CONVERSATION")
}
if docFormat != "" {
return printDocument(zoneRoot, id, docFormat)
}
chain := sessionflow.OpenChain(zoneRoot, id)
files, err := chain.Verify()
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("no rounds for conversation %s; run asz parse first", id)
}
v, err := chain.Fold()
if err != nil {
return err
}
fmt.Printf("conversation %s\n", id)
fmt.Printf(" rounds %d, head %s\n", v.Round, trunc(v.Digest, 13))
fmt.Printf(" landed through seq %d\n", v.ThroughSeq)
fmt.Printf(" entities %d nodes, %d relations, %d unresolved (%d still open)\n\n",
len(v.Nodes), len(v.Relations), len(v.Unresolved), len(v.OpenUnresolved()))
term := termsFor(termMode)
byKind := map[string]int{}
for _, n := range v.Nodes {
byKind[n.Kind]++
}
type kv struct {
k string
n int
}
var ks []kv
for k, n := range byKind {
ks = append(ks, kv{k, n})
}
sort.Slice(ks, func(a, b int) bool {
if ks[a].n != ks[b].n {
return ks[a].n > ks[b].n
}
return ks[a].k < ks[b].k
})
fmt.Println("node kinds:")
for _, x := range ks {
fmt.Printf(" %-40s %6d\n", term(x.k), x.n)
}
byRel := map[string]int{}
quality := map[string]int{}
for _, r := range v.Relations {
byRel[r.Type]++
quality[r.Quality]++
}
if len(byRel) > 0 {
fmt.Println("\nrelations:")
for _, t := range sortedKeys(byRel) {
fmt.Printf(" %-40s %6d\n", term(t), byRel[t])
}
fmt.Println("\ncorrelation quality:")
for _, q := range sortedKeys(quality) {
fmt.Printf(" %-40s %6d\n", term(q), quality[q])
}
}
if open := v.OpenUnresolved(); len(open) > 0 {
fmt.Printf("\nstill unresolved (%d):\n", len(open))
for i, u := range open {
if i >= 12 {
fmt.Printf(" … and %d more\n", len(open)-i)
break
}
fmt.Printf(" %-26s %s\n", u.Kind, u.Reason)
}
}
printTree(v, term)
return nil
}
// printTree renders the containment tree, which carries direct ownership only.
//
// Cross-stream flow does not appear here. A child agent's work stays under the
// child's own stream and the parent holds a reference to it, so nothing is
// shown twice.
func printTree(v *sessionflow.View, term func(string) string) {
fmt.Println("\nstructure (containment only; cross-stream flow is a relation):")
// Breadth is capped per level rather than by a running total, so a session
// with hundreds of children still shows what is under the first of them.
const breadth = 6
var walk func(id string, depth int)
walk = func(id string, depth int) {
if depth > 6 {
return
}
children := v.Children(id)
sort.SliceStable(children, func(i, j int) bool {
return treeRank(children[i].Kind) < treeRank(children[j].Kind)
})
shown := 0
for _, c := range children {
if shown == breadth {
fmt.Printf(" %s… %d more\n", strings.Repeat(" ", depth), len(children)-shown)
break
}
shown++
label := term(c.Kind)
if name := shortID(c.ID); name != "" {
label += " " + name
}
fmt.Printf(" %s%s\n", strings.Repeat(" ", depth), label)
walk(c.ID, depth+1)
}
}
var roots []string
for id, n := range v.Nodes {
if n.Parent == "" || v.Nodes[n.Parent] == nil {
roots = append(roots, id)
}
}
sort.Strings(roots)
for _, id := range roots {
fmt.Printf(" %s %s\n", term(v.Nodes[id].Kind), shortID(id))
walk(id, 1)
}
}
// treeRank orders a node's children so the spine is shown before the leaves.
func treeRank(kind string) int {
switch kind {
case model.KindStream:
return 0
case model.KindEpoch:
return 1
case model.KindTalk:
return 2
case model.KindRun:
return 3
case model.KindLLMCall:
return 4
case model.KindSegment:
return 9
}
return 5
}
func shortID(id string) string {
if i := strings.LastIndex(id, "/"); i >= 0 {
id = id[i+1:]
}
if len(id) > 22 {
return id[:21] + "…"
}
return id
}
func sortedKeys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// sessionDirs lists the sessions present in the storage root.
func sessionDirs(root string) ([]string, error) {
items, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var out []string
for _, d := range items {
// A conversation's rounds live beside the sessions under a reserved name,
// so it must not be mistaken for one.
if d.IsDir() && !strings.HasPrefix(d.Name(), "_") {
out = append(out, d.Name())
}
}
sort.Strings(out)
return out, nil
}