Skip to content

Commit f5ff827

Browse files
feat: add sessionless local tool attribution flow (#23)
* docs(specs): add sessionless local tool attribution design * feat(backend): add initial tool usage event attribution path * feat(backend): add tool usage handler coverage * feat(ae-cli): add local attribution parser and sync skeleton * feat(ae-cli): trigger attribution sync from git hooks * feat(attribution): bind local tool usage to checkpoints * feat(session): finalize sessionless local attribution flow * fix(review): tighten workspace scope and parser behavior * fix(sync): prune replayed spool events incrementally
1 parent a0106b2 commit f5ff827

73 files changed

Lines changed: 14062 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ae-cli/cmd/hook.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"time"
77

8+
"github.com/ai-efficiency/ae-cli/internal/attributionlocal"
89
"github.com/ai-efficiency/ae-cli/internal/hooks"
910
"github.com/ai-efficiency/ae-cli/internal/proxy"
1011
"github.com/spf13/cobra"
@@ -72,10 +73,22 @@ var hookSessionEventCmd = &cobra.Command{
7273
},
7374
}
7475

76+
var hookAttributionSyncCmd = &cobra.Command{
77+
Use: "attribution-sync",
78+
Short: "Run local attribution sync (hidden)",
79+
Hidden: true,
80+
RunE: func(cmd *cobra.Command, args []string) error {
81+
cwd, _ := os.Getwd()
82+
engine := attributionlocal.NewSyncEngine(apiClient)
83+
return engine.RunForWorkspace(context.Background(), cwd)
84+
},
85+
}
86+
7587
func init() {
7688
hookCmd.AddCommand(hookPostCommitCmd)
7789
hookCmd.AddCommand(hookPostRewriteCmd)
7890
hookSessionEventCmd.Flags().String("tool", "", "originating tool name")
7991
hookCmd.AddCommand(hookSessionEventCmd)
92+
hookCmd.AddCommand(hookAttributionSyncCmd)
8093
rootCmd.AddCommand(hookCmd)
8194
}

ae-cli/go.mod

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/google/uuid v1.6.0
1111
github.com/spf13/cobra v1.8.1
1212
github.com/spf13/viper v1.19.0
13+
golang.org/x/sys v0.38.0
1314
)
1415

1516
require (
@@ -23,8 +24,10 @@ require (
2324
github.com/clipperhouse/displaywidth v0.9.0 // indirect
2425
github.com/clipperhouse/stringish v0.1.1 // indirect
2526
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
27+
github.com/dustin/go-humanize v1.0.1 // indirect
2628
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2729
github.com/fsnotify/fsnotify v1.7.0 // indirect
30+
github.com/glebarez/go-sqlite v1.22.0 // indirect
2831
github.com/hashicorp/hcl v1.0.0 // indirect
2932
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3033
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -37,6 +40,7 @@ require (
3740
github.com/muesli/cancelreader v0.2.2 // indirect
3841
github.com/muesli/termenv v0.16.0 // indirect
3942
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
43+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
4044
github.com/rivo/uniseg v0.4.7 // indirect
4145
github.com/sagikazarmark/locafero v0.4.0 // indirect
4246
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -49,8 +53,11 @@ require (
4953
go.uber.org/atomic v1.9.0 // indirect
5054
go.uber.org/multierr v1.9.0 // indirect
5155
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
52-
golang.org/x/sys v0.38.0 // indirect
5356
golang.org/x/text v0.14.0 // indirect
5457
gopkg.in/ini.v1 v1.67.0 // indirect
5558
gopkg.in/yaml.v3 v3.0.1 // indirect
59+
modernc.org/libc v1.37.6 // indirect
60+
modernc.org/mathutil v1.6.0 // indirect
61+
modernc.org/memory v1.7.2 // indirect
62+
modernc.org/sqlite v1.28.0 // indirect
5663
)

ae-cli/go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
2727
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2828
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
2929
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
30+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
31+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3032
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3133
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3234
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
3335
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
3436
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
3537
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
38+
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
39+
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
3640
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
3741
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
3842
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -68,6 +72,8 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
6872
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6973
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
7074
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
75+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
76+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
7177
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
7278
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
7379
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -123,3 +129,11 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
123129
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
124130
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
125131
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
132+
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
133+
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
134+
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
135+
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
136+
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
137+
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
138+
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
139+
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package attributionlocal
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"slices"
9+
"strings"
10+
)
11+
12+
func ParseClaudeJSONL(path, workspaceRoot string) ([]LocalToolUsageEvent, error) {
13+
type candidate struct {
14+
event LocalToolUsageEvent
15+
stopDone bool
16+
score int64
17+
}
18+
19+
best := map[string]candidate{}
20+
f, err := os.Open(path)
21+
if err != nil {
22+
return nil, err
23+
}
24+
defer f.Close()
25+
26+
scanner := bufio.NewScanner(f)
27+
for scanner.Scan() {
28+
line := strings.TrimSpace(scanner.Text())
29+
if line == "" {
30+
continue
31+
}
32+
33+
var row map[string]any
34+
if err := json.Unmarshal([]byte(line), &row); err != nil {
35+
continue
36+
}
37+
if strings.TrimSpace(asString(row["type"])) != "assistant" {
38+
continue
39+
}
40+
if filepath.Clean(asString(row["cwd"])) != filepath.Clean(workspaceRoot) {
41+
continue
42+
}
43+
44+
msg, _ := row["message"].(map[string]any)
45+
msgID := strings.TrimSpace(asString(msg["id"]))
46+
sessionID := strings.TrimSpace(asString(row["sessionId"]))
47+
if msgID == "" || sessionID == "" {
48+
continue
49+
}
50+
51+
usage, _ := msg["usage"].(map[string]any)
52+
score := asInt64(usage["input_tokens"]) + asInt64(usage["output_tokens"]) +
53+
asInt64(usage["cache_creation_input_tokens"]) + asInt64(usage["cache_read_input_tokens"])
54+
55+
cur := candidate{
56+
event: LocalToolUsageEvent{
57+
Tool: "claude",
58+
ToolSessionID: sessionID,
59+
ToolEventID: msgID,
60+
DedupeKey: "claude:" + sessionID + ":" + msgID,
61+
RequestCount: 1,
62+
UsageUnit: UsageUnitToken,
63+
InputTokens: asInt64(usage["input_tokens"]),
64+
OutputTokens: asInt64(usage["output_tokens"]),
65+
CachedInputTokens: asInt64(usage["cache_creation_input_tokens"]) + asInt64(usage["cache_read_input_tokens"]),
66+
RawSourcePath: path,
67+
RawPayload: row,
68+
},
69+
stopDone: strings.TrimSpace(asString(row["stop_reason"])) == "end_turn",
70+
score: score,
71+
}
72+
73+
old, ok := best[cur.event.DedupeKey]
74+
if !ok || (cur.stopDone && !old.stopDone) || (cur.stopDone == old.stopDone && cur.score >= old.score) {
75+
best[cur.event.DedupeKey] = cur
76+
}
77+
}
78+
if err := scanner.Err(); err != nil {
79+
return nil, err
80+
}
81+
82+
out := make([]LocalToolUsageEvent, 0, len(best))
83+
for _, item := range best {
84+
out = append(out, item.event)
85+
}
86+
slices.SortFunc(out, func(a, b LocalToolUsageEvent) int { return strings.Compare(a.DedupeKey, b.DedupeKey) })
87+
return out, nil
88+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package attributionlocal
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestParseClaudeJSONL_PrefersEndTurnRecord(t *testing.T) {
9+
t.Parallel()
10+
11+
path := writeFile(t, "claude.jsonl", strings.Join([]string{
12+
`{"type":"assistant","cwd":"/tmp/repo","sessionId":"claude-1","message":{"id":"msg-1","usage":{"input_tokens":5,"output_tokens":1}},"stop_reason":null}`,
13+
`{"type":"assistant","cwd":"/tmp/repo","sessionId":"claude-1","message":{"id":"msg-1","usage":{"input_tokens":5,"output_tokens":3}},"stop_reason":"end_turn"}`,
14+
}, "\n"))
15+
16+
events, err := ParseClaudeJSONL(path, "/tmp/repo")
17+
if err != nil {
18+
t.Fatalf("ParseClaudeJSONL: %v", err)
19+
}
20+
if len(events) != 1 || events[0].OutputTokens != 3 {
21+
t.Fatalf("events = %+v", events)
22+
}
23+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package attributionlocal
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
func ParseCodexJSONLFallback(path, workspaceRoot string) ([]LocalToolUsageEvent, error) {
11+
lines, err := os.ReadFile(path)
12+
if err != nil {
13+
return nil, err
14+
}
15+
16+
var sessionID string
17+
var events []LocalToolUsageEvent
18+
for _, raw := range strings.Split(string(lines), "\n") {
19+
raw = strings.TrimSpace(raw)
20+
if raw == "" {
21+
continue
22+
}
23+
24+
var row map[string]any
25+
if err := json.Unmarshal([]byte(raw), &row); err != nil {
26+
continue
27+
}
28+
29+
switch strings.TrimSpace(asString(row["type"])) {
30+
case "session_meta":
31+
payload, _ := row["payload"].(map[string]any)
32+
if filepath.Clean(asString(payload["cwd"])) == filepath.Clean(workspaceRoot) {
33+
sessionID = strings.TrimSpace(asString(payload["id"]))
34+
}
35+
case "event_msg":
36+
if sessionID == "" {
37+
continue
38+
}
39+
payload, _ := row["payload"].(map[string]any)
40+
if strings.TrimSpace(asString(payload["type"])) != "token_count" {
41+
continue
42+
}
43+
info, _ := payload["info"].(map[string]any)
44+
lastUsage, _ := info["last_token_usage"].(map[string]any)
45+
totalUsage, _ := info["total_token_usage"].(map[string]any)
46+
selected := lastUsage
47+
if len(selected) == 0 {
48+
selected = totalUsage
49+
}
50+
if len(selected) == 0 {
51+
continue
52+
}
53+
responseID := strings.TrimSpace(asString(payload["response_id"]))
54+
if responseID == "" {
55+
responseID = filepath.Base(path)
56+
}
57+
events = append(events, LocalToolUsageEvent{
58+
Tool: "codex",
59+
ToolSessionID: sessionID,
60+
ToolEventID: responseID,
61+
DedupeKey: "codex-jsonl:" + sessionID + ":" + responseID,
62+
UsageUnit: UsageUnitToken,
63+
RequestCount: 1,
64+
InputTokens: asInt64(selected["input_tokens"]),
65+
OutputTokens: asInt64(selected["output_tokens"]),
66+
CachedInputTokens: asInt64(selected["cached_input_tokens"]),
67+
ReasoningTokens: asInt64(selected["reasoning_output_tokens"]),
68+
RawSourcePath: path,
69+
RawPayload: payload,
70+
})
71+
}
72+
}
73+
74+
if len(events) == 0 {
75+
return nil, nil
76+
}
77+
return events, nil
78+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package attributionlocal
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestParseCodexJSONL_PrefersLastTokenUsage(t *testing.T) {
9+
t.Parallel()
10+
11+
path := writeFile(t, "codex.jsonl", strings.Join([]string{
12+
`{"type":"session_meta","payload":{"id":"sess-1","cwd":"/tmp/repo"}}`,
13+
`{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":10,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":120},"last_token_usage":{"input_tokens":7,"cached_input_tokens":1,"output_tokens":2,"reasoning_output_tokens":1,"total_tokens":9}}}}`,
14+
}, "\n"))
15+
16+
events, err := ParseCodexJSONLFallback(path, "/tmp/repo")
17+
if err != nil {
18+
t.Fatalf("ParseCodexJSONLFallback: %v", err)
19+
}
20+
if len(events) != 1 || events[0].InputTokens != 7 || events[0].OutputTokens != 2 {
21+
t.Fatalf("events = %+v", events)
22+
}
23+
}
24+
25+
func TestParseCodexJSONL_EmitsMultipleEventsPerFile(t *testing.T) {
26+
t.Parallel()
27+
28+
path := writeFile(t, "codex.jsonl", strings.Join([]string{
29+
`{"type":"session_meta","payload":{"id":"sess-1","cwd":"/tmp/repo"}}`,
30+
`{"type":"event_msg","payload":{"type":"token_count","response_id":"resp-1","info":{"last_token_usage":{"input_tokens":7,"cached_input_tokens":1,"output_tokens":2,"reasoning_output_tokens":1,"total_tokens":9}}}}`,
31+
`{"type":"event_msg","payload":{"type":"token_count","response_id":"resp-2","info":{"last_token_usage":{"input_tokens":9,"cached_input_tokens":2,"output_tokens":3,"reasoning_output_tokens":1,"total_tokens":12}}}}`,
32+
}, "\n"))
33+
34+
events, err := ParseCodexJSONLFallback(path, "/tmp/repo")
35+
if err != nil {
36+
t.Fatalf("ParseCodexJSONLFallback: %v", err)
37+
}
38+
if len(events) != 2 {
39+
t.Fatalf("event count = %d, want 2", len(events))
40+
}
41+
if events[0].DedupeKey == events[1].DedupeKey {
42+
t.Fatalf("dedupe keys should differ: %+v", events)
43+
}
44+
}

0 commit comments

Comments
 (0)