From 7d085cc33afe263d386b4f7440a34b33ebcd2e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Sat, 29 Aug 2026 22:13:05 +0800 Subject: [PATCH 1/6] feat: filter noise from successful tool result excerpts (ADR-007 #1) The success path took the first non-empty line with no filtering, so a script's own "--- HEAD ---" was reported as the state of a working tree and a test runner's "RUN v4.0.18" as the result of a test run. Failures already skipped known-noise shapes; this gives success its own set (echoed section headings, progress lines, version banners) and strips ANSI escapes on both paths. Section headings are recognized only when the same rule closes the line, so a diff's "--- a/file.go" survives as an excerpt. The success-only shapes are deliberately not applied to failures: a banner-shaped line can be the error itself when the call failed. Measured on a 1,258-Bash-call session: 292 lines (23%) now carry a better excerpt, none lost one, and ANSI escapes drop from 95 lines to zero. A prose usage banner (gh's "Work seamlessly with GitHub from the command line.") has no shape to key on and is not covered; a test pins that limit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M8xGh8fwz3YjLhFTMYqvXB --- docs/adr-007-format-changes-measured.md | 186 +++++++++++++++++++++++ internal/session/event.go | 62 +++++++- internal/session/success_excerpt_test.go | 118 ++++++++++++++ 3 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 docs/adr-007-format-changes-measured.md create mode 100644 internal/session/success_excerpt_test.go diff --git a/docs/adr-007-format-changes-measured.md b/docs/adr-007-format-changes-measured.md new file mode 100644 index 0000000..a843785 --- /dev/null +++ b/docs/adr-007-format-changes-measured.md @@ -0,0 +1,186 @@ +# ADR-007:五項輸出格式改動,量過才決定 + +**狀態**:已決定,實作待做。量測程式在 `experiment/format-probes` 分支(不合併)。 + +取代 ADR-003 的三項決定:成功結果的摘錄取法、Bash 摘要的內容、成功狀態標記。 +其餘 ADR-003 決定不變。 + +## 五項決定 + +| # | 改動 | token 影響 | +|---|------|-----------:| +| 1 | 成功結果的摘錄套用與失敗路徑同一套雜訊過濾,並剝掉 ANSI | 內容變動,未單獨計 | +| 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,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,截斷少一點。 + +已知瑕疵,實作時一併處理:`/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%。 + +**這個常數綁在 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/internal/session/event.go b/internal/session/event.go index ffe7251..5137572 100644 --- a/internal/session/event.go +++ b/internal/session/event.go @@ -241,8 +241,8 @@ func (r ToolResult) Summary() string { case ToolRead, ToolWrite, ToolEdit, ToolAgent: return fmt.Sprintf(" -> %s", r.Status()) } - 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: %s", r.Status(), excerpt) } return fmt.Sprintf(" -> %s", r.Status()) } @@ -292,22 +292,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/success_excerpt_test.go b/internal/session/success_excerpt_test.go new file mode 100644 index 0000000..12e184f --- /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 := " -> ok: " + 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(), " -> ok: 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(), " -> ok: === 規模 ==="; 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) + } +} From 2898ca87a6e07c6ff3dc2fc586ea63329c1fb382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Sat, 29 Aug 2026 22:17:11 +0800 Subject: [PATCH 2/6] feat: carry the Bash command verb, drop the success marker (ADR-007 #2, #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that pay for each other. The Bash summary printed only the description Claude wrote for itself, so 78% of a session's tool calls could not be checked against what actually ran. And "-> ok" annotated 1,550 of 1,604 calls with the state a reader already assumes. The verb is not a prefix of the command. Real commands open with `cd &&`, an env assignment, a shell keyword, or an echoed heading, so a 20-character prefix reads "cd /Users/maple/Desk" and names nothing; a 60-character one costs +9.8% tokens for the same reason. Stepping over those to the first real program name reaches one on 1,258 of 1,258 Bash calls in the measured session at +2.0%, which the -2.0% from the dropped marker cancels. Measured together over 5 sessions: -0.0%. Success now renders its excerpt or diff stat alone, still introduced by "->" so the call stays separable from its result. FAILED keeps its marker, and with it formatRetryCollapse's insertion point for "×N". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M8xGh8fwz3YjLhFTMYqvXB --- cmd/cc-session/e2e_test.go | 4 +- internal/analyzer/audit_test.go | 12 +-- internal/analyzer/render_test.go | 2 +- internal/analyzer/stats_test.go | 2 +- internal/claudecodec/reader_test.go | 4 +- internal/formatter/collapse_test.go | 8 +- internal/formatter/formatter_test.go | 20 ++--- internal/session/event.go | 24 +++-- internal/session/event_test.go | 12 +-- internal/session/success_excerpt_test.go | 6 +- internal/summarizer/command_verb.go | 96 ++++++++++++++++++++ internal/summarizer/command_verb_test.go | 110 +++++++++++++++++++++++ internal/summarizer/summarizer.go | 5 +- internal/summarizer/summarizer_test.go | 2 +- 14 files changed, 265 insertions(+), 42 deletions(-) create mode 100644 internal/summarizer/command_verb.go create mode 100644 internal/summarizer/command_verb_test.go diff --git a/cmd/cc-session/e2e_test.go b/cmd/cc-session/e2e_test.go index 58a5726..88c6ecc 100644 --- a/cmd/cc-session/e2e_test.go +++ b/cmd/cc-session/e2e_test.go @@ -60,7 +60,7 @@ func TestCLI_WhenSessionExists_ThenListReadContextAndAuditWorkEndToEnd(t *testin want: []string{ "[05-28 00:00] user:\nhello", "[05-28 00:00] assistant:\nhi", - "[Bash#ol-1] Echo ok -> ok: ok", + "[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/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_test.go b/internal/analyzer/stats_test.go index ed75a9e..a4b54a1 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"`) 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/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/formatter_test.go b/internal/formatter/formatter_test.go index 9537aa1..779a30c 100644 --- a/internal/formatter/formatter_test.go +++ b/internal/formatter/formatter_test.go @@ -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) } } @@ -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/session/event.go b/internal/session/event.go index 5137572..96bf636 100644 --- a/internal/session/event.go +++ b/internal/session/event.go @@ -232,6 +232,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 +247,12 @@ func (r ToolResult) Summary() string { } switch r.RawName { case ToolRead, ToolWrite, ToolEdit, ToolAgent: - return fmt.Sprintf(" -> %s", r.Status()) + return "" } if excerpt := firstMeaningfulSuccessLine(r.Text, successExcerptMaxRunes); excerpt != "" { - return fmt.Sprintf(" -> %s: %s", r.Status(), 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 +268,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) } 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 index 12e184f..91755f6 100644 --- a/internal/session/success_excerpt_test.go +++ b/internal/session/success_excerpt_test.go @@ -56,7 +56,7 @@ func TestSuccessExcerpt_GivenNoisyFirstLine_WhenSummarized_ThenSkipsToTheAnswer( for name, tc := range tests { t.Run(name, func(t *testing.T) { result := ToolResult{Success: true, RawName: ToolBash, Text: tc.output} - want := " -> ok: " + tc.want + want := " -> " + tc.want if got := result.Summary(); got != want { t.Errorf("Summary() = %q, want %q", got, want) } @@ -76,7 +76,7 @@ func TestSuccessExcerpt_GivenProseUsageBanner_WhenSummarized_ThenStillReportsIt( Text: "Work seamlessly with GitHub from the command line.\n{\"state\":\"success\"}", } - if got, want := result.Summary(), " -> ok: Work seamlessly with GitHub from the command line."; got != want { + if got, want := result.Summary(), " -> Work seamlessly with GitHub from the command line."; got != want { t.Errorf("Summary() = %q, want %q", got, want) } } @@ -84,7 +84,7 @@ func TestSuccessExcerpt_GivenProseUsageBanner_WhenSummarized_ThenStillReportsIt( func TestSuccessExcerpt_GivenEveryLineIsNoise_WhenSummarized_ThenKeepsTheFirstLineRatherThanDroppingIt(t *testing.T) { result := ToolResult{Success: true, RawName: ToolBash, Text: "=== 規模 ===\n--- HEAD ---"} - if got, want := result.Summary(), " -> ok: === 規模 ==="; got != want { + if got, want := result.Summary(), " -> === 規模 ==="; got != want { t.Errorf("Summary() = %q, want %q", got, want) } } 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", From 14ab66ed55509018c634d4fea8305ed864185cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Sat, 29 Aug 2026 22:20:06 +0800 Subject: [PATCH 3/6] feat: move the date out of every message header (ADR-007 #4) "[08-06 03:06]" repeated a date identical on every message of a day and had no year at all, so a read output gave no way to place a session in time (context could fall back to its session header; read has none). The date moves to a "--- 2026-08-06 ---" marker emitted when it rolls over, and the space it freed buys second precision. Measured over 5 sessions this is -0.4% tokens while carrying strictly more than the old format: seconds and the year. Sessions really do span days in the sample (2 and 3), but sparsely: the marker fired twice across a 1,808-event session. An unparseable timestamp renders a placeholder and leaves the day state alone, so a malformed event cannot invent a date boundary. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M8xGh8fwz3YjLhFTMYqvXB --- cmd/cc-session/e2e_test.go | 4 +- cmd/cc-session/main_test.go | 15 ++--- internal/analyzer/stats_test.go | 4 +- internal/formatter/formatter_test.go | 30 +++++----- internal/formatter/read.go | 28 ++++++++-- internal/formatter/render.go | 3 + internal/formatter/timestamp.go | 46 ++++++++++++++++ internal/formatter/timestamp_test.go | 82 ++++++++++++++++++++++++++++ internal/parser/time.go | 16 ++++++ 9 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 internal/formatter/timestamp.go create mode 100644 internal/formatter/timestamp_test.go diff --git a/cmd/cc-session/e2e_test.go b/cmd/cc-session/e2e_test.go index 88c6ecc..d0f4b6d 100644 --- a/cmd/cc-session/e2e_test.go +++ b/cmd/cc-session/e2e_test.go @@ -58,8 +58,8 @@ 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", + "[00:00:00] user:\nhello", + "[00:00:01] assistant:\nhi", "[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/internal/analyzer/stats_test.go b/internal/analyzer/stats_test.go index a4b54a1..e985328 100644 --- a/internal/analyzer/stats_test.go +++ b/internal/analyzer/stats_test.go @@ -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/formatter/formatter_test.go b/internal/formatter/formatter_test.go index 779a30c..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). @@ -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]) } diff --git a/internal/formatter/read.go b/internal/formatter/read.go index 7711318..0a23c1e 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, "user", 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..bb6ae83 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 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/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 == "" { From 461ef704cc18db91f9e97cff00f3b15f9ce03e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Sat, 29 Aug 2026 22:21:39 +0800 Subject: [PATCH 4/6] feat: raise the inherit page size to 28,000 bytes (ADR-007 #5) The 20,000 limit existed to keep a page inside the harness's Bash stdout cap, but that cap was never measured. Binary search on 2026-08-29 put it at 30,000 characters: 30,000 came back inline, 31,000 was persisted to a file with a 2KB preview. 28,000 leaves headroom for the page marker and footer; the largest real page now renders at 28,073 bytes. A 871KB session drops from 44 pages to 32, so inheriting it costs 12 fewer Bash round-trips, each of which was a separate API request paying cache read on the whole prefix. The constant is exported so the boundary tests derive their cases from it instead of restating 20,000, which had already stopped describing the real boundary in three of them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M8xGh8fwz3YjLhFTMYqvXB --- cmd/cc-session/benchmark_test.go | 18 ++++++++++++------ internal/inject/inject.go | 20 ++++++++++++++++---- internal/inject/inject_test.go | 2 +- 3 files changed, 29 insertions(+), 11 deletions(-) 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/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) } } From ef4cd85e27d2edb8e2020aec3d18def35b2757d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Sat, 29 Aug 2026 22:23:08 +0800 Subject: [PATCH 5/6] docs: record ADR-007's as-built numbers The set lands at +0.3% tokens rather than the predicted -0.4%. The gap is decision 1 doing its job: skipping a noise line reaches a longer, more informative one. Also records the two rules the implementation tightened (symmetric section headings only, indented code not skipped) and the shell control keywords the verb extraction had to step over. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M8xGh8fwz3YjLhFTMYqvXB --- docs/adr-007-format-changes-measured.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/adr-007-format-changes-measured.md b/docs/adr-007-format-changes-measured.md index a843785..01c4617 100644 --- a/docs/adr-007-format-changes-measured.md +++ b/docs/adr-007-format-changes-measured.md @@ -1,6 +1,11 @@ # ADR-007:五項輸出格式改動,量過才決定 -**狀態**:已決定,實作待做。量測程式在 `experiment/format-probes` 分支(不合併)。 +**狀態**:已實作。量測程式在 `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 決定不變。 @@ -9,7 +14,7 @@ | # | 改動 | token 影響 | |---|------|-----------:| -| 1 | 成功結果的摘錄套用與失敗路徑同一套雜訊過濾,並剝掉 ANSI | 內容變動,未單獨計 | +| 1 | 成功結果的摘錄套用與失敗路徑同一套雜訊過濾,並剝掉 ANSI | 內容變動,實測整體 +0.7% | | 2 | Bash 摘要加上指令動詞(跳過鋪陳後的「程式 + 第一個參數」,上限 30 字元) | +1.9% | | 3 | 成功不再標 `-> ok`,只標 `-> FAILED` | −2.0% | | 4 | 訊息時間戳改成 `[HH:MM:SS]`,換日時插一行 `--- YYYY-MM-DD ---` | −0.4% | @@ -49,6 +54,16 @@ 改法是把成功路徑接上 `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 結果,逐筆比對第一行與最後一行, @@ -99,6 +114,10 @@ Bash 佔範例 session 1,604 個 tool 呼叫裡的 1,258 個(78%), 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。 @@ -164,7 +183,7 @@ Bash 佔範例 session 1,604 個 tool 呼叫裡的 1,258 個(78%), | 50,000 | 寫成檔案 | 門檻是 30,000 字元。扣掉 page marker 與 footer 的空間,取 28,000。 -那個 871KB 的 session 從 44 頁降到 32 頁,往返少 27%。 +那個 871KB 的 session 從 44 頁降到 32 頁,往返少 27%。實測最大的一頁 28,073 bytes。 **這個常數綁在 harness 的行為上,不是 cc-session 自己說了算。** 只驗證了這一版 Claude Code,而該上限使用者可以調整。 From 3e80212a9421709574c98b1f74334a1d5dc6deb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Wed, 2 Sep 2026 04:01:46 +0800 Subject: [PATCH 6/6] feat: classify harness-injected events and count turns by policy (ADR-008) Reader now recognizes eight entry types the CLI added after the noise whitelist was written, classifies six harness-injected user messages in the parser layer and renders them under a `harness:` role label, detects teammate messages by a set of markers instead of a disclaimer string that no longer matches, and resolves skill-body injections through the sourceToolUseID link to the Skill tool_use so bundled skills without a base-directory line compact to `[skill: name]`. Turn counting moves out of the character-accounting loop into UserMessage.CountsAsTurn(), calibrated against transcripts: teammate messages, task notifications, and compaction summaries start a round and count; interrupts and agents-stopped notices do not. Docs: SKILL.md and README record the 28K page size and the harness role. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TcvWErnKnuADJsomnZfwFr --- README.md | 2 +- README.zh-TW.md | 2 +- SKILL.md | 6 +- docs/adr-008-harness-event-drift.md | 237 ++++++++++++++++++ internal/analyzer/stats.go | 19 +- internal/claudecodec/classify.go | 179 +++++++++++-- internal/claudecodec/harness_classify_test.go | 172 +++++++++++++ internal/claudecodec/model.go | 17 +- internal/claudecodec/reader.go | 65 +++-- .../claudecodec/skill_injection_link_test.go | 125 +++++++++ internal/formatter/context.go | 6 +- internal/formatter/harness_role_test.go | 91 +++++++ internal/formatter/read.go | 2 +- internal/formatter/render.go | 55 +++- internal/session/compact.go | 29 +++ internal/session/event.go | 56 +++++ internal/session/turn_test.go | 131 ++++++++++ 17 files changed, 1137 insertions(+), 57 deletions(-) create mode 100644 docs/adr-008-harness-event-drift.md create mode 100644 internal/claudecodec/harness_classify_test.go create mode 100644 internal/claudecodec/skill_injection_link_test.go create mode 100644 internal/formatter/harness_role_test.go create mode 100644 internal/session/turn_test.go 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/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/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/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/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/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/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 0a23c1e..dbc9991 100644 --- a/internal/formatter/read.go +++ b/internal/formatter/read.go @@ -59,7 +59,7 @@ func renderReadEvents(events []session.Event, rc renderContext) error { continue } flush() - writeEventBlock(rc, event.Timestamp, "user", rendered.body, true) + writeEventBlock(rc, event.Timestamp, rendered.role, rendered.body, true) if rc.sink != nil { rc.sink(CategoryUserText, rendered.body) } diff --git a/internal/formatter/render.go b/internal/formatter/render.go index bb6ae83..447c575 100644 --- a/internal/formatter/render.go +++ b/internal/formatter/render.go @@ -49,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 } @@ -68,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 { @@ -78,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. @@ -86,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/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 96bf636..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 { 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) + } +}