From e6e8c9a14ce6b9074fa0c8cf2191c920f903b454 Mon Sep 17 00:00:00 2001 From: Chet Zhang Date: Mon, 9 Mar 2026 14:02:16 +0800 Subject: [PATCH] feat(analyze): add session previews and release docs --- AGENTS.md | 6 +- docs/HOMEBREW_TAP.md | 7 ++- docs/RELEASING.md | 20 +++++-- docs/current-plan.md | 1 + docs/current-plans/release-brew-refresh.md | 26 ++++++++ docs/handoffs/refactor-analyze-tui.md | 2 + docs/repo-map.md | 2 +- internal/cleaner/analyze_tui.go | 62 ++++++++++++++++++- internal/cleaner/analyze_tui_test.go | 5 ++ internal/cleaner/openclaw_sessions.go | 69 ++++++++++++++++++++++ 10 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 docs/current-plans/release-brew-refresh.md diff --git a/AGENTS.md b/AGENTS.md index 1c9476c..c25ebcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,10 @@ ## CD Contract -- This repository does not currently define a formal release or deployment pipeline -- If tag releases, Homebrew distribution, installers, or automated publishing are added later, update this file and the related workflows together +- Tag pushes matching `v*` trigger `.github/workflows/release.yml`, which runs GoReleaser on `ubuntu-latest` +- Release packaging publishes macOS archives plus `checksums.txt` for installer and Homebrew consumers +- Homebrew distribution is published through the separate tap repository `carlisle0615/homebrew-openagentcleaner` +- Any change to archive names, release assets, installer behavior, or tap publishing must update this file, `.goreleaser.yaml`, `docs/RELEASING.md`, and `scripts/generate-homebrew-formula.sh` together ## Runbook diff --git a/docs/HOMEBREW_TAP.md b/docs/HOMEBREW_TAP.md index 7a84010..6ab890e 100644 --- a/docs/HOMEBREW_TAP.md +++ b/docs/HOMEBREW_TAP.md @@ -4,7 +4,7 @@ ## Recommended Repository -Create this repository under the same GitHub account: +Use this repository under the same GitHub account: ```text carlisle0615/homebrew-openagentcleaner @@ -29,7 +29,8 @@ homebrew-openagentcleaner/ After a successful release, download `checksums.txt` from the GitHub Release page and run: ```bash -./scripts/generate-homebrew-formula.sh v0.1.1 checksums.txt > Formula/oac.rb +gh release download v0.1.2 -p checksums.txt -D dist/release-artifacts +./scripts/generate-homebrew-formula.sh v0.1.2 dist/release-artifacts/checksums.txt > Formula/oac.rb ``` The generated formula will point to: @@ -45,7 +46,7 @@ Inside the tap repository: mkdir -p Formula cp /path/to/generated/oac.rb Formula/oac.rb git add Formula/oac.rb -git commit -m "feat: add oac v0.1.1 formula" +git commit -m "feat: add oac v0.1.2 formula" git push origin main ``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 096485e..eea7ce4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,12 +1,17 @@ # Releasing -This project ships macOS release archives through GitHub Releases. +This project ships macOS release archives through GitHub Releases and publishes a matching Homebrew formula through a separate tap repository. ## Tag a Release +1. Merge the release-ready branch into `main`. +2. Create and push a new semantic version tag. + ```bash -git tag v0.1.0 -git push origin v0.1.0 +git checkout main +git pull --ff-only +git tag v0.1.2 +git push origin v0.1.2 ``` The release workflow will build: @@ -15,6 +20,8 @@ The release workflow will build: - `oac__darwin_amd64.tar.gz` - `checksums.txt` +The workflow definition lives in `.github/workflows/release.yml` and runs on `ubuntu-latest`. Because `.goreleaser.yaml` executes `go test ./...` in a `before.hooks` step, all release-gating tests must be portable to Linux even if the shipped binaries are macOS-only. + ## One-Line Install Users can install the latest release with: @@ -42,13 +49,14 @@ That command expects a tap repository named `homebrew-openagentcleaner` under th Generate the formula after a release: ```bash -./scripts/generate-homebrew-formula.sh v0.1.0 dist/checksums.txt > Formula/oac.rb +gh release download v0.1.2 -p checksums.txt -D dist/release-artifacts +./scripts/generate-homebrew-formula.sh v0.1.2 dist/release-artifacts/checksums.txt > Formula/oac.rb ``` -Then commit `Formula/oac.rb` into the tap repository. +Then commit `Formula/oac.rb` into the tap repository `carlisle0615/homebrew-openagentcleaner`. ## Notes - The current installer supports macOS only. -- The current Homebrew packaging plan targets a separate tap repo, not this source repository. +- The Homebrew formula should be updated only after the GitHub Release assets are visible and checksums are final. - `oac version` reads the release version injected at build time. diff --git a/docs/current-plan.md b/docs/current-plan.md index a997f7d..44aaea5 100644 --- a/docs/current-plan.md +++ b/docs/current-plan.md @@ -9,6 +9,7 @@ Last updated: 2026-03-09 | Add `GEMINI.md` symlink | Completed (verified) | `docs/current-plans/gemini-md-symlink.md` | | Collaboration doc baseline migration | Completed (verified) | `docs/current-plans/context-doc-bootstrap.md` | | Refactor Analyze TUI | Completed (verified) | `docs/current-plans/refactor-analyze-tui.md` | +| Refresh release and Homebrew tap | In progress | `docs/current-plans/release-brew-refresh.md` | ## Notes diff --git a/docs/current-plans/release-brew-refresh.md b/docs/current-plans/release-brew-refresh.md new file mode 100644 index 0000000..d5e4bbf --- /dev/null +++ b/docs/current-plans/release-brew-refresh.md @@ -0,0 +1,26 @@ +# Release And Homebrew Refresh + +## Goal + +Push the current staged repository changes through the protected-branch workflow, cut a new GitHub Release from a green `main`, and update the Homebrew tap formula to the newly published version. + +## Touched Files + +- `docs/current-plan.md` +- `docs/current-plans/release-brew-refresh.md` +- Release-related code or tests only if required to unblock CI +- Homebrew formula files in this repo or `carlisle0615/homebrew-openagentcleaner` + +## Validation Steps + +1. Run `make fmt`. +2. Run `make build`. +3. Run `make test`. +4. Confirm the release workflow succeeds for the new tag. +5. Confirm the Homebrew formula points to the new tag and checksum. + +## Doc-Sync Steps + +- Keep `docs/current-plan.md` aligned with task status. +- Update `docs/repo-map.md` or `AGENTS.md` only if release or tap workflow changes materially. +- Add or update a handoff note under `docs/handoffs/` if the release/tap work leaves follow-up items. diff --git a/docs/handoffs/refactor-analyze-tui.md b/docs/handoffs/refactor-analyze-tui.md index b660adb..ce603c8 100644 --- a/docs/handoffs/refactor-analyze-tui.md +++ b/docs/handoffs/refactor-analyze-tui.md @@ -13,6 +13,8 @@ - 对过长的 Session Label 执行了截断 `trimForDisplay(str, 22)` 处理,以此消灭了因终端窄宽或长 ID 名字引发的布局彻底崩散(挤出多行的 Bug)。 - 所有 List 使用 `❯` 作为激活高亮,仅改变前景强调色,消除长短不一的色块。 - Header 与 Footer 转变为简单统一的暗色提示序列和面包屑,整体对齐在代码或详情上。 +- **对话详情预览层(Session Preview)**: + - 加载了 `openclaw_sessions.go` 解析 `.jsonl` 时抽取 `message` 内容的提取逻辑(`previewOpenClawSession`),支持在列表按 `Enter` 切入完整的对话预览页面查阅最近几条的互动内容,防止误删会话。 ## 验证结论 diff --git a/docs/repo-map.md b/docs/repo-map.md index c5be92f..d8ecca8 100644 --- a/docs/repo-map.md +++ b/docs/repo-map.md @@ -102,7 +102,7 @@ Last updated: 2026-03-09 - `manual` is the last safety boundary and should not be downgraded just because a path looks cache-like - The non-interactive behavior in `cleanReport` depends on `--yes` / `--dry-run`; this is a core agent-mode guardrail - `openclaw_sessions.go` edits `sessions.json` after transcript deletions; mismatches there can create orphaned metadata or confusing UX -- CI runs on `macos-latest`, but `go test ./...` should remain independent from local machine state whenever possible +- CI runs on `macos-latest`, while the release workflow runs GoReleaser plus `go test ./...` on `ubuntu-latest`; tests must stay cross-platform unless they are explicitly guarded - The release installer and Homebrew formula generator assume archive names shaped like `oac__darwin_.tar.gz` - `gofmt -w ... && git diff --exit-code` modifies files before validating cleanliness, so any format target changes must stay in sync across CI and `Makefile` diff --git a/internal/cleaner/analyze_tui.go b/internal/cleaner/analyze_tui.go index 60fedcf..b142ee4 100644 --- a/internal/cleaner/analyze_tui.go +++ b/internal/cleaner/analyze_tui.go @@ -18,6 +18,7 @@ const ( screenAssistants analyzeScreen = iota screenOpenClawMenu screenSessions + screenSessionPreview screenCandidates ) @@ -84,6 +85,7 @@ type analyzeModel struct { confirmItem Candidate status string lastErr error + previewText string } func runAnalyzeTUI(assistants []string, before time.Time, stdout, stderr io.Writer) error { @@ -373,6 +375,18 @@ func (m *analyzeModel) activateSelection() error { } m.screen = screenCandidates return m.reloadCandidates("openclaw") + case screenSessions: + if len(m.sessions) == 0 { + return nil + } + session := m.sessions[m.sessionIndex] + preview, err := previewOpenClawSession(session.TranscriptPath) + if err != nil { + return err + } + m.previewText = preview + m.screen = screenSessionPreview + return nil } return nil } @@ -460,6 +474,9 @@ func (m *analyzeModel) clearConfirm() { func (m *analyzeModel) navigateBack() { switch m.screen { + case screenSessionPreview: + m.screen = screenSessions + return case screenCandidates: if m.activeAssistant == "openclaw" { m.screen = screenOpenClawMenu @@ -601,14 +618,17 @@ func (m analyzeModel) View() string { func (m analyzeModel) renderHeader() string { breadcrumbs := []string{"Analyze"} switch m.screen { - case screenOpenClawMenu, screenSessions, screenCandidates: + case screenOpenClawMenu, screenSessions, screenSessionPreview, screenCandidates: if m.activeAssistant != "" { breadcrumbs = append(breadcrumbs, displayAssistant(m.activeAssistant)) } } - if m.screen == screenSessions { + if m.screen == screenSessions || m.screen == screenSessionPreview { breadcrumbs = append(breadcrumbs, "Conversations") } + if m.screen == screenSessionPreview { + breadcrumbs = append(breadcrumbs, "Preview") + } if m.screen == screenCandidates { breadcrumbs = append(breadcrumbs, "Items") } @@ -644,6 +664,8 @@ func (m analyzeModel) renderBody() (string, string) { return m.renderOpenClawMenu(), m.renderOpenClawDetail() case screenSessions: return m.renderSessionsList(), m.renderSessionDetail() + case screenSessionPreview: + return m.renderSessionsList(), m.renderSessionPreview() case screenCandidates: return m.renderCandidateList(), m.renderCandidateDetail() default: @@ -654,6 +676,8 @@ func (m analyzeModel) renderBody() (string, string) { func (m analyzeModel) renderFooter() string { keys := []string{"↑/↓ Navigate", "Enter Select", "q Back"} switch m.screen { + case screenSessionPreview: + keys = []string{"q Back to conversations"} case screenSessions: keys = append(keys, "d Delete item", "f Filter date", "x Bulk delete", "c Clear filter") case screenCandidates: @@ -785,6 +809,40 @@ func (m analyzeModel) renderSessionDetail() string { return strings.Join(lines, "\n") } +func (m analyzeModel) renderSessionPreview() string { + if m.previewText == "" { + return m.styles.muted.Render("Unable to load preview data.") + } + + lines := []string{ + m.styles.header.Render("Conversation Preview"), + "", + } + + // Truncate the preview text to fit reasonably well + previewLines := strings.Split(m.previewText, "\n") + maxLines := m.height - 12 // Leave space for headers, footers + if maxLines < 10 { + maxLines = 10 + } + + if len(previewLines) > maxLines { + // Just take the tail part + lines = append(lines, m.styles.muted.Render(fmt.Sprintf("... (%d truncated lines) ...", len(previewLines)-maxLines))) + previewLines = previewLines[len(previewLines)-maxLines:] + } + + for _, l := range previewLines { + if strings.HasPrefix(l, "== User ==") || strings.HasPrefix(l, "== Assistant ==") || strings.HasPrefix(l, "== System ==") { + lines = append(lines, m.styles.accent.Render(strings.TrimSpace(l))) + } else { + lines = append(lines, l) + } + } + + return strings.Join(lines, "\n") +} + func (m analyzeModel) renderCandidateList() string { lines := []string{m.styles.header.Render(displayAssistant(m.activeAssistant) + " items"), ""} if len(m.candidates) == 0 { diff --git a/internal/cleaner/analyze_tui_test.go b/internal/cleaner/analyze_tui_test.go index 00c4779..589fa38 100644 --- a/internal/cleaner/analyze_tui_test.go +++ b/internal/cleaner/analyze_tui_test.go @@ -2,6 +2,7 @@ package cleaner import ( "path/filepath" + "runtime" "strings" "testing" "time" @@ -391,6 +392,10 @@ func TestAnalyzeRenderingHelpers(t *testing.T) { } func TestRunAnalyzeTUIQuit(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Bubble Tea quit smoke test is only reliable on macOS in this project") + } + home := setTestHome(t) writeTestFile(t, filepath.Join(home, ".ollama", "logs", "server.log"), "log") diff --git a/internal/cleaner/openclaw_sessions.go b/internal/cleaner/openclaw_sessions.go index 20e7eb3..9071c54 100644 --- a/internal/cleaner/openclaw_sessions.go +++ b/internal/cleaner/openclaw_sessions.go @@ -3,6 +3,7 @@ package cleaner import ( "bufio" "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -48,6 +49,18 @@ type openClawSessionEvent struct { Timestamp string `json:"timestamp"` } +type openClawMessageEvent struct { + Type string `json:"type"` + Message struct { + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + ErrorMessage string `json:"errorMessage,omitempty"` + } `json:"message"` +} + func discoverOpenClawSessions() ([]OpenClawSession, error) { home, err := os.UserHomeDir() if err != nil { @@ -181,6 +194,62 @@ func scanOpenClawTranscript(path string) (time.Time, int, error) { return startedAt, lines, nil } +func previewOpenClawSession(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + buffer := make([]byte, 0, 64*1024) + scanner.Buffer(buffer, 2*1024*1024) + + var messages []string + for scanner.Scan() { + var event openClawMessageEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Type == "message" { + role := event.Message.Role + if role == "" { + continue + } + + var textParts []string + if event.Message.ErrorMessage != "" { + textParts = append(textParts, "[Error] "+event.Message.ErrorMessage) + } + for _, c := range event.Message.Content { + if c.Type == "text" && strings.TrimSpace(c.Text) != "" { + textParts = append(textParts, strings.TrimSpace(c.Text)) + } + } + + content := strings.Join(textParts, "\n") + if content != "" { + prefix := "User" + if role == "assistant" { + prefix = "Assistant" + } else if role == "system" { + prefix = "System" + } + messages = append(messages, fmt.Sprintf("== %s ==\n%s", prefix, content)) + } + } + } + if err := scanner.Err(); err != nil { + return "", err + } + if len(messages) == 0 { + return "No text messages found in this conversation.", nil + } + + // Keep only the last 4 messages to avoid overwhelming the terminal + if len(messages) > 4 { + messages = messages[len(messages)-4:] + } + return strings.Join(messages, "\n\n"), nil +} + func deleteOpenClawSessions(sessions []OpenClawSession) error { type sessionKey struct { Key string