diff --git a/README.md b/README.md index 85c6a37..d8f387b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Installs into `$env:LOCALAPPDATA\cc-session\`; in interactive mode it asks wheth | `list` | Browse recent sessions (those that already used cc-session are marked `[refs]`) | `cc-session list -n 10 -p myproject` | | `read` | Full conversation with inline tool summaries | `cc-session read -max-lines 200` | | `context` | Compact injection format, including a session metadata header | `cc-session context ` | -| `inherit` | Paginated context inheritance (≤20K chars per page, progress tracked automatically, `-reset` starts over) | `cc-session inherit ` | +| `inherit` | Paginated context inheritance (≤28K chars per page, progress tracked automatically, `-reset` starts over) | `cc-session inherit ` | | `stats` | Character and token distribution plus compression ratio | `cc-session stats -no-tokens` | | `audit` | Sample the filtered-out content to confirm nothing important was dropped | `cc-session audit -n 10` | | `expand` | Expand the full input/result of a specific tool call | `cc-session expand uCVa` | diff --git a/README.zh-TW.md b/README.zh-TW.md index 3bd7df4..7d1798a 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -49,7 +49,7 @@ irm https://raw.githubusercontent.com/Mapleeeeeeeeeee/cc-session-reader/main/ins | `list` | 瀏覽最近的 session(用過 cc-session 的標 `[refs]`) | `cc-session list -n 10 -p myproject` | | `read` | 完整對話 + inline tool 摘要 | `cc-session read -max-lines 200` | | `context` | 精簡注入格式,含 session metadata header | `cc-session context ` | -| `inherit` | 分頁 context 繼承(每頁 ≤20K chars,自動追蹤進度,`-reset` 重來) | `cc-session inherit ` | +| `inherit` | 分頁 context 繼承(每頁 ≤28K chars,自動追蹤進度,`-reset` 重來) | `cc-session inherit ` | | `stats` | 字元與 token 分佈統計及壓縮比 | `cc-session stats -no-tokens` | | `audit` | 取樣被過濾的內容,確認沒漏掉重要資訊 | `cc-session audit -n 10` | | `expand` | 展開特定 tool call 的完整 input/result | `cc-session expand uCVa` | diff --git a/SKILL.md b/SKILL.md index 3a63847..5b34b80 100644 --- a/SKILL.md +++ b/SKILL.md @@ -26,7 +26,7 @@ allowed-tools: ## 讀取 session 內容 read 預設截斷在 200 行——大多數 session 遠超這個長度,只看得到開頭一小段。 -inherit 將完整 session 分頁載入(每頁 ≤20K chars),確保完整覆蓋。 +inherit 將完整 session 分頁載入(每頁 ≤28K chars),確保完整覆蓋。 讀 session 時用 inherit。只在使用者明確指名要看某段特定內容時用 read 搭配 `-offset` 跳讀。 @@ -49,6 +49,10 @@ inherit 記住讀取進度,重複呼叫同一個命令即自動翻頁: ## 輸出行為 +- 每則訊息的 header 只帶時鐘 `[HH:MM:SS]`,日期在換日時以獨立一行 `--- YYYY-MM-DD ---` 標示 +- 角色標籤三種:`user:` 是人打的、`assistant:` 是 Claude 的回應、`harness:` 是 Claude Code 自己塞進對話的訊息 + (stop hook 目標、背景任務完成通知、壓縮續接摘要、中斷標記等),不是使用者說的話 +- tool 摘要行只在失敗時標 `FAILED`,成功不另標記;Bash 行帶 `| <程式名 子命令>` 讓讀者知道實際跑了什麼 - 當 session 內有 `cc-session inherit/read/context` 呼叫時,連續的同 session 呼叫會被壓成一行: `(cc-session#Y1dg: inherited session 16d06326 here, 1320 lines omitted)` - 舊 session 裡的 `cc-session inject`(改名前的舊命令名)也會比照壓成一行,維持 `injected session X here` 的措辭 diff --git a/cmd/cc-session/benchmark_test.go b/cmd/cc-session/benchmark_test.go index 4e8e2bc..a698660 100644 --- a/cmd/cc-session/benchmark_test.go +++ b/cmd/cc-session/benchmark_test.go @@ -10,6 +10,7 @@ import ( "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/analyzer" bm "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/benchmark" + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/inject" "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/parser" ) @@ -600,6 +601,11 @@ func TestRunBenchmark_GivenFractionalK_ThenDerivesPromptFromFractionalCallsPerTu } func TestCountInjectPages_GivenBoundaryText_ThenMatchesInjectPagination(t *testing.T) { + // Each "x\n" pair costs 2 bytes, so half the limit in pairs fills a page + // exactly. Derived rather than written out so the table still describes the + // real boundary when inject.MaxPageBytes moves. + half := inject.MaxPageBytes / 2 + tests := []struct { name string fullText string @@ -607,12 +613,12 @@ func TestCountInjectPages_GivenBoundaryText_ThenMatchesInjectPagination(t *testi }{ {name: "empty", fullText: "", want: 0}, {name: "short single line", fullText: "abc", want: 1}, - {name: "single line at newline-adjusted limit", fullText: strings.Repeat("x", 19_999), want: 1}, - {name: "single line over newline-adjusted limit", fullText: strings.Repeat("x", 20_000), want: 1}, - {name: "two lines exactly at limit", fullText: strings.Repeat("x", 9_999) + "\n" + strings.Repeat("x", 9_999), want: 1}, - {name: "two lines one byte over limit", fullText: strings.Repeat("x", 9_999) + "\n" + strings.Repeat("x", 10_000), want: 2}, - {name: "many tiny lines exactly at limit", fullText: strings.Repeat("x\n", 10_000), want: 1}, - {name: "many tiny lines one line over limit", fullText: strings.Repeat("x\n", 10_001), want: 2}, + {name: "single line at newline-adjusted limit", fullText: strings.Repeat("x", inject.MaxPageBytes-1), want: 1}, + {name: "single line over newline-adjusted limit", fullText: strings.Repeat("x", inject.MaxPageBytes), want: 1}, + {name: "two lines exactly at limit", fullText: strings.Repeat("x", half-1) + "\n" + strings.Repeat("x", half-1), want: 1}, + {name: "two lines one byte over limit", fullText: strings.Repeat("x", half-1) + "\n" + strings.Repeat("x", half), want: 2}, + {name: "many tiny lines exactly at limit", fullText: strings.Repeat("x\n", half), want: 1}, + {name: "many tiny lines one line over limit", fullText: strings.Repeat("x\n", half+1), want: 2}, {name: "trailing newline is dropped", fullText: "line\n", want: 1}, } diff --git a/cmd/cc-session/e2e_test.go b/cmd/cc-session/e2e_test.go index 58a5726..d0f4b6d 100644 --- a/cmd/cc-session/e2e_test.go +++ b/cmd/cc-session/e2e_test.go @@ -58,9 +58,9 @@ func TestCLI_WhenSessionExists_ThenListReadContextAndAuditWorkEndToEnd(t *testin name: "read shows dialogue and tool summary with short ID", args: []string{"read", sid}, want: []string{ - "[05-28 00:00] user:\nhello", - "[05-28 00:00] assistant:\nhi", - "[Bash#ol-1] Echo ok -> ok: ok", + "[00:00:00] user:\nhello", + "[00:00:01] assistant:\nhi", + "[Bash#ol-1] Echo ok", }, }, { @@ -70,7 +70,7 @@ func TestCLI_WhenSessionExists_ThenListReadContextAndAuditWorkEndToEnd(t *testin "# Session 12345678 | proj | 1m", "U: hello", "A: hi", - "[Bash#ol-1] Echo ok -> ok: ok", + "[Bash#ol-1] Echo ok", }, }, { diff --git a/cmd/cc-session/main_test.go b/cmd/cc-session/main_test.go index bea90d5..a62276d 100644 --- a/cmd/cc-session/main_test.go +++ b/cmd/cc-session/main_test.go @@ -350,7 +350,7 @@ func TestRunRead_WhenSessionExists_ThenWritesOutput(t *testing.T) { if err != nil { t.Fatalf("runRead returned error: %v", err) } - if !strings.Contains(stdout.String(), "[05-28 00:00] user:\nhello") { + if !strings.Contains(stdout.String(), "[00:00:00] user:\nhello") { t.Fatalf("stdout missing read output:\n%s", stdout.String()) } } @@ -1144,18 +1144,19 @@ func TestRunRead_GivenLargeSession_WhenOffsetFlag_ThenSkipsLines(t *testing.T) { } var stdout, stderr bytes.Buffer // -max-lines 0 (unlimited) so we see whether offset works independently. - if err := runRead([]string{sid, "-offset", "3", "-max-lines", "0"}, &stdout, &stderr, store, testReader); err != nil { - t.Fatalf("runRead with -offset 3 returned error: %v", err) + // Offset 5 skips the two-line day marker plus the first message block + // (header, body, blank). + if err := runRead([]string{sid, "-offset", "5", "-max-lines", "0"}, &stdout, &stderr, store, testReader); err != nil { + t.Fatalf("runRead with -offset 5 returned error: %v", err) } got := stdout.String() - // With offset=3 the first message block (lines 0-2: header, body, blank) is - // skipped. "msgxxxxx 0" (message body on line 1) must be absent. + // "msgxxxxx 0" (message body) must be absent. if strings.Contains(got, "msgxxxxx 0") { - t.Fatalf("offset=3 must skip message 0 body; still present:\n%s", got) + t.Fatalf("offset=5 must skip message 0 body; still present:\n%s", got) } // Later messages must still appear. if !strings.Contains(got, "msgxxxxx 5") { - t.Fatalf("offset=3 output must include message 5:\n%s", got) + t.Fatalf("offset=5 output must include message 5:\n%s", got) } } diff --git a/docs/adr-007-format-changes-measured.md b/docs/adr-007-format-changes-measured.md new file mode 100644 index 0000000..01c4617 --- /dev/null +++ b/docs/adr-007-format-changes-measured.md @@ -0,0 +1,205 @@ +# ADR-007:五項輸出格式改動,量過才決定 + +**狀態**:已實作。量測程式在 `experiment/format-probes` 分支(不合併)。 + +實作後對同五個 session 重新量整份 read 輸出:656,370 → 658,278 token(**+0.3%**)。 +比決策時預估的 −0.4% 高,差額來自第 1 項:跳過雜訊行之後取到的下一行通常更長也更有內容 +(`=== engine exports ===` 22 字元變成 `32:export function createBaseGameSnapshot(): GameSnapshotV4 {` 57 字元)。 +那是這項改動要的效果,不是回歸。 + +取代 ADR-003 的三項決定:成功結果的摘錄取法、Bash 摘要的內容、成功狀態標記。 +其餘 ADR-003 決定不變。 + +## 五項決定 + +| # | 改動 | token 影響 | +|---|------|-----------:| +| 1 | 成功結果的摘錄套用與失敗路徑同一套雜訊過濾,並剝掉 ANSI | 內容變動,實測整體 +0.7% | +| 2 | Bash 摘要加上指令動詞(跳過鋪陳後的「程式 + 第一個參數」,上限 30 字元) | +1.9% | +| 3 | 成功不再標 `-> ok`,只標 `-> FAILED` | −2.0% | +| 4 | 訊息時間戳改成 `[HH:MM:SS]`,換日時插一行 `--- YYYY-MM-DD ---` | −0.4% | +| 5 | 分頁上限從 20,000 提到 28,000 bytes | 頁數 −27% | + +第 2 項和第 3 項綁在一起做,合計 **−0.0%**。 + +量測方法:`cc-session formatbench`,對 5 個 33K 到 389K token 的 session 渲染每個變體, +各送一次 Anthropic token counting API。表中是五個 session 的平均。 + +## 1. 成功結果的摘錄要過濾雜訊,但不改成取最後一行 + +`ToolResult.Summary()` 目前兩條路徑不對稱:失敗走 `firstMeaningfulErrorLine`, +會跳過 cat 行號、裸 `Exit code N`、hook 樣板;成功只走 `FirstLine`,取第一個非空行,零過濾。 + +後果是讀者拿到橫幅當結論: + +``` +[Bash#Gj9T] 查 PR #407 狀態與 CI -> ok: Work seamlessly with GitHub from the command line. +[Bash#XhoN] 檢查 agent 中斷後的工作區狀態 -> ok: --- HEAD --- +[Bash#tQvn] 跑新增的 archetype 測試 -> ok: \x1b[1m\x1b[46m RUN \x1b[49m\x1b[22m \x1b[36mv4.0.18 +``` + +第一行是 `gh` 的說明橫幅,讀的人會以為那就是 PR 狀態。第二行是腳本自己 echo 的區段標題。 + +掃 1,391 個成功且有輸出的 Bash 結果,**第一行有 31.9% 命中雜訊樣式**: + +| 樣式 | 佔比 | +|------|-----:| +| 腳本 echo 的區段標題(`=== x ===`、`--- x ---`) | 13.7% | +| 程式碼片段(兩格以上縮排開頭) | 8.4% | +| ANSI 色碼開頭 | 6.1% | +| 進度或狀態前綴(`[STARTED]`、`Checking `、`> `) | 3.3% | +| 版本橫幅 | 0.4% | +| 至少命中一種 | **31.9%** | + +改法是把成功路徑接上 `isNoiseExcerptLine`,補進上表的橫幅與進度樣式, +並在取摘錄前套 `session.StripANSI`。跳過雜訊行後取下一行,不是整段丟掉。 + +實作後在那個 1,258 個 Bash 呼叫的 session 上:292 行(23%)拿到更好的摘錄, +沒有任何一行失去摘錄,帶 ANSI 的行從 95 降到 0。 +23% 低於上表的 31.9%,因為實作的規則比量測時的樣式嚴格: +區段標題要頭尾同一種符號才算(`--- HEAD ---` 算,diff 的 `--- a/file.go` 不算), +縮排的程式碼片段不跳過(跳過只會換到另一段程式碼,還可能丟掉答案)。 + +一個抓不到的情況:純散文的說明橫幅沒有可辨識的形狀, +`gh` 的 `Work seamlessly with GitHub from the command line.` 過濾不掉。 +那個案例靠第 2 項緩解:同一行有 `| gh pr`,讀者看得出橫幅不是答案。 + +### 不改成取最後一行 + +抽樣 1,060 個成功的多行 Bash 結果,逐筆比對第一行與最後一行, +最後一行沒有系統性比較好:大量是 ` })`、diff 尾巴、程式碼片段。 + +``` +DESC 讀 VM 要改的區段 (44 lines) + 1st ' const snapshot = useMemo(' + last' transitionLockRef.current = true' +DESC 看剩餘兩處改動 (58 lines) + 1st 'diff --git a/src/features/nccuLifeSimulator/components/LifeSimulatorCo' + last' })' +``` + +位置換不出正確性,過濾才行。 + +## 2. Bash 摘要要帶指令動詞,而抽取方式決定它便不便宜 + +`summarizer.go` 目前有 description 就只印 description,command 整個丟掉。 +description 是當時那個 Claude 寫給人看的轉述,讀的人無法確認實際跑了什麼。 +Bash 佔範例 session 1,604 個 tool 呼叫裡的 1,258 個(78%), +等於這個 session 大部分的動作都不可驗證。 + +直接接上指令前綴很貴,因為預算會花在鋪陳上。實測 20 字元經常整段被吃掉: + +``` +[Bash#KPso] 更新 #423 內文 | cd /Users/maple/Desk +[Bash#6yGZ] 驗證兩處測試斷言 | echo "=== game-engin +``` + +改成掃過 `cd`、環境變數指派、`export`、`echo` 這些片段, +取第一個真正的程式名加它的第一個參數: + +``` +[Bash#TV5d] 用型別檢查找出所有斷掉的引用 | pnpm tsc: src/...(19,3): error TS24 +[Bash#DK5W] Force-push rebased bento branch | git push: To https://github.com/... +[Bash#MS6r] 看索引那一行 | grep -n: 71:- [教學中 Joyride overlay 攔截底部按鈕...] +``` + +動詞涵蓋率 1,261 行中 1,258 行(99.8%),成本減半: + +| 抽取方式與長度 | token | +|----------------|------:| +| 原始前綴 20 字元 | +3.7% | +| 原始前綴 60 字元 | +9.8% | +| 動詞抽取 20 字元 | +1.9% | +| 動詞抽取 30 字元 | +2.0% | + +30 字元和 20 字元幾乎同價,因為抽出來的動詞很少超過 20 字元。取 30,截斷少一點。 + +實作後 1,258 個 Bash 呼叫全部抽到動詞。除了 `cd`、環境變數、`echo`, +還要跳過 shell 控制關鍵字(`until`、`while`、`for`), +否則 `until gh pr checks` 會把 `until` 當成程式名(實測有 30 行)。 + +已知瑕疵,實作時一併處理:`/opt/homebrew/bin/gh`、`$HOME/.nvm/versions/` 這類 +絕對路徑程式名會吃掉預算,取 basename。 + +## 3. 成功不標記,只標失敗 + +範例 session 的 1,604 個 tool 摘要裡 1,550 個 ok、41 個 FAILED、13 個沒有結果。 +成功是預設狀況,每行講一遍等於對每一行課稅。其中 96 行是連摘錄都沒有的裸 `-> ok`。 + +保留 `-> FAILED`、成功不標,語意完全相同,省 2.0%。 + +`formatRetryCollapse` 靠 summary 字串裡的 `FAILED` 字樣插入 `×N`,要一起改。 + +成功行去掉標記後變成 `描述: 摘錄`。實作時保留 `->` 作為分隔 +(`描述 -> 摘錄`),成本差極小,但讀者一眼分得出哪裡是呼叫、哪裡是結果。 + +## 4. 時間戳改成時鐘含秒加換日標記 + +現行 `01-02 15:04` 每則訊息重複日期,而且沒有年份。 +`context` 還有 session header 可以推年份,`read` 沒有 header,完全沒有線索。 + +浪費的是每行重複的日期,不是時間本身。把日期抽成換日標記後, +省下來的空間足以換到秒精度,總計還是省的: + +| 方案 | 樣子 | token | +|------|------|------:| +| 現行 | `[08-06 03:06]` | — | +| 加年份 | `[2026-08-06 03:06]` | +0.6% | +| 加秒 | `[08-06 03:06:15]` | +0.4% | +| 時鐘 + 換日標記 | `[03:06]` 加 `--- 2026-08-06 ---` | −0.8% | +| **時鐘含秒 + 換日標記** | `[03:06:15]` 加 `--- 2026-08-06 ---` | **−0.4%** | +| 相對分鐘 | `[+83]` | −1.0% | + +選時鐘含秒:比現行多了秒和年份,還便宜 0.4%。 + +相對分鐘最便宜但不選:跟外部 log(CI、伺服器 log)對時間時要先做一次換算。 + +換日標記是必要的。實測 session 真的跨天:兩個樣本一個跨 3 天、一個跨 2 天。 +但很稀疏,1,808 個事件的 session 只觸發兩次。 + +``` +--- 2026-08-06 --- + +[03:06:15] user: +... +--- 2026-08-07 --- + +[02:08:31] user: +``` + +## 5. 分頁上限提到 28,000 bytes + +`maxPageBytes = 20_000` 的理由寫在 `internal/inject/inject.go`: +「怕超過就被 Claude Code 的 Bash tool 寫成檔案而不是回傳 stdout」。 + +二分實測那個門檻: + +| 輸出 bytes | 結果 | +|-----------:|------| +| 30,000 | 完整 inline 回傳 | +| 31,000 | 寫成檔案,只回 2KB 預覽 | +| 32,768 | 寫成檔案 | +| 40,000 | 寫成檔案 | +| 50,000 | 寫成檔案 | + +門檻是 30,000 字元。扣掉 page marker 與 footer 的空間,取 28,000。 +那個 871KB 的 session 從 44 頁降到 32 頁,往返少 27%。實測最大的一頁 28,073 bytes。 + +**這個常數綁在 harness 的行為上,不是 cc-session 自己說了算。** +只驗證了這一版 Claude Code,而該上限使用者可以調整。 +實作時把 30,000 這個實測值寫進常數旁的註解,日後 harness 改了才知道要重測。 + +## 尚未決定 + +跨 session 搜尋(`cc-session search`)確認要做,但介面與輸出格式還沒定, +另立 ADR。已確定的一點:比對要在渲染後的過濾文字上做, +不能 shell out 給 `grep`:那份文字是 renderer 在記憶體裡產生的,磁碟上不存在。 +`grep` 只搜得到原始 JSONL,那正是這個功能要取代的做法。 + +## 這些數字的界線 + +百分比來自 5 個 session 的平均,最小 33K token、最大 389K token。 +第 2、3 項的比例跟 Bash 呼叫佔比高度相關,Bash 少的 session 影響會小 +(樣本裡 630f4c64 的每一項都明顯低於其他四個)。 +第 1 項的 31.9% 來自兩個 session 的 1,391 筆結果。 +第 5 項的門檻只驗證了一版 harness。 diff --git a/docs/adr-008-harness-event-drift.md b/docs/adr-008-harness-event-drift.md new file mode 100644 index 0000000..568a038 --- /dev/null +++ b/docs/adr-008-harness-event-drift.md @@ -0,0 +1,237 @@ +# ADR-008:追上 harness 事件格式的漂移,並把 turn 計數從字元統計裡拆出來 + +**狀態**:已實作。 + +Reader 靠比對字面字串認出 Claude Code 寫進 transcript 的事件。那些字串會變,而且已經變過了。 +盤點最近 60 天的 120 個 transcript(35,940 筆 entry)發現三類落差,這份 ADR 記錄五項決定。 + +| # | 決定 | 影響 | +|---|------|------| +| 1 | `noiseTypes` 補上 8 個 entry type | 597 KB 從「沒被解析」變成 `system_noise` | +| 2 | 6 種 harness 注入的 user 訊息改在 parser 層分類 | 不再冒充使用者訊息 | +| 3 | 新增 `harness:` 角色標籤 | 讀的人分得出哪幾行是人打的 | +| 4 | teammate 偵測改成多重標記 | 主判斷已死,修好並讓下次改字只失效一個標記 | +| 5 | 拆出 `UserMessage.CountsAsTurn()`,內容依實測校準 | 八個 session 的 turn 計數誤差 70 → 29 | +| 6 | skill 注入改走 `sourceToolUseID` 結構連結認定 | 沒有 base-directory 行的 bundled skill 不再全文渲染 | + +量測基礎:`~/.claude/projects` 下依修改時間取最新 120 個 `.jsonl`(2026-08-30 往前 60 天)。 + +## 1. `noiseTypes` 漏了 8 個 CLI 後來才加的 entry type + +`noiseTypes` 是手寫的白名單,收 13 個型別。實測出現、不在名單裡的有 8 個: + +| type | 筆數 | 位元組 | +|---|---:|---:| +| `atis-latch` | 1,600 | 191,680 | +| `frame-link` | 664 | 96,167 | +| `worktree-state` | 285 | 115,708 | +| `file-history-delta` | 168 | 80,618 | +| `artifact-autoreact-ledger` | 159 | 80,936 | +| `artifact-comment-monitor` | 49 | 17,522 | +| `agent-setting` | 37 | 3,947 | +| `cost-state` | 14 | 10,608 | +| 合計 | 2,976 | 597,186 | + +這些型別都沒有 `message` 欄位,所以 `parseLineWithToolNames` 走到 +`raw.Message == nil` 那條路徑,型別不在白名單就 `return session.Event{}, false, nil`, +整行不產生 Event。 + +**輸出沒有錯**:不管認不認得,它們都不會出現在 filtered text 裡。 +錯的是統計:`EventNoise` 會把字元累進 `analyzer` 的 `system_noise`, +fall-through 不會,這 597 KB 在分類統計裡是隱形的。 + +決定:補進白名單。這是白名單設計的固有成本:每次 CLI 加型別就要補一次。 +沒有改成「反向白名單」(只列出要解析的型別,其餘一律 noise), +因為那會讓一個真的帶對話內容的新型別被靜默吃掉,那個失敗方向比統計少算嚴重得多。 + +`cost-state` 帶 `totalCostUSD` 和 `modelUsage`,正好是 benchmark 現在靠 API usage +逐筆重算的東西。這次只把它歸為 noise,沒有拿來用;要用是另一件事。 + +## 2. 6 種 harness 注入的 user 訊息完全沒分類 + +`classifyHarnessUserMessage` 認得 system-reminder、skill 注入、teammate、 +context usage、command 注入。實測還有 6 種沒認得: + +| 訊息開頭 | 則數 | 字元 | 平均 | +|---|---:|---:|---:| +| `` | 64 | 147,340 | 2,302 | +| `This session is being continued…` | 13 | 190,493 | 14,653 | +| `[Request interrupted by user` | 13 | 403 | 31 | +| `N background agents were stopped…` | 4 | 1,605 | 401 | +| `A session-scoped Stop hook is now active` | 2 | 1,034 | 517 | +| `Skill /X was loaded earlier…` | 2 | 357 | 178 | + +`` 是特例:formatter 認得它、會壓成 `[summary]`,但 parser 不認得。 +`classify.go` 開頭寫著「detection lives here in the parser layer so the formatter and +stats consumers branch on domain fields, never re-match tags」, +而 `render.go` 對原文再比對一次 `strings.Contains(text, "")`。 +這次把它移到 parser,formatter 改判 flag。 + +各自的 compact 形式,沿用現有的 `[kind: id]` 慣例: + +| 訊息 | 呈現 | 字元 | +|---|---|---:| +| Stop hook | `[goal] <條件>` | 517 → 50 | +| agents stopped | `[agents stopped: 7]` | 401 → 19 | +| Skill 重複載入 | `[skill: X] (repeat)`(走既有的 skill 路徑) | 178 → 33 | +| Request interrupted | `[interrupted]` | 31 → 13 | +| 壓縮續接 | `[compaction summary]` + 全文 | 14,653 → 14,320 | +| task-notification | 維持既有壓縮,只改角色標籤 | 不變 | + +**壓縮續接訊息的 body 全留**。那是前一段對話的摘要,正是繼承 context 的人要的東西; +只剝掉開頭三句 harness 框架和結尾的「read the full transcript at」指示。 + +**這不是為了省 token**。上表六種加起來 60 天只省約 7,300 字元, +攤到 120 個 session 是每個約 60 字元。價值在第 3 項和第 5 項。 + +## 3. 新增 `harness:` 角色標籤 + +改之前所有 user-role entry 都印成 `user:`,不管是人打的還是 harness 塞的。 +繼承這段 context 的 Claude 沒有辦法分辨。實測這個 session(`b11858cf`): +46 則標成 `user:`,其中 10 則不是人打的。 + +改之後 36 則 `user:` + 10 則 `harness:`,總數不變。 + +`context` 格式的前綴同步從 `U:` 分出 `H:`。 + +字要叫什麼考慮過 `system:` 和 `hook:`。選 `harness:` 是因為 +`system` 在 transcript 裡已經是一個 entry type(`system/compact_boundary` 等), +再借來當角色標籤會有兩個意思;`hook` 只涵蓋六種裡的一種。 + +## 4. teammate 的主判斷已經是死碼 + +```go +teammateWarning = "IMPORTANT: This is NOT from your user" +``` + +60 天內 133 則 teammate message,**這個字串命中 0 則**。 +harness 改寫成「This came from another Claude session — not typed by your user…」了。 + +現在還認得出來,純粹靠第二個 fallback 分支比對開場白 +`Another Claude session sent a message:`,那條 133/133 全中。 +也就是整個 teammate 偵測吊在單一條件上。開場白哪天再改一次, +teammate message 就會整個變成一般 user 訊息:全文渲染、樣板不砍。 +turn 數會剛好還是對的(一般訊息本來就算一個 turn),所以這個故障不會反映在 K 上。 + +決定:改成一組標記,開場白或任一版免責聲明命中即可,新舊聲明都留著。 +下次改字只會讓其中一個標記失效,不會讓偵測整個垮掉。 + +`CompactTeammateMessage` 裡剝除警告的那段同理也是死碼,但無害: +抽取迴圈只取 `` 標籤之間的內容,尾巴的警告本來就進不來。這次沒動它。 + +## 5. 一個 flag 同時回答了兩個不相干的問題 + +`ComputeStats` 的 user 分支同時做兩件事:分類字元(哪些被砍、哪些被留), +以及數 turn。分類用 `continue` 提早跳出,而 `userTurnCount++` 寫在迴圈末尾, +於是**每一個為了字元統計寫的 `continue`,都順手把 turn 也跳掉了**。 + +對 system-reminder、command 輸出是對的。對 teammate message 是錯的。 + +K = API call 數 ÷ turn 數,`cost.go` 用 `extraCallsPerTurn(K) = K − 1` 算 +「一輪之內除了首呼叫還要再打幾次」。所以 turn 的定義是 +**一個由外部 prompt 啟動、跑到 agent 停下為止的工作單位**。 +teammate message 啟動的正是這種單位。 + +決定性的理由不是「teammate message 像不像使用者」,是分子分母必須數同一群: +`apiCallCount` 對每一則 usage 不同的 assistant 訊息都加一,不管那一輪是誰觸發的, +teammate message 引發的 API call 全在分子裡。 +改之前的 K 是「全部的 API call ÷ 只有人類發起的 turn」。 + +`fc65aeeb` 是 165 次 call ÷ 4 個 turn = 41.2,模型於是認為每輪要重讀 prefix 40 次; +它實際有 11 則 teammate message。 + +決定:把政策收進 `UserMessage.CountsAsTurn()`,在字元統計的分支之前就決定完, +不再靠 `continue` 的副作用。 + +### 哪幾種算,用量的不用推的 + +第一版政策是推理出來的:teammate 算,其餘 harness 注入一律不算。 +那一版比不改還糟(見下面的校準表)。 + +改成量。對每一則 harness 訊息,往後掃到下一則真正的 user 訊息為止, +看中間有沒有出現帶 usage 的 assistant 訊息,也就是這則訊息有沒有把 agent 叫起來做事: + +| 種類 | 啟動一輪 | 沒有 | 比例 | +|---|---:|---:|---:| +| teammate | 128 | 8 | 94% | +| compaction 續接 | 12 | 0 | 100% | +| task-notification | 63 | 8 | 89% | +| stop hook | 2 | 0 | 100% | +| skill 重複載入 | 3 | 0 | 100% | +| interrupted | 0 | 14 | 0% | +| agents stopped | 0 | 4 | 0% | + +stop hook 和 skill 重複載入雖然 100%,但這個測試對它們無效: +它們是某個已經由別的 entry 啟動的 invocation 的尾巴(`/goal` 的 marker 在前兩筆), +測試分不出「這則啟動了工作」和「這則只是工作開始前的最後一筆」。 +teammate、compaction、task-notification 沒有這個問題,它們前面沒有同一輪的其他 user entry。 + +| 訊息種類 | 渲染 | 算 turn | +|---|---|---| +| system-reminder / context-usage | 丟棄 | 否 | +| command 輸出 / caveat | 丟棄 | 否 | +| skill / command 注入 | 壓縮 | 否(伴隨觸發它的使用者訊息出現,算了會重複) | +| stop hook / skill 重複載入 | 壓縮 | 否(同上) | +| interrupted / agents stopped | 壓縮 | 否(實測不啟動任何一輪) | +| teammate message | 壓縮 | **是** | +| task-notification | 壓縮 | **是** | +| 壓縮續接 | 保留 | **是** | +| 一般訊息 | 保留 | 是 | + +### 校準 + +真值取「agent 被叫起來做事的次數」:掃過 transcript, +每當一則帶 usage 的 assistant 訊息前面不是另一則 assistant,就是一輪的開始。 +八個 session 的累計絕對誤差: + +| 政策 | 誤差 | +|---|---:| +| 改動前 | 70 | +| 第一版(只加 teammate,其餘 harness 全不算) | 81 | +| 採用的版本 | **29** | + +真值本身也是啟發式的(`921fb399` 三種政策都 +12,`3017876d` 都 +9, +表示還有別的來源在多算),所以絕對數字不可當精確值,這裡看的是相對高低。 + +### 方向 + +取壓縮率相近(25–26%)的六個 session 看,cold cache 的 10-turn 節省隨 K 上升而下降 +(K 2.8 → 68%,K 8.6 → 59%),所以 K 高估會**低估**效益。 +同一批的 warm cache(54–57%)沒有趨勢。這是固定壓縮率區間的觀察,不是受控實驗。 + +## 6. skill 注入只認文字前綴,bundled skill 沒有那行 + +`classifyHarnessUserMessage` 認 skill 注入的條件是文字以 `Base directory for this skill:` 開頭。 +bundled skill(如 `artifact-design`)的注入沒有這一行,內文直接開始, +於是整份 skill 以 `user:` 全文渲染。60 天內 50 則,約佔 skill 注入的 12%, +單一則就是數 KB;讀 `b11858cf` 時它佔了輸出的一成。 + +原始 JSONL 裡這類 entry 帶 `isMeta: true` 和 `sourceToolUseID`,後者指回前一則 assistant 的 +`Skill` tool_use,而那個 tool_use 的 `input.skill` 就是 skill 名字。 +`isMeta` 單獨不能當標記:圖片佔位符、stop hook feedback 也帶它。 + +決定:reader 既有的 tool_use id 對照表順便記下 Skill 的 `skill` 與 `args`, +user 訊息先查這條連結,命中就是 skill 注入;文字前綴降為備援, +讓無狀態的 `ParseLine`(沒有對照表)維持原行為。 +對過真實 transcript,`input.skill` 與文字路徑從路徑抽出來的名字一致, +同一個 skill 走兩條路都能在 `seenSkills` 去重。 + +這是第 2 項同一種病的另一個實例:harness 早就給了結構欄位,reader 還在比對字串。 +teammate message 沒有這種欄位(頂層欄位與一般 user 訊息完全相同),所以第 4 項只能留在字串比對。 + +## 沒有解決的 + +**teammate 的 `` 變體沒認**。60 天內 1,112 則 teammate message 有 80 則 +用 `` 而不是 `` 包,目前全文渲染、算成一般 user turn。 +修法很小(兩個標籤都收),但 teammate 偵測整體要不要改成只認標籤、不看散文, +一起留到下次決定。 + +**slash / bang command 不算 turn**,維持改動前的行為。 +`/goal` 這類 invocation 會觸發一整輪工作,照第 5 項的定義應該算; +但它在 transcript 裡是三筆 entry(`` marker、`` 注入、 +可能還有 skill body),要決定哪一筆代表那個 turn 才不會重複計算。 +`!` 開頭的 bang command 又不觸發 API 回合,不能跟 slash 一起處理。 +這是另一個決定,不混進這次。 + +**`cost-state` 沒有拿來用**。它帶著 `totalCostUSD` 和 `modelUsage`, +benchmark 現在是靠 API usage 逐筆重算的。要不要改用它是另一件事。 diff --git a/internal/analyzer/audit_test.go b/internal/analyzer/audit_test.go index 48f2f78..9e08342 100644 --- a/internal/analyzer/audit_test.go +++ b/internal/analyzer/audit_test.go @@ -90,12 +90,12 @@ func TestComputeAudit_GivenSuccessfulToolResults_ThenBucketedByToolFamily(t *tes wantBucket string wantKept string }{ - {"Read collapses to bare ok", session.ToolRead, BucketSuccessReadFile, " -> ok"}, - {"Write collapses to bare ok", session.ToolWrite, BucketSuccessReadFile, " -> ok"}, - {"Edit collapses to bare ok", session.ToolEdit, BucketSuccessReadFile, " -> ok"}, - {"Agent collapses to bare ok", session.ToolAgent, BucketSuccessAgent, " -> ok"}, - {"Bash keeps a first-line excerpt", session.ToolBash, BucketSuccessBash, " -> ok: " + firstLine}, - {"Grep (other) keeps a first-line excerpt", "Grep", BucketSuccessOther, " -> ok: " + firstLine}, + {"Read collapses to bare ok", session.ToolRead, BucketSuccessReadFile, ""}, + {"Write collapses to bare ok", session.ToolWrite, BucketSuccessReadFile, ""}, + {"Edit collapses to bare ok", session.ToolEdit, BucketSuccessReadFile, ""}, + {"Agent collapses to bare ok", session.ToolAgent, BucketSuccessAgent, ""}, + {"Bash keeps a first-line excerpt", session.ToolBash, BucketSuccessBash, " -> " + firstLine}, + {"Grep (other) keeps a first-line excerpt", "Grep", BucketSuccessOther, " -> " + firstLine}, } for _, tt := range tests { diff --git a/internal/analyzer/render_test.go b/internal/analyzer/render_test.go index a40b4ee..d898061 100644 --- a/internal/analyzer/render_test.go +++ b/internal/analyzer/render_test.go @@ -111,7 +111,7 @@ func TestRenderStats_GivenFullData_WhenRendered_ThenCharactersSectionPresent(t * assertOutputContains(t, body, "=== Characters ===") assertOutputLineContaining(t, body, "Raw:", "1,000") // RawChars anchored to label assertOutputLineContaining(t, body, "Filtered:", "600") // FilteredChars anchored to label - assertOutputContains(t, body, "40.0%") // (1000-600)/1000 = 40% + assertOutputContains(t, body, "40.0%") // (1000-600)/1000 = 40% } // --- RenderStats: Breakdown section --- diff --git a/internal/analyzer/stats.go b/internal/analyzer/stats.go index 16c93ef..9b3b320 100644 --- a/internal/analyzer/stats.go +++ b/internal/analyzer/stats.go @@ -77,6 +77,13 @@ func ComputeStats(events []session.Event) StatsResult { if event.User == nil { continue } + // Counted before the character-accounting branches below, not by + // falling through them: each of those ends in `continue`, and a + // turn counter at the end of the loop inherits every one of those + // early exits (ADR-008). + if event.User.CountsAsTurn() { + userTurnCount++ + } // Command invocation marker: cheap and identical in both raw and // filtered streams, so it contributes no reduction here. Its KEPT // weight is measured below via the render pass, not here. @@ -104,15 +111,17 @@ func ComputeStats(events []session.Event) StatsResult { rawParts = append(rawParts, event.User.Text) continue } - // Skill/teammate/command injections are compacted rather than - // dropped; their KEPT (compacted) size is measured below via the - // render pass. Only the raw side is recorded here. - if event.User.IsSkillInjection || event.User.IsTeammateMessage || event.User.IsCommandInjection { + // Harness injections that are compacted rather than dropped; + // their KEPT (compacted) size is measured below via the render + // pass. Only the raw side is recorded here. + if event.User.IsSkillInjection || event.User.IsTeammateMessage || + event.User.IsCommandInjection || event.User.IsTaskNotification || + event.User.IsCompactionSummary || event.User.IsStopHookGoal || + event.User.IsAgentsStopped || event.User.IsInterrupted { rawParts = append(rawParts, event.User.Text) continue } - userTurnCount++ rawParts = append(rawParts, event.User.Text) case session.EventAssistantMessage: diff --git a/internal/analyzer/stats_test.go b/internal/analyzer/stats_test.go index ed75a9e..e985328 100644 --- a/internal/analyzer/stats_test.go +++ b/internal/analyzer/stats_test.go @@ -52,7 +52,7 @@ func TestComputeStats_SeparatesRawFromFilteredByContent(t *testing.T) { assertContains(t, "FilteredText", result.FilteredText, "hello user") assertContains(t, "FilteredText", result.FilteredText, "hi there") assertContains(t, "FilteredText", result.FilteredText, "[Bash] Echo ok") - assertContains(t, "FilteredText", result.FilteredText, " -> ok: tool-result-body") + assertContains(t, "FilteredText", result.FilteredText, " -> tool-result-body") // Verbose raw content must NOT leak into the filtered stream. assertNotContains(t, "FilteredText", result.FilteredText, "sys-noise-body") assertNotContains(t, "FilteredText", result.FilteredText, `"command"`) @@ -100,11 +100,11 @@ func TestComputeStats_CountsCharsForSingleUserMessage(t *testing.T) { t.Fatalf("RawText = %q, want %q", result.RawText, message) } - // "??-?? ??:??" is the placeholder parser.FormatTimestamp("") returns for a + // "??:??:??" is the placeholder the timestamp writer emits for a // missing timestamp; that contract is pinned independently by // parser.TestFormatTimestamp, so it is hardcoded here rather than // re-invoking the SUT to build its own expected value. - wantFiltered := fmt.Sprintf("[%s] user:\n%s\n\n", "??-?? ??:??", message) + wantFiltered := fmt.Sprintf("[%s] user:\n%s\n\n", "??:??:??", message) if result.FilteredText != wantFiltered { t.Fatalf("FilteredText = %q, want %q", result.FilteredText, wantFiltered) } diff --git a/internal/claudecodec/classify.go b/internal/claudecodec/classify.go index 9c98828..9bb32bf 100644 --- a/internal/claudecodec/classify.go +++ b/internal/claudecodec/classify.go @@ -1,6 +1,8 @@ package claudecodec import ( + "regexp" + "strconv" "strings" "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" @@ -31,13 +33,32 @@ const ( skillInjectionPrefix = "Base directory for this skill:" systemReminderOpen = "" teammateOpen = "= 0 { - firstLine := rest[:nl] - if len(rest) > nl+1 { - return session.Truncate(firstLine, 120) + "..." + return formatSkillArgsPreview(strings.TrimSpace(text[idx+len(skillArgsPrefix):])) +} + +// skillArgsPreviewMaxRunes caps a skill's args at one line for the compact +// "[skill: name] args" rendering. +const skillArgsPreviewMaxRunes = 120 + +// formatSkillArgsPreview truncates raw skill args to a single line, shared by +// the text-prefix path's "ARGUMENTS: ..." line and the Skill tool_use's +// "args" input — both carry the same value observed in real transcripts, so +// one truncation rule keeps their rendering identical regardless of which +// path classified the injection. +func formatSkillArgsPreview(raw string) string { + if nl := strings.Index(raw, "\n"); nl >= 0 { + firstLine := raw[:nl] + if len(raw) > nl+1 { + return session.Truncate(firstLine, skillArgsPreviewMaxRunes) + "..." } - return session.Truncate(firstLine, 120) + return session.Truncate(firstLine, skillArgsPreviewMaxRunes) } - return session.Truncate(rest, 120) + return session.Truncate(raw, skillArgsPreviewMaxRunes) } // extractBetween returns the substring between the first openTag and the next diff --git a/internal/claudecodec/harness_classify_test.go b/internal/claudecodec/harness_classify_test.go new file mode 100644 index 0000000..1f94286 --- /dev/null +++ b/internal/claudecodec/harness_classify_test.go @@ -0,0 +1,172 @@ +package claudecodec + +import ( + "testing" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" +) + +// ADR-008: these six shapes reached the formatter unclassified, so they were +// rendered verbatim under a "user" label and counted toward K. The bodies here +// are the real harness wording observed in transcripts from the 60 days before +// 2026-08-30. + +func TestClassifyHarnessUserMessage_GivenHarnessInjection_WhenClassified_ThenSetsItsDomainField(t *testing.T) { + tests := map[string]struct { + text string + check func(*testing.T, *session.UserMessage) + }{ + "a background-task report is a task notification": { + text: "\nbxw75arip\n" + + "Background command \"Run both benchmarks\" completed (exit code 0)\n" + + "", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsTaskNotification { + t.Error("IsTaskNotification = false, want true") + } + }, + }, + "a continuation prompt is a compaction summary": { + text: "This session is being continued from a previous conversation that ran " + + "out of context.\n\nSummary:\n1. Primary Request and Intent:", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsCompactionSummary { + t.Error("IsCompactionSummary = false, want true") + } + }, + }, + "an interruption sentinel is marked interrupted": { + text: "[Request interrupted by user]", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsInterrupted { + t.Error("IsInterrupted = false, want true") + } + }, + }, + "the tool-use variant of the sentinel is too": { + text: "[Request interrupted by user for tool use]", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsInterrupted { + t.Error("IsInterrupted = false, want true") + } + }, + }, + "an agents-stopped notice carries the count": { + text: `7 background agents were stopped by the user: "工作區:` + + "`/Users/maple/Desktop/nccu-toolkit/.claude/wor...\".", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsAgentsStopped { + t.Fatal("IsAgentsStopped = false, want true") + } + if got.StoppedAgentCount != 7 { + t.Errorf("StoppedAgentCount = %d, want 7", got.StoppedAgentCount) + } + }, + }, + "a stop hook notice carries the goal condition": { + text: `A session-scoped Stop hook is now active with condition: "開一個 branch ` + + `測試,把資料結構改為用 XML 的形式呈現". Briefly acknowledge the goal, then ` + + "immediately start working toward it.", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsStopHookGoal { + t.Fatal("IsStopHookGoal = false, want true") + } + want := "開一個 branch 測試,把資料結構改為用 XML 的形式呈現" + if got.GoalCondition != want { + t.Errorf("GoalCondition = %q, want %q", got.GoalCondition, want) + } + }, + }, + "a skill re-invocation notice reuses the skill-injection path": { + text: "Skill /artifact-design was loaded earlier (see the invoked-skills " + + "reminder above); this is a NEW invocation — follow those instructions now.", + check: func(t *testing.T, got *session.UserMessage) { + if !got.IsSkillInjection { + t.Fatal("IsSkillInjection = false, want true") + } + if got.SkillName != "artifact-design" { + t.Errorf("SkillName = %q, want %q", got.SkillName, "artifact-design") + } + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := classifyHarnessUserMessage(tc.text) + if got == nil { + t.Fatalf("classifyHarnessUserMessage(%q) = nil, want a classified message", tc.text) + } + tc.check(t, got) + }) + } +} + +// The first disclaimer wording matched 0 of the 133 teammate messages observed +// in the 60 days before 2026-08-30 — the harness had reworded it, and detection +// survived only because the opening line happened to match every one. Each +// marker must classify on its own so a single rewording cannot break detection. +func TestClassifyHarnessUserMessage_GivenTeammateMarkerVariant_WhenClassified_ThenStillDetectsIt(t *testing.T) { + const block = "\nRebase 完成。\n" + + tests := map[string]string{ + "the opening line alone": "Another Claude session sent a message:\n" + block, + "the current disclaimer alone": block + + "\n\nThis came from another Claude session — not typed by your user.", + "the superseded disclaimer alone": block + + "\n\nIMPORTANT: This is NOT from your user, but from another Claude session.", + } + + for name, text := range tests { + t.Run(name, func(t *testing.T) { + got := classifyHarnessUserMessage(text) + if got == nil || !got.IsTeammateMessage { + t.Errorf("classifyHarnessUserMessage() did not classify a teammate message: %+v", got) + } + }) + } +} + +// A message that merely quotes harness wording mid-body is a real user message. +func TestClassifyHarnessUserMessage_GivenPlainMessage_WhenClassified_ThenReturnsNil(t *testing.T) { + tests := map[string]string{ + "a typed question": "為什麼 K 會被高估?", + "a quoted notice inside a real message": "我看到 [Request interrupted by user] 之後就沒反應了,為什麼?", + } + + for name, text := range tests { + t.Run(name, func(t *testing.T) { + if got := classifyHarnessUserMessage(text); got != nil { + t.Errorf("classifyHarnessUserMessage(%q) = %+v, want nil", text, got) + } + }) + } +} + +// These entry types were added to Claude Code after the original noise list. +// Without an entry they fell through as unparsed rather than as EventNoise, +// which left their bytes out of the analyzer's system_noise accounting. +func TestParseLine_GivenRecentlyAddedEntryType_WhenParsed_ThenYieldsNoise(t *testing.T) { + types := []string{ + "atis-latch", "frame-link", "worktree-state", "file-history-delta", + "artifact-autoreact-ledger", "artifact-comment-monitor", + "agent-setting", "cost-state", + } + + for _, entryType := range types { + t.Run(entryType, func(t *testing.T) { + line := []byte(`{"type":"` + entryType + `","sessionId":"abc","timestamp":"2026-08-30T13:00:00Z"}`) + + event, ok, err := ParseLine(line) + if err != nil { + t.Fatalf("ParseLine returned error: %v", err) + } + if !ok { + t.Fatalf("ParseLine dropped %q instead of yielding noise", entryType) + } + if event.Kind != session.EventNoise { + t.Errorf("Kind = %q, want %q", event.Kind, session.EventNoise) + } + }) + } +} diff --git a/internal/claudecodec/model.go b/internal/claudecodec/model.go index a103a4a..ff409ef 100644 --- a/internal/claudecodec/model.go +++ b/internal/claudecodec/model.go @@ -21,6 +21,15 @@ type rawEntry struct { Message *rawMessage `json:"message"` ToolUseResult json.RawMessage `json:"toolUseResult"` Cwd string `json:"cwd"` + + // IsMeta and SourceToolUseID identify a harness-injected user entry that + // is the tail of an earlier tool_use block (e.g. a skill body injection + // following a Skill tool call), correlated by tool_use_id. Not every + // isMeta entry is a skill injection — image placeholders and stop-hook + // feedback also carry it — so the link must be resolved against the + // preceding tool_use's name, not treated as a marker on its own. + IsMeta bool `json:"isMeta"` + SourceToolUseID string `json:"sourceToolUseID"` } type rawMessage struct { @@ -142,12 +151,12 @@ func cleanCwdPaths(text string, cwd string) string { } // toToolResult builds a ToolResult from the entry's toolUseResult/tool_result -// block. toolNames is the tool_use_id -> tool name map accumulated by the -// caller's sequential read (nil when called from the stateless public +// block. toolCalls is the tool_use_id -> tool call info map accumulated by +// the caller's sequential read (nil when called from the stateless public // ParseLine): real transcripts carry no commandName/agentType field on // Bash/Edit/Write/Read results, so name falls back to the map, which is // populated from the preceding assistant tool_use block's declared name. -func (e rawEntry) toToolResult(toolNames map[string]string) session.ToolResult { +func (e rawEntry) toToolResult(toolCalls map[string]toolCallInfo) session.ToolResult { var result rawToolUseResult if len(e.ToolUseResult) > 0 { _ = json.Unmarshal(e.ToolUseResult, &result) @@ -158,7 +167,7 @@ func (e rawEntry) toToolResult(toolNames map[string]string) session.ToolResult { name = result.AgentType } if name == "" { - name = toolNames[toolUseID] + name = toolCalls[toolUseID].Name } cleanText := cleanCwdPaths(text, e.Cwd) success := determineSuccess(result.Success, isError, cleanText) diff --git a/internal/claudecodec/reader.go b/internal/claudecodec/reader.go index 7bbd1f6..8cf6f05 100644 --- a/internal/claudecodec/reader.go +++ b/internal/claudecodec/reader.go @@ -32,6 +32,21 @@ var noiseTypes = map[string]bool{ "queue-operation": true, "progress": true, "system": true, + + // Added by Claude Code after the original list; observed in 120 + // transcripts from the 60 days before 2026-08-30 (2,976 entries, 597 KB). + // Listing them changes no output: they carry no "message" field, so + // parseLine drops them either way. It changes accounting, because an + // unlisted type falls through unparsed instead of becoming EventNoise, + // leaving its bytes out of the analyzer's system_noise bucket. + "atis-latch": true, + "frame-link": true, + "worktree-state": true, + "file-history-delta": true, + "artifact-autoreact-ledger": true, + "artifact-comment-monitor": true, + "agent-setting": true, + "cost-state": true, } func ReadFile(path string, handle func(session.Event) error) error { @@ -41,10 +56,10 @@ func ReadFile(path string, handle func(session.Event) error) error { } defer f.Close() - // toolNames accumulates tool_use_id -> tool name across the sequential - // read, scoped to this file. See parseLineWithToolNames for why this - // state can't live inside the stateless public ParseLine. - toolNames := map[string]string{} + // toolCalls accumulates tool_use_id -> tool call info across the + // sequential read, scoped to this file. See parseLineWithToolNames for why + // this state can't live inside the stateless public ParseLine. + toolCalls := map[string]toolCallInfo{} reader := bufio.NewReader(f) for { line, readErr := reader.ReadBytes('\n') @@ -60,7 +75,7 @@ func ReadFile(path string, handle func(session.Event) error) error { } continue } - event, ok, parseErr := parseLineWithToolNames(line, toolNames) + event, ok, parseErr := parseLineWithToolNames(line, toolCalls) if parseErr != nil { return parseErr } @@ -89,16 +104,29 @@ func ParseLine(line []byte) (session.Event, bool, error) { return parseLineWithToolNames(line, nil) } +// toolCallInfo is the per-tool_use state ReadFile threads across the +// sequential read, correlated to later entries by tool_use_id: the tool's +// name (for tool_result RawName resolution, see toToolResult) and, for a +// Skill invocation, the skill/args from its input (for classifying the +// isMeta entry that injects the skill body, see classifySkillInjectionByLink). +type toolCallInfo struct { + Name string + Skill string + SkillArgs string +} + // parseLineWithToolNames is ParseLine's implementation, extended with the -// tool_use_id -> tool name state ReadFile accumulates across a sequential -// read. Real transcripts carry no commandName/agentType field on +// tool_use_id -> tool call info state ReadFile accumulates across a +// sequential read. Real transcripts carry no commandName/agentType field on // Bash/Edit/Write/Read toolUseResults — the tool name only exists on the // preceding assistant tool_use block, correlated by tool_use_id — so a // tool_result's name/DiffStat can only be resolved with that cross-line -// state. ParseLine keeps its public, stateless single-line contract by -// passing toolNames=nil, under which name resolution falls back to -// commandName/agentType only, same as before this fix. -func parseLineWithToolNames(line []byte, toolNames map[string]string) (session.Event, bool, error) { +// state. The same correlation resolves a skill-body injection that carries +// no "Base directory for this skill:" line (see classifySkillInjectionByLink). +// ParseLine keeps its public, stateless single-line contract by passing +// toolCalls=nil, under which both fall back to their text-based paths only, +// same as before this fix. +func parseLineWithToolNames(line []byte, toolCalls map[string]toolCallInfo) (session.Event, bool, error) { var raw rawEntry if err := json.Unmarshal(line, &raw); err != nil { return session.Event{}, false, fmt.Errorf("parse transcript line: %w", err) @@ -129,7 +157,7 @@ func parseLineWithToolNames(line []byte, toolNames map[string]string) (session.E } if len(raw.ToolUseResult) > 0 { - toolResult := raw.toToolResult(toolNames) + toolResult := raw.toToolResult(toolCalls) event.Kind = session.EventToolResult event.Tool = &toolResult if answer := extractUserAnswer(raw.Message.Blocks); answer != "" { @@ -145,7 +173,9 @@ func parseLineWithToolNames(line []byte, toolNames map[string]string) (session.E return session.Event{}, false, nil } event.Kind = session.EventUserMessage - if classified := classifyCommandUserMessage(text); classified != nil { + if classified := classifySkillInjectionByLink(text, raw.IsMeta, raw.SourceToolUseID, toolCalls); classified != nil { + event.User = classified + } else if classified := classifyCommandUserMessage(text); classified != nil { event.User = classified } else if classified := classifyHarnessUserMessage(text); classified != nil { event.User = classified @@ -161,9 +191,14 @@ func parseLineWithToolNames(line []byte, toolNames map[string]string) (session.E for i := range assistant.ToolUses { assistant.ToolUses[i].Cwd = raw.Cwd } - if toolNames != nil { + if toolCalls != nil { for _, toolUse := range assistant.ToolUses { - toolNames[toolUse.ID] = toolUse.Name + info := toolCallInfo{Name: toolUse.Name} + if toolUse.Name == session.ToolSkill { + info.Skill = toolUse.Input.String("skill") + info.SkillArgs = toolUse.Input.String("args") + } + toolCalls[toolUse.ID] = info } } event.Kind = session.EventAssistantMessage diff --git a/internal/claudecodec/reader_test.go b/internal/claudecodec/reader_test.go index 025ad27..97f0bc5 100644 --- a/internal/claudecodec/reader_test.go +++ b/internal/claudecodec/reader_test.go @@ -329,7 +329,7 @@ func TestReadAll_GivenEditToolResultWithoutCommandName_ThenRawNameResolvedFromPr if tool.RawName != session.ToolEdit { t.Fatalf("RawName = %q, want %q (resolved from preceding tool_use)", tool.RawName, session.ToolEdit) } - wantSummary := " -> ok (+2, -1 @ L10)" + wantSummary := " -> (+2, -1 @ L10)" if got := tool.Summary(); got != wantSummary { t.Fatalf("Summary() = %q, want %q", got, wantSummary) } @@ -369,7 +369,7 @@ func TestReadAll_GivenReadToolResultWithoutCommandName_ThenBareOkSuppressionAppl if tool.RawName != session.ToolRead { t.Fatalf("RawName = %q, want %q (resolved from preceding tool_use)", tool.RawName, session.ToolRead) } - wantSummary := " -> ok" + wantSummary := "" if got := tool.Summary(); got != wantSummary { t.Fatalf("Summary() = %q, want %q (boilerplate body content must be suppressed)", got, wantSummary) } diff --git a/internal/claudecodec/skill_injection_link_test.go b/internal/claudecodec/skill_injection_link_test.go new file mode 100644 index 0000000..6061e42 --- /dev/null +++ b/internal/claudecodec/skill_injection_link_test.go @@ -0,0 +1,125 @@ +package claudecodec + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Regression: bundled skills such as artifact-design inject their body with +// no "Base directory for this skill:" line — the text starts directly with +// the skill's own prose — so classifyHarnessUserMessage's text-prefix path +// never classified them, and the injection rendered as a full "user:" +// message. The harness instead links the injection to its Skill tool_use via +// isMeta/sourceToolUseID, which ReadFile can resolve across lines. +func TestReadAll_GivenSkillInjectionWithoutBaseDirectoryLine_ThenClassifiedViaSourceToolUseLink(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + lines := []string{ + `{"type":"assistant","timestamp":"2026-08-30T00:00:00Z","message":{"role":"assistant","content":[` + + `{"type":"tool_use","id":"toolu_skill1","name":"Skill","input":{"skill":"artifact-design"}}` + + `]}}`, + `{"type":"user","timestamp":"2026-08-30T00:00:01Z","isMeta":true,"sourceToolUseID":"toolu_skill1",` + + `"message":{"role":"user","content":"Approach this as the design lead at a small studio..."}}`, + "", + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + events, err := ReadAll(path) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + if len(events) != 2 || events[1].User == nil { + t.Fatalf("events = %#v, want an assistant event followed by a user event", events) + } + + user := events[1].User + if !user.IsSkillInjection { + t.Fatalf("IsSkillInjection = false, want true (linked via sourceToolUseID)") + } + if user.SkillName != "artifact-design" { + t.Errorf("SkillName = %q, want %q", user.SkillName, "artifact-design") + } +} + +// A skill invoked with ARGUMENTS carries them on the Skill tool_use's "args" +// input, not in the injected body text, so the link path must read args from +// there rather than leaving SkillArgs empty. +func TestReadAll_GivenSkillInjectionWithArgsViaLink_ThenSkillArgsPopulated(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + lines := []string{ + `{"type":"assistant","timestamp":"2026-08-30T00:00:00Z","message":{"role":"assistant","content":[` + + `{"type":"tool_use","id":"toolu_skill2","name":"Skill","input":{"skill":"pm","args":"build login page"}}` + + `]}}`, + `{"type":"user","timestamp":"2026-08-30T00:00:01Z","isMeta":true,"sourceToolUseID":"toolu_skill2",` + + `"message":{"role":"user","content":"Approach this as a PM..."}}`, + "", + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + events, err := ReadAll(path) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + if len(events) != 2 || events[1].User == nil { + t.Fatalf("events = %#v, want an assistant event followed by a user event", events) + } + + user := events[1].User + if user.SkillArgs != "build login page" { + t.Errorf("SkillArgs = %q, want %q", user.SkillArgs, "build login page") + } +} + +// isMeta alone is not a skill marker — image placeholders and stop-hook +// feedback also carry it. A sourceToolUseID pointing at a non-Skill tool_use +// (or at nothing) must fall through to ordinary classification instead of +// being misclassified as a skill injection. +func TestReadAll_GivenIsMetaEntryNotLinkedToSkillToolUse_ThenNotClassifiedAsSkillInjection(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + lines := []string{ + `{"type":"assistant","timestamp":"2026-08-30T00:00:00Z","message":{"role":"assistant","content":[` + + `{"type":"tool_use","id":"toolu_read1","name":"Read","input":{"file_path":"/repo/README.md"}}` + + `]}}`, + `{"type":"user","timestamp":"2026-08-30T00:00:01Z","isMeta":true,"sourceToolUseID":"toolu_read1",` + + `"message":{"role":"user","content":"[Image #1]"}}`, + "", + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + events, err := ReadAll(path) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + if len(events) != 2 || events[1].User == nil { + t.Fatalf("events = %#v, want an assistant event followed by a user event", events) + } + + if user := events[1].User; user.IsSkillInjection { + t.Errorf("IsSkillInjection = true, want false (sourceToolUseID resolves to a Read, not a Skill, tool_use)") + } +} + +// The stateless public ParseLine has no cross-line state to resolve +// sourceToolUseID against, so an isMeta entry with no "Base directory for +// this skill:" line falls through unclassified there — same behavior as +// before this fix. This pins that contract rather than silently changing it. +func TestParseLine_GivenSkillInjectionLinkWithoutBaseDirectoryLine_ThenNotClassified(t *testing.T) { + event := parseLine(t, `{"type":"user","isMeta":true,"sourceToolUseID":"toolu_skill1",`+ + `"message":{"role":"user","content":"Approach this as the design lead..."}}`) + if event.User == nil { + t.Fatal("event.User = nil") + } + if event.User.IsSkillInjection { + t.Error("IsSkillInjection = true, want false (ParseLine has no cross-line state to resolve the link)") + } + if event.User.Text != "Approach this as the design lead..." { + t.Errorf("Text = %q, want the raw body preserved as a plain message", event.User.Text) + } +} diff --git a/internal/formatter/collapse_test.go b/internal/formatter/collapse_test.go index 87ad3bd..e742a73 100644 --- a/internal/formatter/collapse_test.go +++ b/internal/formatter/collapse_test.go @@ -99,7 +99,7 @@ func TestFormatRead_GivenConsecutiveFailedRetriesWithSameCommandAndSameError_The } got := out.String() - want := "[Bash#ol-3] Run tests -> FAILED ×3: Error: connection refused" + want := "[Bash#ol-3] Run tests | npm test -> FAILED ×3: Error: connection refused" if !strings.Contains(got, want) { t.Fatalf("expected collapsed retry-loop line\nwant substring: %q\ngot:\n%s", want, got) } @@ -154,7 +154,7 @@ func TestFormatRead_GivenSingleFailedBashCall_ThenDoesNotShowMultiplier(t *testi } got := out.String() - want := "[Bash#ol-1] Run tests -> FAILED: Error: 1 test failed" + want := "[Bash#ol-1] Run tests | npm test -> FAILED: Error: 1 test failed" if !strings.Contains(got, want) { t.Fatalf("expected uncollapsed single failure line\nwant substring: %q\ngot:\n%s", want, got) } @@ -284,7 +284,7 @@ func TestFormatRead_GivenRetryCommandExtendedWithTrailingArgs_ThenStillCollapses } got := out.String() - want := "[Bash#ol-2] Run tests -> FAILED ×2: Error: connection refused" + want := "[Bash#ol-2] Run tests | npm test -> FAILED ×2: Error: connection refused" if !strings.Contains(got, want) { t.Fatalf("expected collapsed retry-loop line\nwant substring: %q\ngot:\n%s", want, got) } @@ -369,7 +369,7 @@ func TestFormatRead_GivenConsecutiveReadsOfSameFile_ThenCollapsesIntoReadCountLi } got := out.String() - want := "[Read#ol-3 ×3] src/main.go -> ok" + want := "[Read#ol-3 ×3] src/main.go" if !strings.Contains(got, want) { t.Fatalf("expected collapsed same-file-read line\nwant substring: %q\ngot:\n%s", want, got) } diff --git a/internal/formatter/context.go b/internal/formatter/context.go index befaa0b..a2f9e04 100644 --- a/internal/formatter/context.go +++ b/internal/formatter/context.go @@ -53,7 +53,11 @@ func renderContextEvents(events []session.Event, agentIDs map[string]bool, opts continue } flush() - fmt.Fprintf(out, "U: %s\n\n", rendered.body) + prefix := "U" + if rendered.role == RoleHarness { + prefix = "H" + } + fmt.Fprintf(out, "%s: %s\n\n", prefix, rendered.body) case session.EventAssistantMessage: if event.Assistant == nil { diff --git a/internal/formatter/formatter_test.go b/internal/formatter/formatter_test.go index 9537aa1..dec4a16 100644 --- a/internal/formatter/formatter_test.go +++ b/internal/formatter/formatter_test.go @@ -28,10 +28,10 @@ func TestFormatRead_WhenTranscriptHasDialogueAndToolUse_ThenWritesReadableTimeli if !strings.Contains(got, "[Bash#ol-1] Echo ok") { t.Fatalf("FormatRead output missing short ID tag in tool summary\ngot:\n%q", got) } - if !strings.Contains(got, "[05-28 00:00] user:\nhello") { + if !strings.Contains(got, "[00:00:00] user:\nhello") { t.Fatalf("FormatRead output missing user message\ngot:\n%q", got) } - if !strings.Contains(got, "[05-28 00:00] assistant:\nhi") { + if !strings.Contains(got, "[00:00:01] assistant:\nhi") { t.Fatalf("FormatRead output missing assistant message\ngot:\n%q", got) } } @@ -106,7 +106,7 @@ func TestFormatContext_WhenSessionMetadataMissing_ThenWritesMinimalHeaderFromTra // The leading "mode" line also guards a bug found during manual acceptance: // real transcripts open with noise entries (mode/permission-mode/bridge- // session/...) that carry no "timestamp" field, so reading events[0].Timestamp -// directly produced "??-?? ??:??" even though a real timestamp was a few +// directly produced "??:??:??" even though a real timestamp was a few // lines down. func TestFormatContext_WhenSessionMetadataMissingAndTranscriptHasNoCwd_ThenDerivesProjectFromTranscriptDirectory(t *testing.T) { root := t.TempDir() @@ -144,20 +144,20 @@ func TestFormatRead_WhenMaxLinesReached_ThenStopsWithTruncationMessage(t *testin transcriptPath, _ := writeFormatterFixture(t) var out bytes.Buffer - if err := FormatRead(transcriptPath, 3, 0, FormatOptions{}, &out, claudecodec.Codec{}); err != nil { + if err := FormatRead(transcriptPath, 4, 0, FormatOptions{}, &out, claudecodec.Codec{}); err != nil { t.Fatalf("FormatRead returned error: %v", err) } got := out.String() // First 3 lines of the user block must be present. - if !strings.Contains(got, "[05-28 00:00] user:\nhello") { + if !strings.Contains(got, "[00:00:00] user:\nhello") { t.Fatalf("FormatRead truncated output missing user block\ngot:\n%q", got) } // Truncation message must name the resume offset so the user can continue. - if !strings.Contains(got, "--- truncated at line 3") { + if !strings.Contains(got, "--- truncated at line 4") { t.Fatalf("FormatRead truncated output missing truncation marker\ngot:\n%q", got) } - if !strings.Contains(got, "use --offset 3 to continue") { + if !strings.Contains(got, "use --offset 4 to continue") { t.Fatalf("FormatRead truncated output missing offset continuation hint\ngot:\n%q", got) } // No assistant content should appear (it starts after line 3). @@ -206,7 +206,7 @@ func TestFormatRead_WhenVerboseThinkingDisabled_ThenOmitsThinkingBlocks(t *testi } // The surrounding assistant text must still render so we know the fixture // itself is non-empty and the absence above is meaningful. - if !strings.Contains(got, "[05-28 00:00] assistant:\nfinal answer") { + if !strings.Contains(got, "[00:00:00] assistant:\nfinal answer") { t.Fatalf("read output missing assistant text\ngot:\n%q", got) } } @@ -224,10 +224,10 @@ func TestFormatRead_WhenVerboseThinkingEnabled_ThenRendersEachThinkingBlock(t *t } got := out.String() - if !strings.Contains(got, "[05-28 00:00] thinking:\n"+thinkingFixtureFirstBlock) { + if !strings.Contains(got, "[00:00:00] thinking:\n"+thinkingFixtureFirstBlock) { t.Fatalf("verbose-thinking read output missing first thinking block\ngot:\n%q", got) } - if !strings.Contains(got, "[05-28 00:00] thinking:\n"+thinkingFixtureSecondBlock) { + if !strings.Contains(got, "[00:00:00] thinking:\n"+thinkingFixtureSecondBlock) { t.Fatalf("verbose-thinking read output missing second thinking block\ngot:\n%q", got) } // Thinking precedes the assistant text in the output (timeline order). @@ -443,7 +443,7 @@ func TestFormatRead_WhenToolResultHasNoPendingTool_ThenStillWritesSummary(t *tes t.Fatalf("FormatRead returned error: %v", err) } - want := " [Bash] -> ok: orphan output\n\n" + want := " [Bash] -> orphan output\n\n" if got := out.String(); got != want { t.Fatalf("FormatRead orphan output mismatch\nwant:\n%q\ngot:\n%q", want, got) } @@ -592,8 +592,8 @@ func TestFormatReadEvents_WhenVerboseBash_ThenNonBashToolsStillCompressed(t *tes if strings.Contains(got, "line4") { t.Fatalf("non-Bash tool should remain compressed with verbose-bash, got:\n%s", got) } - if !strings.Contains(got, "-> ok") { - t.Fatalf("non-Bash tool summary should contain ok status, got:\n%s", got) + if !strings.Contains(got, "[Read#ol-1] tmp/foo.go") { + t.Fatalf("non-Bash tool should still render its one-line summary, got:\n%s", got) } } @@ -616,7 +616,7 @@ func TestFormatReadEvents_WhenToolResultIsUserAnswer_ThenWritesAnswerBlock(t *te t.Fatalf("FormatReadEvents returned error: %v", err) } - want := "[05-28 00:00] user (answer):\nship it\n\n" + want := "--- 2026-05-28 ---\n\n[00:00:00] user (answer):\nship it\n\n" if got := out.String(); got != want { t.Fatalf("answer block mismatch\nwant:\n%q\ngot:\n%q", want, got) } @@ -666,7 +666,7 @@ func generateManyEvents(n int) []session.Event { // footer when maxLines=200, offset=0. func TestFormatReadEvents_GivenManyLines_WhenDefaultMaxLines_ThenTruncatesAt200(t *testing.T) { // Each user message renders as 3 output lines: - // [05-28 00:00] user: + // [00:00:00] user: // message N // (blank) // 250 messages → 750 total lines. maxLines=200 must cut at line 200. @@ -743,7 +743,7 @@ func TestFormatReadEvents_GivenOffsetAndMaxLines_WhenCombined_ThenWindowsCorrect if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &full); err != nil { t.Fatalf("FormatReadEvents (full) error: %v", err) } - if err := FormatReadEvents(events, nil, 3, 2, FormatOptions{}, &windowed); err != nil { + if err := FormatReadEvents(events, nil, 3, 4, FormatOptions{}, &windowed); err != nil { t.Fatalf("FormatReadEvents (windowed) error: %v", err) } @@ -764,11 +764,11 @@ func TestFormatReadEvents_GivenOffsetAndMaxLines_WhenCombined_ThenWindowsCorrect windowedLines = windowedLines[:len(windowedLines)-1] } - // Content lines must be exactly allLines[2:5]. + // Content lines must be exactly allLines[4:7]. if len(windowedLines) != 3 { t.Fatalf("expected 3 content lines, got %d: %q", len(windowedLines), windowedLines) } - for i, want := range allLines[2:5] { + for i, want := range allLines[4:7] { if windowedLines[i] != want { t.Fatalf("windowed line %d mismatch\nwant: %q\ngot: %q", i, want, windowedLines[i]) } @@ -829,7 +829,7 @@ func TestAppendToolResult_WhenParallelToolCalls_ThenMatchesByToolUseID(t *testin {toolUseID: "aaa", summary: "[Read] main.go", name: "Read"}, {toolUseID: "bbb", summary: "[Read] util.go", name: "Read"}, }, - result: session.ToolResult{ToolUseID: "aaa", Success: true}, + result: session.ToolResult{ToolUseID: "aaa", Success: true, RawName: "Bash", Text: "attached"}, wantMatch: 0, wantNoMatch: 1, }, @@ -839,7 +839,7 @@ func TestAppendToolResult_WhenParallelToolCalls_ThenMatchesByToolUseID(t *testin {toolUseID: "aaa", summary: "[Read] main.go", name: "Read"}, {toolUseID: "bbb", summary: "[Read] util.go", name: "Read"}, }, - result: session.ToolResult{ToolUseID: "", Success: true}, + result: session.ToolResult{ToolUseID: "", Success: true, RawName: "Bash", Text: "attached"}, wantMatch: 1, wantNoMatch: 0, }, @@ -849,14 +849,14 @@ func TestAppendToolResult_WhenParallelToolCalls_ThenMatchesByToolUseID(t *testin {toolUseID: "aaa", summary: "[Read] main.go", name: "Read"}, {toolUseID: "bbb", summary: "[Read] util.go", name: "Read"}, }, - result: session.ToolResult{ToolUseID: "zzz", Success: true}, + result: session.ToolResult{ToolUseID: "zzz", Success: true, RawName: "Bash", Text: "attached"}, wantMatch: 1, wantNoMatch: 0, }, { name: "given no pending tools then creates orphan entry", pending: []pendingTool{}, - result: session.ToolResult{ToolUseID: "aaa", Success: true, RawName: "Bash"}, + result: session.ToolResult{ToolUseID: "aaa", Success: true, RawName: "Bash", Text: "attached"}, wantMatch: -1, wantNoMatch: -1, }, @@ -873,16 +873,16 @@ func TestAppendToolResult_WhenParallelToolCalls_ThenMatchesByToolUseID(t *testin if len(pending) != 1 { t.Fatalf("expected 1 orphan entry, got %d", len(pending)) } - if !strings.Contains(pending[0].summary, "-> ok") { + if !strings.Contains(pending[0].summary, "-> attached") { t.Fatalf("orphan entry missing result summary, got: %q", pending[0].summary) } return } - if !strings.Contains(pending[tt.wantMatch].summary, "-> ok") { + if !strings.Contains(pending[tt.wantMatch].summary, "-> attached") { t.Fatalf("pending[%d] should have result summary, got: %q", tt.wantMatch, pending[tt.wantMatch].summary) } - if tt.wantNoMatch >= 0 && strings.Contains(pending[tt.wantNoMatch].summary, "-> ok") { + if tt.wantNoMatch >= 0 && strings.Contains(pending[tt.wantNoMatch].summary, "-> attached") { t.Fatalf("pending[%d] should NOT have result summary, got: %q", tt.wantNoMatch, pending[tt.wantNoMatch].summary) } }) diff --git a/internal/formatter/harness_role_test.go b/internal/formatter/harness_role_test.go new file mode 100644 index 0000000..7739f06 --- /dev/null +++ b/internal/formatter/harness_role_test.go @@ -0,0 +1,91 @@ +package formatter + +import ( + "bytes" + "strings" + "testing" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" +) + +// ADR-008: harness-injected messages were labelled "user", identical to a +// message the person actually typed, so a reader inheriting the transcript +// could not tell them apart. + +func TestFormatReadEvents_GivenHarnessInjection_WhenRendered_ThenLabelsItHarness(t *testing.T) { + tests := map[string]struct { + user session.UserMessage + wantBody string + }{ + "a stop hook notice keeps only its goal": { + user: session.UserMessage{ + IsStopHookGoal: true, + GoalCondition: "把資料結構改為用 XML 的形式呈現", + Text: "A session-scoped Stop hook is now active with condition: …", + }, + wantBody: "[goal] 把資料結構改為用 XML 的形式呈現", + }, + "an agents-stopped notice keeps only its count": { + user: session.UserMessage{IsAgentsStopped: true, StoppedAgentCount: 7, Text: "7 background agents…"}, + wantBody: "[agents stopped: 7]", + }, + "an interruption sentinel collapses to a marker": { + user: session.UserMessage{IsInterrupted: true, Text: "[Request interrupted by user]"}, + wantBody: "[interrupted]", + }, + "a task notification keeps its summary": { + user: session.UserMessage{ + IsTaskNotification: true, + Text: "\nbenchmark done\n", + }, + wantBody: "[benchmark done]", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + events := []session.Event{{ + Kind: session.EventUserMessage, + Timestamp: "2026-08-30T13:00:00Z", + User: &tc.user, + }} + + var out bytes.Buffer + if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + want := "[13:00:00] harness:\n" + tc.wantBody + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q\ngot:\n%s", want, out.String()) + } + }) + } +} + +func TestFormatReadEvents_GivenTypedMessage_WhenRendered_ThenStillLabelsItUser(t *testing.T) { + events := []session.Event{ + { + Kind: session.EventUserMessage, + Timestamp: "2026-08-30T13:00:00Z", + User: &session.UserMessage{CommandMarker: "[/goal]"}, + }, + { + Kind: session.EventUserMessage, + Timestamp: "2026-08-30T13:00:01Z", + User: &session.UserMessage{Text: "為什麼 K 會被高估?"}, + }, + } + + var out bytes.Buffer + if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + got := out.String() + for _, want := range []string{"[13:00:00] user:\n[/goal]", "[13:00:01] user:\n為什麼 K 會被高估?"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, got) + } + } +} diff --git a/internal/formatter/read.go b/internal/formatter/read.go index 7711318..dbc9991 100644 --- a/internal/formatter/read.go +++ b/internal/formatter/read.go @@ -6,7 +6,6 @@ import ( "io" "strings" - "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/parser" "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" ) @@ -46,6 +45,7 @@ func RenderReadEventsWithSink(events []session.Event, agentIDs map[string]bool, func renderReadEvents(events []session.Event, rc renderContext) error { var pendingTools []pendingTool seenSkills := make(map[string]bool) + rc.ts = ×tampWriter{} flush := func() { flushPendingTools(&pendingTools, rc) @@ -59,7 +59,7 @@ func renderReadEvents(events []session.Event, rc renderContext) error { continue } flush() - fmt.Fprintf(rc.out, "[%s] user:\n%s\n\n", parser.FormatTimestamp(event.Timestamp), rendered.body) + writeEventBlock(rc, event.Timestamp, rendered.role, rendered.body, true) if rc.sink != nil { rc.sink(CategoryUserText, rendered.body) } @@ -71,14 +71,14 @@ func renderReadEvents(events []session.Event, rc renderContext) error { if rc.opts.VerboseThinking { for _, thinking := range event.Assistant.Thinking { flush() - fmt.Fprintf(rc.out, "[%s] thinking:\n%s\n\n", parser.FormatTimestamp(event.Timestamp), thinking) + writeEventBlock(rc, event.Timestamp, "thinking", thinking, true) } } hasText := strings.TrimSpace(event.Assistant.Text) != "" hasTools := len(event.Assistant.ToolUses) > 0 if hasText { flush() - fmt.Fprintf(rc.out, "[%s] assistant:\n%s\n", parser.FormatTimestamp(event.Timestamp), event.Assistant.Text) + writeEventBlock(rc, event.Timestamp, "assistant", event.Assistant.Text, false) if rc.sink != nil { rc.sink(CategoryAssistantText, event.Assistant.Text) } @@ -102,7 +102,7 @@ func renderReadEvents(events []session.Event, rc renderContext) error { func handleToolResultRead(event session.Event, pendingTools *[]pendingTool, flushFn func(), rc renderContext) { if event.User != nil && event.User.IsAnswer { flushFn() - fmt.Fprintf(rc.out, "[%s] user (answer):\n%s\n\n", parser.FormatTimestamp(event.Timestamp), event.User.Text) + writeEventBlock(rc, event.Timestamp, "user (answer)", event.User.Text, true) if rc.sink != nil { rc.sink(CategoryUserAnswer, event.User.Text) } @@ -113,7 +113,7 @@ func handleToolResultRead(event session.Event, pendingTools *[]pendingTool, flus } if rc.agentIDs[event.Tool.ToolUseID] && strings.TrimSpace(event.Tool.Text) != "" { flushFn() - fmt.Fprintf(rc.out, "[%s] agent result:\n%s\n\n", parser.FormatTimestamp(event.Timestamp), event.Tool.Text) + writeEventBlock(rc, event.Timestamp, "agent result", event.Tool.Text, true) if rc.sink != nil { rc.sink(CategoryToolSummary, event.Tool.Text) } @@ -121,3 +121,19 @@ func handleToolResultRead(event session.Event, pendingTools *[]pendingTool, flus } appendToolResult(event.Tool, pendingTools, rc.opts) } + +// writeEventBlock prints one "[time] role:" block. A day marker precedes it +// whenever the date has rolled over since the previous block, which is where +// the date lives now that the per-message header carries only a clock time. +// trailingBlank is false for assistant text, which may be followed by its own +// tool-call block and supplies the separating blank line itself. +func writeEventBlock(rc renderContext, timestamp string, role string, body string, trailingBlank bool) { + label, dayMarker := rc.ts.format(timestamp) + if dayMarker != "" { + fmt.Fprintf(rc.out, "%s\n\n", dayMarker) + } + fmt.Fprintf(rc.out, "[%s] %s:\n%s\n", label, role, body) + if trailingBlank { + fmt.Fprintln(rc.out) + } +} diff --git a/internal/formatter/render.go b/internal/formatter/render.go index d8bc6b7..447c575 100644 --- a/internal/formatter/render.go +++ b/internal/formatter/render.go @@ -35,6 +35,9 @@ type renderContext struct { opts FormatOptions out io.Writer sink ContentSink + // ts renders message-header clock times and the day markers that carry + // the date the per-message header no longer repeats (ADR-007 decision 4). + ts *timestampWriter } // Content categories reported to ContentSink. The values match the keys @@ -46,10 +49,20 @@ const ( CategoryToolSummary = "tool_summaries" ) -// userRender is the rendered form of a user-message event: the body to print -// and whether anything should be printed at all. +// Role labels written in the per-message header. RoleHarness separates +// messages the harness injected from messages the user typed: before ADR-008 +// both were labelled "user", so a reader inheriting the transcript could not +// tell which lines a person actually wrote. +const ( + RoleUser = "user" + RoleHarness = "harness" +) + +// userRender is the rendered form of a user-message event: the body to print, +// the role label to print it under, and whether anything should be printed. type userRender struct { body string + role string show bool } @@ -65,7 +78,7 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills return userRender{} } if user.CommandMarker != "" { - return userRender{body: user.CommandMarker, show: true} + return userRender{body: user.CommandMarker, role: RoleUser, show: true} } if user.IsCommandNoise { if !opts.VerboseCommands || user.IsCaveat { @@ -75,7 +88,7 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills if body == "" { return userRender{} } - return userRender{body: body, show: true} + return userRender{body: body, role: RoleHarness, show: true} } // Harness-injected subtypes: strip or compact. @@ -83,28 +96,47 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills return userRender{} } if user.IsSkillInjection { - return userRender{body: session.CompactSkillInjection(user, seenSkills), show: true} + return harnessRender(session.CompactSkillInjection(user, seenSkills)) } if user.IsTeammateMessage { if body, ok := session.CompactTeammateMessage(user.Text); ok { - return userRender{body: body, show: true} + return harnessRender(body) } - return userRender{body: user.Text, show: true} + return harnessRender(user.Text) } if user.IsCommandInjection { if body, ok := session.CompactCommandInjection(user.Text); ok { - return userRender{body: body, show: true} + return harnessRender(body) } - return userRender{body: user.Text, show: true} + return harnessRender(user.Text) + } + if user.IsTaskNotification { + if body, ok := session.CompactTaskNotification(user.Text); ok { + return harnessRender(body) + } + return harnessRender(user.Text) + } + if user.IsStopHookGoal { + return harnessRender(session.CompactStopHookGoal(user)) + } + if user.IsAgentsStopped { + return harnessRender(session.CompactAgentsStopped(user)) + } + if user.IsCompactionSummary { + return harnessRender(session.CompactCompactionSummary(user.Text)) + } + if user.IsInterrupted { + return harnessRender("[interrupted]") } if strings.TrimSpace(user.Text) == "" { return userRender{} } - if body, ok := session.CompactTaskNotification(user.Text); ok { - return userRender{body: body, show: true} - } - return userRender{body: user.Text, show: true} + return userRender{body: user.Text, role: RoleUser, show: true} +} + +func harnessRender(body string) userRender { + return userRender{body: body, role: RoleHarness, show: true} } type pendingTool struct { diff --git a/internal/formatter/timestamp.go b/internal/formatter/timestamp.go new file mode 100644 index 0000000..d65882c --- /dev/null +++ b/internal/formatter/timestamp.go @@ -0,0 +1,46 @@ +package formatter + +import ( + "fmt" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/parser" +) + +// timestampLayout is the per-message clock, at second precision. The date is +// deliberately absent: it is identical on every message of a day, and a +// session's messages cluster tightly (98% of consecutive events in the +// measured sessions are under a minute apart), so repeating the date on every +// header taxed every header. dayMarkerLayout carries it instead, once per day. +// +// Second precision is what the repeated date paid for: ADR-007 decision 4 +// measured this pair at -0.4% tokens against the old "01-02 15:04", while +// adding both seconds and the year that format never had. +const ( + timestampLayout = "15:04:05" + dayMarkerLayout = "2006-01-02" + unknownTime = "??:??:??" +) + +// timestampWriter renders one session's message-header times and emits a day +// marker when the date rolls over. Sessions really do span days (the measured +// samples span 2 and 3), so the date has to appear; it just appears 2-3 times +// instead of once per message. +type timestampWriter struct { + lastDate string +} + +// format returns the label for a message header and, when the date has just +// rolled over, the marker line to print above it. An unparseable timestamp +// yields a placeholder rather than a marker, so a malformed event cannot +// inject a bogus date boundary. +func (w *timestampWriter) format(tsStr string) (label string, dayMarker string) { + t, ok := parser.ParseTimestamp(tsStr) + if !ok { + return unknownTime, "" + } + if date := t.Format(dayMarkerLayout); date != w.lastDate { + w.lastDate = date + dayMarker = fmt.Sprintf("--- %s ---", date) + } + return t.Format(timestampLayout), dayMarker +} diff --git a/internal/formatter/timestamp_test.go b/internal/formatter/timestamp_test.go new file mode 100644 index 0000000..09b76b7 --- /dev/null +++ b/internal/formatter/timestamp_test.go @@ -0,0 +1,82 @@ +package formatter + +import ( + "bytes" + "strings" + "testing" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/claudecodec" +) + +// ADR-007 decision 4: the per-message header repeated a date that is identical +// all day and omitted the year entirely, so a session read months later could +// not be placed in time. The date moved to a day marker and the space it freed +// bought second precision. + +func TestTimestampWriter_GivenEventsOnOneDay_WhenFormatted_ThenMarksTheDateOnceAndClocksEveryMessage(t *testing.T) { + writer := ×tampWriter{} + + label, marker := writer.format("2026-08-06T03:06:15Z") + if want := "--- 2026-08-06 ---"; marker != want { + t.Errorf("first event marker = %q, want %q", marker, want) + } + if want := "03:06:15"; label != want { + t.Errorf("first event label = %q, want %q", label, want) + } + + label, marker = writer.format("2026-08-06T11:23:04Z") + if marker != "" { + t.Errorf("same-day event must not repeat the date, got marker %q", marker) + } + if want := "11:23:04"; label != want { + t.Errorf("second event label = %q, want %q", label, want) + } +} + +func TestTimestampWriter_GivenSessionCrossingMidnight_WhenFormatted_ThenMarksTheNewDate(t *testing.T) { + writer := ×tampWriter{} + writer.format("2026-08-06T23:59:00Z") + + _, marker := writer.format("2026-08-07T02:08:31Z") + + if want := "--- 2026-08-07 ---"; marker != want { + t.Errorf("day-change marker = %q, want %q", marker, want) + } +} + +// A malformed timestamp must not reset the day state or invent a boundary: +// doing so would print a marker for a date the session never reached. +func TestTimestampWriter_GivenUnparseableTimestamp_WhenFormatted_ThenPlaceholderWithoutMarker(t *testing.T) { + writer := ×tampWriter{} + writer.format("2026-08-06T03:06:15Z") + + label, marker := writer.format("not-a-timestamp") + + if want := unknownTime; label != want { + t.Errorf("label = %q, want %q", label, want) + } + if marker != "" { + t.Errorf("unparseable timestamp must not emit a day marker, got %q", marker) + } + + if _, marker := writer.format("2026-08-06T04:00:00Z"); marker != "" { + t.Errorf("day state must survive an unparseable timestamp, got marker %q", marker) + } +} + +func TestFormatRead_GivenTranscript_WhenRendered_ThenOpensWithTheDateAndCarriesTheYear(t *testing.T) { + transcriptPath, _ := writeFormatterFixture(t) + + var out bytes.Buffer + if err := FormatRead(transcriptPath, 0, 0, FormatOptions{}, &out, claudecodec.Codec{}); err != nil { + t.Fatalf("FormatRead returned error: %v", err) + } + got := out.String() + + if !strings.HasPrefix(got, "--- 2026-05-28 ---\n") { + t.Errorf("read output must open with the full date\ngot:\n%q", got) + } + if strings.Contains(got, "[05-28") { + t.Errorf("per-message header must no longer repeat the date\ngot:\n%q", got) + } +} diff --git a/internal/inject/inject.go b/internal/inject/inject.go index 9ef236a..441056d 100644 --- a/internal/inject/inject.go +++ b/internal/inject/inject.go @@ -1,6 +1,6 @@ // Package inject implements paginated session output for the inherit subcommand // (formerly named "inject"; the CLI-facing name changed, this package did not). -// Each page stays under 20K chars so Claude Code's Bash tool returns it as +// Each page stays under MaxPageBytes so Claude Code's Bash tool returns it as // stdout rather than persisting it to a file. package inject @@ -18,7 +18,19 @@ import ( "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/skillpath" ) -const maxPageBytes = 20_000 +// MaxPageBytes bounds one page so Claude Code's Bash tool returns it as stdout +// instead of persisting it to a file and handing back a 2KB preview. +// +// The real threshold is the harness's, not this program's: binary search on +// 2026-08-29 put it at 30,000 characters (30,000 came back inline, 31,000 was +// persisted). This leaves ~2,000 characters of headroom for the page marker, +// the footer, and any future harness change. Re-measure before raising it, and +// note the harness lets users configure that cap. +// +// Exported so tests can derive their boundary cases from it: the page limit is +// an invariant, and a copy hard-coded in a test silently stops describing the +// real boundary the moment this value changes. +const MaxPageBytes = 28_000 // State tracks pagination progress for one session. type State struct { @@ -94,7 +106,7 @@ func ClearState(sessionID string) error { return nil } -// SplitPages divides lines into pages whose byte count stays under maxPageBytes. +// SplitPages divides lines into pages whose byte count stays under MaxPageBytes. // Page breaks always fall on line boundaries. func SplitPages(lines []string) [][]string { if len(lines) == 0 { @@ -106,7 +118,7 @@ func SplitPages(lines []string) [][]string { for _, line := range lines { lineBytes := len(line) + 1 // +1 for newline - if currentBytes+lineBytes > maxPageBytes && len(current) > 0 { + if currentBytes+lineBytes > MaxPageBytes && len(current) > 0 { pages = append(pages, current) current = nil currentBytes = 0 diff --git a/internal/inject/inject_test.go b/internal/inject/inject_test.go index 8c745a6..2192751 100644 --- a/internal/inject/inject_test.go +++ b/internal/inject/inject_test.go @@ -70,7 +70,7 @@ func TestGivenManySmallLines_WhenSplitPages_ThenNoPagesExceedLimit(t *testing.T) charCount += len(l) + 1 } // Allow a single oversized line to push past, but normally must be under. - if charCount > 21_000 { + if charCount > inject.MaxPageBytes+1_000 { t.Errorf("page %d has %d chars, exceeds limit", i, charCount) } } diff --git a/internal/parser/time.go b/internal/parser/time.go index 89caee6..f45f029 100644 --- a/internal/parser/time.go +++ b/internal/parser/time.go @@ -1,5 +1,21 @@ package parser +import "time" + +// ParseTimestamp parses a transcript ISO timestamp. It reports false when the +// string is empty or in no recognized form, so callers doing their own +// formatting can render a placeholder rather than a wrong time. +func ParseTimestamp(tsStr string) (time.Time, bool) { + if tsStr == "" { + return time.Time{}, false + } + t, err := parseISO(tsStr) + if err != nil { + return time.Time{}, false + } + return t, true +} + // FormatTimestamp converts an ISO timestamp string to "MM-DD HH:MM" format. func FormatTimestamp(tsStr string) string { if tsStr == "" { diff --git a/internal/session/compact.go b/internal/session/compact.go index 91d4264..60f6018 100644 --- a/internal/session/compact.go +++ b/internal/session/compact.go @@ -28,6 +28,35 @@ func CompactTaskNotification(text string) (string, bool) { return strings.TrimSpace(b.String()), true } +// CompactStopHookGoal renders a Stop hook notice as "[goal] ". +// The rest of the notice describes how the hook behaves and is identical +// every time. Returns the whole notice when no condition was extracted. +func CompactStopHookGoal(user *UserMessage) string { + if user.GoalCondition == "" { + return user.Text + } + return "[goal] " + user.GoalCondition +} + +// CompactAgentsStopped renders the notice as "[agents stopped: N]". The +// notice also lists the stopped agents' prompts, but the harness has already +// truncated each to an unusable fragment. +func CompactAgentsStopped(user *UserMessage) string { + return fmt.Sprintf("[agents stopped: %d]", user.StoppedAgentCount) +} + +// CompactCompactionSummary replaces the harness framing around an injected +// conversation summary with a marker, keeping the body: the body is the +// previous conversation, which is what a reader inheriting this session needs. +func CompactCompactionSummary(text string) string { + const marker = "[compaction summary]" + idx := strings.Index(text, "Summary:") + if idx < 0 { + return marker + "\n" + strings.TrimSpace(text) + } + return marker + "\n" + strings.TrimSpace(text[idx:]) +} + // CompactSkillInjection returns a one-line summary of a SKILL.md injection. // seenSkills tracks which skills have appeared; repeats get a shorter form. func CompactSkillInjection(user *UserMessage, seenSkills map[string]bool) string { diff --git a/internal/session/event.go b/internal/session/event.go index ffe7251..64f6705 100644 --- a/internal/session/event.go +++ b/internal/session/event.go @@ -96,6 +96,62 @@ type UserMessage struct { // IsSystemReminder marks a harness injection. IsSystemReminder bool + + // IsTaskNotification marks a background-task report. + IsTaskNotification bool + + // IsCompactionSummary marks the harness-authored conversation summary + // injected when a session continues past a context compaction. + IsCompactionSummary bool + + // IsInterrupted marks the "[Request interrupted by user]" sentinel. + IsInterrupted bool + + // IsAgentsStopped marks the "N background agents were stopped" notice. + IsAgentsStopped bool + StoppedAgentCount int + + // IsStopHookGoal marks the session-scoped Stop hook activation notice. + // GoalCondition is the goal text, the only part of the ~490-character + // notice that is not fixed boilerplate. + IsStopHookGoal bool + GoalCondition string +} + +// CountsAsTurn reports whether this message starts a unit of agent work: an +// incoming prompt that runs until the agent stops. It is the denominator of +// the cost model's K. +// +// The numerator counts every API call regardless of what triggered it, so +// anything that wakes the agent has to count here or the ratio divides two +// different populations. Which kinds those are was measured rather than +// argued, by checking what actually follows each kind in 120 transcripts: a +// teammate message (94%), a background-task notification (89%) and a +// compaction summary (100%) each drive an API call, while an interruption +// sentinel and an agents-stopped notice drive none (0% each). +// +// The kinds that return false despite preceding an API call are the ones +// that arrive as the tail of an invocation something else already started: +// skill and command injections, the Stop hook notice that follows a /goal, +// and the notice that a skill was re-invoked. Counting those would count one +// turn twice. CommandMarker is false for the same reason; ADR-008's open +// questions cover attributing a slash command's turn to one of its entries. +func (u UserMessage) CountsAsTurn() bool { + if u.CommandMarker != "" { + return false + } + if u.IsTeammateMessage || u.IsTaskNotification || u.IsCompactionSummary { + return true + } + return !u.IsCommandNoise && + !u.IsCaveat && + !u.IsSkillInjection && + !u.IsCommandInjection && + !u.IsContextUsage && + !u.IsSystemReminder && + !u.IsInterrupted && + !u.IsAgentsStopped && + !u.IsStopHookGoal } type Usage struct { @@ -232,6 +288,14 @@ const ( failureExcerptMaxRunes = 200 ) +// Summary renders the "-> ..." tail appended after a tool call's label. +// +// Success is the default state and carries no marker (ADR-007 decision 3): +// 1,550 of 1,604 tool calls in the measured session succeeded, so naming it +// on every line taxed every line for information the reader already assumes. +// Only FAILED is announced. A successful call still shows its excerpt or diff +// stat, introduced by the same "->" arrow so the call and its result stay +// visually separable. func (r ToolResult) Summary() string { if r.Success { if diff := r.diffSummary(); diff != "" { @@ -239,12 +303,12 @@ func (r ToolResult) Summary() string { } switch r.RawName { case ToolRead, ToolWrite, ToolEdit, ToolAgent: - return fmt.Sprintf(" -> %s", r.Status()) + return "" } - if firstLine := FirstLine(r.Text, successExcerptMaxRunes); firstLine != "" { - return fmt.Sprintf(" -> %s: %s", r.Status(), firstLine) + if excerpt := firstMeaningfulSuccessLine(r.Text, successExcerptMaxRunes); excerpt != "" { + return fmt.Sprintf(" -> %s", excerpt) } - return fmt.Sprintf(" -> %s", r.Status()) + return "" } if excerpt := firstMeaningfulErrorLine(r.Text, failureExcerptMaxRunes); excerpt != "" { return fmt.Sprintf(" -> %s: %s", r.Status(), excerpt) @@ -260,10 +324,16 @@ func (r ToolResult) diffSummary() string { if r.DiffStat == nil { return "" } + // A failed Edit/Write keeps its FAILED marker; a successful one shows the + // diff stat alone, since the stat itself is proof the edit landed. + status := "" + if !r.Success { + status = " " + r.Status() + } if r.DiffStat.IsNewFile { - return fmt.Sprintf(" -> %s (new file, %d lines)", r.Status(), r.DiffStat.NewFileLines) + return fmt.Sprintf(" ->%s (new file, %d lines)", status, r.DiffStat.NewFileLines) } - summary := fmt.Sprintf(" -> %s (+%d, -%d @ L%d", r.Status(), r.DiffStat.Additions, r.DiffStat.Deletions, r.DiffStat.NewStartLine) + summary := fmt.Sprintf(" ->%s (+%d, -%d @ L%d", status, r.DiffStat.Additions, r.DiffStat.Deletions, r.DiffStat.NewStartLine) if r.DiffStat.HunkCount > 1 { summary += fmt.Sprintf(", %d hunks", r.DiffStat.HunkCount) } @@ -292,22 +362,76 @@ func isNoiseExcerptLine(line string) bool { hookErrorBoilerplate.MatchString(line) } +// progressLine matches a line where a tool announces what it is about to do +// rather than what it found: "Checking formatting...", "[STARTED] Backing up +// original state...", "> nccu-toolkit@1.17.0 dev". The answer is below it. +var progressLine = regexp.MustCompile(`^(?:\[[A-Z][A-Z ]*\]|>\s|(?:Checking|Running|Loading|Installing|Fetching|Building|Compiling|Starting)\b)`) + +// versionBanner matches a short line whose payload is a version stamp, the +// shape a test runner prints before any result ("RUN v4.0.18"). +var versionBanner = regexp.MustCompile(`^.{0,40}\bv?\d+\.\d+\.\d+\b.{0,10}$`) + +// sectionHeaderMarkers are the rules a script echoes around its own headings. +// A heading is recognized only when the same marker closes the line, so a +// diff's "--- a/file.go" is not mistaken for one. +var sectionHeaderMarkers = []string{"===", "---", "***"} + +// isEchoedSectionHeader reports whether line is a heading a script printed to +// label the output beneath it ("=== branch 落後/超前 staging ===", +// "--- HEAD ---") rather than output of its own. +func isEchoedSectionHeader(line string) bool { + for _, marker := range sectionHeaderMarkers { + if len(line) > 2*len(marker) && strings.HasPrefix(line, marker) && strings.HasSuffix(line, marker) { + return true + } + } + return false +} + +// isSuccessNoiseLine reports the extra shapes skipped when picking an excerpt +// from a SUCCESSFUL result (ADR-007 decision 1). They are deliberately not +// applied to failures: a line that is banner-shaped in passing output can be +// the error itself in failing output, and ADR-004 keeps failure information. +func isSuccessNoiseLine(line string) bool { + return isNoiseExcerptLine(line) || + isEchoedSectionHeader(line) || + progressLine.MatchString(line) || + versionBanner.MatchString(line) +} + // firstMeaningfulErrorLine returns the first non-noise line of text, skipping // cat -n prefixes, bare "Exit code N" lines, and hook boilerplate so the // excerpt surfaces the actual error instead of the noise around it. If every // line is noise, it falls back to the first non-empty line rather than // dropping the excerpt entirely. func firstMeaningfulErrorLine(text string, maxRunes int) string { + return firstMeaningfulLine(text, maxRunes, isNoiseExcerptLine) +} + +// firstMeaningfulSuccessLine is firstMeaningfulErrorLine's counterpart for a +// successful result. Before ADR-007 the success path took the first non-empty +// line with no filtering at all, which surfaced `gh`'s usage banner as the +// state of a pull request and a script's own "--- HEAD ---" as the state of a +// working tree. +func firstMeaningfulSuccessLine(text string, maxRunes int) string { + return firstMeaningfulLine(text, maxRunes, isSuccessNoiseLine) +} + +// firstMeaningfulLine returns the first line of text that isNoise rejects +// nothing about, with terminal escape sequences removed. Falling back to the +// first non-empty line keeps an excerpt when every line looks like noise, +// rather than reporting a bare status for a call that did produce output. +func firstMeaningfulLine(text string, maxRunes int, isNoise func(string) bool) string { var firstNonEmpty string for _, raw := range strings.Split(text, "\n") { - line := strings.TrimSpace(raw) + line := strings.TrimSpace(StripANSI(raw)) if line == "" { continue } if firstNonEmpty == "" { firstNonEmpty = line } - if !isNoiseExcerptLine(line) { + if !isNoise(line) { return Truncate(line, maxRunes) } } diff --git a/internal/session/event_test.go b/internal/session/event_test.go index 5d37558..7c93e26 100644 --- a/internal/session/event_test.go +++ b/internal/session/event_test.go @@ -372,9 +372,9 @@ func TestToolResultSummary(t *testing.T) { result ToolResult want string }{ - {name: "success with text", result: ToolResult{Success: true, Text: "first\nsecond"}, want: " -> ok: first"}, + {name: "success with text", result: ToolResult{Success: true, Text: "first\nsecond"}, want: " -> first"}, {name: "failure with text", result: ToolResult{Success: false, Text: "bad"}, want: " -> FAILED: bad"}, - {name: "success without text", result: ToolResult{Success: true}, want: " -> ok"}, + {name: "success without text", result: ToolResult{Success: true}, want: ""}, } for _, tt := range tests { @@ -459,14 +459,14 @@ func TestToolResultSummary_GivenEditDiffStat_ThenRendersDiffAnnotation(t *testin result: ToolResult{Success: true, RawName: ToolEdit, DiffStat: &DiffStat{ Additions: 2, Deletions: 1, NewStartLine: 10, HunkCount: 1, }}, - want: " -> ok (+2, -1 @ L10)", + want: " -> (+2, -1 @ L10)", }, { name: "given multiple hunks then appends hunk count", result: ToolResult{Success: true, RawName: ToolEdit, DiffStat: &DiffStat{ Additions: 5, Deletions: 3, NewStartLine: 5, HunkCount: 2, }}, - want: " -> ok (+5, -3 @ L5, 2 hunks)", + want: " -> (+5, -3 @ L5, 2 hunks)", }, } @@ -485,7 +485,7 @@ func TestToolResultSummary_GivenWriteDiffStatNewFile_ThenRendersLineCount(t *tes result := ToolResult{Success: true, RawName: ToolWrite, DiffStat: &DiffStat{ IsNewFile: true, NewFileLines: 42, }} - want := " -> ok (new file, 42 lines)" + want := " -> (new file, 42 lines)" if got := result.Summary(); got != want { t.Fatalf("Summary() = %q, want %q", got, want) } @@ -498,7 +498,7 @@ func TestToolResultSummary_GivenWriteDiffStatNewFile_ThenRendersLineCount(t *tes // already got before diff summaries existed. func TestToolResultSummary_GivenNoDiffStat_ThenFallsBackToBareOk(t *testing.T) { result := ToolResult{Success: true, RawName: ToolEdit, Text: "irrelevant body"} - want := " -> ok" + want := "" if got := result.Summary(); got != want { t.Fatalf("Summary() = %q, want %q", got, want) } diff --git a/internal/session/success_excerpt_test.go b/internal/session/success_excerpt_test.go new file mode 100644 index 0000000..91755f6 --- /dev/null +++ b/internal/session/success_excerpt_test.go @@ -0,0 +1,118 @@ +package session + +import "testing" + +// ADR-007 decision 1: before it, a successful result's excerpt was the first +// non-empty line with no filtering, so `gh`'s usage banner was reported as the +// state of a pull request and a script's own "--- HEAD ---" as the state of a +// working tree. These cases are the real transcript lines that motivated the +// change; each one guards against the excerpt reverting to line 1. + +func TestSuccessExcerpt_GivenNoisyFirstLine_WhenSummarized_ThenSkipsToTheAnswer(t *testing.T) { + tests := map[string]struct { + output string + want string + }{ + "a script's own section heading labels the answer below it": { + output: "--- HEAD ---\n8fe4ebc feat: add archetype rules", + want: "8fe4ebc feat: add archetype rules", + }, + "a heading closed by its own rule is skipped, a diff header is not": { + output: "=== branch 落後/超前 ===\nahead 3, behind 0", + want: "ahead 3, behind 0", + }, + "a progress line announces work rather than reporting it": { + output: "Checking formatting...\nAll matched files use Prettier code style!", + want: "All matched files use Prettier code style!", + }, + "a bracketed status prefix is progress, not outcome": { + output: "[STARTED] Backing up original state...\nBackup complete: 42 files", + want: "Backup complete: 42 files", + }, + "a test runner's version banner precedes the result": { + output: "RUN v4.0.18\nTest Files 23 passed (23)", + want: "Test Files 23 passed (23)", + }, + "terminal escape sequences are stripped from the excerpt": { + output: "\x1b[32m✓\x1b[39m 23 passed", + want: "✓ 23 passed", + }, + "a banner wrapped in escape sequences is still recognized as one": { + output: "\x1b[1m\x1b[46m RUN \x1b[49m \x1b[36mv4.0.18\x1b[39m\nTest Files 23 passed (23)", + want: "Test Files 23 passed (23)", + }, + "an ordinary first line is kept": { + output: "65: CAMPUS_BUS_AND_HOUSING: 'campus-bus-and-housing',\nmore output", + want: "65: CAMPUS_BUS_AND_HOUSING: 'campus-bus-and-housing',", + }, + // A diff's "--- a/file.go" opens with the same rule as a section + // heading but is not closed by one, so it must survive as the excerpt. + "a diff header is not mistaken for a section heading": { + output: "--- a/src/game-engine.ts\n+++ b/src/game-engine.ts", + want: "--- a/src/game-engine.ts", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := ToolResult{Success: true, RawName: ToolBash, Text: tc.output} + want := " -> " + tc.want + if got := result.Summary(); got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } + }) + } +} + +// A prose usage banner carries no shape to key on, so pattern filtering +// cannot reach it. This pins the limitation so a future reader does not +// assume ADR-007 decision 1 covers every misleading excerpt: `gh`'s banner is +// addressed by decision 2 instead, which puts "gh pr view" in the same line +// and lets the reader see that the banner is not the answer. +func TestSuccessExcerpt_GivenProseUsageBanner_WhenSummarized_ThenStillReportsIt(t *testing.T) { + result := ToolResult{ + Success: true, + RawName: ToolBash, + Text: "Work seamlessly with GitHub from the command line.\n{\"state\":\"success\"}", + } + + if got, want := result.Summary(), " -> Work seamlessly with GitHub from the command line."; got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } +} + +func TestSuccessExcerpt_GivenEveryLineIsNoise_WhenSummarized_ThenKeepsTheFirstLineRatherThanDroppingIt(t *testing.T) { + result := ToolResult{Success: true, RawName: ToolBash, Text: "=== 規模 ===\n--- HEAD ---"} + + if got, want := result.Summary(), " -> === 規模 ==="; got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } +} + +// The success filter must not leak into the failure path: ADR-004 keeps +// failure information, and a line that reads as a banner in passing output can +// be the error itself when the call failed. +func TestFailureExcerpt_GivenSuccessOnlyNoiseShapes_WhenSummarized_ThenKeepsThemAsTheError(t *testing.T) { + tests := map[string]string{ + "a version-shaped line can be the failure itself": "expected v1.2.3, got v1.2.4", + "a heading-shaped line can be the failure itself": "=== FAILED: 3 assertions ===", + } + + for name, output := range tests { + t.Run(name, func(t *testing.T) { + result := ToolResult{Success: false, RawName: ToolBash, Text: output} + want := " -> FAILED: " + output + if got := result.Summary(); got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } + }) + } +} + +func TestFailureExcerpt_GivenEscapeSequences_WhenSummarized_ThenStripsThem(t *testing.T) { + result := ToolResult{Success: false, RawName: ToolBash, Text: "\x1b[31m× Scenario: a date with trust at 100\x1b[39m"} + + if got, want := result.Summary(), " -> FAILED: × Scenario: a date with trust at 100"; got != want { + t.Errorf("Summary() = %q, want %q", got, want) + } +} diff --git a/internal/session/turn_test.go b/internal/session/turn_test.go new file mode 100644 index 0000000..d0ccee9 --- /dev/null +++ b/internal/session/turn_test.go @@ -0,0 +1,131 @@ +package session + +import "testing" + +// ADR-008: turn counting used to be whatever survived the character-accounting +// branches in ComputeStats, so every `continue` written for one purpose also +// changed K. These cases pin the policy itself: a message counts when it starts +// a unit of agent work, independent of how much of it survives compression. + +func TestCountsAsTurn_GivenMessageKind_WhenCounted_ThenFollowsWorkUnitPolicy(t *testing.T) { + tests := map[string]struct { + message UserMessage + want bool + }{ + "a typed message is a turn": { + message: UserMessage{Text: "把這個修好"}, + want: true, + }, + "a teammate message is a turn: it drives a full agent response": { + message: UserMessage{Text: "…", IsTeammateMessage: true}, + want: true, + }, + "a task notification is a turn: it wakes the agent (89% drive an API call)": { + message: UserMessage{Text: "…", IsTaskNotification: true}, + want: true, + }, + "a compaction summary is a turn: the session resumes on it (100%)": { + message: UserMessage{Text: "…", IsCompactionSummary: true}, + want: true, + }, + "a stop hook notice is not: it is the tail of the /goal that started the turn": { + message: UserMessage{Text: "…", IsStopHookGoal: true}, + want: false, + }, + "an agents-stopped notice is not": { + message: UserMessage{Text: "…", IsAgentsStopped: true}, + want: false, + }, + "an interruption sentinel is not": { + message: UserMessage{Text: "…", IsInterrupted: true}, + want: false, + }, + "a skill injection is not: it arrives alongside the message that triggered it": { + message: UserMessage{Text: "…", IsSkillInjection: true}, + want: false, + }, + "a command injection is not, for the same reason": { + message: UserMessage{Text: "…", IsCommandInjection: true}, + want: false, + }, + "a system reminder is not": { + message: UserMessage{Text: "…", IsSystemReminder: true}, + want: false, + }, + "a context usage block is not": { + message: UserMessage{Text: "…", IsContextUsage: true}, + want: false, + }, + "command output is not": { + message: UserMessage{Text: "…", IsCommandNoise: true}, + want: false, + }, + "a command invocation marker is not (unchanged by ADR-008)": { + message: UserMessage{CommandMarker: "[/goal]"}, + want: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if got := tc.message.CountsAsTurn(); got != tc.want { + t.Errorf("CountsAsTurn() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestCompactStopHookGoal_GivenNotice_WhenCompacted_ThenKeepsOnlyTheCondition(t *testing.T) { + user := &UserMessage{ + IsStopHookGoal: true, + GoalCondition: "把資料結構改為用 XML 的形式呈現", + Text: "A session-scoped Stop hook is now active with condition: …", + } + + if got, want := CompactStopHookGoal(user), "[goal] 把資料結構改為用 XML 的形式呈現"; got != want { + t.Errorf("CompactStopHookGoal() = %q, want %q", got, want) + } +} + +// Without a condition there is nothing to promote, and claiming an empty goal +// would be worse than showing the notice, so the notice survives intact. +func TestCompactStopHookGoal_GivenNoCondition_WhenCompacted_ThenKeepsTheNotice(t *testing.T) { + user := &UserMessage{IsStopHookGoal: true, Text: "A session-scoped Stop hook is now active"} + + if got := CompactStopHookGoal(user); got != user.Text { + t.Errorf("CompactStopHookGoal() = %q, want the original notice", got) + } +} + +func TestCompactAgentsStopped_GivenNotice_WhenCompacted_ThenKeepsOnlyTheCount(t *testing.T) { + user := &UserMessage{IsAgentsStopped: true, StoppedAgentCount: 7} + + if got, want := CompactAgentsStopped(user), "[agents stopped: 7]"; got != want { + t.Errorf("CompactAgentsStopped() = %q, want %q", got, want) + } +} + +func TestCompactCompactionSummary_GivenInjectedSummary_WhenCompacted_ThenDropsFramingAndKeepsBody(t *testing.T) { + text := "This session is being continued from a previous conversation that ran " + + "out of context. The summary below covers the earlier portion of the " + + "conversation.\n\nSummary:\n1. Primary Request and Intent:\n 蓋 benchmark" + + got := CompactCompactionSummary(text) + + want := "[compaction summary]\nSummary:\n1. Primary Request and Intent:\n 蓋 benchmark" + if got != want { + t.Errorf("CompactCompactionSummary() = %q, want %q", got, want) + } +} + +// A summary whose body never reaches the "Summary:" heading must not be +// silently emptied — the body is the previous conversation. +func TestCompactCompactionSummary_GivenNoSummaryHeading_WhenCompacted_ThenKeepsWholeBody(t *testing.T) { + text := "This session is being continued from a previous conversation.\n\n之前在查 K" + + got := CompactCompactionSummary(text) + + if got != "[compaction summary]\n"+text { + t.Errorf("CompactCompactionSummary() = %q, want the whole body kept", got) + } +} diff --git a/internal/summarizer/command_verb.go b/internal/summarizer/command_verb.go new file mode 100644 index 0000000..73bd866 --- /dev/null +++ b/internal/summarizer/command_verb.go @@ -0,0 +1,96 @@ +package summarizer + +import ( + "path/filepath" + "strings" +) + +// maxVerbLen bounds the command fragment appended to a Bash summary. Measured +// against the alternatives in ADR-007 decision 2: a raw 60-character prefix +// costs +9.8% tokens because the budget is spent on scaffolding, while the +// extracted verb at this length costs +2.0% and is offset by dropping the +// success marker. +const maxVerbLen = 30 + +// scaffoldingPrograms lead a command segment that positions the shell rather +// than doing the work a reader wants to identify. `echo` is here because the +// commands in real transcripts overwhelmingly use it to label their own +// output ("echo '=== game-engine ===' && sed -n ..."). +var scaffoldingPrograms = map[string]bool{ + "cd": true, "export": true, "set": true, "source": true, ".": true, + "echo": true, "printf": true, "true": true, "clear": true, +} + +// commandVerb returns the program and first argument of the first segment of +// cmd that names real work: "pnpm tsc", "git push", "grep -n". It returns "" +// when nothing in cmd names a program. +// +// A plain prefix of the command does not work as a summary. Real commands +// overwhelmingly open with `cd &&`, an env assignment, or an +// echoed heading, so the first 20 characters are usually +// "cd /Users/maple/Desk" and identify nothing. Walking past those segments +// reaches a real program name on 99.8% of the Bash calls in the measured +// sample. +func commandVerb(cmd string) string { + for _, line := range strings.Split(cmd, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + for _, segment := range splitShellSegments(line) { + if verb := segmentVerb(segment); verb != "" { + return verb + } + } + } + return "" +} + +// splitShellSegments breaks a command line on the operators separating one +// invocation from the next, so `cd x && real-command` yields the real command +// as a segment of its own. +func splitShellSegments(line string) []string { + segments := strings.FieldsFunc(line, func(r rune) bool { + return r == '&' || r == ';' || r == '|' + }) + for i, segment := range segments { + segments[i] = strings.TrimSpace(segment) + } + return segments +} + +// controlKeywords open a shell construct without naming the work inside it. +// `until gh pr checks` is a poll loop around `gh`, and the reader wants the +// `gh`, so these are stepped over the way an env assignment is. +var controlKeywords = map[string]bool{ + "until": true, "while": true, "for": true, "if": true, "do": true, + "then": true, "else": true, "elif": true, "time": true, "command": true, +} + +// segmentVerb returns " " for a segment naming a real +// program, or "" for scaffolding, a bare env assignment, or an empty segment. +// The program is reduced to its base name so an absolute interpreter path +// ("/opt/homebrew/bin/gh") does not consume the whole budget. +func segmentVerb(segment string) string { + fields := strings.Fields(segment) + for len(fields) > 0 && (isEnvAssignment(fields[0]) || controlKeywords[fields[0]]) { + fields = fields[1:] + } + if len(fields) == 0 { + return "" + } + program := filepath.Base(fields[0]) + if scaffoldingPrograms[program] { + return "" + } + if len(fields) == 1 { + return program + } + return program + " " + fields[1] +} + +// isEnvAssignment reports whether field is a leading VAR=value prefix rather +// than the program name. +func isEnvAssignment(field string) bool { + return strings.Contains(field, "=") && !strings.HasPrefix(field, "-") +} diff --git a/internal/summarizer/command_verb_test.go b/internal/summarizer/command_verb_test.go new file mode 100644 index 0000000..5a549fa --- /dev/null +++ b/internal/summarizer/command_verb_test.go @@ -0,0 +1,110 @@ +package summarizer + +import ( + "strings" + "testing" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" +) + +// ADR-007 decision 2: the Bash summary dropped the command entirely, leaving +// the reader unable to tell what actually ran. A plain prefix of the command +// does not fix it, because real commands open with scaffolding: these cases +// are the shapes that made a naive 20-character prefix useless. + +func TestCommandVerb_GivenScaffoldedCommand_WhenExtracted_ThenNamesTheRealProgram(t *testing.T) { + tests := map[string]struct { + command string + want string + }{ + "a bare command is its own verb": { + command: "pnpm tsc --noEmit", + want: "pnpm tsc", + }, + "a leading cd is skipped": { + command: "cd /Users/maple/Desktop/nccu-toolkit && git push --force", + want: "git push", + }, + "a leading env assignment is skipped": { + command: "NODE_ENV=test SEED=42 npx vitest run", + want: "npx vitest", + }, + "an echoed heading is skipped": { + command: `echo "=== game-engine ===" && sed -n '210,225p' src/engine.ts`, + want: "sed -n", + }, + "a pipeline reports the program that produces the data": { + command: "grep -n TODO src/*.ts | head -20", + want: "grep -n", + }, + "an absolute interpreter path is reduced to its base name": { + command: "/opt/homebrew/bin/gh pr view 407 --json state", + want: "gh pr", + }, + "a comment line is skipped for the command below it": { + command: "# Search only main session files\nrg --files-with-matches race", + want: "rg --files-with-matches", + }, + "a poll loop reports the program it polls with": { + command: "until /opt/homebrew/bin/gh pr checks 409; do sleep 30; done", + want: "gh pr", + }, + "a program with no arguments is still a verb": { + command: "cd /tmp && ls", + want: "ls", + }, + "scaffolding alone yields nothing to name": { + command: "cd /Users/maple/Desktop && export PATH=/usr/bin", + want: "", + }, + "an empty command yields nothing": { + command: "", + want: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if got := commandVerb(tc.command); got != tc.want { + t.Errorf("commandVerb(%q) = %q, want %q", tc.command, got, tc.want) + } + }) + } +} + +func TestSummarizeToolUse_GivenBashWithDescription_WhenSummarized_ThenCarriesBothDescriptionAndVerb(t *testing.T) { + input := session.ToolInput{Raw: map[string]any{ + "description": "查 PR #407 狀態與 CI", + "command": "cd /Users/maple/Desktop/nccu-toolkit && gh pr view 407 --json state", + }} + + if got, want := SummarizeToolUse("Bash", input, ""), "[Bash] 查 PR #407 狀態與 CI | gh pr"; got != want { + t.Errorf("SummarizeToolUse() = %q, want %q", got, want) + } +} + +// When the command is nothing but scaffolding there is no verb to add, and +// the summary must not end in a dangling separator. +func TestSummarizeToolUse_GivenBashWhoseCommandIsAllScaffolding_WhenSummarized_ThenOmitsTheSeparator(t *testing.T) { + input := session.ToolInput{Raw: map[string]any{ + "description": "切到專案目錄", + "command": "cd /Users/maple/Desktop/nccu-toolkit", + }} + + if got, want := SummarizeToolUse("Bash", input, ""), "[Bash] 切到專案目錄"; got != want { + t.Errorf("SummarizeToolUse() = %q, want %q", got, want) + } +} + +func TestSummarizeToolUse_GivenBashWithLongVerb_WhenSummarized_ThenTruncatesToTheVerbBudget(t *testing.T) { + input := session.ToolInput{Raw: map[string]any{ + "description": "跑覆蓋率", + "command": "npx " + strings.Repeat("v", 60), + }} + + got := SummarizeToolUse("Bash", input, "") + verb := strings.TrimPrefix(got, "[Bash] 跑覆蓋率 | ") + if len([]rune(verb)) != maxVerbLen { + t.Errorf("verb %q has %d runes, want %d", verb, len([]rune(verb)), maxVerbLen) + } +} diff --git a/internal/summarizer/summarizer.go b/internal/summarizer/summarizer.go index 6baa262..d9b79e1 100644 --- a/internal/summarizer/summarizer.go +++ b/internal/summarizer/summarizer.go @@ -48,11 +48,14 @@ func CleanPath(path string, cwd string) string { func SummarizeToolUse(name string, inp session.ToolInput, cwd string) string { switch name { case session.ToolBash: + cmd := inp.String("command") desc := inp.String("description") if desc != "" { + if verb := commandVerb(cmd); verb != "" { + return fmt.Sprintf("[Bash] %s | %s", desc, session.Truncate(verb, maxVerbLen)) + } return fmt.Sprintf("[Bash] %s", desc) } - cmd := inp.String("command") return fmt.Sprintf("[Bash] %s", session.Truncate(cmd, maxCommandLen)) case session.ToolRead: diff --git a/internal/summarizer/summarizer_test.go b/internal/summarizer/summarizer_test.go index 739e2d8..b4ce653 100644 --- a/internal/summarizer/summarizer_test.go +++ b/internal/summarizer/summarizer_test.go @@ -20,7 +20,7 @@ func TestSummarizeToolUse_Bash(t *testing.T) { { name: "with description", inp: toolInput(map[string]any{"command": "ls -la /some/path", "description": "List files in directory"}), - want: "[Bash] List files in directory", + want: "[Bash] List files in directory | ls -la", }, { name: "without description",