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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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 <id> -max-lines 200` |
| `context` | 精簡注入格式,含 session metadata header | `cc-session context <id>` |
| `inject` | 分頁 context 注入(每頁 ≤20K chars,自動追蹤進度,`-reset` 重來) | `cc-session inject <id>` |
| `inherit` | 分頁 context 繼承(每頁 ≤20K chars,自動追蹤進度,`-reset` 重來) | `cc-session inherit <id>` |
| `stats` | 字元與 token 分佈統計及壓縮比 | `cc-session stats <id> -no-tokens` |
| `audit` | 取樣被過濾的內容,確認沒漏掉重要資訊 | `cc-session audit <id> -n 10` |
| `expand` | 展開特定 tool call 的完整 input/result | `cc-session expand <id> uCVa` |
Expand All @@ -60,7 +60,7 @@ Session ID 支援 prefix match,通常前 8 碼就夠。`read` 和 `context`

## 壓縮邏輯

工具呼叫、Bash 輸出、Agent 結果、thinking 都預設壓成摘要或一行 marker;User/Assistant 對話文字完整保留。當 session 內有 cc-session inject/read/context 呼叫時,連續呼叫壓成一行(如 `(cc-session#id: injected session X here, N lines omitted)`),`-verbose-bash` 跳過此壓縮。Skill injection、teammate warning、command injection、Context Usage 區塊、system-reminder 等 injection 類型會額外壓縮或整段移除,減少 context 噪音。詳細過濾規則見 [SKILL.md](SKILL.md)。
工具呼叫、Bash 輸出、Agent 結果、thinking 都預設壓成摘要或一行 marker;User/Assistant 對話文字完整保留。當 session 內有 cc-session inherit/read/context 呼叫時,連續呼叫壓成一行(如 `(cc-session#id: inherited session X here, N lines omitted)`),`-verbose-bash` 跳過此壓縮。舊 session 裡的 `cc-session inject` 呼叫(改名前的舊命令名)也會比照壓成一行,維持 `injected session X here` 的措辭。Skill injection、teammate warning、command injection、Context Usage 區塊、system-reminder 等 injection 類型會額外壓縮或整段移除,減少 context 噪音。詳細過濾規則見 [SKILL.md](SKILL.md)。

## Config 設定

Expand Down
21 changes: 11 additions & 10 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ allowed-tools:
## 讀取 session 內容

read 預設截斷在 200 行——大多數 session 遠超這個長度,只看得到開頭一小段。
inject 將完整 session 分頁載入(每頁 ≤20K chars),確保完整覆蓋。
inherit 將完整 session 分頁載入(每頁 ≤20K chars),確保完整覆蓋。

讀 session 時用 inject。只在使用者明確指名要看某段特定內容時用 read 搭配 `-offset` 跳讀。
讀 session 時用 inherit。只在使用者明確指名要看某段特定內容時用 read 搭配 `-offset` 跳讀。

### inject 操作方式
### inherit 操作方式

inject 記住讀取進度,重複呼叫同一個命令即自動翻頁:
inherit 記住讀取進度,重複呼叫同一個命令即自動翻頁:

1. `cc-session inject <id>` → 第 1 頁,標示 `[page 1/N | lines X-Y of Z]`
2. 再次呼叫 `cc-session inject <id>`(同樣的命令,同樣的參數)→ 第 2 頁
3. 持續呼叫,直到輸出包含 `[inject complete]`
1. `cc-session inherit <id>` → 第 1 頁,標示 `[page 1/N | lines X-Y of Z]`
2. 再次呼叫 `cc-session inherit <id>`(同樣的命令,同樣的參數)→ 第 2 頁
3. 持續呼叫,直到輸出包含 `[inherit complete]`
4. 所有頁面讀完後,分析內容並回答使用者

`-page N` 跳到指定頁。`-reset` 清除進度從頭開始。
Expand All @@ -32,7 +32,7 @@ inject 記住讀取進度,重複呼叫同一個命令即自動翻頁:
| 意圖 | 命令 |
|------|------|
| 找目標 session | `cc-session list` — 列出最近 session,`-p` 過濾專案,用過 cc-session 的標 `[refs]` |
| 讀 session(預設) | `cc-session inject <id>` — 分頁載入,重複呼叫翻頁 |
| 讀 session(預設) | `cc-session inherit <id>` — 分頁載入,重複呼叫翻頁 |
| 查特定片段 | `cc-session read <id>` — 預設 200 行,`-offset` 跳讀 |
| 緊湊單次輸出 | `cc-session context <id>` — 同 read 但更緊湊,帶 metadata header |
| 展開單一 tool call | `cc-session expand <id> <tool-id>` — tool-id 取自輸出中的 `[Tool#xxxx]` |
Expand All @@ -45,8 +45,9 @@ Session ID 支援 prefix match,前 8 碼通常就夠。各子命令的 flags

## 輸出行為

- 當 session 內有 `cc-session inject/read/context` 呼叫時,連續的同 session 呼叫會被壓成一行:
`(cc-session#Y1dg: injected session 16d06326 here, 1320 lines omitted)`
- 當 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` 的措辭
- tool ID(`#Y1dg`)保留,可用 `cc-session expand` 查看原始內容
- `-verbose-bash` 會跳過此壓縮,顯示完整 Bash 輸出

Expand Down
30 changes: 30 additions & 0 deletions cmd/cc-session/e2e_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"errors"
"os"
"os/exec"
Expand Down Expand Up @@ -121,6 +122,35 @@ func TestCLI_WhenSessionExists_ThenListReadContextAndAuditWorkEndToEnd(t *testin
}
}

func TestCLI_GivenInjectAlias_WhenInvoked_ThenBehavesLikeInheritAndWarnsOnStderr(t *testing.T) {
root := t.TempDir()
bin := filepath.Join(root, binaryName())
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = "."
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build CLI: %v\n%s", err, out)
}

sid := "12345678-1234-1234-1234-123456789abc"
writeE2EFixture(t, root, sid)

var stdout, stderr bytes.Buffer
cmd := exec.Command(bin, "inject", sid)
cmd.Env = homeEnv(root)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("cc-session inject %s failed: %v\nstderr:\n%s", sid, err, stderr.String())
}

if !strings.Contains(stderr.String(), "cc-session inject 已改名為 cc-session inherit") {
t.Fatalf("stderr missing deprecation notice, got: %q", stderr.String())
}
if !strings.Contains(stdout.String(), "[page 1/1") || !strings.Contains(stdout.String(), "[inherit complete: 1 pages") {
t.Fatalf("stdout missing paginated session output, got: %q", stdout.String())
}
}

// The cmdX wrappers funnel every error through a single contract: print
// "Error: <msg>" to stderr and exit non-zero. This is the user's only signal
// that a command failed. Tested out-of-process because the contract lives in
Expand Down
20 changes: 10 additions & 10 deletions cmd/cc-session/inject_cmd.go → cmd/cc-session/inherit_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@ import (
"github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session"
)

func cmdInject(args []string, reader session.TranscriptReader) {
exitOnError(runInject(args, os.Stdout, os.Stderr, parser.DefaultStore(), reader))
func cmdInherit(args []string, reader session.TranscriptReader) {
exitOnError(runInherit(args, os.Stdout, os.Stderr, parser.DefaultStore(), reader))
}

func runInject(args []string, out io.Writer, errOut io.Writer, store parser.Store, reader session.TranscriptReader) error {
fs := flag.NewFlagSet("inject", flag.ContinueOnError)
func runInherit(args []string, out io.Writer, errOut io.Writer, store parser.Store, reader session.TranscriptReader) error {
fs := flag.NewFlagSet("inherit", flag.ContinueOnError)
fs.SetOutput(errOut)
pageFlag := fs.Int("page", 0, "jump to specific page (1-based; 0 = auto-advance)")
resetFlag := fs.Bool("reset", false, "clear state and start from the beginning")
if err := fs.Parse(reorderArgs(args)); err != nil {
return err
}
if fs.NArg() < 1 {
return fmt.Errorf("usage: cc-session inject <session-id> [--page N] [--reset]")
return fmt.Errorf("usage: cc-session inherit <session-id> [--page N] [--reset]")
}

sessionPrefix := fs.Arg(0)
Expand All @@ -36,7 +36,7 @@ func runInject(args []string, out io.Writer, errOut io.Writer, store parser.Stor
if resolved.Path == "" {
return fmt.Errorf("transcript not found: %s", resolved.ID)
}
logUsageAsync("inject", session.ShortID(resolved.ID, 8))
logUsageAsync("inherit", session.ShortID(resolved.ID, 8))

if *resetFlag {
if err := inject.ClearState(resolved.ID); err != nil {
Expand All @@ -59,7 +59,7 @@ func runInject(args []string, out io.Writer, errOut io.Writer, store parser.Stor
pages := inject.SplitPages(allLines)
totalPages := len(pages)
if totalPages == 0 {
fmt.Fprintln(out, "[inject complete: 0 pages, 0 lines]")
fmt.Fprintln(out, "[inherit complete: 0 pages, 0 lines]")
return nil
}

Expand All @@ -72,7 +72,7 @@ func runInject(args []string, out io.Writer, errOut io.Writer, store parser.Stor
} else {
state, loadErr := inject.LoadState(resolved.ID)
if loadErr != nil {
return fmt.Errorf("load inject state: %w", loadErr)
return fmt.Errorf("load inherit state: %w", loadErr)
}
if state == nil {
pageNum = 1
Expand All @@ -82,7 +82,7 @@ func runInject(args []string, out io.Writer, errOut io.Writer, store parser.Stor
}

if pageNum > totalPages {
fmt.Fprintf(out, "[inject complete: %d pages, %d lines] — use -reset to start over\n", totalPages, totalLines)
fmt.Fprintf(out, "[inherit complete: %d pages, %d lines] — use -reset to start over\n", totalPages, totalLines)
return nil
}
if pageNum < 1 {
Expand Down Expand Up @@ -110,7 +110,7 @@ func runInject(args []string, out io.Writer, errOut io.Writer, store parser.Stor
}
if err := inject.SaveState(newState); err != nil {
// Non-fatal: output was already written.
fmt.Fprintf(errOut, "warning: could not save inject state: %v\n", err)
fmt.Fprintf(errOut, "warning: could not save inherit state: %v\n", err)
}
return nil
}
10 changes: 7 additions & 3 deletions cmd/cc-session/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Package main is the CLI entry point for the Claude session reader.
// Subcommands: list, read, context, stats, audit, expand, usage, inject.
// Subcommands: list, read, context, stats, audit, expand, usage, inherit.
// "inject" is kept as a hidden, deprecated alias for "inherit".
package main

import (
Expand Down Expand Up @@ -62,8 +63,11 @@ func main() {
cmdExpand(os.Args[2:], reader)
case "usage":
cmdUsage(os.Args[2:])
case "inherit":
cmdInherit(os.Args[2:], reader)
case "inject":
cmdInject(os.Args[2:], reader)
fmt.Fprintln(os.Stderr, "cc-session inject 已改名為 cc-session inherit,inject 別名將於未來版本移除,請改用 inherit。")
cmdInherit(os.Args[2:], reader)
case "benchmark":
cmdBenchmark(os.Args[2:], reader)
default:
Expand All @@ -84,7 +88,7 @@ func printUsage() {
fmt.Fprintln(os.Stderr, " audit 檢視被過濾的內容取樣")
fmt.Fprintln(os.Stderr, " expand 展開特定 tool call 完整內容")
fmt.Fprintln(os.Stderr, " usage CLI 使用紀錄")
fmt.Fprintln(os.Stderr, " inject 分頁注入 session 到 context")
fmt.Fprintln(os.Stderr, " inherit 分頁繼承 session 到 context")
fmt.Fprintln(os.Stderr, " benchmark 掃描近期 session,計算壓縮率與成本比較")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Run 'cc-session <command> -h' for command-specific flags.")
Expand Down
6 changes: 3 additions & 3 deletions cmd/cc-session/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1220,11 +1220,11 @@ func TestRunList_GivenHelpFlag_ThenReturnsErrHelp(t *testing.T) {
}
}

func TestRunInject_GivenHelpFlag_ThenReturnsErrHelp(t *testing.T) {
func TestRunInherit_GivenHelpFlag_ThenReturnsErrHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runInject([]string{"-h"}, &stdout, &stderr, parser.Store{}, testReader)
err := runInherit([]string{"-h"}, &stdout, &stderr, parser.Store{}, testReader)
if err == nil || err.Error() != "flag: help requested" {
t.Fatalf("runInject(-h) = %v, want flag.ErrHelp", err)
t.Fatalf("runInherit(-h) = %v, want flag.ErrHelp", err)
}
if !strings.Contains(stderr.String(), "page") {
t.Fatalf("stderr should contain flag descriptions, got: %q", stderr.String())
Expand Down
20 changes: 11 additions & 9 deletions docs/adr-001-collapse-cc-session-calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,43 @@ Accepted

## Context

When a Claude Code session uses `cc-session inject` to load another session's content, the JSONL records each inject page as a separate Bash tool call. A 4-page inject produces 4 lines like:
When a Claude Code session uses `cc-session inherit` (formerly `cc-session inject`, see the CLI rename) to load another session's content, the JSONL records each page as a separate Bash tool call. A 4-page inherit produces 4 lines like:

```
[Bash#Y1dg] cc-session inject 16d06326-... -> ok: [page 1/4 | lines 1-377 of 1320]
[Bash#Ybwi] cc-session inject 16d06326-... -> ok: [page 2/4 | lines 378-720 of 1320]
[Bash#iNMY] cc-session inject 16d06326-... -> ok: [page 3/4 | lines 721-1051 of 1320]
[Bash#iMqP] cc-session inject 16d06326-... -> ok: [page 4/4 | lines 1052-1320 of 1320]
[Bash#Y1dg] cc-session inherit 16d06326-... -> ok: [page 1/4 | lines 1-377 of 1320]
[Bash#Ybwi] cc-session inherit 16d06326-... -> ok: [page 2/4 | lines 378-720 of 1320]
[Bash#iNMY] cc-session inherit 16d06326-... -> ok: [page 3/4 | lines 721-1051 of 1320]
[Bash#iMqP] cc-session inherit 16d06326-... -> ok: [page 4/4 | lines 1052-1320 of 1320]
```

These lines carry zero information for the reader — the injected content was consumed by the AI in that session, and its conclusions already appear in the subsequent assistant messages. Showing 4 redundant lines wastes output space and context tokens when the output is fed back into another session.

## Decision

Collapse consecutive `cc-session inject/read/context` Bash tool calls targeting the same session into a single descriptive line:
Collapse consecutive `cc-session inherit/read/context` Bash tool calls targeting the same session into a single descriptive line:

```
(cc-session: injected session 16d06326 here, 1320 lines omitted)
(cc-session: inherited session 16d06326 here, 1320 lines omitted)
```

The collapsing is:
- **Automatic** — no flag required. The original tool call details remain accessible via `cc-session expand <session> <tool-id>`.
- **Scoped to cc-session CLI calls only** — other Bash tool calls (including the model fumbling with `which cc-session`, `node cc-session.mjs`, etc.) render normally as one-line summaries.
- **Applied in both `read` and `context` output formats** — the collapsing logic lives in a shared `collapseCCSessionTools()` function called by both renderers' flush paths.
- **Backward compatible with `inject`** — older transcripts recorded before the `inject` → `inherit` CLI rename still contain literal `cc-session inject` calls. Those still collapse the same way, keeping their historical "injected" wording so old sessions read naturally.

### Detection

`parseCCSessionCommand(cmd)` checks if a Bash command matches `cc-session {inject|read|context} <session-id>`. It returns the subcommand and an 8-char session ID prefix. Args starting with `-` (flags like `-h`) are rejected to avoid false positives.
`parseCCSessionCommand(cmd)` checks if a Bash command matches `cc-session {inherit|inject|read|context} <session-id>`. It returns the subcommand and an 8-char session ID prefix. Args starting with `-` (flags like `-h`) are rejected to avoid false positives.

### Total lines extraction

`parseTotalLines(text)` extracts the total line count from the first line of a tool result, matching the `of N]` pattern in cc-session's page markers. Only the first line is searched to avoid false matches against session content that contains "of " elsewhere.

### Verb selection

- `inject` → "injected session X here"
- `inherit` → "inherited session X here"
- `inject` (legacy) → "injected session X here"
- `read` / `context` → "loaded session X here"

## Consequences
Expand Down
47 changes: 42 additions & 5 deletions internal/formatter/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,13 @@ func TestParseCCSessionCommand(t *testing.T) {
wantSession string
}{
{
name: "given inject with full uuid then returns inject and 8-char prefix",
name: "given inherit with full uuid then returns inherit and 8-char prefix",
cmd: "cc-session inherit 16d06326-977b-4c82-a38f-9b2358aa80ca",
wantSub: "inherit",
wantSession: "16d06326",
},
{
name: "given legacy inject with full uuid then returns inject and 8-char prefix",
cmd: "cc-session inject 16d06326-977b-4c82-a38f-9b2358aa80ca",
wantSub: "inject",
wantSession: "16d06326",
Expand Down Expand Up @@ -934,10 +940,13 @@ func TestParseTotalLines(t *testing.T) {
}
}

func makeInjectEvents(sessionID string, pages int) []session.Event {
// makeInjectEvents builds a sequence of Bash tool-use/result events for a
// paginated "cc-session <subcommand> <sessionID>" call, e.g. "inherit" (current)
// or "inject" (legacy, kept for old transcripts).
func makeCCSessionEvents(subcommand, sessionID string, pages int) []session.Event {
events := make([]session.Event, 0, pages*2)
for p := 1; p <= pages; p++ {
toolID := fmt.Sprintf("inject-tool-%d", p)
toolID := fmt.Sprintf("%s-tool-%d", subcommand, p)
events = append(events,
session.Event{
Kind: session.EventAssistantMessage,
Expand All @@ -948,8 +957,8 @@ func makeInjectEvents(sessionID string, pages int) []session.Event {
ID: toolID,
Name: "Bash",
Input: session.ToolInput{Raw: map[string]any{
"command": "cc-session inject " + sessionID,
"description": "Inject session",
"command": fmt.Sprintf("cc-session %s %s", subcommand, sessionID),
"description": "Load session",
}},
},
},
Expand All @@ -968,6 +977,12 @@ func makeInjectEvents(sessionID string, pages int) []session.Event {
return events
}

// makeInjectEvents is a convenience wrapper for the legacy "cc-session inject"
// verb, used by tests that specifically cover pre-rename transcripts.
func makeInjectEvents(sessionID string, pages int) []session.Event {
return makeCCSessionEvents("inject", sessionID, pages)
}

func TestFormatReadEvents_GivenConsecutiveInjectCalls_WhenRendered_ThenCollapsesIntoOneLine(t *testing.T) {
sessionID := "16d06326-977b-4c82-a38f-9b2358aa80ca"
events := makeInjectEvents(sessionID, 4)
Expand All @@ -990,6 +1005,28 @@ func TestFormatReadEvents_GivenConsecutiveInjectCalls_WhenRendered_ThenCollapses
}
}

func TestFormatReadEvents_GivenConsecutiveInheritCalls_WhenRendered_ThenCollapsesWithInheritedVerb(t *testing.T) {
sessionID := "16d06326-977b-4c82-a38f-9b2358aa80ca"
events := makeCCSessionEvents("inherit", sessionID, 4)

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()

want := "(cc-session#ol-1: inherited session 16d06326 here, 400 lines omitted)"
if !strings.Contains(got, want) {
t.Fatalf("expected collapsed inherit line with 'inherited' verb\nwant substring: %q\ngot:\n%s", want, got)
}
if strings.Contains(got, "[Bash") {
t.Fatalf("individual inherit Bash entries must not appear in output\ngot:\n%s", got)
}
if strings.Count(got, "cc-session#") > 1 {
t.Fatalf("must collapse to exactly one inherit summary line\ngot:\n%s", got)
}
}

func TestFormatReadEvents_GivenMixedToolsWithInject_WhenRendered_ThenOnlyCollapsesInjectOnes(t *testing.T) {
sessionID := "16d06326-977b-4c82-a38f-9b2358aa80ca"
injectEvents := makeInjectEvents(sessionID, 2)
Expand Down
Loading
Loading