diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 4d6f10d..d5f5e71 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -1,4 +1,4 @@ -name: Go Test Reminder +name: Go Test on: pull_request: @@ -6,6 +6,7 @@ on: - '.github/workflows/go-test.yml' - 'go.mod' - 'go.sum' + - 'cli/codex_inspector/**' - '**/*.go' push: branches: @@ -14,6 +15,7 @@ on: - '.github/workflows/go-test.yml' - 'go.mod' - 'go.sum' + - 'cli/codex_inspector/**' - '**/*.go' permissions: @@ -21,7 +23,7 @@ permissions: jobs: go-test: - name: Go test reminder + name: Go test runs-on: ubuntu-latest steps: - name: Checkout @@ -33,24 +35,8 @@ jobs: go-version: '1.21.x' cache: true - - name: Run Go tests as reminder - id: go_test + - name: Run Go tests shell: bash run: | - set +e - go test ./... 2>&1 | tee go-test.log - status="${PIPESTATUS[0]}" - if [ "$status" -ne 0 ]; then - echo "failed=true" >> "$GITHUB_OUTPUT" - echo "::warning::go test ./... failed with exit code $status. This workflow is advisory and does not block release packaging." - else - echo "failed=false" >> "$GITHUB_OUTPUT" - fi - exit 0 - - - name: Write reminder summary - if: steps.go_test.outputs.failed == 'true' - run: | - echo '## Go test reminder' >> "$GITHUB_STEP_SUMMARY" - echo '' >> "$GITHUB_STEP_SUMMARY" - echo '`go test ./...` failed. This workflow is advisory and does not block release packaging.' >> "$GITHUB_STEP_SUMMARY" + set -euo pipefail + go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b06eefc..aa602c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,6 +90,7 @@ jobs: GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/retry_exec" ./cli/retry_exec/... GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/codex_hook_notify" ./cli/codex_hook_notify/... GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/file_share" ./cli/file_share/... + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/codex_inspector" ./cli/codex_inspector/... cp README.MD install.sh "$out/" cp sample/life_tools/*.json "$out/sample/life_tools/" diff --git a/AGENTS.md b/AGENTS.md index c9253a9..96d0898 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ - `cli/retry_exec/`:命令失败重试和失败通知工具。 - `cli/codex_hook_notify/`:Codex lifecycle hook 飞书提醒工具。 - `cli/file_share/`:临时 HTTP 文件分享工具。 +- `cli/codex_inspector/`:本机 Codex 历史会话、活跃度和记忆内容只读查看工具,当前是实验 CLI,不进默认安装清单。 - `cli/video_subtitle/`:单视频自动生成中文字幕工具,详细说明见 `docs/cli/video_subtitle.md`。 - `emby_plugins/video_subtitle/`:Emby Server 插件,后端调用 `video_subtitle` 生成字幕,详细说明见 `docs/plugins/emby_video_subtitle.md`。 - `gui/interview_timer/`:macOS 面试悬浮计时 GUI 应用,说明见 `docs/gui/interview_timer.md`。 @@ -161,11 +162,13 @@ emby_plugins/video_subtitle/install.sh --help `.github/workflows/swift-mac-app.yml` 负责 `gui/interview_timer` 的 Swift 单测、编译和未签名 `.app` 发布。 - tag 触发规则保持 `v*`,避免普通分支 push 意外创建 Release;`pull_request` 只能做 dry-run,不能创建 Release。 -- Go 测试放在 `.github/workflows/go-test.yml`,Python 单元测试放在 `.github/workflows/python-test.yml`,测试失败只能写 GitHub warning 和 summary,不能让 reminder workflow 或 release workflow 失败。 -- Go 二进制包只放稳定 CLI 工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`file_share`。 +- Go 测试放在 `.github/workflows/go-test.yml`,PR 时必须真实运行 `go test ./...`,不能做成只提醒不阻塞的 reminder。 +- Python 单元测试放在 `.github/workflows/python-test.yml`,失败只写 GitHub warning 和 summary,不能阻塞 release workflow。 +- Go 二进制包包含稳定 CLI 工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`file_share`;`codex_inspector` 作为实验工具参与 release 二进制产物,但仍不进入根目录 `install.sh` 默认安装清单。 - `video_subtitle` 只能按源码包发布,不能宣传成免依赖二进制;它仍依赖 Python、ffmpeg、TOS、ASR 和 LLM 配置。 - Emby 插件包只放 `LifeTools.Emby.VideoSubtitle.Emby.dll` 和文档,不要把 `MediaBrowser.*`、`Emby.*` 或核心库 DLL 打进插件发布包。 - `InterviewTimer` macOS App 只在 macOS runner 上构建,tag 发布包为未签名的 `InterviewTimer.app` zip,不要把它塞进根目录 `install.sh` 或 Go 二进制发布包。 +- 新增 CLI、构建入口、release 产物或测试要求时,必须检查 `.github/workflows/*` 是否需要同步,避免代码合入后没有 CI 或 tag 发布产物缺失。 - 修改发布包内容时同步更新 `docs/release.md` 和 README 的发布入口。 - 发布 workflow 需要 `permissions: contents: write`,不要扩大到无关权限。 diff --git a/README.MD b/README.MD index 3386085..2dbb2ba 100644 --- a/README.MD +++ b/README.MD @@ -2,6 +2,20 @@ 个人日常使用的小工具集合,按应用类型组织源码:命令行程序放在 `cli/`,插件程序放在 `emby_plugins/`,图形界面程序放在 `gui/`。 +## 界面预览 + +下面这些工具带有浏览器或桌面界面,截图均使用脱敏演示数据。点击截图可进入对应文档。 + +| Codex Inspector:总览 | Codex Inspector:会话详情 | +|---|---| +| [Codex Inspector Overview](docs/cli/codex_inspector.md) | [Codex Inspector Sessions](docs/cli/codex_inspector.md) | +| 本机只读查看 Codex 会话数量、token 用量、活跃度热力图和最近 session。 | 按原生交互顺序查看 user、assistant、tool call、tool result 和 raw JSONL。 | + +| file_share | Emby 字幕插件 | InterviewTimer | +|---|---|---| +| [file_share browser UI](docs/cli/file_share.md) | [Emby video subtitle plugin](docs/plugins/emby_video_subtitle.md) | [InterviewTimer floating panel](docs/gui/interview_timer.md) | +| 临时启动本机只读 HTTP 文件分享服务,支持浏览、下载和目录打包。 | 在 Emby Web 中选择媒体、配置参数并提交字幕生成任务。 | macOS 悬浮面试计时器,按模板推进多环节流程。 | + ## 快速安装 支持 Linux 和 macOS。仓库根目录执行下面命令,默认安装稳定命令行工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`video_subtitle`、`file_share`。 @@ -43,6 +57,7 @@ | CLI | `codex_hook_notify` | `cli/codex_hook_notify/` | `codex_hook_notify` | 接收 Codex hook 并推送飞书提醒 | [docs/cli/codex_hook_notify.md](docs/cli/codex_hook_notify.md) | | CLI | `file_share` | `cli/file_share/` | `file_share` | 临时启动只读 HTTP 文件分享服务 | [docs/cli/file_share.md](docs/cli/file_share.md) | | CLI | `video_subtitle` | `cli/video_subtitle/` | `video_subtitle` | 为单个视频生成简体中文字幕 | [docs/cli/video_subtitle.md](docs/cli/video_subtitle.md) | +| Experimental CLI | `codex_inspector` | `cli/codex_inspector/` | `codex_inspector` | 本机只读查看 Codex 会话、token 用量、活跃度和记忆内容 | [docs/cli/codex_inspector.md](docs/cli/codex_inspector.md) | | Plugin | Emby 字幕插件 | `emby_plugins/video_subtitle/` | `LifeTools.Emby.VideoSubtitle.Emby.dll` | 在 Emby 后台调用 `video_subtitle` 生成字幕 | [docs/plugins/emby_video_subtitle.md](docs/plugins/emby_video_subtitle.md) | | GUI | InterviewTimer | `gui/interview_timer/` | `InterviewTimer.app` | macOS 面试悬浮计时器 | [docs/gui/interview_timer.md](docs/gui/interview_timer.md) | @@ -60,6 +75,38 @@ | `build.sh` | 构建 Go CLI 工具到 `output/`。 | | `install.sh` | 安装 CLI 工具、示例配置和可选 hook。 | +`codex_inspector` 当前是实验工具,不在根目录 `install.sh` 默认稳定安装清单中。需要快速试用时使用专用安装脚本,默认安装到用户级目录 `$HOME/.local/bin`,不会写 `/usr/local`、`/etc`,也不会主动 sudo: + +```bash +./cli/codex_inspector/install.sh +codex_inspector -addr 127.0.0.1:8787 +``` + +macOS 和 Linux 都支持自定义前缀;只有显式传 `--system` 或 `--allow-sudo` 时才会尝试写系统目录: + +```bash +./cli/codex_inspector/install.sh --prefix "$HOME/.local" +./cli/codex_inspector/install.sh --system +``` + +也可以不安装,直接单独构建: + +```bash +go build -o output/codex_inspector ./cli/codex_inspector/... +./output/codex_inspector -addr 127.0.0.1:8787 +``` + +历史 session summary 会写入本机 SQLite cache,加速重复浏览;可通过 `-cache-path` 指定位置,或用 `-no-cache` 禁用。 + +cache 是派生数据,不写入 `~/.codex`。服务启动时只打开已有 cache;cache 缺失时页面仍会实时解析 JSONL,Diagnostics 页面可以手动创建。已有健康 cache 会按 `-cache-workers` 后台补齐历史 session,默认 2 个 worker,`0` 表示关闭自动补齐: + +```bash +codex_inspector -cache-workers 2 +codex_inspector -cache-workers 0 +``` + +Diagnostics 支持查看 cache 状态并执行 `Create cache`、`Fill missing cache`、`Backup and rebuild cache`。损坏 cache 会先重命名为 `.corrupt..bak`,再新建 cache。 + ## 构建与验证 构建 Go CLI: @@ -97,4 +144,4 @@ swift build --product InterviewTimerApp ## 发布 -推送 `v*` tag 会通过 GitHub Actions 构建 GitHub Release 资产,包括 Linux/macOS Go 二进制包、`video_subtitle` 源码包、Emby 插件包和未签名的 `InterviewTimer.app` 包。发布流程见 [docs/release.md](docs/release.md)。 +推送 `v*` tag 会通过 GitHub Actions 构建 GitHub Release 资产,包括 Linux/macOS Go 二进制包、`video_subtitle` 源码包、Emby 插件包和未签名的 `InterviewTimer.app` 包。Go 二进制包包含 `codex_inspector`,但它仍是实验工具,不进入默认安装清单。发布流程见 [docs/release.md](docs/release.md)。 diff --git a/cli/codex_inspector/AGENTS.md b/cli/codex_inspector/AGENTS.md new file mode 100644 index 0000000..66f1450 --- /dev/null +++ b/cli/codex_inspector/AGENTS.md @@ -0,0 +1,56 @@ +# codex_inspector 维护规则 + +`codex_inspector` 是实验 CLI,用浏览器只读查看本机 Codex session、活跃度、token 统计、memory 和 diagnostics。改动时优先保持实现简单,不引入复杂前端构建链、任务队列或配置系统。 + +## 回归样例维护 + +功能回归样例统一维护在: + +```text +docs/cli/codex_inspector_regression.md +``` + +每次修改 `cli/codex_inspector/` 下的代码或静态资源时,必须检查是否需要同步更新该文档。以下改动必须新增或更新回归样例: + +- 新增或调整 Overview、Sessions、Memory、Diagnostics 页面内容。 +- 新增或调整按钮、菜单、时间范围、搜索、raw JSONL 展开、token usage 展示。 +- 修改 `/api/overview`、`/api/sessions`、`/api/memory`、`/api/diagnostics` 或 cache API。 +- 修改 JSONL parser、SessionSummary、token 聚合、memory 读取、redaction 规则。 +- 修改 SQLite summary cache 的状态、后台补齐、重建、损坏识别、降级或 CLI 参数。 +- 修改响应式布局、移动端断点、heatmap 行列规则或数字格式化规则。 + +如果改动不需要更新回归样例,提交说明或 PR 描述中必须写清原因,例如: + +```text +Regression docs unchanged: only internal variable rename; no behavior, API, UI, parser, cache, or privacy boundary changed. +``` + +## 样例质量要求 + +新增样例必须包含: + +- 前置数据或 fixture。 +- 操作步骤。 +- 期望结果。 +- 可由 AI 工具自动检查的断言点,例如 API 字段、selector、文本、console error、network error、viewport overflow。 + +样例不得包含真实 `~/.codex` 内容、真实 session 文本、真实路径、auth、token、cookie、secret、API key 或内部信息。需要敏感字段时只能使用假值,并明确期望展示为脱敏值。 + +## 验证要求 + +涉及 Go 行为时优先运行: + +```bash +go test ./cli/codex_inspector +go build -o output/codex_inspector ./cli/codex_inspector/... +``` + +涉及前端交互或布局时,启动服务后用 Chrome 检查 Overview、Sessions、Memory、Diagnostics,并至少覆盖: + +```text +390x844 +768x1024 +1440x900 +``` + +纯文档改动不需要运行 Go 测试,但必须检查 diff,确认文档链接、命令、页面名称和 API 名称与当前实现一致。 diff --git a/cli/codex_inspector/cache_manager.go b/cli/codex_inspector/cache_manager.go new file mode 100644 index 0000000..4d1bdf2 --- /dev/null +++ b/cli/codex_inspector/cache_manager.go @@ -0,0 +1,291 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + cacheStatusDisabled = "disabled" + cacheStatusMissing = "missing" + cacheStatusHealthy = "healthy" + cacheStatusCorrupt = "corrupt" + cacheStatusRebuilding = "rebuilding" + cacheStatusUnavailable = "unavailable" +) + +type summaryCacheManager struct { + mu sync.Mutex + path string + workers int + status string + reason string + backupPath string + disk *summaryDiskCache + job CacheJobState + jobSeq int +} + +type cacheBuildTask struct { + Path string + Meta fileMeta + Summary SessionSummary +} + +type cacheBuildResult struct { + Path string + Meta fileMeta + Summary SessionSummary + Error string +} + +func newSummaryCacheManager(path string, workers int) *summaryCacheManager { + manager := &summaryCacheManager{path: path, workers: workers} + manager.refreshLocked() + return manager +} + +func (m *summaryCacheManager) refreshLocked() { + if m.path == "" { + m.status = cacheStatusDisabled + m.reason = "cache disabled by -no-cache" + return + } + if _, err := os.Stat(m.path); err != nil { + if os.IsNotExist(err) { + m.status = cacheStatusMissing + m.reason = "cache file does not exist" + return + } + m.status = cacheStatusUnavailable + m.reason = err.Error() + return + } + cache, err := openExistingSummaryDiskCache(m.path) + if err != nil { + if isSQLiteCorruptError(err) { + m.status = cacheStatusCorrupt + m.reason = err.Error() + return + } + m.status = cacheStatusUnavailable + m.reason = err.Error() + return + } + m.disk = cache + m.status = cacheStatusHealthy + m.reason = "" +} + +func (m *summaryCacheManager) Close() { + m.mu.Lock() + defer m.mu.Unlock() + if m.disk != nil { + _ = m.disk.Close() + m.disk = nil + } +} + +func (m *summaryCacheManager) DiskCache() *summaryDiskCache { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + return m.disk +} + +func (m *summaryCacheManager) Status() CacheStatus { + if m == nil { + return CacheStatus{Status: cacheStatusDisabled, Reason: "cache manager unavailable", GeneratedAt: time.Now().Format(time.RFC3339)} + } + m.mu.Lock() + defer m.mu.Unlock() + return m.statusLocked() +} + +func (m *summaryCacheManager) statusLocked() CacheStatus { + status := m.status + if m.job.Running { + status = cacheStatusRebuilding + } + canBuild := !m.job.Running && (m.status == cacheStatusMissing || m.status == cacheStatusHealthy) + canRebuild := !m.job.Running && m.status == cacheStatusCorrupt + return CacheStatus{ + Status: status, + Path: m.path, + Workers: m.workers, + Reason: m.reason, + BackupPath: m.backupPath, + CanBuild: canBuild, + CanRebuild: canRebuild, + AutoFill: m.workers > 0 && m.status == cacheStatusHealthy, + GeneratedAt: time.Now().Format(time.RFC3339), + Job: m.job, + } +} + +func (m *summaryCacheManager) StartAutoBuild(store *Store) CacheStatus { + if m == nil || m.workers <= 0 { + return m.Status() + } + m.mu.Lock() + if m.status != cacheStatusHealthy || m.job.Running { + status := m.statusLocked() + m.mu.Unlock() + return status + } + m.mu.Unlock() + return m.StartBuild(store, false) +} + +func (m *summaryCacheManager) StartBuild(store *Store, rebuild bool) CacheStatus { + if m == nil { + return CacheStatus{Status: cacheStatusDisabled, Reason: "cache manager unavailable", GeneratedAt: time.Now().Format(time.RFC3339)} + } + m.mu.Lock() + if m.job.Running { + status := m.statusLocked() + m.mu.Unlock() + return status + } + if rebuild { + if m.status != cacheStatusCorrupt { + status := m.statusLocked() + m.mu.Unlock() + return status + } + if err := m.backupCorruptAndCreateLocked(); err != nil { + m.status = cacheStatusUnavailable + m.reason = err.Error() + status := m.statusLocked() + m.mu.Unlock() + return status + } + } else { + switch m.status { + case cacheStatusMissing: + if err := m.createLocked(); err != nil { + m.status = cacheStatusUnavailable + m.reason = err.Error() + status := m.statusLocked() + m.mu.Unlock() + return status + } + case cacheStatusHealthy: + default: + status := m.statusLocked() + m.mu.Unlock() + return status + } + } + + m.jobSeq++ + seq := m.jobSeq + now := time.Now().Format(time.RFC3339) + m.job = CacheJobState{Running: true, StartedAt: now} + m.status = cacheStatusRebuilding + m.reason = "" + workers := m.workers + if workers <= 0 { + workers = 1 + } + status := m.statusLocked() + m.mu.Unlock() + + go store.runCacheBuild(seq, workers) + return status +} + +func (m *summaryCacheManager) createLocked() error { + cache, err := openSummaryDiskCache(m.path) + if err != nil { + return err + } + m.disk = cache + m.status = cacheStatusHealthy + m.reason = "" + return nil +} + +func (m *summaryCacheManager) backupCorruptAndCreateLocked() error { + if m.disk != nil { + _ = m.disk.Close() + m.disk = nil + } + backupPath := fmt.Sprintf("%s.corrupt.%s.bak", m.path, time.Now().Format("20060102T150405")) + if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil { + return err + } + if err := os.Rename(m.path, backupPath); err != nil { + return err + } + m.backupPath = backupPath + return m.createLocked() +} + +func (m *summaryCacheManager) setJobTotal(seq int, total int) { + m.mu.Lock() + defer m.mu.Unlock() + if seq == m.jobSeq && m.job.Running { + m.job.Total = total + } +} + +func (m *summaryCacheManager) addJobProgress(seq int, cached int, skipped int, failed int, lastError string) { + m.mu.Lock() + defer m.mu.Unlock() + if seq != m.jobSeq || !m.job.Running { + return + } + m.job.Done++ + m.job.Cached += cached + m.job.Skipped += skipped + m.job.Failed += failed + if lastError != "" { + m.job.LastError = lastError + } +} + +func (m *summaryCacheManager) finishJob(seq int, lastError string) { + m.mu.Lock() + defer m.mu.Unlock() + if seq != m.jobSeq || !m.job.Running { + return + } + m.job.Running = false + m.job.FinishedAt = time.Now().Format(time.RFC3339) + if lastError != "" { + m.job.LastError = lastError + } + if m.disk == nil { + m.status = cacheStatusUnavailable + if m.reason == "" { + m.reason = "cache unavailable after build" + } + return + } + m.status = cacheStatusHealthy + m.reason = "" +} + +func (m *summaryCacheManager) ReportRuntimeError(err error) { + if m == nil || err == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if m.disk != nil { + _ = m.disk.Close() + m.disk = nil + } + if isSQLiteCorruptError(err) { + m.status = cacheStatusCorrupt + } else { + m.status = cacheStatusUnavailable + } + m.reason = err.Error() +} diff --git a/cli/codex_inspector/cache_manager_test.go b/cli/codex_inspector/cache_manager_test.go new file mode 100644 index 0000000..7099aa9 --- /dev/null +++ b/cli/codex_inspector/cache_manager_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDefaultCachePathUsesSystemTempDir(t *testing.T) { + path := defaultCachePath() + require.Equal(t, filepath.Join(os.TempDir(), "life_tools-codex-inspector-"+strconv.Itoa(os.Getuid()), "session_summary_cache.sqlite"), path) +} + +func TestCacheDirectoryIsPrivateWhenCreated(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "nested", "summary.sqlite") + cache, err := openSummaryDiskCache(cachePath) + require.NoError(t, err) + require.NoError(t, cache.Close()) + + dirInfo, err := os.Stat(filepath.Dir(cachePath)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0700), dirInfo.Mode().Perm()) + + fileInfo, err := os.Stat(cachePath) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), fileInfo.Mode().Perm()) +} + +func TestCacheMissingCanCreateAndFill(t *testing.T) { + codexHome, rolloutPath := writeCacheTestRollout(t, "2000", "01", "02", "missing-cache-session") + cachePath := filepath.Join(t.TempDir(), "summary.sqlite") + store := NewStoreWithCacheWorkers(codexHome, cachePath, 0) + defer store.Close() + + require.Equal(t, cacheStatusMissing, store.CacheStatus().Status) + status := store.StartCacheBuild() + require.Equal(t, cacheStatusRebuilding, status.Status) + status = waitCacheJob(t, store) + require.Equal(t, cacheStatusHealthy, status.Status) + require.Equal(t, 1, status.Job.Cached) + + cache, err := openExistingSummaryDiskCache(cachePath) + require.NoError(t, err) + defer cache.Close() + meta, ok := statFile(rolloutPath) + require.True(t, ok) + summary, ok, err := cache.Lookup(rolloutPath, meta) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "missing-cache-session", summary.ID) +} + +func TestCacheCorruptBackupAndRebuild(t *testing.T) { + codexHome, rolloutPath := writeCacheTestRollout(t, "2000", "01", "03", "corrupt-cache-session") + cachePath := filepath.Join(t.TempDir(), "summary.sqlite") + require.NoError(t, os.WriteFile(cachePath, []byte("not a sqlite database"), 0600)) + store := NewStoreWithCacheWorkers(codexHome, cachePath, 0) + defer store.Close() + + require.Equal(t, cacheStatusCorrupt, store.CacheStatus().Status) + status := store.StartCacheRebuild() + require.Equal(t, cacheStatusRebuilding, status.Status) + status = waitCacheJob(t, store) + require.Equal(t, cacheStatusHealthy, status.Status) + require.NotEmpty(t, status.BackupPath) + require.FileExists(t, status.BackupPath) + + cache, err := openExistingSummaryDiskCache(cachePath) + require.NoError(t, err) + defer cache.Close() + meta, ok := statFile(rolloutPath) + require.True(t, ok) + summary, ok, err := cache.Lookup(rolloutPath, meta) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "corrupt-cache-session", summary.ID) +} + +func TestCacheDisabledAPIStatus(t *testing.T) { + store := NewStoreWithCacheWorkers(t.TempDir(), "", 2) + defer store.Close() + server := httptest.NewServer(NewServer(store)) + defer server.Close() + + resp, err := http.Get(server.URL + "/api/cache/status") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var status CacheStatus + require.NoError(t, json.NewDecoder(resp.Body).Decode(&status)) + require.Equal(t, cacheStatusDisabled, status.Status) + require.Contains(t, status.Reason, "-no-cache") +} + +func TestCacheBuildSkipsTodayRollout(t *testing.T) { + codexHome, oldPath := writeCacheTestRollout(t, "2000", "01", "04", "old-session") + today := time.Now() + todayDir := filepath.Join(codexHome, "sessions", today.Format("2006"), today.Format("01"), today.Format("02")) + require.NoError(t, os.MkdirAll(todayDir, 0755)) + todayPath := filepath.Join(todayDir, "rollout-"+today.Format("2006-01-02T15-04-05")+"-today.jsonl") + require.NoError(t, os.WriteFile(todayPath, []byte(`{"type":"session_meta","timestamp":"`+today.Format(time.RFC3339)+`","payload":{"id":"today-session"}} +{"type":"response_item","timestamp":"`+today.Format(time.RFC3339)+`","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"today"}]}} +`), 0644)) + + cachePath := filepath.Join(t.TempDir(), "summary.sqlite") + store := NewStoreWithCacheWorkers(codexHome, cachePath, 0) + defer store.Close() + store.StartCacheBuild() + status := waitCacheJob(t, store) + require.Equal(t, cacheStatusHealthy, status.Status) + require.Equal(t, 1, status.Job.Cached) + + cache, err := openExistingSummaryDiskCache(cachePath) + require.NoError(t, err) + defer cache.Close() + oldMeta, ok := statFile(oldPath) + require.True(t, ok) + _, ok, err = cache.Lookup(oldPath, oldMeta) + require.NoError(t, err) + require.True(t, ok) + todayMeta, ok := statFile(todayPath) + require.True(t, ok) + _, ok, err = cache.Lookup(todayPath, todayMeta) + require.NoError(t, err) + require.False(t, ok) +} + +func TestRepeatedBuildRequestReturnsRunningJob(t *testing.T) { + codexHome, _ := writeCacheTestRollout(t, "2000", "01", "05", "repeat-session") + cachePath := filepath.Join(t.TempDir(), "summary.sqlite") + store := NewStoreWithCacheWorkers(codexHome, cachePath, 0) + defer store.Close() + require.Equal(t, cacheStatusMissing, store.CacheStatus().Status) + require.Equal(t, cacheStatusRebuilding, store.StartCacheBuild().Status) + + store.cacheManager.mu.Lock() + firstSeq := store.cacheManager.jobSeq + store.cacheManager.mu.Unlock() + + second := store.StartCacheBuild() + require.Equal(t, cacheStatusRebuilding, second.Status) + store.cacheManager.mu.Lock() + require.Equal(t, firstSeq, store.cacheManager.jobSeq) + store.cacheManager.mu.Unlock() + _ = waitCacheJob(t, store) +} + +func writeCacheTestRollout(t *testing.T, year string, month string, day string, id string) (string, string) { + t.Helper() + codexHome := t.TempDir() + sessionDir := filepath.Join(codexHome, "sessions", year, month, day) + require.NoError(t, os.MkdirAll(sessionDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(codexHome, "session_index.jsonl"), nil, 0644)) + path := filepath.Join(sessionDir, "rollout-"+year+"-"+month+"-"+day+"T10-00-00-"+id+".jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","timestamp":"`+year+`-`+month+`-`+day+`T10:00:00Z","payload":{"id":"`+id+`","cwd":"/tmp/demo","model":"gpt-test"}} +{"type":"response_item","timestamp":"`+year+`-`+month+`-`+day+`T10:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}} +`), 0644)) + oldTime := time.Date(2000, 1, 2, 10, 0, 0, 0, time.Local) + require.NoError(t, os.Chtimes(path, oldTime, oldTime)) + return codexHome, path +} + +func waitCacheJob(t *testing.T, store *Store) CacheStatus { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + status := store.CacheStatus() + if !status.Job.Running { + return status + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("cache job did not finish: %+v", store.CacheStatus()) + return CacheStatus{} +} diff --git a/cli/codex_inspector/diagnostics.go b/cli/codex_inspector/diagnostics.go new file mode 100644 index 0000000..118a7df --- /dev/null +++ b/cli/codex_inspector/diagnostics.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +func (s *Store) Diagnostics() DiagnosticsResponse { + response := DiagnosticsResponse{ + CodexHome: s.CodexHome, + Sources: s.Sources(), + Cache: s.CacheStatus(), + } + dbs := []struct { + name string + rel string + }{ + {name: "state_5.sqlite", rel: "state_5.sqlite"}, + {name: "goals_1.sqlite", rel: "goals_1.sqlite"}, + {name: "memories_1.sqlite", rel: "memories_1.sqlite"}, + } + for _, db := range dbs { + response.Schemas = append(response.Schemas, loadSQLiteSchema(db.name, filepath.Join(s.CodexHome, db.rel))) + } + return response +} + +func loadSQLiteSchema(name string, path string) SQLiteSchema { + schema := SQLiteSchema{Name: name, Path: path, ReadMode: "sqlite3 file URI mode=ro"} + if _, err := os.Stat(path); err != nil { + schema.Exists = false + schema.Error = err.Error() + return schema + } + schema.Exists = true + if _, err := exec.LookPath("sqlite3"); err != nil { + schema.Error = "sqlite3 command not found" + return schema + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + uri := fmt.Sprintf("file:%s?mode=ro", path) + output, err := exec.CommandContext(ctx, "sqlite3", uri, ".schema").CombinedOutput() + if err != nil { + schema.Error = sanitizeText(string(output)) + if schema.Error == "" { + schema.Error = err.Error() + } + return schema + } + schema.Loaded = true + schema.Schema = truncateText(sanitizeText(string(output)), 30000) + return schema +} diff --git a/cli/codex_inspector/install.sh b/cli/codex_inspector/install.sh new file mode 100755 index 0000000..0e08c46 --- /dev/null +++ b/cli/codex_inspector/install.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/output" +PREFIX="${CODEX_INSPECTOR_PREFIX:-$HOME/.local}" +ALLOW_SUDO=0 +OS_NAME="$(uname -s)" + +usage() { + cat <<'EOF' +Usage: + ./cli/codex_inspector/install.sh [options] + +Options: + --prefix DIR Install under DIR/bin. Default: $HOME/.local. + --system Install under /usr/local and allow sudo if needed. + --allow-sudo Allow sudo when the selected prefix is not writable. + -h, --help Show this help. + +Examples: + ./cli/codex_inspector/install.sh + ./cli/codex_inspector/install.sh --prefix "$HOME/.local" + ./cli/codex_inspector/install.sh --system +EOF +} + +die() { + echo "codex_inspector install: $*" >&2 + exit 1 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + die "$1 command not found" + fi +} + +run_privileged() { + if [ "$ALLOW_SUDO" -ne 1 ]; then + die "cannot write target path; choose --prefix \"\$HOME/.local\" or pass --allow-sudo/--system explicitly" + fi + if [ "$(id -u)" -eq 0 ]; then + "$@" + return + fi + require_command sudo + sudo "$@" +} + +ensure_supported_os() { + case "$OS_NAME" in + Darwin|Linux) + ;; + *) + die "unsupported OS: $OS_NAME; supported systems are macOS and Linux" + ;; + esac +} + +ensure_bin_dir() { + local bin_dir="$1" + + if [ -d "$bin_dir" ]; then + if [ -w "$bin_dir" ]; then + return + fi + run_privileged mkdir -p "$bin_dir" + return + fi + + if mkdir -p "$bin_dir" 2>/dev/null; then + return + fi + run_privileged mkdir -p "$bin_dir" +} + +install_binary() { + local src="$1" + local dest="$2" + + if install -m 0755 "$src" "$dest" 2>/dev/null; then + return + fi + run_privileged install -m 0755 "$src" "$dest" +} + +print_path_hint() { + local bin_dir="$1" + + case ":$PATH:" in + *":$bin_dir:"*) + return + ;; + esac + + cat </dev/null 2>&1; then + echo " xdg-open http://127.0.0.1:8787" + else + echo " visit http://127.0.0.1:8787 in your browser" + fi + ;; + esac +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) + if [ "$#" -lt 2 ]; then + die "--prefix requires a directory" + fi + PREFIX="${2%/}" + shift 2 + ;; + --prefix=*) + PREFIX="${1#--prefix=}" + PREFIX="${PREFIX%/}" + shift + ;; + --system) + PREFIX="/usr/local" + ALLOW_SUDO=1 + shift + ;; + --allow-sudo) + ALLOW_SUDO=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac + done +} + +main() { + local bin_dir + local output_bin + local install_path + + parse_args "$@" + ensure_supported_os + require_command go + + bin_dir="$PREFIX/bin" + output_bin="$OUTPUT_DIR/codex_inspector" + install_path="$bin_dir/codex_inspector" + + mkdir -p "$OUTPUT_DIR" + echo "building codex_inspector" + (cd "$ROOT_DIR" && go build -v -o "$output_bin" ./cli/codex_inspector/...) + + ensure_bin_dir "$bin_dir" + install_binary "$output_bin" "$install_path" + + echo "installed codex_inspector: $install_path" + print_cache_hint + print_path_hint "$bin_dir" + print_start_hint "$install_path" +} + +main "$@" diff --git a/cli/codex_inspector/main.go b/cli/codex_inspector/main.go new file mode 100644 index 0000000..2cbbd38 --- /dev/null +++ b/cli/codex_inspector/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "path/filepath" +) + +const defaultAddr = "127.0.0.1:8787" + +func main() { + addr := flag.String("addr", defaultAddr, "HTTP listen address") + codexHome := flag.String("codex-home", defaultCodexHome(), "Codex home directory") + cachePath := flag.String("cache-path", defaultCachePath(), "SQLite cache path for historical session summaries") + cacheWorkers := flag.Int("cache-workers", 2, "background workers for filling historical summary cache; 0 disables automatic fill") + noCache := flag.Bool("no-cache", false, "disable SQLite summary cache") + flag.Parse() + + if *noCache { + *cachePath = "" + } + if *cacheWorkers < 0 { + *cacheWorkers = 0 + } + store := NewStoreWithCacheWorkers(*codexHome, *cachePath, *cacheWorkers) + defer store.Close() + fmt.Printf("codex_inspector reading data from %s\n", store.CodexHome) + if store.CachePath != "" { + fmt.Printf("codex_inspector caching historical summaries in %s\n", store.CachePath) + if *cacheWorkers == 0 { + fmt.Println("codex_inspector automatic cache fill disabled") + } else { + fmt.Printf("codex_inspector automatic cache fill workers: %d\n", *cacheWorkers) + } + } + fmt.Printf("codex_inspector listening on http://%s\n", *addr) + fmt.Println("privacy: this tool is read-only and should be bound to 127.0.0.1 unless you understand the data exposure risk") + + if err := http.ListenAndServe(*addr, NewServer(store)); err != nil { + fmt.Fprintf(os.Stderr, "codex_inspector: listen failed: %v\n", err) + os.Exit(1) + } +} + +func defaultCodexHome() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return ".codex" + } + return filepath.Join(home, ".codex") +} diff --git a/cli/codex_inspector/memory.go b/cli/codex_inspector/memory.go new file mode 100644 index 0000000..3902029 --- /dev/null +++ b/cli/codex_inspector/memory.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const maxMemoryReadBytes = 512 * 1024 + +func (s *Store) LoadMemory(query string, limit int) MemoryResponse { + files, warnings := s.memoryFiles() + out := make([]MemoryFile, 0, len(files)) + for _, path := range files { + item, err := s.memoryFileSummary(path, query) + if err != nil { + warnings = append(warnings, err.Error()) + continue + } + if query != "" && item.Matches == 0 { + continue + } + out = append(out, item) + } + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return MemoryResponse{Files: out, Warnings: warnings} +} + +func (s *Store) LoadMemoryDetail(relPath string) (MemoryDetail, bool) { + path, ok := s.safeMemoryPath(relPath) + if !ok { + return MemoryDetail{Warnings: []string{"invalid memory path"}}, false + } + item, err := s.memoryFileSummary(path, "") + if err != nil { + return MemoryDetail{Warnings: []string{err.Error()}}, false + } + content, truncated, err := readTextFileLimit(path, maxMemoryReadBytes) + if err != nil { + return MemoryDetail{File: item, Warnings: []string{err.Error()}}, false + } + content = sanitizeText(content) + detail := MemoryDetail{File: item, Content: content} + if truncated { + detail.Warnings = append(detail.Warnings, fmt.Sprintf("content truncated at %d bytes", maxMemoryReadBytes)) + } + return detail, true +} + +func (s *Store) memoryFiles() ([]string, []string) { + root := filepath.Join(s.CodexHome, "memories") + allowed := map[string]bool{ + "MEMORY.md": true, + "memory_summary.md": true, + "raw_memories.md": true, + } + var files []string + var warnings []string + for name := range allowed { + path := filepath.Join(root, name) + if _, err := os.Stat(path); err == nil { + files = append(files, path) + } + } + rolloutDir := filepath.Join(root, "rollout_summaries") + err := filepath.WalkDir(rolloutDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + warnings = append(warnings, fmt.Sprintf("walk %s: %v", path, err)) + return nil + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(d.Name(), ".md") { + files = append(files, path) + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + warnings = append(warnings, fmt.Sprintf("walk memory rollouts: %v", err)) + } + sort.Slice(files, func(i, j int) bool { + left, leftErr := os.Stat(files[i]) + right, rightErr := os.Stat(files[j]) + if leftErr == nil && rightErr == nil && !left.ModTime().Equal(right.ModTime()) { + return left.ModTime().After(right.ModTime()) + } + return files[i] < files[j] + }) + return files, warnings +} + +func (s *Store) memoryFileSummary(path string, query string) (MemoryFile, error) { + info, err := os.Stat(path) + if err != nil { + return MemoryFile{}, fmt.Errorf("stat memory file %s: %w", path, err) + } + content, truncated, err := readTextFileLimit(path, 96*1024) + if err != nil { + return MemoryFile{}, fmt.Errorf("read memory file %s: %w", path, err) + } + content = sanitizeText(content) + rel, _ := filepath.Rel(filepath.Join(s.CodexHome, "memories"), path) + rel = filepath.ToSlash(rel) + item := MemoryFile{ + Path: rel, + Kind: memoryKind(rel), + Size: info.Size(), + ModifiedAt: info.ModTime().Format("2006-01-02T15:04:05Z07:00"), + Title: memoryTitle(rel, content), + Preview: memoryPreview(content, query), + Matches: countMatches(content, query), + } + if truncated { + item.Preview = truncateText(item.Preview+"\n(content preview truncated)", 1200) + } + return item, nil +} + +func (s *Store) safeMemoryPath(relPath string) (string, bool) { + relPath = filepath.Clean(strings.TrimSpace(relPath)) + if relPath == "." || strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + return "", false + } + path := filepath.Join(s.CodexHome, "memories", relPath) + root := filepath.Join(s.CodexHome, "memories") + if !strings.HasPrefix(path, root+string(filepath.Separator)) && path != root { + return "", false + } + if !strings.HasSuffix(path, ".md") { + return "", false + } + return path, true +} + +func readTextFileLimit(path string, maxBytes int64) (string, bool, error) { + file, err := os.Open(path) + if err != nil { + return "", false, err + } + defer file.Close() + + content := make([]byte, maxBytes+1) + n, err := file.Read(content) + if err != nil && n == 0 { + return "", false, err + } + truncated := int64(n) > maxBytes + if truncated { + n = int(maxBytes) + } + return string(content[:n]), truncated, nil +} + +func memoryKind(rel string) string { + switch { + case rel == "MEMORY.md": + return "index" + case rel == "memory_summary.md": + return "summary" + case strings.HasPrefix(rel, "rollout_summaries/"): + return "rollout" + default: + return "note" + } +} + +func memoryTitle(rel string, content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + return strings.TrimSpace(strings.TrimLeft(line, "#")) + } + if strings.HasPrefix(line, "thread_id:") { + continue + } + if line != "" { + break + } + } + return strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)) +} + +func memoryPreview(content string, query string) string { + lines := strings.Split(content, "\n") + if query != "" { + for i, line := range lines { + if containsFold(line, query) { + start := i - 2 + if start < 0 { + start = 0 + } + end := i + 3 + if end > len(lines) { + end = len(lines) + } + return truncateText(strings.Join(lines[start:end], "\n"), 1000) + } + } + } + var picked []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + picked = append(picked, line) + if len(picked) >= 8 { + break + } + } + return truncateText(strings.Join(picked, "\n"), 1000) +} + +func countMatches(content string, query string) int { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return 0 + } + return strings.Count(strings.ToLower(content), query) +} diff --git a/cli/codex_inspector/memory_test.go b/cli/codex_inspector/memory_test.go new file mode 100644 index 0000000..f54aa34 --- /dev/null +++ b/cli/codex_inspector/memory_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadMemorySearchAndDetail(t *testing.T) { + codexHome := t.TempDir() + memDir := filepath.Join(codexHome, "memories") + require.NoError(t, os.MkdirAll(filepath.Join(memDir, "rollout_summaries"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory Index\n\ncodex inspector note\napi_key=should-hide\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(memDir, "memory_summary.md"), []byte("summary only\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(memDir, "rollout_summaries", "demo.md"), []byte("thread_id: demo\n# Demo Rollout\nkeyword appears here\n"), 0644)) + + store := NewStoreWithCache(codexHome, "") + response := store.LoadMemory("keyword", 10) + require.Len(t, response.Files, 1) + require.Equal(t, "rollout_summaries/demo.md", response.Files[0].Path) + + detail, ok := store.LoadMemoryDetail("MEMORY.md") + require.True(t, ok) + require.Contains(t, detail.Content, "Memory Index") + require.NotContains(t, detail.Content, "should-hide") + require.Contains(t, detail.Content, "[REDACTED]") +} + +func TestLoadMemoryRejectsTraversal(t *testing.T) { + store := NewStoreWithCache(t.TempDir(), "") + _, ok := store.LoadMemoryDetail("../auth.json") + require.False(t, ok) +} diff --git a/cli/codex_inspector/parser.go b/cli/codex_inspector/parser.go new file mode 100644 index 0000000..fb8aff8 --- /dev/null +++ b/cli/codex_inspector/parser.go @@ -0,0 +1,592 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type rolloutLine struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Payload json.RawMessage `json:"payload"` +} + +type responseItemHeader struct { + Type string `json:"type"` + Role string `json:"role"` +} + +func ParseRolloutFile(path string, includeEvents bool) (SessionDetail, error) { + return ParseRolloutFileWithOptions(path, SessionDetailOptions{ + IncludeEvents: includeEvents, + IncludeRawLines: includeEvents, + }) +} + +func ParseRolloutFileWithOptions(path string, options SessionDetailOptions) (SessionDetail, error) { + file, err := os.Open(path) + if err != nil { + return SessionDetail{}, err + } + defer file.Close() + + detail := SessionDetail{ + Summary: SessionSummary{ + Path: path, + Title: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), + }, + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024), 8*1024*1024) + + lineNo := 0 + displayIndex := 0 + for scanner.Scan() { + lineNo++ + line := scanner.Bytes() + trimmed := strings.TrimSpace(string(line)) + if trimmed == "" { + continue + } + detail.Summary.EventCount++ + + var record rolloutLine + if err := json.Unmarshal(line, &record); err != nil { + warning := fmt.Sprintf("line %d invalid JSON: %v", lineNo, err) + detail.Warnings = append(detail.Warnings, warning) + if options.IncludeEvents { + if eventInWindow(displayIndex, options) { + detail.Events = append(detail.Events, DisplayEvent{Line: lineNo, Kind: "system_event", Label: "Bad JSON", Text: warning}) + appendRawLine(&detail, lineNo, line, options) + } + displayIndex++ + if shouldStopAfterEventWindow(displayIndex, options) { + break + } + } + continue + } + if !options.IncludeEvents { + updateSummaryFast(&detail.Summary, record) + continue + } + event := parseDisplayEvent(record, lineNo) + updateSummary(&detail.Summary, record, event) + if eventInWindow(displayIndex, options) { + detail.Events = append(detail.Events, event) + appendRawLine(&detail, lineNo, line, options) + } + displayIndex++ + if shouldStopAfterEventWindow(displayIndex, options) { + break + } + } + if err := scanner.Err(); err != nil { + detail.Warnings = append(detail.Warnings, fmt.Sprintf("scan %s: %v", path, err)) + } + if options.IncludeEvents { + if detail.Events == nil { + detail.Events = []DisplayEvent{} + } + if detail.RawLines == nil { + detail.RawLines = []RawLine{} + } + detail.EventOffset = options.EventOffset + detail.EventLimit = options.EventLimit + detail.EventTotal = displayIndex + if options.EventTotalHint > detail.EventTotal { + detail.EventTotal = options.EventTotalHint + } + detail.HasMore = options.EventLimit > 0 && options.EventOffset+len(detail.Events) < displayIndex + if options.EventTotalHint > 0 { + detail.HasMore = options.EventLimit > 0 && options.EventOffset+len(detail.Events) < options.EventTotalHint + } + } + if detail.Summary.ID == "" { + detail.Summary.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + if detail.Summary.Title == "" { + detail.Summary.Title = detail.Summary.ID + } + return detail, nil +} + +func eventInWindow(index int, options SessionDetailOptions) bool { + if index < options.EventOffset { + return false + } + return options.EventLimit <= 0 || index < options.EventOffset+options.EventLimit +} + +func shouldStopAfterEventWindow(displayIndex int, options SessionDetailOptions) bool { + if options.EventTotalHint <= 0 || options.EventLimit <= 0 || options.IncludeRawLines { + return false + } + return displayIndex >= options.EventOffset+options.EventLimit +} + +func appendRawLine(detail *SessionDetail, lineNo int, line []byte, options SessionDetailOptions) { + if !options.IncludeRawLines { + return + } + detail.RawLines = append(detail.RawLines, RawLine{Line: lineNo, Text: truncateText(sanitizedRawJSON(line), 60000)}) +} + +func ParseRolloutRawLine(path string, targetLine int) (RawLine, bool, error) { + if targetLine <= 0 { + return RawLine{}, false, nil + } + file, err := os.Open(path) + if err != nil { + return RawLine{}, false, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024), 8*1024*1024) + lineNo := 0 + for scanner.Scan() { + lineNo++ + if lineNo == targetLine { + return RawLine{Line: lineNo, Text: truncateText(sanitizedRawJSON(scanner.Bytes()), 60000)}, true, nil + } + } + if err := scanner.Err(); err != nil { + return RawLine{}, false, err + } + return RawLine{}, false, nil +} + +func updateSummaryFast(summary *SessionSummary, record rolloutLine) { + if summary.StartedAt == "" && record.Timestamp != "" { + summary.StartedAt = record.Timestamp + } + if record.Timestamp != "" { + summary.UpdatedAt = record.Timestamp + } + + if record.Type == "session_meta" || record.Type == "turn_context" { + payload := decodePayload(record.Payload) + if record.Type == "session_meta" { + if id := stringField(payload, "id"); id != "" { + summary.ID = id + } + if provider := stringField(payload, "model_provider"); provider != "" { + summary.ModelProvider = sanitizeText(provider) + } + } + if cwd := stringField(payload, "cwd"); cwd != "" && summary.Cwd == "" { + summary.Cwd = sanitizeText(cwd) + } + if model := stringField(payload, "model"); model != "" && summary.Model == "" { + summary.Model = sanitizeText(model) + } + summary.SystemEvents++ + return + } + + if updateTokenStats(summary, record) { + summary.SystemEvents++ + return + } + + if record.Type != "response_item" { + summary.SystemEvents++ + return + } + + var header responseItemHeader + if err := json.Unmarshal(record.Payload, &header); err != nil { + summary.SystemEvents++ + return + } + switch header.Type { + case "message": + if header.Role == "user" { + summary.UserMessages++ + } + if header.Role == "assistant" { + summary.AssistantMessages++ + } + if summary.Preview == "" { + payload := decodePayload(record.Payload) + summary.Preview = truncateText(extractContentText(payload["content"]), 220) + } + case "function_call", "custom_tool_call", "tool_search_call", "web_search_call": + summary.ToolCalls++ + case "function_call_output", "custom_tool_call_output", "tool_search_output": + summary.ToolResults++ + default: + summary.SystemEvents++ + } +} + +func parseDisplayEvent(record rolloutLine, lineNo int) DisplayEvent { + event := DisplayEvent{ + Line: lineNo, + Timestamp: record.Timestamp, + Kind: "system_event", + Label: record.Type, + } + + payload := decodePayload(record.Payload) + switch record.Type { + case "session_meta": + event.Label = "Session metadata" + event.Text = joinNonEmpty([]string{ + "id: " + stringField(payload, "id"), + "cwd: " + stringField(payload, "cwd"), + "model: " + stringField(payload, "model"), + "provider: " + stringField(payload, "model_provider"), + }, "\n") + case "turn_context": + event.Label = "Turn context" + event.Text = joinNonEmpty([]string{ + "cwd: " + stringField(payload, "cwd"), + "model: " + stringField(payload, "model"), + "approval: " + stringField(payload, "approval_policy"), + }, "\n") + case "event_msg": + event.Label = "System event" + if stringField(payload, "type") == "token_count" { + event.Label = "Token usage" + event.Text = tokenEventText(payload) + } else { + event.Text = eventMessageText(payload) + } + case "compacted": + event.Label = "Compacted context" + event.Text = stringField(payload, "message") + case "response_item": + return responseItemEvent(payload, record.Timestamp, lineNo) + default: + event.Label = "Unknown event: " + record.Type + event.Text = truncateText(sanitizeText(string(record.Payload)), 1200) + } + event.Text = truncateText(sanitizeText(event.Text), 3000) + return event +} + +func responseItemEvent(payload map[string]any, timestamp string, lineNo int) DisplayEvent { + payloadType := stringField(payload, "type") + event := DisplayEvent{ + Line: lineNo, + Timestamp: timestamp, + Kind: "system_event", + Label: payloadType, + } + + switch payloadType { + case "message": + role := stringField(payload, "role") + event.Kind = "message" + event.Role = role + if role == "user" { + event.Label = "User" + } else if role == "assistant" { + event.Label = "Codex" + } else { + event.Label = "Message" + } + event.Text = truncateText(extractContentText(payload["content"]), 8000) + case "function_call", "custom_tool_call", "tool_search_call", "web_search_call": + event.Kind = "tool_call" + event.Label = firstNonEmpty(stringField(payload, "name"), payloadType) + event.Text = truncateText(toolCallText(payload), 4000) + case "function_call_output", "custom_tool_call_output", "tool_search_output": + event.Kind = "tool_result" + event.Label = firstNonEmpty(stringField(payload, "call_id"), payloadType) + event.Text = truncateText(toolOutputText(payload), 6000) + case "reasoning": + event.Kind = "reasoning" + event.Label = "Reasoning" + event.Text = "encrypted or summarized reasoning item" + default: + event.Label = "Response item: " + payloadType + event.Text = truncateText(fmt.Sprint(sanitizeJSONValue(payload)), 2000) + } + event.Text = sanitizeText(event.Text) + return event +} + +func updateSummary(summary *SessionSummary, record rolloutLine, event DisplayEvent) { + if summary.StartedAt == "" && record.Timestamp != "" { + summary.StartedAt = record.Timestamp + } + if record.Timestamp != "" { + summary.UpdatedAt = record.Timestamp + } + + payload := decodePayload(record.Payload) + if record.Type == "session_meta" { + if id := stringField(payload, "id"); id != "" { + summary.ID = id + } + if cwd := stringField(payload, "cwd"); cwd != "" { + summary.Cwd = sanitizeText(cwd) + } + if model := stringField(payload, "model"); model != "" { + summary.Model = sanitizeText(model) + } + if provider := stringField(payload, "model_provider"); provider != "" { + summary.ModelProvider = sanitizeText(provider) + } + } + if record.Type == "turn_context" { + if cwd := stringField(payload, "cwd"); cwd != "" && summary.Cwd == "" { + summary.Cwd = sanitizeText(cwd) + } + if model := stringField(payload, "model"); model != "" && summary.Model == "" { + summary.Model = sanitizeText(model) + } + } + updateTokenStats(summary, record) + + switch event.Kind { + case "message": + if event.Role == "user" { + summary.UserMessages++ + } + if event.Role == "assistant" { + summary.AssistantMessages++ + } + if summary.Preview == "" && event.Text != "" { + summary.Preview = truncateText(event.Text, 220) + } + case "tool_call": + summary.ToolCalls++ + case "tool_result": + summary.ToolResults++ + default: + summary.SystemEvents++ + } +} + +func updateTokenStats(summary *SessionSummary, record rolloutLine) bool { + if record.Type != "event_msg" { + return false + } + payload := decodePayload(record.Payload) + if stringField(payload, "type") != "token_count" { + return false + } + applyTokenPayload(summary, payload) + return true +} + +func applyTokenPayload(summary *SessionSummary, payload map[string]any) { + info := mapField(payload, "info") + rateLimits := mapField(payload, "rate_limits") + summary.TokenStats.Total = tokenUsageFromMap(mapField(info, "total_token_usage")) + summary.TokenStats.Last = tokenUsageFromMap(mapField(info, "last_token_usage")) + summary.TokenStats.ModelContextWindow = int64Field(info, "model_context_window") + summary.TokenStats.PrimaryRateLimit = rateLimitFromMap(mapField(rateLimits, "primary")) + summary.TokenStats.SecondaryRateLimit = rateLimitFromMap(mapField(rateLimits, "secondary")) + summary.TokenStats.TokenEvents++ +} + +func tokenUsageFromMap(values map[string]any) TokenUsage { + return TokenUsage{ + InputTokens: int64Field(values, "input_tokens"), + CachedInputTokens: int64Field(values, "cached_input_tokens"), + OutputTokens: int64Field(values, "output_tokens"), + ReasoningOutputTokens: int64Field(values, "reasoning_output_tokens"), + TotalTokens: int64Field(values, "total_tokens"), + } +} + +func rateLimitFromMap(values map[string]any) RateLimitUsage { + return RateLimitUsage{ + UsedPercent: floatField(values, "used_percent"), + WindowMinutes: int64Field(values, "window_minutes"), + } +} + +func decodePayload(raw json.RawMessage) map[string]any { + var payload map[string]any + if len(raw) == 0 { + return map[string]any{} + } + if err := json.Unmarshal(raw, &payload); err != nil { + return map[string]any{} + } + return payload +} + +func stringField(payload map[string]any, key string) string { + value, ok := payload[key] + if !ok || value == nil { + return "" + } + if text, ok := value.(string); ok { + return sanitizeText(text) + } + return sanitizeText(fmt.Sprint(value)) +} + +func extractContentText(value any) string { + items, ok := value.([]any) + if !ok { + return sanitizeText(fmt.Sprint(value)) + } + parts := make([]string, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]any) + if !ok { + continue + } + text := stringField(obj, "text") + if text == "" { + text = stringField(obj, "input") + } + if text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n") +} + +func toolCallText(payload map[string]any) string { + parts := []string{} + for _, key := range []string{"name", "status", "execution", "arguments", "input"} { + value := stringField(payload, key) + if value != "" { + parts = append(parts, key+": "+value) + } + } + if len(parts) == 0 { + return truncateText(fmt.Sprint(sanitizeJSONValue(payload)), 1200) + } + return strings.Join(parts, "\n") +} + +func toolOutputText(payload map[string]any) string { + for _, key := range []string{"output", "tools", "status"} { + value, ok := payload[key] + if !ok || value == nil { + continue + } + if text, ok := value.(string); ok && text != "" { + return text + } + content, err := json.Marshal(sanitizeJSONValue(value)) + if err == nil { + return string(content) + } + } + return truncateText(fmt.Sprint(sanitizeJSONValue(payload)), 1200) +} + +func eventMessageText(payload map[string]any) string { + for _, key := range []string{"message", "type", "turn_id", "collaboration_mode_kind"} { + value := stringField(payload, key) + if value != "" { + return value + } + } + return truncateText(fmt.Sprint(sanitizeJSONValue(payload)), 1200) +} + +func tokenEventText(payload map[string]any) string { + info := mapField(payload, "info") + total := tokenUsageFromMap(mapField(info, "total_token_usage")) + last := tokenUsageFromMap(mapField(info, "last_token_usage")) + primary := rateLimitFromMap(mapField(mapField(payload, "rate_limits"), "primary")) + secondary := rateLimitFromMap(mapField(mapField(payload, "rate_limits"), "secondary")) + parts := []string{ + fmt.Sprintf("total: %s tokens (%s input, %s cached input, %s output, %s reasoning)", + formatInt64(total.TotalTokens), formatInt64(total.InputTokens), formatInt64(total.CachedInputTokens), formatInt64(total.OutputTokens), formatInt64(total.ReasoningOutputTokens)), + fmt.Sprintf("last: %s tokens (%s input, %s cached input, %s output, %s reasoning)", + formatInt64(last.TotalTokens), formatInt64(last.InputTokens), formatInt64(last.CachedInputTokens), formatInt64(last.OutputTokens), formatInt64(last.ReasoningOutputTokens)), + } + if window := int64Field(info, "model_context_window"); window > 0 { + parts = append(parts, "context window: "+formatInt64(window)) + } + if primary.UsedPercent > 0 || primary.WindowMinutes > 0 { + parts = append(parts, fmt.Sprintf("primary rate limit: %.1f%% / %d min", primary.UsedPercent, primary.WindowMinutes)) + } + if secondary.UsedPercent > 0 || secondary.WindowMinutes > 0 { + parts = append(parts, fmt.Sprintf("secondary rate limit: %.1f%% / %d min", secondary.UsedPercent, secondary.WindowMinutes)) + } + return strings.Join(parts, "\n") +} + +func mapField(payload map[string]any, key string) map[string]any { + value, ok := payload[key] + if !ok || value == nil { + return map[string]any{} + } + if typed, ok := value.(map[string]any); ok { + return typed + } + return map[string]any{} +} + +func int64Field(payload map[string]any, key string) int64 { + value, ok := payload[key] + if !ok || value == nil { + return 0 + } + switch typed := value.(type) { + case int: + return int64(typed) + case int64: + return typed + case float64: + return int64(typed) + case json.Number: + n, _ := typed.Int64() + return n + default: + return 0 + } +} + +func floatField(payload map[string]any, key string) float64 { + value, ok := payload[key] + if !ok || value == nil { + return 0 + } + switch typed := value.(type) { + case float64: + return typed + case int: + return float64(typed) + case int64: + return float64(typed) + case json.Number: + n, _ := typed.Float64() + return n + default: + return 0 + } +} + +func formatInt64(value int64) string { + return fmt.Sprintf("%d", value) +} + +func joinNonEmpty(values []string, sep string) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || strings.HasSuffix(value, ":") || strings.HasSuffix(value, ": ") { + continue + } + parts = append(parts, value) + } + return strings.Join(parts, sep) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/cli/codex_inspector/parser_test.go b/cli/codex_inspector/parser_test.go new file mode 100644 index 0000000..174ccac --- /dev/null +++ b/cli/codex_inspector/parser_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestParseRolloutFileToleratesBadJSONAndRedactsSecrets(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rollout-2026-07-03T10-00-00-demo.jsonl") + content := `{"type":"session_meta","timestamp":"2026-07-03T10:00:00Z","payload":{"id":"demo-session","cwd":"/tmp/demo","model_provider":"openai","model":"gpt-test"}} +{"type":"response_item","timestamp":"2026-07-03T10:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"please inspect token=abc123456789"}]}} +{"type":"response_item","timestamp":"2026-07-03T10:00:02Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}} +{"type":"event_msg","timestamp":"2026-07-03T10:00:02Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":400,"output_tokens":80,"reasoning_output_tokens":20,"total_tokens":1080},"last_token_usage":{"input_tokens":300,"cached_input_tokens":100,"output_tokens":30,"reasoning_output_tokens":10,"total_tokens":330},"model_context_window":200000},"rate_limits":{"primary":{"used_percent":12.5,"window_minutes":300},"secondary":{"used_percent":2.5,"window_minutes":10080}}}} +{"type":"response_item","timestamp":"2026-07-03T10:00:03Z","payload":{"type":"function_call","name":"shell","call_id":"call-1","arguments":"{\"password\":\"secret-value\"}"}} +{"type":"response_item","timestamp":"2026-07-03T10:00:04Z","payload":{"type":"function_call_output","call_id":"call-1","output":"ok cookie=real-cookie-value"}} +not json +` + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + + detail, err := ParseRolloutFile(path, true) + require.NoError(t, err) + require.Equal(t, "demo-session", detail.Summary.ID) + require.Equal(t, "/tmp/demo", detail.Summary.Cwd) + require.Equal(t, 1, detail.Summary.UserMessages) + require.Equal(t, 1, detail.Summary.AssistantMessages) + require.Equal(t, 1, detail.Summary.ToolCalls) + require.Equal(t, 1, detail.Summary.ToolResults) + require.Equal(t, int64(1080), detail.Summary.TokenStats.Total.TotalTokens) + require.Equal(t, int64(400), detail.Summary.TokenStats.Total.CachedInputTokens) + require.Equal(t, int64(330), detail.Summary.TokenStats.Last.TotalTokens) + require.Equal(t, int64(200000), detail.Summary.TokenStats.ModelContextWindow) + require.Equal(t, 12.5, detail.Summary.TokenStats.PrimaryRateLimit.UsedPercent) + require.NotEmpty(t, detail.Warnings) + require.Len(t, detail.RawLines, 7) + + combined := "" + for _, event := range detail.Events { + combined += event.Text + "\n" + } + for _, raw := range detail.RawLines { + combined += raw.Text + "\n" + } + require.NotContains(t, combined, "abc123456789") + require.NotContains(t, combined, "secret-value") + require.NotContains(t, combined, "real-cookie-value") + require.Contains(t, combined, "[REDACTED]") +} + +func TestParseRolloutFileStopsAtEventWindowWithTotalHint(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rollout-2026-07-03T10-00-00-window.jsonl") + content := `{"type":"session_meta","timestamp":"2026-07-03T10:00:00Z","payload":{"id":"window-session","cwd":"/tmp/demo"}} +{"type":"response_item","timestamp":"2026-07-03T10:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first"}]}} +not json after requested window +` + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + + detail, err := ParseRolloutFileWithOptions(path, SessionDetailOptions{ + IncludeEvents: true, + EventLimit: 2, + EventTotalHint: 3, + }) + require.NoError(t, err) + require.Empty(t, detail.Warnings) + require.Len(t, detail.Events, 2) + require.Equal(t, 3, detail.EventTotal) + require.True(t, detail.HasMore) +} + +func TestStoreLoadsSessionIndexAndRolloutSummaries(t *testing.T) { + codexHome := t.TempDir() + sessionDir := filepath.Join(codexHome, "sessions", "2026", "07", "03") + require.NoError(t, os.MkdirAll(sessionDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(codexHome, "session_index.jsonl"), []byte(`{"id":"demo-session","thread_name":"Demo Thread","updated_at":"2026-07-03T11:00:00Z"}`+"\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(sessionDir, "rollout-2026-07-03T10-00-00-demo.jsonl"), []byte(`{"type":"session_meta","timestamp":"2026-07-03T10:00:00Z","payload":{"id":"demo-session","cwd":"/tmp/demo"}} +{"type":"response_item","timestamp":"2026-07-03T10:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}} +`), 0644)) + + store := NewStoreWithCache(codexHome, filepath.Join(t.TempDir(), "cache.sqlite")) + defer store.Close() + sessions, warnings := store.LoadSessions(parseFilter("2026-07-01", "2026-07-31", "", 0)) + require.Empty(t, warnings) + require.Len(t, sessions, 1) + require.Equal(t, "Demo Thread", sessions[0].Title) + require.Equal(t, "2026-07-03T11:00:00Z", sessions[0].UpdatedAt) + + detail, ok, warnings := store.LoadSessionDetail("demo-session") + require.True(t, ok) + require.Empty(t, warnings) + require.Equal(t, "Demo Thread", detail.Summary.Title) + require.Len(t, detail.Events, 2) +} + +func TestHistoricalSessionSummaryIsCachedInSQLite(t *testing.T) { + codexHome := t.TempDir() + sessionDir := filepath.Join(codexHome, "sessions", "2000", "01", "01") + require.NoError(t, os.MkdirAll(sessionDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(codexHome, "session_index.jsonl"), nil, 0644)) + path := filepath.Join(sessionDir, "rollout-2000-01-01T10-00-00-demo.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","timestamp":"2000-01-01T10:00:00Z","payload":{"id":"cached-session","cwd":"/tmp/demo","model":"gpt-test"}} +{"type":"response_item","timestamp":"2000-01-01T10:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}} +`), 0644)) + oldTime := time.Date(2000, 1, 1, 10, 0, 0, 0, time.Local) + require.NoError(t, os.Chtimes(path, oldTime, oldTime)) + + cachePath := filepath.Join(t.TempDir(), "summary.sqlite") + store := NewStoreWithCache(codexHome, cachePath) + sessions, warnings := store.LoadSessions(parseFilter("2000-01-01", "2000-01-02", "", 0)) + store.StartCacheBuild() + status := waitCacheJob(t, store) + store.Close() + + require.Empty(t, warnings) + require.Len(t, sessions, 1) + require.Equal(t, 1, sessions[0].UserMessages) + require.Equal(t, cacheStatusHealthy, status.Status) + + cache, err := openExistingSummaryDiskCache(cachePath) + require.NoError(t, err) + defer cache.Close() + meta, ok := statFile(path) + require.True(t, ok) + summary, ok, err := cache.Lookup(path, meta) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "cached-session", summary.ID) + require.Equal(t, 1, summary.UserMessages) + require.Equal(t, "gpt-test", summary.Model) +} diff --git a/cli/codex_inspector/redact.go b/cli/codex_inspector/redact.go new file mode 100644 index 0000000..7802407 --- /dev/null +++ b/cli/codex_inspector/redact.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "regexp" + "strings" +) + +var sensitiveKeyPattern = regexp.MustCompile(`(?i)(auth|authorization|bearer|cookie|credential|jwt|passwd|password|secret|session[_-]?token|token|api[_-]?key|access[_-]?key)`) +var assignmentSecretPattern = regexp.MustCompile(`(?i)(auth|authorization|cookie|jwt|passwd|password|secret|session[_-]?token|token|api[_-]?key|access[_-]?key)([A-Za-z0-9_.-]{0,30})(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)`) +var jsonStringSecretPattern = regexp.MustCompile(`(?i)(\\?"(?:auth|authorization|cookie|credential|jwt|passwd|password|secret|session[_-]?token|token|api[_-]?key|access[_-]?key)"\\?\s*:\s*)\\?"[^"\\]*(?:\\.[^"\\]*)*\\?"`) +var bearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}`) + +func sanitizeText(value string) string { + if value == "" { + return "" + } + value = assignmentSecretPattern.ReplaceAllString(value, "$1$2$3[REDACTED]") + value = jsonStringSecretPattern.ReplaceAllString(value, `${1}"[REDACTED]"`) + value = bearerPattern.ReplaceAllString(value, "Bearer [REDACTED]") + return value +} + +func sanitizeJSONValue(value any) any { + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + if sensitiveKeyPattern.MatchString(key) { + out[key] = "[REDACTED]" + continue + } + out[key] = sanitizeJSONValue(item) + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, sanitizeJSONValue(item)) + } + return out + case string: + return sanitizeText(typed) + default: + return value + } +} + +func sanitizedRawJSON(line []byte) string { + var value any + if err := json.Unmarshal(line, &value); err != nil { + return sanitizeText(string(line)) + } + value = sanitizeJSONValue(value) + content, err := json.Marshal(value) + if err != nil { + return sanitizeText(string(line)) + } + return string(content) +} + +func truncateText(value string, max int) string { + value = strings.TrimSpace(value) + if max <= 0 || len(value) <= max { + return value + } + return strings.TrimSpace(value[:max]) + "..." +} + +func containsFold(value string, query string) bool { + if query == "" { + return true + } + return strings.Contains(strings.ToLower(value), strings.ToLower(query)) +} diff --git a/cli/codex_inspector/server.go b/cli/codex_inspector/server.go new file mode 100644 index 0000000..d4d2532 --- /dev/null +++ b/cli/codex_inspector/server.go @@ -0,0 +1,174 @@ +package main + +import ( + "embed" + "encoding/json" + "io/fs" + "net/http" + "net/url" + "strconv" + "strings" +) + +//go:embed static/* +var staticFiles embed.FS + +func NewServer(store *Store) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/overview", func(w http.ResponseWriter, r *http.Request) { + filter := requestFilter(r, 0) + sessions, warnings := store.LoadSessions(filter) + heatmapSessions, heatmapWarnings := store.LoadSessionIndex() + warnings = append(warnings, heatmapWarnings...) + writeJSON(w, http.StatusOK, BuildOverview(store.CodexHome, sessions, heatmapSessions, store.Sources(), warnings)) + }) + mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) { + filter := requestFilter(r, 250) + sessions, warnings := store.LoadSessions(filter) + writeJSON(w, http.StatusOK, SessionsResponse{Sessions: sessions, Warnings: warnings}) + }) + mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/api/sessions/") + if strings.HasSuffix(rest, "/raw") { + id, err := url.PathUnescape(strings.TrimSuffix(rest, "/raw")) + if err != nil || strings.TrimSpace(id) == "" { + http.Error(w, "invalid session id", http.StatusBadRequest) + return + } + line := offsetFromString(r.URL.Query().Get("line")) + raw, ok, warnings := store.LoadSessionRawLine(id, line) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "raw line not found", "warnings": warnings}) + return + } + writeJSON(w, http.StatusOK, RawLineResponse{Line: raw, Warnings: warnings}) + return + } + + id, err := url.PathUnescape(rest) + if err != nil || strings.TrimSpace(id) == "" { + http.Error(w, "invalid session id", http.StatusBadRequest) + return + } + detail, ok, warnings := store.LoadSessionDetailWithOptions(id, sessionDetailOptions(r)) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "session not found", "warnings": warnings}) + return + } + detail.Warnings = append(detail.Warnings, warnings...) + writeJSON(w, http.StatusOK, detail) + }) + mux.HandleFunc("/api/memory", func(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("q")) + limit := limitFromString(r.URL.Query().Get("limit"), 250) + writeJSON(w, http.StatusOK, store.LoadMemory(query, limit)) + }) + mux.HandleFunc("/api/memory/file", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimSpace(r.URL.Query().Get("path")) + detail, ok := store.LoadMemoryDetail(path) + if !ok { + writeJSON(w, http.StatusBadRequest, detail) + return + } + writeJSON(w, http.StatusOK, detail) + }) + mux.HandleFunc("/api/diagnostics", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, store.Diagnostics()) + }) + mux.HandleFunc("/api/cache/status", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, store.CacheStatus()) + }) + mux.HandleFunc("/api/cache/build", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, store.StartCacheBuild()) + }) + mux.HandleFunc("/api/cache/rebuild", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, store.StartCacheRebuild()) + }) + + sub, err := fs.Sub(staticFiles, "static") + if err != nil { + panic(err) + } + static := http.FileServer(http.FS(sub)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + if r.URL.Path == "/" { + index, err := fs.ReadFile(sub, "index.html") + if err != nil { + http.Error(w, "index.html not found", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(index) + return + } + static.ServeHTTP(w, r) + }) + return mux +} + +func requestFilter(r *http.Request, fallbackLimit int) QueryFilter { + query := r.URL.Query() + return parseFilter( + query.Get("from"), + query.Get("to"), + query.Get("q"), + limitFromString(query.Get("limit"), fallbackLimit), + ) +} + +func sessionDetailOptions(r *http.Request) SessionDetailOptions { + query := r.URL.Query() + return SessionDetailOptions{ + IncludeEvents: true, + IncludeRawLines: boolQuery(query.Get("raw"), true), + EventOffset: offsetFromString(query.Get("event_offset")), + EventLimit: limitFromString(query.Get("event_limit"), 0), + } +} + +func boolQuery(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return fallback + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func offsetFromString(value string) int { + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n < 0 { + return 0 + } + if n > 100000 { + return 100000 + } + return n +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + _ = encoder.Encode(value) +} diff --git a/cli/codex_inspector/server_test.go b/cli/codex_inspector/server_test.go new file mode 100644 index 0000000..afc9dd0 --- /dev/null +++ b/cli/codex_inspector/server_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSessionDetailAPIUsesLightweightOptions(t *testing.T) { + codexHome := t.TempDir() + sessionDir := filepath.Join(codexHome, "sessions", "2026", "07", "03") + require.NoError(t, os.MkdirAll(sessionDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(codexHome, "session_index.jsonl"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(sessionDir, "rollout-2026-07-03T10-00-00-demo.jsonl"), []byte(`{"type":"session_meta","timestamp":"2026-07-03T10:00:00Z","payload":{"id":"demo-session","cwd":"/tmp/demo"}} +{"type":"response_item","timestamp":"2026-07-03T10:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello token=abc123456789"}]}} +{"type":"response_item","timestamp":"2026-07-03T10:00:02Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}} +`), 0644)) + + store := NewStoreWithCache(codexHome, "") + defer store.Close() + server := httptest.NewServer(NewServer(store)) + defer server.Close() + + resp, err := http.Get(server.URL + "/api/sessions/demo-session?event_limit=1&raw=0") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var detail SessionDetail + require.NoError(t, json.NewDecoder(resp.Body).Decode(&detail)) + require.Len(t, detail.Events, 1) + require.Empty(t, detail.RawLines) + require.Equal(t, 0, detail.EventOffset) + require.Equal(t, 1, detail.EventLimit) + require.Equal(t, 3, detail.EventTotal) + require.True(t, detail.HasMore) + + rawResp, err := http.Get(server.URL + "/api/sessions/demo-session/raw?line=2") + require.NoError(t, err) + defer rawResp.Body.Close() + require.Equal(t, http.StatusOK, rawResp.StatusCode) + + var raw RawLineResponse + require.NoError(t, json.NewDecoder(rawResp.Body).Decode(&raw)) + require.Equal(t, 2, raw.Line.Line) + require.Contains(t, raw.Line.Text, "[REDACTED]") + require.NotContains(t, raw.Line.Text, "abc123456789") +} diff --git a/cli/codex_inspector/sqlite_cache.go b/cli/codex_inspector/sqlite_cache.go new file mode 100644 index 0000000..4755c53 --- /dev/null +++ b/cli/codex_inspector/sqlite_cache.go @@ -0,0 +1,200 @@ +package main + +import ( + "database/sql" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +const summaryCacheVersion = 1 + +type summaryDiskCache struct { + path string + db *sql.DB + mu sync.Mutex +} + +type fileMeta struct { + Size int64 + ModifiedAt int64 + ModTime time.Time +} + +func defaultCachePath() string { + return filepath.Join(os.TempDir(), "life_tools-codex-inspector-"+strconv.Itoa(os.Getuid()), "session_summary_cache.sqlite") +} + +func openSummaryDiskCache(path string) (*summaryDiskCache, error) { + return openSummaryDiskCacheAt(path, true) +} + +func openExistingSummaryDiskCache(path string) (*summaryDiskCache, error) { + return openSummaryDiskCacheAt(path, false) +} + +func openSummaryDiskCacheAt(path string, create bool) (*summaryDiskCache, error) { + if path == "" { + return nil, nil + } + if create { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return nil, err + } + _ = file.Close() + } else { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, os.ErrInvalid + } + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + _ = os.Chmod(path, 0600) + db.SetMaxOpenConns(1) + cache := &summaryDiskCache{path: path, db: db} + if err := cache.init(); err != nil { + _ = db.Close() + return nil, err + } + return cache, nil +} + +func (c *summaryDiskCache) init() error { + if _, err := c.db.Exec(`PRAGMA busy_timeout = 5000;`); err != nil { + return err + } + var check string + if err := c.db.QueryRow(`PRAGMA quick_check;`).Scan(&check); err != nil { + return err + } + _, err := c.db.Exec(`CREATE TABLE IF NOT EXISTS session_summaries ( + path TEXT PRIMARY KEY, + size INTEGER NOT NULL, + modified_at_ns INTEGER NOT NULL, + session_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + summary_json TEXT NOT NULL, + cached_at TEXT NOT NULL, + version INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_session_summaries_session_id ON session_summaries(session_id); +`) + return err +} + +func (c *summaryDiskCache) Close() error { + if c == nil || c.db == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + return c.db.Close() +} + +func (c *summaryDiskCache) Lookup(path string, meta fileMeta) (SessionSummary, bool, error) { + if c == nil || c.db == nil { + return SessionSummary{}, false, nil + } + c.mu.Lock() + defer c.mu.Unlock() + var raw string + err := c.db.QueryRow( + `SELECT summary_json FROM session_summaries + WHERE path = ? AND size = ? AND modified_at_ns = ? AND version = ?`, + path, meta.Size, meta.ModifiedAt, summaryCacheVersion, + ).Scan(&raw) + if err == sql.ErrNoRows { + return SessionSummary{}, false, nil + } + if err != nil { + return SessionSummary{}, false, err + } + var summary SessionSummary + if err := json.Unmarshal([]byte(raw), &summary); err != nil { + return SessionSummary{}, false, err + } + summary.Enriched = true + return summary, true, nil +} + +func (c *summaryDiskCache) Upsert(path string, meta fileMeta, summary SessionSummary) error { + if c == nil || c.db == nil || path == "" { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + summary.Enriched = true + raw, err := json.Marshal(summary) + if err != nil { + return err + } + _, err = c.db.Exec( + `INSERT INTO session_summaries + (path, size, modified_at_ns, session_id, updated_at, summary_json, cached_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + size = excluded.size, + modified_at_ns = excluded.modified_at_ns, + session_id = excluded.session_id, + updated_at = excluded.updated_at, + summary_json = excluded.summary_json, + cached_at = excluded.cached_at, + version = excluded.version`, + path, + meta.Size, + meta.ModifiedAt, + summary.ID, + firstNonEmpty(summary.UpdatedAt, summary.StartedAt), + string(raw), + time.Now().Format(time.RFC3339), + summaryCacheVersion, + ) + return err +} + +func statFile(path string) (fileMeta, bool) { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return fileMeta{}, false + } + return fileMeta{Size: info.Size(), ModifiedAt: info.ModTime().UnixNano(), ModTime: info.ModTime()}, true +} + +func cacheableFile(meta fileMeta, now time.Time) bool { + return startOfDay(meta.ModTime).Before(startOfDay(now)) +} + +func cacheableSummary(summary SessionSummary, now time.Time) bool { + value := firstNonEmpty(summary.UpdatedAt, summary.StartedAt) + t, ok := parseTime(value) + return ok && startOfDay(t).Before(startOfDay(now)) +} + +func isSQLiteCorruptError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + for _, marker := range []string{"malformed", "file is not a database", "schema is corrupt"} { + if strings.Contains(message, marker) { + return true + } + } + return false +} diff --git a/cli/codex_inspector/static/app.js b/cli/codex_inspector/static/app.js new file mode 100644 index 0000000..a376aa2 --- /dev/null +++ b/cli/codex_inspector/static/app.js @@ -0,0 +1,790 @@ +const state = { + page: "overview", + sessions: [], + selectedSession: "", + detailRequest: 0, + memoryFiles: [], + selectedMemory: "", + cachePollTimer: 0, + cacheRebuildConfirmOpen: false, + cacheRebuildBusy: false, +}; + +const RANGE_HISTORY_KEY = "codexInspectorRangeHistory"; +const QUICK_RANGES = { + "1d": { label: "Last 24 hours", days: 1 }, + "7d": { label: "Last 7 days", days: 7 }, + "30d": { label: "Last 30 days", days: 30 }, +}; +const SESSION_EVENT_PAGE_SIZE = 300; + +const $ = (selector) => document.querySelector(selector); + +function params(extra = {}) { + const query = new URLSearchParams(); + const from = $("#fromDate").value; + const to = $("#toDate").value; + if (from) query.set("from", from); + if (to) query.set("to", to); + Object.entries(extra).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== "") { + query.set(key, value); + } + }); + const text = query.toString(); + return text ? `?${text}` : ""; +} + +async function api(path, options = {}) { + const response = await fetch(path, { cache: "no-store", ...options }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `${response.status} ${response.statusText}`); + } + return response.json(); +} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function isoDate(date) { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 10); +} + +function applyQuickRange(key) { + const range = QUICK_RANGES[key] || QUICK_RANGES["7d"]; + const to = new Date(); + const from = new Date(to); + from.setDate(to.getDate() - range.days + 1); + $("#fromDate").value = isoDate(from); + $("#toDate").value = isoDate(to); + $("#rangePreset").value = key; +} + +function rangeLabel(from, to) { + const quick = Object.entries(QUICK_RANGES).find(([, range]) => { + const today = new Date(); + const start = new Date(today); + start.setDate(today.getDate() - range.days + 1); + return from === isoDate(start) && to === isoDate(today); + }); + if (quick) return quick[1].label; + return `${from || "Any"} to ${to || "Any"}`; +} + +function rangeHistory() { + try { + const items = JSON.parse(localStorage.getItem(RANGE_HISTORY_KEY) || "[]"); + return Array.isArray(items) ? items.filter((item) => item && (item.from || item.to)) : []; + } catch { + return []; + } +} + +function saveRangeHistory() { + const from = $("#fromDate").value; + const to = $("#toDate").value; + if (!from && !to) return; + const current = { from, to, label: rangeLabel(from, to) }; + const items = [current, ...rangeHistory().filter((item) => item.from !== from || item.to !== to)].slice(0, 10); + localStorage.setItem(RANGE_HISTORY_KEY, JSON.stringify(items)); + renderRangePreset(items); +} + +function renderRangePreset(items = rangeHistory()) { + const select = $("#rangePreset"); + const current = { from: $("#fromDate").value, to: $("#toDate").value }; + select.textContent = ""; + Object.entries(QUICK_RANGES).forEach(([key, range]) => { + const option = el("option", "", range.label); + option.value = key; + select.append(option); + }); + if (items.length) { + const group = document.createElement("optgroup"); + group.label = "Recent ranges"; + items.forEach((item, index) => { + const option = el("option", "", item.label || rangeLabel(item.from, item.to)); + option.value = `history:${index}`; + option.dataset.from = item.from || ""; + option.dataset.to = item.to || ""; + group.append(option); + }); + select.append(group); + } + const quickKey = matchingQuickRange(current.from, current.to); + if (quickKey) { + select.value = quickKey; + return; + } + const historyIndex = items.findIndex((item) => item.from === current.from && item.to === current.to); + if (historyIndex >= 0) { + select.value = `history:${historyIndex}`; + return; + } + if (current.from || current.to) { + const custom = el("option", "", rangeLabel(current.from, current.to)); + custom.value = "custom"; + select.prepend(custom); + select.value = "custom"; + } +} + +function matchingQuickRange(from, to) { + return Object.entries(QUICK_RANGES).find(([key, range]) => { + const today = new Date(); + const start = new Date(today); + start.setDate(today.getDate() - range.days + 1); + return from === isoDate(start) && to === isoDate(today) ? key : ""; + })?.[0] || ""; +} + +function initializeRangeControls() { + if (!$("#fromDate").value && !$("#toDate").value) { + applyQuickRange("7d"); + } + renderRangePreset(); +} + +function showToast(message) { + const toast = $("#toast"); + toast.textContent = message; + toast.classList.add("show"); + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => toast.classList.remove("show"), 2600); +} + +function setPage(page) { + state.page = page; + document.querySelectorAll(".nav-item").forEach((item) => { + item.classList.toggle("active", item.dataset.page === page); + }); + document.querySelectorAll(".page").forEach((item) => { + item.classList.toggle("active", item.id === `${page}Page`); + }); + refresh(); +} + +async function refresh() { + const refreshButton = $("#refreshButton"); + refreshButton.disabled = true; + refreshButton.textContent = "Refreshing"; + try { + saveRangeHistory(); + if (state.page === "overview") await loadOverview(); + if (state.page === "sessions") await loadSessions(); + if (state.page === "memory") await loadMemory(); + if (state.page === "diagnostics") await loadDiagnostics(); + } catch (error) { + showToast(error.message); + } finally { + refreshButton.disabled = false; + refreshButton.textContent = "Refresh"; + } +} + +async function loadOverview() { + const data = await api(`/api/overview${params()}`); + $("#sourceStatus").textContent = sourceStatusText(data.sources); + $("#codexHomeBadge").textContent = data.codexHome; + renderStats(data.stats); + renderHeatmap(data.stats.heatmap); + renderMonthBars(data.stats.byMonth); + renderRecent(data.recentSessions); + if (data.warnings && data.warnings.length) showToast(data.warnings[0]); +} + +function sourceStatusText(sources) { + const found = sources.filter((item) => item.exists).length; + return `${found}/${sources.length} data sources visible`; +} + +function renderStats(stats) { + const cards = [ + ["Sessions", formatNumber(stats.totalSessions), `${stats.activeDays} active days`], + ["Events", formatNumber(stats.totalEvents), "JSONL records parsed"], + ["Messages", formatNumber(stats.userMessages + stats.assistantMessages), `${formatNumber(stats.userMessages)} user / ${formatNumber(stats.assistantMessages)} Codex`], + ["Tools", formatNumber(stats.toolCalls + stats.toolResults), `${formatNumber(stats.toolCalls)} calls / ${formatNumber(stats.toolResults)} results`], + ["Tokens", formatCompact(stats.totalTokens), `${formatNumber(stats.tokenizedSessions)} sessions with usage`], + ["Output", formatCompact((stats.outputTokens || 0) + (stats.reasoningTokens || 0)), `${formatCompact(stats.outputTokens || 0)} visible / ${formatCompact(stats.reasoningTokens || 0)} reasoning`], + ]; + const root = $("#statCards"); + root.textContent = ""; + cards.forEach(([label, value, note]) => { + const card = el("div", "stat-card"); + card.append(el("div", "stat-label", label)); + card.append(el("div", "stat-value", String(value || 0))); + card.append(el("div", "stat-note", note)); + root.append(card); + }); +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString(); +} + +function formatCompact(value) { + const number = Number(value || 0); + const abs = Math.abs(number); + const units = [ + [1_000_000_000, "b"], + [1_000_000, "m"], + [1_000, "k"], + ]; + for (const [base, suffix] of units) { + if (abs >= base) { + const scaled = number / base; + const digits = Math.abs(scaled) >= 100 ? 0 : 1; + return `${trimNumber(scaled.toFixed(digits))}${suffix}`; + } + } + return formatNumber(number); +} + +function trimNumber(value) { + return value.replace(/\.0$/, ""); +} + +function renderHeatmap(cells) { + const root = $("#heatmap"); + root.textContent = ""; + if (!cells || !cells.length) { + root.append(el("div", "empty-state", "No activity data in the last 6 months.")); + $("#heatmapHint").textContent = ""; + return; + } + const normalized = normalizedHeatmap(cells); + const max = Math.max(...cells.map((cell) => cell.count), 1); + root.style.setProperty("--heatmap-columns", Math.max(1, Math.ceil(normalized.length / 7))); + normalized.forEach((cell) => { + const item = el("div", `heat-cell ${cell.date ? heatClass(cell.count, max) : "heat-empty"}`); + if (cell.date) { + const detail = `日期:${cell.date}\n对话次数:${cell.count}`; + item.title = detail; + item.setAttribute("aria-label", detail); + } + root.append(item); + }); + $("#heatmapHint").textContent = `Last 6 months · ${cells[0].date} to ${cells[cells.length - 1].date}`; +} + +function normalizedHeatmap(cells) { + const first = new Date(`${cells[0].date}T00:00:00`); + const leading = Number.isNaN(first.getTime()) ? 0 : first.getDay(); + const out = Array.from({ length: leading }, () => ({ date: "", count: 0 })); + cells.forEach((cell) => out.push(cell)); + while (out.length % 7 !== 0) out.push({ date: "", count: 0 }); + return out; +} + +function heatClass(count, max) { + if (!count) return ""; + const ratio = count / max; + if (ratio > 0.75) return "heat-4"; + if (ratio > 0.45) return "heat-3"; + if (ratio > 0.2) return "heat-2"; + return "heat-1"; +} + +function renderMonthBars(points) { + const root = $("#monthBars"); + root.textContent = ""; + if (!points || !points.length) { + root.append(el("div", "empty-state", "No monthly data.")); + return; + } + const recent = points.slice(-12); + const max = Math.max(...recent.map((item) => item.count), 1); + recent.forEach((item) => { + const row = el("div", "bar-row"); + row.append(el("div", "", item.key)); + const track = el("div", "bar-track"); + const fill = el("div", "bar-fill"); + fill.style.width = `${Math.max(4, (item.count / max) * 100)}%`; + track.append(fill); + row.append(track); + row.append(el("div", "", String(item.count))); + root.append(row); + }); +} + +function renderRecent(sessions) { + const root = $("#recentSessions"); + root.textContent = ""; + if (!sessions || !sessions.length) { + root.append(el("div", "empty-state", "No recent sessions.")); + return; + } + sessions.forEach((session) => { + const card = sessionCard(session, "list-card"); + card.addEventListener("click", () => { + state.selectedSession = session.id; + setPage("sessions"); + }); + root.append(card); + }); +} + +async function loadSessions() { + const q = $("#sessionSearch").value.trim(); + const data = await api(`/api/sessions${params({ q, limit: 300 })}`); + state.sessions = data.sessions || []; + $("#sessionCount").textContent = `${state.sessions.length} shown`; + renderSessionList(); + if (!state.selectedSession && state.sessions.length) { + state.selectedSession = state.sessions[0].id; + } + if (state.selectedSession) { + await loadSessionDetail(state.selectedSession); + } +} + +function renderSessionList() { + const root = $("#sessionList"); + root.textContent = ""; + if (!state.sessions.length) { + root.append(el("div", "empty-state", "No sessions match this filter.")); + return; + } + state.sessions.forEach((session) => { + const card = sessionCard(session, "session-item"); + card.classList.toggle("active", session.id === state.selectedSession); + card.addEventListener("click", async () => { + state.selectedSession = session.id; + renderSessionList(); + await loadSessionDetail(session.id); + }); + root.append(card); + }); +} + +function sessionCard(session, className) { + const card = el("button", className); + card.type = "button"; + card.append(el("div", "item-title", session.title || session.id)); + const meta = el("div", "item-meta"); + meta.append(el("span", "", shortDate(session.updatedAt || session.startedAt))); + meta.append(el("span", "", `${session.eventCount || 0} events`)); + if (session.model) meta.append(el("span", "", session.model)); + if (session.tokenStats && session.tokenStats.tokenEvents) meta.append(el("span", "", `${formatCompact(session.tokenStats.total.totalTokens)} tokens`)); + card.append(meta); + if (session.preview) card.append(el("div", "item-preview", session.preview)); + return card; +} + +async function loadSessionDetail(id, options = {}) { + const offset = options.offset || 0; + const append = !!options.append; + const request = ++state.detailRequest; + if (!append) { + setSessionDetailState("Loading session detail...", "Loading"); + } + let detail; + try { + detail = await api(`/api/sessions/${encodeURIComponent(id)}?event_offset=${offset}&event_limit=${SESSION_EVENT_PAGE_SIZE}&raw=0`); + } catch (error) { + if (request === state.detailRequest) { + setSessionDetailState(error.message || "Failed to load session detail.", "Load failed"); + } + throw error; + } + if (request !== state.detailRequest) return; + $("#sessionDetailTitle").textContent = detail.summary.title || detail.summary.id; + $("#sessionDetailMeta").textContent = sessionDetailMeta(detail); + const root = $("#sessionDetail"); + root.classList.remove("empty-state"); + root.querySelector(".load-more-row")?.remove(); + if (!append) { + root.textContent = ""; + } + if (!append && detail.summary.tokenStats && detail.summary.tokenStats.tokenEvents) { + root.append(tokenSummaryPanel(detail.summary.tokenStats)); + } + if (!detail.events || !detail.events.length) { + if (!append) root.append(el("div", "empty-state", "No displayable events.")); + return; + } + detail.events.forEach((event) => { + const item = el("article", `event ${event.kind} ${event.role || ""}`); + item.append(el("div", "event-label", labelForEvent(event))); + const body = el("div", "event-body"); + body.append(el("div", "event-meta", `line ${event.line}${event.timestamp ? ` · ${event.timestamp}` : ""}`)); + body.append(el("div", "event-text", event.text || "(empty)")); + body.append(rawDetailsForEvent(id, event)); + item.append(body); + root.append(item); + }); + appendLoadMore(root, detail, id); +} + +function sessionDetailMeta(detail) { + const total = detail.eventTotal || detail.summary.eventCount || 0; + const offset = detail.eventOffset || 0; + const shown = (detail.events || []).length; + const start = total && shown ? 1 : 0; + const end = total && shown ? Math.min(offset + shown, total) : 0; + const range = total ? `showing ${formatNumber(start)}-${formatNumber(end)} of ${formatNumber(total)} events` : ""; + return [detail.summary.updatedAt || "", detail.summary.cwd || "", range].filter(Boolean).join(" "); +} + +function rawDetailsForEvent(sessionID, event) { + const details = el("details", "raw-details"); + const summary = el("summary", "", "Raw JSONL"); + const raw = el("pre", "raw-block", "Open to load raw JSONL for this line."); + details.append(summary); + details.append(raw); + details.addEventListener("toggle", () => { + if (!details.open || details.dataset.loaded === "1" || details.dataset.loading === "1") return; + details.dataset.loading = "1"; + raw.textContent = "Loading raw JSONL..."; + loadRawLine(sessionID, event.line, raw, details).catch((error) => { + raw.textContent = error.message || "Failed to load raw JSONL."; + details.dataset.loading = "0"; + }); + }); + return details; +} + +async function loadRawLine(sessionID, line, raw, details) { + const data = await api(`/api/sessions/${encodeURIComponent(sessionID)}/raw?line=${line}`); + raw.textContent = data.line?.text || "(empty)"; + details.dataset.loaded = "1"; + details.dataset.loading = "0"; +} + +function appendLoadMore(root, detail, sessionID) { + if (!detail.hasMore) return; + const row = el("div", "load-more-row"); + const button = el("button", "secondary-button", "Load more events"); + button.type = "button"; + const nextOffset = (detail.eventOffset || 0) + (detail.events || []).length; + button.addEventListener("click", async () => { + button.disabled = true; + button.textContent = "Loading more"; + try { + await loadSessionDetail(sessionID, { append: true, offset: nextOffset }); + } catch (error) { + showToast(error.message); + button.disabled = false; + button.textContent = "Load more events"; + } + }); + row.append(button); + root.append(row); +} + +function setSessionDetailState(message, title = "Session detail") { + $("#sessionDetailTitle").textContent = title; + $("#sessionDetailMeta").textContent = ""; + const root = $("#sessionDetail"); + root.classList.add("empty-state"); + root.textContent = message; +} + +function tokenSummaryPanel(stats) { + const panel = el("section", "token-summary"); + panel.append(tokenMetric("Total tokens", formatCompact(stats.total.totalTokens), formatNumber(stats.total.totalTokens))); + panel.append(tokenMetric("Input", `${formatCompact(stats.total.inputTokens)} (${formatCompact(stats.total.cachedInputTokens)} cached)`, `${formatNumber(stats.total.inputTokens)} input, ${formatNumber(stats.total.cachedInputTokens)} cached input`)); + panel.append(tokenMetric("Output", `${formatCompact(stats.total.outputTokens)} + ${formatCompact(stats.total.reasoningOutputTokens)} reasoning`, `${formatNumber(stats.total.outputTokens)} output, ${formatNumber(stats.total.reasoningOutputTokens)} reasoning output`)); + panel.append(tokenMetric("Last turn", formatCompact(stats.last.totalTokens), formatNumber(stats.last.totalTokens))); + if (stats.modelContextWindow) panel.append(tokenMetric("Context", formatCompact(stats.modelContextWindow), formatNumber(stats.modelContextWindow))); + if (stats.primaryRateLimit && stats.primaryRateLimit.usedPercent) panel.append(tokenMetric("Primary limit", `${stats.primaryRateLimit.usedPercent.toFixed(1)}%`)); + if (stats.secondaryRateLimit && stats.secondaryRateLimit.usedPercent) panel.append(tokenMetric("Weekly limit", `${stats.secondaryRateLimit.usedPercent.toFixed(1)}%`)); + return panel; +} + +function tokenMetric(label, value, exact) { + const item = el("div", "token-metric"); + item.append(el("span", "", label)); + const strong = el("strong", "", value); + if (exact) strong.title = exact; + item.append(strong); + return item; +} + +function labelForEvent(event) { + if (event.role === "assistant") return "Codex"; + if (event.role === "user") return "User"; + if (event.kind === "tool_call") return "Tool call"; + if (event.kind === "tool_result") return "Tool result"; + if (event.kind === "reasoning") return "Reasoning"; + return "System"; +} + +async function loadMemory() { + const q = $("#memorySearch").value.trim(); + const data = await api(`/api/memory${params({ q, limit: 300 })}`); + state.memoryFiles = data.files || []; + $("#memoryCount").textContent = `${state.memoryFiles.length} shown`; + renderMemoryList(); + if (!state.selectedMemory && state.memoryFiles.length) { + state.selectedMemory = state.memoryFiles[0].path; + } + if (state.selectedMemory) await loadMemoryDetail(state.selectedMemory); +} + +function renderMemoryList() { + const root = $("#memoryList"); + root.textContent = ""; + if (!state.memoryFiles.length) { + root.append(el("div", "empty-state", "No memory files match this query.")); + return; + } + state.memoryFiles.forEach((file) => { + const card = el("button", "memory-item"); + card.type = "button"; + card.classList.toggle("active", file.path === state.selectedMemory); + card.append(el("div", "item-title", file.title || file.path)); + const meta = el("div", "item-meta"); + meta.append(el("span", "", file.kind)); + meta.append(el("span", "", shortDate(file.modifiedAt))); + if (file.matches) meta.append(el("span", "", `${file.matches} match`)); + card.append(meta); + if (file.preview) card.append(el("div", "item-preview", file.preview)); + card.addEventListener("click", async () => { + state.selectedMemory = file.path; + renderMemoryList(); + await loadMemoryDetail(file.path); + }); + root.append(card); + }); +} + +async function loadMemoryDetail(path) { + const detail = await api(`/api/memory/file?path=${encodeURIComponent(path)}`); + $("#memoryDetailTitle").textContent = detail.file.title || detail.file.path; + $("#memoryDetailMeta").textContent = `${detail.file.kind} · ${detail.file.path}`; + const root = $("#memoryDetail"); + root.classList.remove("empty-state"); + root.textContent = detail.content || "(empty)"; +} + +async function loadDiagnostics() { + const data = await api("/api/diagnostics"); + $("#sourceStatus").textContent = sourceStatusText(data.sources); + renderCacheStatus(data.cache); + renderSourceTable(data.sources); + renderSchemas(data.schemas); +} + +async function loadCacheStatus() { + const data = await api("/api/cache/status"); + renderCacheStatus(data); +} + +async function startCacheBuild(rebuild = false) { + if (rebuild) renderCacheRebuildModal(false); + const path = rebuild ? "/api/cache/rebuild" : "/api/cache/build"; + const data = await api(path, { method: "POST" }); + renderCacheStatus(data); + showToast(rebuild ? "Cache rebuild started" : "Cache build started"); +} + +function renderCacheRebuildModal(open) { + const modal = $("#cacheRebuildModal"); + if (!modal) return; + state.cacheRebuildConfirmOpen = open; + modal.hidden = !open; + if (open) { + $("#cancelCacheRebuild").focus(); + } +} + +async function confirmCacheRebuild() { + if (state.cacheRebuildBusy) return; + state.cacheRebuildBusy = true; + $("#confirmCacheRebuild").disabled = true; + $("#cancelCacheRebuild").disabled = true; + try { + await startCacheBuild(true); + } catch (error) { + renderCacheRebuildModal(true); + showToast(error.message); + } finally { + state.cacheRebuildBusy = false; + $("#confirmCacheRebuild").disabled = false; + $("#cancelCacheRebuild").disabled = false; + } +} + +function renderCacheStatus(cache) { + const actions = $("#cacheActions"); + const root = $("#cacheStatus"); + const meta = $("#cacheMeta"); + clearTimeout(state.cachePollTimer); + actions.textContent = ""; + root.textContent = ""; + + if (!cache) { + meta.textContent = "cache status unavailable"; + root.append(el("div", "item-preview", "Cache status was not returned by the server.")); + return; + } + + const statusClass = cache.status === "healthy" || cache.status === "rebuilding" ? "status-ok" : "status-miss"; + const status = el("span", `status-pill ${statusClass}`, cache.status); + meta.textContent = cache.path || "no cache path"; + const summary = el("div", "cache-summary"); + summary.append(status); + summary.append(el("span", "item-meta", cache.reason || (cache.autoFill ? `auto fill workers: ${cache.workers}` : `workers: ${cache.workers}`))); + root.append(summary); + + if (cache.backupPath) { + root.append(el("div", "item-preview", `Backup: ${cache.backupPath}`)); + } + + renderCacheJob(root, cache.job || {}); + + if (cache.canBuild) { + const button = el("button", "primary-button", cache.status === "missing" ? "Create cache" : "Fill missing cache"); + button.type = "button"; + button.addEventListener("click", () => startCacheBuild(false).catch((error) => showToast(error.message))); + actions.append(button); + } + if (cache.canRebuild) { + const button = el("button", "primary-button danger-button", "Backup and rebuild cache"); + button.type = "button"; + button.addEventListener("click", () => renderCacheRebuildModal(true)); + actions.append(button); + } + if (cache.job?.running) { + const button = el("button", "primary-button", "Refreshing"); + button.type = "button"; + button.disabled = true; + actions.append(button); + state.cachePollTimer = setTimeout(() => { + if (state.page === "diagnostics") loadCacheStatus().catch((error) => showToast(error.message)); + }, 1200); + } +} + +function renderCacheJob(root, job) { + const grid = el("div", "cache-job-grid"); + [ + ["Total", job.total || 0], + ["Done", job.done || 0], + ["Cached", job.cached || 0], + ["Skipped", job.skipped || 0], + ["Failed", job.failed || 0], + ].forEach(([label, value]) => { + const item = el("div", "cache-job-item"); + item.append(el("span", "metric-label", label)); + item.append(el("strong", "", formatNumber(value))); + grid.append(item); + }); + root.append(grid); + const times = [job.startedAt ? `Started ${shortDate(job.startedAt)}` : "", job.finishedAt ? `Finished ${shortDate(job.finishedAt)}` : ""].filter(Boolean).join(" · "); + if (times) root.append(el("div", "item-meta", times)); + if (job.lastError) root.append(el("div", "item-preview danger-text", job.lastError)); +} + +function renderSourceTable(sources) { + const root = $("#sourceTable"); + root.textContent = ""; + const table = document.createElement("table"); + const head = document.createElement("thead"); + head.innerHTML = "NameStatusPathNotes"; + table.append(head); + const body = document.createElement("tbody"); + sources.forEach((source) => { + const row = document.createElement("tr"); + row.append(td(source.name, "Name")); + const status = el("span", `status-pill ${source.exists ? "status-ok" : "status-miss"}`, source.exists ? "visible" : "missing"); + const statusCell = td("", "Status"); + statusCell.append(status); + row.append(statusCell); + row.append(td(source.path, "Path")); + row.append(td((source.notes || []).join("; "), "Notes")); + body.append(row); + }); + table.append(body); + root.append(table); +} + +function renderSchemas(schemas) { + const root = $("#schemaList"); + root.textContent = ""; + schemas.forEach((schema) => { + const card = el("div", "schema-card"); + const title = el("div", "item-title", schema.name); + card.append(title); + card.append(el("div", "item-meta", `${schema.readMode} · ${schema.path}`)); + if (schema.loaded) { + card.append(el("pre", "schema-code", schema.schema || "(empty schema)")); + } else { + card.append(el("div", "item-preview", schema.error || "schema not loaded")); + } + root.append(card); + }); +} + +function td(text, label) { + const cell = document.createElement("td"); + if (label) cell.dataset.label = label; + cell.textContent = text || ""; + return cell; +} + +function shortDate(value) { + if (!value) return "unknown time"; + return value.length > 19 ? value.slice(0, 19).replace("T", " ") : value.replace("T", " "); +} + +function debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +document.querySelectorAll(".nav-item").forEach((item) => { + item.addEventListener("click", () => setPage(item.dataset.page)); +}); + +$("#refreshButton").addEventListener("click", refresh); +$("#rangePreset").addEventListener("change", () => { + const value = $("#rangePreset").value; + if (value.startsWith("history:")) { + const option = $("#rangePreset").selectedOptions[0]; + $("#fromDate").value = option.dataset.from || ""; + $("#toDate").value = option.dataset.to || ""; + } else if (QUICK_RANGES[value]) { + applyQuickRange(value); + } + refresh(); +}); +$("#fromDate").addEventListener("change", () => { + renderRangePreset(); + refresh(); +}); +$("#toDate").addEventListener("change", () => { + renderRangePreset(); + refresh(); +}); +$("#sessionSearch").addEventListener("input", debounce(() => { + state.selectedSession = ""; + if (state.page === "sessions") refresh(); +}, 220)); +$("#memorySearch").addEventListener("input", debounce(() => { + state.selectedMemory = ""; + if (state.page === "memory") refresh(); +}, 220)); +$("#cancelCacheRebuild").addEventListener("click", () => renderCacheRebuildModal(false)); +$("#confirmCacheRebuild").addEventListener("click", () => confirmCacheRebuild()); +$("#cacheRebuildModal").addEventListener("click", (event) => { + if (event.target.id === "cacheRebuildModal") renderCacheRebuildModal(false); +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.cacheRebuildConfirmOpen && !state.cacheRebuildBusy) { + renderCacheRebuildModal(false); + } +}); + +initializeRangeControls(); +refresh(); diff --git a/cli/codex_inspector/static/index.html b/cli/codex_inspector/static/index.html new file mode 100644 index 0000000..fa76157 --- /dev/null +++ b/cli/codex_inspector/static/index.html @@ -0,0 +1,195 @@ + + + + + + Codex Inspector + + + +
+
+
CI
+
+
Codex Inspector
+
Loading local data source
+
+
+
+ + + + +
+
+ +
+ 本工具只读展示本机 Codex 数据,默认运行在 127.0.0.1;auth、token、cookie、secret 类字段会被排除或脱敏。 +
+ +
+ + +
+
+
+
+

Overview

+

会话总量、活跃度和最近对话。

+
+
+
+
+
+
+

Activity heatmap

+ +
+
+
+
+
+
+
+
+

Monthly sessions

+
+
+
+
+
+

Recent sessions

+
+
+
+
+
+ +
+
+
+

Sessions

+

按时间范围浏览 session,右侧展示原生交互顺序和 raw JSONL。

+
+ +
+
+
+
+

Session list

+ +
+
+
+
+
+

Session detail

+ +
+
Select a session to inspect.
+
+
+
+ +
+
+
+

Memory

+

按摘要、索引和 rollout summary 查看本机记忆文件。

+
+ +
+
+
+
+

Memory files

+ +
+
+
+
+
+

Memory detail

+ +
+
Select a memory file to preview.
+
+
+
+ +
+
+
+

Diagnostics

+

数据源状态、SQLite schema 探测和只读边界。

+
+
+
+
+
+

Summary cache

+ +
+
+
+
+
+
+
+

Data sources

+
+
+
+
+
+

SQLite schema

+
+
+
+
+
+
+ + +
+ + + diff --git a/cli/codex_inspector/static/styles.css b/cli/codex_inspector/static/styles.css new file mode 100644 index 0000000..6cdee14 --- /dev/null +++ b/cli/codex_inspector/static/styles.css @@ -0,0 +1,1103 @@ +:root { + --bg: #f6f7fb; + --surface: #ffffff; + --surface-2: #eef4f2; + --text: #17201b; + --muted: #66736d; + --border: #d9e1dc; + --accent: #1f7a5c; + --accent-strong: #135b43; + --accent-soft: #dff2ea; + --blue: #315c9b; + --amber: #a56820; + --danger: #a33c3c; + --shadow: 0 18px 42px rgba(34, 45, 40, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: + linear-gradient(180deg, rgba(31, 122, 92, 0.07), transparent 260px), + var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +button, +input, +select { + font: inherit; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: 72px; + padding: 14px 24px; + border-bottom: 1px solid rgba(217, 225, 220, 0.84); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(18px); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 10px; + background: var(--text); + color: #fff; + font-size: 13px; + font-weight: 800; +} + +.brand-title { + font-size: 18px; + font-weight: 800; +} + +.brand-subtitle { + max-width: 52vw; + overflow: hidden; + color: var(--muted); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.top-actions { + display: flex; + align-items: end; + gap: 10px; +} + +.date-field, +.range-field, +.search-box { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.date-field input, +.range-field select, +.search-box input { + min-width: 142px; + height: 38px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + padding: 0 11px; + outline: none; +} + +.range-field select { + min-width: 164px; + cursor: pointer; +} + +.search-box input { + min-width: 260px; +} + +.date-field input:focus, +.range-field select:focus, +.search-box input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(31, 122, 92, 0.14); +} + +.primary-button { + height: 38px; + padding: 0 15px; + border: 0; + border-radius: 8px; + background: var(--accent); + color: #fff; + cursor: pointer; + font-size: 13px; + font-weight: 800; +} + +.primary-button:hover { + background: var(--accent-strong); +} + +.primary-button:active { + transform: translateY(1px); +} + +.primary-button:disabled { + cursor: progress; + opacity: 0.72; +} + +.secondary-button { + height: 38px; + padding: 0 15px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); + cursor: pointer; + font-size: 13px; + font-weight: 800; +} + +.secondary-button:hover { + border-color: var(--accent); + color: var(--accent-strong); +} + +.secondary-button:disabled { + cursor: progress; + opacity: 0.72; +} + +.danger-button { + background: var(--danger); +} + +.danger-button:hover { + background: #963333; +} + +.privacy-strip { + padding: 9px 24px; + border-bottom: 1px solid var(--border); + background: #fff8e8; + color: #6f4c1f; + font-size: 13px; +} + +.app-shell { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + gap: 24px; + max-width: 1500px; + margin: 0 auto; + padding: 24px; +} + +.sidebar { + position: sticky; + top: 112px; + align-self: start; + display: grid; + gap: 8px; +} + +.nav-item { + height: 40px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 14px; + font-weight: 800; + text-align: left; + padding: 0 12px; +} + +.nav-item:hover { + background: rgba(255, 255, 255, 0.72); + color: var(--text); +} + +.nav-item.active { + border-color: var(--border); + background: var(--surface); + color: var(--accent-strong); + box-shadow: inset 3px 0 0 var(--accent), var(--shadow); +} + +.nav-item:focus-visible, +.primary-button:focus-visible { + outline: 3px solid rgba(31, 122, 92, 0.22); + outline-offset: 2px; +} + +.content { + min-width: 0; +} + +.page { + display: none; +} + +.page.active { + display: block; +} + +.page-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 30px; + line-height: 1.12; +} + +h2 { + font-size: 15px; + line-height: 1.2; +} + +.page-heading p { + margin-top: 7px; + color: var(--muted); + font-size: 14px; +} + +.path-badge { + max-width: 46vw; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + padding: 9px 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panel { + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.panel-header { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.panel-header > * { + min-width: 0; +} + +.panel-header .muted { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.muted { + color: var(--muted); + font-size: 12px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.stat-card { + min-height: 112px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + padding: 16px; + box-shadow: var(--shadow); +} + +.stat-label { + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.stat-value { + margin-top: 12px; + font-size: 32px; + font-weight: 850; + line-height: 1; +} + +.stat-note { + margin-top: 8px; + color: var(--muted); + font-size: 13px; +} + +.heatmap-wrap { + overflow-x: auto; + padding: 16px; +} + +.heatmap { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 13px; + grid-template-rows: repeat(7, 13px); + gap: 4px; + width: max-content; + min-width: 100%; +} + +.heat-cell { + width: 13px; + height: 13px; + border-radius: 3px; + background: #e5ebe8; +} + +.heat-empty { opacity: 0; } +.heat-1 { background: #c9e8d9; } +.heat-2 { background: #8fd0ae; } +.heat-3 { background: #4fa77b; } +.heat-4 { background: #176b4e; } + +.split-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + margin-top: 16px; +} + +.bar-list, +.compact-list, +.session-list, +.memory-list, +.schema-list { + padding: 12px; +} + +.bar-row { + display: grid; + grid-template-columns: 86px minmax(0, 1fr) 42px; + align-items: center; + gap: 10px; + min-height: 30px; + color: var(--muted); + font-size: 13px; +} + +.bar-track { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: var(--surface-2); +} + +.bar-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--blue)); +} + +.list-card, +.session-item, +.memory-item { + width: 100%; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + padding: 10px; +} + +.list-card + .list-card, +.session-item + .session-item, +.memory-item + .memory-item { + margin-top: 6px; +} + +.list-card:hover, +.session-item:hover, +.memory-item:hover { + border-color: var(--border); + background: #f9fbfa; +} + +@media (hover: hover) { + .list-card:hover, + .session-item:hover, + .memory-item:hover { + transform: translateY(-1px); + } +} + +.session-item.active, +.memory-item.active { + border-color: rgba(31, 122, 92, 0.32); + background: var(--accent-soft); + box-shadow: inset 3px 0 0 var(--accent); +} + +.item-title { + overflow: hidden; + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + +.item-preview { + display: -webkit-box; + overflow: hidden; + margin-top: 8px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.session-layout, +.memory-layout { + display: grid; + grid-template-columns: minmax(270px, 360px) minmax(0, 1fr); + gap: 16px; + min-height: 660px; +} + +.session-list-panel, +.memory-list-panel, +.session-detail-panel, +.memory-detail-panel { + min-height: 0; + min-width: 0; +} + +.session-list, +.memory-list { + max-height: 72vh; + overflow: auto; +} + +.session-detail { + display: grid; + gap: 12px; + max-height: 72vh; + overflow: auto; + padding: 16px; +} + +.token-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px; + border: 1px solid rgba(31, 122, 92, 0.22); + border-radius: 8px; + background: var(--accent-soft); + padding: 12px; +} + +.token-metric { + display: grid; + gap: 4px; + min-width: 0; +} + +.token-metric span { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.token-metric strong { + font-size: 14px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.event { + position: relative; + display: grid; + grid-template-columns: 98px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.event::before { + position: absolute; + top: 31px; + bottom: -12px; + left: 49px; + width: 1px; + background: var(--border); + content: ""; +} + +.event:last-child::before { + display: none; +} + +.event-label { + position: relative; + z-index: 1; + border-radius: 8px; + padding: 6px 8px; + color: #fff; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.event.message.user .event-label { background: var(--blue); } +.event.message.assistant .event-label { background: var(--accent); } +.event.tool_call .event-label { background: var(--amber); } +.event.tool_result .event-label { background: #6b5f8d; } +.event.system_event .event-label, +.event.reasoning .event-label { background: #79847f; } + +.event-body { + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: #fbfcfc; + padding: 10px 12px; +} + +.event-meta { + margin-bottom: 6px; + color: var(--muted); + font-size: 12px; +} + +.event-text, +.raw-block, +.memory-detail, +.schema-code { + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-word; +} + +.event-text { + font-size: 13px; + line-height: 1.55; +} + +details.raw-details { + margin-top: 8px; +} + +details.raw-details summary { + cursor: pointer; + color: var(--accent-strong); + font-size: 12px; + font-weight: 800; +} + +.load-more-row { + display: flex; + justify-content: center; + padding: 4px 0 2px; +} + +.load-more-row .secondary-button { + min-width: 180px; +} + +.raw-block, +.memory-detail, +.schema-code { + border: 1px solid var(--border); + border-radius: 8px; + background: #111815; + color: #d9ede4; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; + padding: 12px; +} + +.memory-detail { + box-sizing: border-box; + max-width: 100%; + min-width: 0; + max-height: 72vh; + overflow: auto; + margin: 0; +} + +.empty-state { + color: var(--muted); +} + +.table-wrap { + overflow: auto; + padding: 12px; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + table-layout: fixed; +} + +th, +td { + border-bottom: 1px solid var(--border); + padding: 10px; + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.status-pill { + display: inline-flex; + align-items: center; + height: 22px; + border-radius: 999px; + padding: 0 8px; + font-size: 12px; + font-weight: 800; +} + +.status-ok { + background: var(--accent-soft); + color: var(--accent-strong); +} + +.status-miss { + background: #f6e4e4; + color: var(--danger); +} + +.cache-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.cache-status { + display: grid; + gap: 12px; + padding: 12px; +} + +.cache-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 0; +} + +.cache-job-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.cache-job-item { + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + background: #fbfcfb; +} + +.cache-job-item strong { + display: block; + margin-top: 3px; + font-size: 18px; +} + +.danger-text { + color: var(--danger); +} + +.schema-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; +} + +.schema-card + .schema-card { + margin-top: 10px; +} + +.toast { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 40; + max-width: min(420px, calc(100vw - 36px)); + transform: translateY(18px); + opacity: 0; + pointer-events: none; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--text); + color: #fff; + box-shadow: var(--shadow); + font-size: 13px; + padding: 12px 14px; + transition: opacity 160ms ease, transform 160ms ease; +} + +.toast.show { + transform: translateY(0); + opacity: 1; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 24px; + background: rgba(12, 24, 19, 0.54); +} + +.modal-backdrop[hidden] { + display: none; +} + +.confirm-modal { + width: min(520px, 100%); + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + box-shadow: 0 28px 80px rgba(13, 27, 23, 0.24); + padding: 22px; +} + +.confirm-modal h2 { + margin: 0; + font-size: 20px; + letter-spacing: 0; +} + +.confirm-modal p { + margin: 10px 0 0; + color: var(--muted); + line-height: 1.55; +} + +.confirm-modal code { + overflow-wrap: anywhere; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.modal-actions .primary-button, +.modal-actions .secondary-button { + min-width: 128px; +} + +@media (max-width: 980px) { + .topbar { + align-items: stretch; + flex-direction: column; + gap: 16px; + } + + .top-actions { + display: grid; + grid-template-columns: minmax(150px, 1.2fr) minmax(132px, 1fr) minmax(132px, 1fr) auto; + min-width: min(100%, 680px); + } + + .range-field, + .date-field { + min-width: 0; + } + + .app-shell { + grid-template-columns: 1fr; + gap: 14px; + padding: 16px; + } + + .sidebar { + position: static; + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; + } + + .nav-item { + min-width: 132px; + text-align: center; + } + + .nav-item.active { + box-shadow: inset 0 -3px 0 var(--accent), var(--shadow); + } + + .stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .cache-job-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .split-grid, + .session-layout, + .memory-layout { + grid-template-columns: 1fr; + } + + .session-list, + .memory-list, + .session-detail, + .memory-detail { + max-height: none; + } +} + +@media (max-width: 560px) { + .topbar { + padding: 12px 14px; + gap: 12px; + align-items: stretch; + flex-direction: column; + } + + .privacy-strip { + padding: 8px 14px; + font-size: 12px; + } + + .top-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + width: 100%; + gap: 8px; + } + + .range-field { + grid-column: 1; + } + + .range-field, + .range-field select, + .date-field, + .date-field input, + .search-box, + .search-box input { + min-width: 0; + width: 100%; + } + + .date-field input, + .range-field select, + .search-box input { + height: 34px; + } + + .primary-button { + align-self: end; + width: 100%; + height: 34px; + } + + .secondary-button { + height: 34px; + } + + .brand-subtitle { + max-width: calc(100vw - 96px); + } + + .sidebar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: visible; + } + + .nav-item { + min-width: 0; + padding: 0 4px; + font-size: 13px; + } + + .stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .cache-job-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stat-card { + min-height: 96px; + padding: 14px; + } + + .stat-value { + font-size: 28px; + } + + .modal-backdrop { + align-items: end; + padding: 14px; + } + + .confirm-modal { + padding: 18px; + } + + .modal-actions { + flex-direction: column-reverse; + } + + .modal-actions .primary-button, + .modal-actions .secondary-button { + width: 100%; + } + + .heatmap-wrap { + padding: 12px; + } + + .heatmap { + grid-auto-columns: 11px; + grid-template-rows: repeat(7, 11px); + gap: 3px; + } + + .heat-cell { + width: 11px; + height: 11px; + } + + .page-heading { + align-items: start; + flex-direction: column; + gap: 10px; + } + + h1 { + font-size: 25px; + } + + .path-badge { + display: none; + } + + .event { + grid-template-columns: 1fr; + gap: 6px; + } + + .event::before { + display: none; + } + + .event-label { + width: fit-content; + min-width: 86px; + } + + table, + thead, + tbody, + tr, + th, + td { + display: block; + } + + thead { + display: none; + } + + tr { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + padding: 10px; + } + + tr + tr { + margin-top: 10px; + } + + td { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 10px; + border-bottom: 0; + padding: 6px 0; + } + + td::before { + color: var(--muted); + content: attr(data-label); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + } +} diff --git a/cli/codex_inspector/stats.go b/cli/codex_inspector/stats.go new file mode 100644 index 0000000..a1ed175 --- /dev/null +++ b/cli/codex_inspector/stats.go @@ -0,0 +1,132 @@ +package main + +import ( + "sort" + "time" +) + +const heatmapDays = 183 + +func BuildOverview(codexHome string, sessions []SessionSummary, heatmapSessions []SessionSummary, sources []SourceStatus, warnings []string) OverviewResponse { + stats := BuildStats(sessions) + stats.Heatmap = BuildRecentHeatmap(heatmapSessions, time.Now()) + recent := sessions + if len(recent) > 8 { + recent = recent[:8] + } + return OverviewResponse{ + CodexHome: codexHome, + PrivacyNotice: "Runs on 127.0.0.1 by default. Codex auth, token, cookie and secret-like fields are excluded or redacted.", + Sources: sources, + Stats: stats, + RecentSessions: recent, + Warnings: warnings, + } +} + +func BuildStats(sessions []SessionSummary) OverviewStats { + stats := OverviewStats{TotalSessions: len(sessions)} + dayCounts := map[string]int{} + weekCounts := map[string]int{} + monthCounts := map[string]int{} + + for _, session := range sessions { + stats.TotalEvents += session.EventCount + stats.UserMessages += session.UserMessages + stats.AssistantMessages += session.AssistantMessages + stats.ToolCalls += session.ToolCalls + stats.ToolResults += session.ToolResults + if session.TokenStats.TokenEvents > 0 { + stats.TokenizedSessions++ + stats.InputTokens += session.TokenStats.Total.InputTokens + stats.CachedInputTokens += session.TokenStats.Total.CachedInputTokens + stats.OutputTokens += session.TokenStats.Total.OutputTokens + stats.ReasoningTokens += session.TokenStats.Total.ReasoningOutputTokens + stats.TotalTokens += session.TokenStats.Total.TotalTokens + } + + timeValue := session.UpdatedAt + if timeValue == "" { + timeValue = session.StartedAt + } + day := dayKey(timeValue) + if day == "" { + continue + } + dayCounts[day]++ + weekCounts[weekKey(timeValue)]++ + monthCounts[monthKey(timeValue)]++ + } + + stats.ActiveDays = len(dayCounts) + stats.ByDay = countPoints(dayCounts) + stats.ByWeek = countPoints(weekCounts) + stats.ByMonth = countPoints(monthCounts) + stats.Heatmap = buildHeatmap(dayCounts) + return stats +} + +func BuildRecentHeatmap(sessions []SessionSummary, now time.Time) []HeatmapCell { + dayCounts := map[string]int{} + for _, session := range sessions { + timeValue := session.UpdatedAt + if timeValue == "" { + timeValue = session.StartedAt + } + day := dayKey(timeValue) + if day != "" { + dayCounts[day]++ + } + } + end := startOfDay(now) + start := end.AddDate(0, 0, -heatmapDays+1) + return buildHeatmapRange(dayCounts, start, end) +} + +func countPoints(values map[string]int) []CountPoint { + keys := make([]string, 0, len(values)) + for key := range values { + if key != "" { + keys = append(keys, key) + } + } + sort.Strings(keys) + points := make([]CountPoint, 0, len(keys)) + for _, key := range keys { + points = append(points, CountPoint{Key: key, Count: values[key]}) + } + return points +} + +func buildHeatmap(dayCounts map[string]int) []HeatmapCell { + var minDay time.Time + var maxDay time.Time + for key := range dayCounts { + parsed, ok := parseTime(key) + if !ok { + continue + } + if minDay.IsZero() || parsed.Before(minDay) { + minDay = parsed + } + if maxDay.IsZero() || parsed.After(maxDay) { + maxDay = parsed + } + } + if minDay.IsZero() || maxDay.IsZero() || maxDay.Before(minDay) { + return nil + } + if maxDay.Sub(minDay) > 370*24*time.Hour { + minDay = maxDay.AddDate(0, 0, -370) + } + return buildHeatmapRange(dayCounts, minDay, maxDay) +} + +func buildHeatmapRange(dayCounts map[string]int, minDay time.Time, maxDay time.Time) []HeatmapCell { + cells := []HeatmapCell{} + for day := minDay; !day.After(maxDay); day = day.AddDate(0, 0, 1) { + key := day.Format("2006-01-02") + cells = append(cells, HeatmapCell{Date: key, Count: dayCounts[key]}) + } + return cells +} diff --git a/cli/codex_inspector/stats_test.go b/cli/codex_inspector/stats_test.go new file mode 100644 index 0000000..f583abc --- /dev/null +++ b/cli/codex_inspector/stats_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBuildStatsGroupsByDayWeekMonthAndHeatmap(t *testing.T) { + sessions := []SessionSummary{ + {ID: "a", UpdatedAt: "2026-07-01T10:00:00Z", EventCount: 3, UserMessages: 1, AssistantMessages: 1, ToolCalls: 1, TokenStats: TokenStats{TokenEvents: 1, Total: TokenUsage{InputTokens: 1000, CachedInputTokens: 300, OutputTokens: 90, ReasoningOutputTokens: 10, TotalTokens: 1100}}}, + {ID: "b", UpdatedAt: "2026-07-01T12:00:00Z", EventCount: 2, UserMessages: 1, AssistantMessages: 1, ToolResults: 1}, + {ID: "c", UpdatedAt: "2026-07-08T10:00:00Z", EventCount: 4, UserMessages: 2, AssistantMessages: 1, TokenStats: TokenStats{TokenEvents: 1, Total: TokenUsage{InputTokens: 2000, CachedInputTokens: 500, OutputTokens: 180, ReasoningOutputTokens: 20, TotalTokens: 2200}}}, + } + + stats := BuildStats(sessions) + require.Equal(t, 3, stats.TotalSessions) + require.Equal(t, 9, stats.TotalEvents) + require.Equal(t, 2, stats.ActiveDays) + require.Equal(t, 4, stats.UserMessages) + require.Equal(t, 3, stats.AssistantMessages) + require.Equal(t, 2, stats.TokenizedSessions) + require.Equal(t, int64(3000), stats.InputTokens) + require.Equal(t, int64(800), stats.CachedInputTokens) + require.Equal(t, int64(270), stats.OutputTokens) + require.Equal(t, int64(30), stats.ReasoningTokens) + require.Equal(t, int64(3300), stats.TotalTokens) + require.Equal(t, []CountPoint{{Key: "2026-07-01", Count: 2}, {Key: "2026-07-08", Count: 1}}, stats.ByDay) + require.Equal(t, []CountPoint{{Key: "2026-07", Count: 3}}, stats.ByMonth) + require.NotEmpty(t, stats.Heatmap) + require.Equal(t, "2026-07-01", stats.Heatmap[0].Date) + require.Equal(t, "2026-07-08", stats.Heatmap[len(stats.Heatmap)-1].Date) +} + +func TestBuildRecentHeatmapUsesSixMonthWindowIndependentOfFilteredStats(t *testing.T) { + filtered := []SessionSummary{ + {ID: "filtered", UpdatedAt: "2026-07-03T10:00:00Z"}, + } + all := []SessionSummary{ + {ID: "old", UpdatedAt: "2026-01-02T10:00:00Z"}, + {ID: "current", UpdatedAt: "2026-07-03T10:00:00Z"}, + } + + stats := BuildStats(filtered) + stats.Heatmap = BuildRecentHeatmap(all, time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)) + + require.Equal(t, 1, stats.TotalSessions) + require.Len(t, stats.Heatmap, heatmapDays) + require.Equal(t, "2026-01-02", stats.Heatmap[0].Date) + require.Equal(t, 1, stats.Heatmap[0].Count) + require.Equal(t, "2026-07-03", stats.Heatmap[len(stats.Heatmap)-1].Date) + require.Equal(t, 1, stats.Heatmap[len(stats.Heatmap)-1].Count) +} + +func TestParseFilterKeepsInvalidDatesNonFatal(t *testing.T) { + filter := parseFilter("bad", "2026-07-03", "demo", 10) + require.False(t, filter.hasFrom) + require.True(t, filter.hasTo) + require.Equal(t, "demo", filter.Query) +} diff --git a/cli/codex_inspector/store.go b/cli/codex_inspector/store.go new file mode 100644 index 0000000..1ce6ada --- /dev/null +++ b/cli/codex_inspector/store.go @@ -0,0 +1,779 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +type Store struct { + CodexHome string + CachePath string + mu sync.Mutex + cond *sync.Cond + loading bool + sessionCache *sessionCache + cacheManager *summaryCacheManager +} + +type sessionCache struct { + loadedAt time.Time + sessions []SessionSummary + byID map[string]SessionSummary + warnings []string +} + +type sessionIndexLine struct { + ID string `json:"id"` + ThreadName string `json:"thread_name"` + UpdatedAt string `json:"updated_at"` +} + +func NewStore(codexHome string) *Store { + return NewStoreWithCache(codexHome, defaultCachePath()) +} + +func NewStoreWithCache(codexHome string, cachePath string) *Store { + return NewStoreWithCacheWorkers(codexHome, cachePath, 0) +} + +func NewStoreWithCacheWorkers(codexHome string, cachePath string, cacheWorkers int) *Store { + if codexHome == "" { + codexHome = defaultCodexHome() + } + store := &Store{CodexHome: filepath.Clean(codexHome), CachePath: cachePath} + store.cond = sync.NewCond(&store.mu) + store.cacheManager = newSummaryCacheManager(cachePath, cacheWorkers) + store.cacheManager.StartAutoBuild(store) + return store +} + +func (s *Store) Sources() []SourceStatus { + items := []struct { + name string + rel string + notes []string + }{ + {name: "session index", rel: "session_index.jsonl"}, + {name: "sessions", rel: "sessions"}, + {name: "memory index", rel: filepath.Join("memories", "MEMORY.md")}, + {name: "memory summary", rel: filepath.Join("memories", "memory_summary.md")}, + {name: "memory rollouts", rel: filepath.Join("memories", "rollout_summaries")}, + {name: "thread sqlite", rel: "state_5.sqlite", notes: []string{"schema only in diagnostics"}}, + {name: "goal sqlite", rel: "goals_1.sqlite", notes: []string{"schema only in diagnostics"}}, + {name: "memory sqlite", rel: "memories_1.sqlite", notes: []string{"schema only in diagnostics"}}, + {name: "auth files", rel: "auth.json", notes: []string{"excluded by design; content is never read"}}, + } + + statuses := make([]SourceStatus, 0, len(items)) + for _, item := range items { + statuses = append(statuses, sourceStatus(item.name, filepath.Join(s.CodexHome, item.rel), item.notes)) + } + if s.CachePath != "" { + statuses = append(statuses, sourceStatus("summary cache", s.CachePath, []string{"tool-owned sqlite cache; no auth data"})) + } + return statuses +} + +func (s *Store) Close() { + if s.cacheManager != nil { + s.cacheManager.Close() + } +} + +func sourceStatus(name string, path string, notes []string) SourceStatus { + status := SourceStatus{Name: name, Path: path, Notes: append([]string(nil), notes...)} + info, err := os.Stat(path) + if err != nil { + status.Exists = false + return status + } + status.Exists = true + status.Size = info.Size() + status.ModifiedAt = info.ModTime().Format("2006-01-02T15:04:05Z07:00") + if info.IsDir() { + status.Size = 0 + } + return status +} + +func (s *Store) LoadSessions(filter QueryFilter) ([]SessionSummary, []string) { + allSessions, warnings := s.loadAllSessions() + candidates := make([]SessionSummary, 0, len(allSessions)) + for _, summary := range allSessions { + if sessionMatchesRange(summary, filter) { + candidates = append(candidates, summary) + } + } + sortSessions(candidates) + if filter.Query != "" { + var enrichWarnings []string + candidates, enrichWarnings = s.enrichSummaries(candidates) + warnings = append(warnings, enrichWarnings...) + candidates = filterByQuery(candidates, filter.Query) + sortSessions(candidates) + } + if filter.Limit > 0 && len(candidates) > filter.Limit { + candidates = candidates[:filter.Limit] + } + sessions, enrichWarnings := s.enrichSummaries(candidates) + warnings = append(warnings, enrichWarnings...) + return sessions, append([]string(nil), warnings...) +} + +func (s *Store) LoadSessionIndex() ([]SessionSummary, []string) { + return s.loadAllSessions() +} + +func (s *Store) loadAllSessions() ([]SessionSummary, []string) { + s.mu.Lock() + for { + if s.sessionCache != nil && time.Since(s.sessionCache.loadedAt) < 5*time.Minute { + sessions := append([]SessionSummary(nil), s.sessionCache.sessions...) + warnings := append([]string(nil), s.sessionCache.warnings...) + s.mu.Unlock() + return sessions, warnings + } + if !s.loading { + s.loading = true + break + } + s.cond.Wait() + } + s.mu.Unlock() + + sessions, byID, warnings := s.buildSessionCache() + if warning := s.cacheWarning(); warning != "" { + warnings = append(warnings, warning) + } + + s.mu.Lock() + s.sessionCache = &sessionCache{ + loadedAt: time.Now(), + sessions: append([]SessionSummary(nil), sessions...), + byID: copySessionMap(byID), + warnings: append([]string(nil), warnings...), + } + s.loading = false + s.cond.Broadcast() + s.mu.Unlock() + return sessions, warnings +} + +func (s *Store) buildSessionCache() ([]SessionSummary, map[string]SessionSummary, []string) { + index, warnings := s.readSessionIndex() + files, fileWarnings := s.rolloutFiles() + warnings = append(warnings, fileWarnings...) + + byID := make(map[string]SessionSummary) + for _, path := range files { + summary := summaryFromPath(path) + if summary.ID == "" { + summary.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + summary.Path = path + if item, ok := index[summary.ID]; ok { + mergeIndex(&summary, item) + } + byID[summary.ID] = summary + } + + for id, item := range index { + if _, ok := byID[id]; ok { + continue + } + summary := SessionSummary{ID: id} + mergeIndex(&summary, item) + byID[id] = summary + } + + sessions := make([]SessionSummary, 0, len(byID)) + for _, summary := range byID { + sessions = append(sessions, summary) + } + sortSessions(sessions) + return sessions, byID, warnings +} + +func (s *Store) enrichSummaries(sessions []SessionSummary) ([]SessionSummary, []string) { + out := make([]SessionSummary, 0, len(sessions)) + var warnings []string + now := time.Now() + for _, summary := range sessions { + enriched := summary + meta, metaOK := statFile(summary.Path) + historicalFile := metaOK && cacheableFile(meta, now) + if cached, ok := s.cachedSummary(summary.ID); ok && cached.Enriched && historicalFile && cacheableSummary(cached, now) { + enriched = cached + } else if summary.Path != "" { + if cache := s.diskCache(); historicalFile && cache != nil { + cached, ok, err := cache.Lookup(summary.Path, meta) + if err != nil { + warnings = append(warnings, fmt.Sprintf("cache lookup %s: %v", summary.Path, err)) + s.reportCacheError(err) + } + if ok && cacheableSummary(cached, now) { + detail := SessionDetail{Summary: cached} + mergeCachedSummary(&detail, summary) + enriched = detail.Summary + enriched.Enriched = true + s.updateCachedSummary(enriched) + out = append(out, enriched) + continue + } + } + detail, err := ParseRolloutFile(summary.Path, false) + if err != nil { + warnings = append(warnings, fmt.Sprintf("parse %s: %v", summary.Path, err)) + } else { + mergeCachedSummary(&detail, summary) + enriched = detail.Summary + enriched.Enriched = true + s.updateCachedSummary(enriched) + if cache := s.diskCache(); historicalFile && cacheableSummary(enriched, now) && cache != nil { + if err := cache.Upsert(summary.Path, meta, enriched); err != nil { + warnings = append(warnings, fmt.Sprintf("cache write %s: %v", summary.Path, err)) + s.reportCacheError(err) + } + } + } + } + out = append(out, enriched) + } + return out, warnings +} + +func (s *Store) diskCache() *summaryDiskCache { + if s.cacheManager == nil { + return nil + } + return s.cacheManager.DiskCache() +} + +func (s *Store) reportCacheError(err error) { + if s.cacheManager != nil { + s.cacheManager.ReportRuntimeError(err) + } +} + +func (s *Store) cacheWarning() string { + if s.cacheManager == nil { + return "" + } + status := s.cacheManager.Status() + switch status.Status { + case cacheStatusCorrupt, cacheStatusUnavailable: + if status.Reason != "" { + return fmt.Sprintf("summary cache %s: %s", status.Status, status.Reason) + } + return fmt.Sprintf("summary cache %s", status.Status) + default: + return "" + } +} + +func (s *Store) CacheStatus() CacheStatus { + if s.cacheManager == nil { + return CacheStatus{Status: cacheStatusDisabled, Reason: "cache manager unavailable", GeneratedAt: time.Now().Format(time.RFC3339)} + } + return s.cacheManager.Status() +} + +func (s *Store) StartCacheBuild() CacheStatus { + if s.cacheManager == nil { + return s.CacheStatus() + } + return s.cacheManager.StartBuild(s, false) +} + +func (s *Store) StartCacheRebuild() CacheStatus { + if s.cacheManager == nil { + return s.CacheStatus() + } + return s.cacheManager.StartBuild(s, true) +} + +func (s *Store) runCacheBuild(seq int, workers int) { + tasks, skipped, failed, lastError := s.cacheBuildTasks() + if workers < 1 { + workers = 1 + } + if workers > 16 { + workers = 16 + } + s.cacheManager.setJobTotal(seq, len(tasks)+skipped+failed) + for i := 0; i < skipped; i++ { + s.cacheManager.addJobProgress(seq, 0, 1, 0, "") + } + for i := 0; i < failed; i++ { + s.cacheManager.addJobProgress(seq, 0, 0, 1, lastError) + } + if len(tasks) == 0 || s.diskCache() == nil { + s.cacheManager.finishJob(seq, lastError) + return + } + + taskCh := make(chan cacheBuildTask) + resultCh := make(chan cacheBuildResult) + var wg sync.WaitGroup + now := time.Now() + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for task := range taskCh { + detail, err := ParseRolloutFile(task.Path, false) + if err != nil { + resultCh <- cacheBuildResult{Path: task.Path, Meta: task.Meta, Error: err.Error()} + continue + } + mergeCachedSummary(&detail, task.Summary) + detail.Summary.Enriched = true + if !cacheableSummary(detail.Summary, now) { + resultCh <- cacheBuildResult{Path: task.Path, Meta: task.Meta, Error: "session is not historical"} + continue + } + resultCh <- cacheBuildResult{Path: task.Path, Meta: task.Meta, Summary: detail.Summary} + } + }() + } + + go func() { + for _, task := range tasks { + taskCh <- task + } + close(taskCh) + wg.Wait() + close(resultCh) + }() + + for result := range resultCh { + if result.Error != "" { + s.cacheManager.addJobProgress(seq, 0, 0, 1, result.Error) + lastError = result.Error + continue + } + cache := s.diskCache() + if cache == nil { + lastError = "cache unavailable" + s.cacheManager.addJobProgress(seq, 0, 0, 1, lastError) + continue + } + if err := cache.Upsert(result.Path, result.Meta, result.Summary); err != nil { + lastError = err.Error() + s.reportCacheError(err) + s.cacheManager.addJobProgress(seq, 0, 0, 1, lastError) + continue + } + s.updateCachedSummary(result.Summary) + s.cacheManager.addJobProgress(seq, 1, 0, 0, "") + } + s.cacheManager.finishJob(seq, lastError) +} + +func (s *Store) cacheBuildTasks() ([]cacheBuildTask, int, int, string) { + index, _ := s.readSessionIndex() + files, _ := s.rolloutFiles() + now := time.Now() + cache := s.diskCache() + if cache == nil { + return nil, 0, 1, "cache unavailable" + } + tasks := make([]cacheBuildTask, 0, len(files)) + skipped := 0 + failed := 0 + lastError := "" + for _, path := range files { + meta, ok := statFile(path) + if !ok || !cacheableFile(meta, now) { + continue + } + if cached, ok, err := cache.Lookup(path, meta); err != nil { + lastError = err.Error() + failed++ + s.reportCacheError(err) + break + } else if ok && cacheableSummary(cached, now) { + skipped++ + continue + } + + summary := summaryFromPath(path) + if summary.ID == "" { + summary.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + summary.Path = path + if item, ok := index[summary.ID]; ok { + mergeIndex(&summary, item) + } + tasks = append(tasks, cacheBuildTask{Path: path, Meta: meta, Summary: summary}) + } + return tasks, skipped, failed, lastError +} + +func (s *Store) cachedSummary(id string) (SessionSummary, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.sessionCache == nil { + return SessionSummary{}, false + } + summary, ok := s.sessionCache.byID[id] + return summary, ok +} + +func (s *Store) updateCachedSummary(summary SessionSummary) { + if summary.ID == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.sessionCache == nil { + return + } + s.sessionCache.byID[summary.ID] = summary + for i := range s.sessionCache.sessions { + if s.sessionCache.sessions[i].ID == summary.ID { + s.sessionCache.sessions[i] = summary + return + } + } +} + +func (s *Store) cachedSession(id string) (SessionSummary, bool, []string) { + _, warnings := s.loadAllSessions() + s.mu.Lock() + defer s.mu.Unlock() + if s.sessionCache == nil { + return SessionSummary{}, false, warnings + } + if summary, ok := s.sessionCache.byID[id]; ok { + return summary, true, warnings + } + for _, summary := range s.sessionCache.sessions { + if summary.ID == id || strings.TrimSuffix(filepath.Base(summary.Path), filepath.Ext(summary.Path)) == id || summary.Title == id { + return summary, true, warnings + } + } + return SessionSummary{}, false, warnings +} + +func copySessionMap(in map[string]SessionSummary) map[string]SessionSummary { + out := make(map[string]SessionSummary, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func mergeCachedSummary(detail *SessionDetail, cached SessionSummary) { + if cached.ID != "" { + detail.Summary.ID = cached.ID + } + if cached.Title != "" { + detail.Summary.Title = cached.Title + } + if cached.Path != "" { + detail.Summary.Path = cached.Path + } + if detail.Summary.Cwd == "" { + detail.Summary.Cwd = cached.Cwd + } + if detail.Summary.Model == "" { + detail.Summary.Model = cached.Model + } + if detail.Summary.ModelProvider == "" { + detail.Summary.ModelProvider = cached.ModelProvider + } + if cached.UpdatedAt != "" { + detail.Summary.UpdatedAt = cached.UpdatedAt + } + if detail.Summary.StartedAt == "" { + detail.Summary.StartedAt = cached.StartedAt + } + if detail.Summary.Preview == "" { + detail.Summary.Preview = cached.Preview + } +} + +func (s *Store) LoadSessionDetail(id string) (SessionDetail, bool, []string) { + return s.LoadSessionDetailWithOptions(id, SessionDetailOptions{IncludeEvents: true, IncludeRawLines: true}) +} + +func (s *Store) LoadSessionDetailWithOptions(id string, options SessionDetailOptions) (SessionDetail, bool, []string) { + summary, ok, warnings := s.rolloutSession(id) + if !ok { + return SessionDetail{}, false, warnings + } + useSummaryHint := false + if options.IncludeEvents && !options.IncludeRawLines && options.EventLimit > 0 && summary.Enriched && summary.EventCount > 0 { + if meta, metaOK := statFile(summary.Path); metaOK && cacheableFile(meta, time.Now()) { + options.EventTotalHint = summary.EventCount + useSummaryHint = true + } + } + detail, err := ParseRolloutFileWithOptions(summary.Path, options) + if err != nil { + warnings = append(warnings, fmt.Sprintf("parse %s: %v", summary.Path, err)) + return SessionDetail{}, false, warnings + } + mergeCachedSummary(&detail, summary) + if useSummaryHint { + detail.Summary = summary + } + return detail, true, warnings +} + +func (s *Store) LoadSessionRawLine(id string, line int) (RawLine, bool, []string) { + summary, ok, warnings := s.rolloutSession(id) + if !ok { + return RawLine{}, false, warnings + } + raw, ok, err := ParseRolloutRawLine(summary.Path, line) + if err != nil { + warnings = append(warnings, fmt.Sprintf("raw line %s:%d: %v", summary.Path, line, err)) + return RawLine{}, false, warnings + } + return raw, ok, warnings +} + +func (s *Store) rolloutSession(id string) (SessionSummary, bool, []string) { + summary, ok, warnings := s.cachedSession(id) + if ok && summary.Path != "" { + return summary, true, warnings + } + files, fileWarnings := s.rolloutFiles() + warnings = append(warnings, fileWarnings...) + for i := len(files) - 1; i >= 0; i-- { + path := files[i] + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if id != base && !strings.Contains(base, id) { + continue + } + summary := summaryFromPath(path) + if summary.ID == "" { + summary.ID = base + } + summary.Path = path + return summary, true, warnings + } + return SessionSummary{}, false, warnings +} + +func (s *Store) readSessionIndex() (map[string]sessionIndexLine, []string) { + path := filepath.Join(s.CodexHome, "session_index.jsonl") + file, err := os.Open(path) + if err != nil { + return map[string]sessionIndexLine{}, []string{fmt.Sprintf("session index unavailable: %v", err)} + } + defer file.Close() + + out := make(map[string]sessionIndexLine) + var warnings []string + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024), 4*1024*1024) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var item sessionIndexLine + if err := json.Unmarshal([]byte(line), &item); err != nil { + warnings = append(warnings, fmt.Sprintf("bad session_index line %d: %v", lineNo, err)) + continue + } + if item.ID == "" { + warnings = append(warnings, fmt.Sprintf("session_index line %d has no id", lineNo)) + continue + } + out[item.ID] = item + } + if err := scanner.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("read session_index: %v", err)) + } + return out, warnings +} + +func (s *Store) rolloutFiles() ([]string, []string) { + root := filepath.Join(s.CodexHome, "sessions") + var files []string + var warnings []string + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + warnings = append(warnings, fmt.Sprintf("walk %s: %v", path, err)) + return nil + } + if d.IsDir() { + return nil + } + if strings.HasPrefix(d.Name(), "rollout-") && strings.HasSuffix(d.Name(), ".jsonl") { + files = append(files, path) + } + return nil + }) + if err != nil { + warnings = append(warnings, fmt.Sprintf("walk sessions: %v", err)) + } + sort.Strings(files) + return files, warnings +} + +func mergeIndex(summary *SessionSummary, item sessionIndexLine) { + if summary.ID == "" { + summary.ID = item.ID + } + if item.ThreadName != "" { + summary.Title = sanitizeText(item.ThreadName) + } + if summary.Title == "" { + summary.Title = summary.ID + } + if item.UpdatedAt != "" { + summary.UpdatedAt = item.UpdatedAt + } +} + +func summaryFromPath(path string) SessionSummary { + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + summary := SessionSummary{ + ID: base, + Title: base, + Path: path, + StartedAt: timeFromRolloutPath(path), + UpdatedAt: timeFromRolloutPath(path), + } + file, err := os.Open(path) + if err != nil { + return summary + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024), 512*1024) + for lineNo := 0; scanner.Scan() && lineNo < 12; lineNo++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var record rolloutLine + if err := json.Unmarshal([]byte(line), &record); err != nil { + continue + } + if summary.StartedAt == "" && record.Timestamp != "" { + summary.StartedAt = record.Timestamp + } + if record.Timestamp != "" { + summary.UpdatedAt = record.Timestamp + } + if record.Type != "session_meta" && record.Type != "turn_context" { + continue + } + payload := decodePayload(record.Payload) + if id := stringField(payload, "id"); id != "" { + summary.ID = id + } + if cwd := stringField(payload, "cwd"); cwd != "" && summary.Cwd == "" { + summary.Cwd = sanitizeText(cwd) + } + if model := stringField(payload, "model"); model != "" && summary.Model == "" { + summary.Model = sanitizeText(model) + } + if provider := stringField(payload, "model_provider"); provider != "" && summary.ModelProvider == "" { + summary.ModelProvider = sanitizeText(provider) + } + if summary.ID != base && summary.Cwd != "" { + break + } + } + if summary.Title == base { + summary.Title = summary.ID + } + return summary +} + +func timeFromRolloutPath(path string) string { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + name = strings.TrimPrefix(name, "rollout-") + if len(name) < len("2006-01-02T15-04-05") { + return "" + } + candidate := name[:len("2006-01-02T15-04-05")] + date := candidate[:10] + clock := strings.ReplaceAll(candidate[11:], "-", ":") + return date + "T" + clock +} + +func sessionMatchesRange(summary SessionSummary, filter QueryFilter) bool { + timeValue := summary.UpdatedAt + if timeValue == "" { + timeValue = summary.StartedAt + } + return inRange(timeValue, filter) +} + +func sessionMatches(summary SessionSummary, filter QueryFilter) bool { + if !sessionMatchesRange(summary, filter) { + return false + } + query := strings.TrimSpace(filter.Query) + if query == "" { + return true + } + fields := []string{summary.ID, summary.Title, summary.Cwd, summary.Model, summary.ModelProvider, summary.Preview} + return containsFold(strings.Join(fields, "\n"), query) +} + +func filterByQuery(sessions []SessionSummary, query string) []SessionSummary { + filter := QueryFilter{Query: strings.TrimSpace(query)} + out := make([]SessionSummary, 0, len(sessions)) + for _, summary := range sessions { + if sessionMatches(summary, filter) { + out = append(out, summary) + } + } + return out +} + +func sortSessions(sessions []SessionSummary) { + sort.Slice(sessions, func(i, j int) bool { + left := sessions[i].UpdatedAt + if left == "" { + left = sessions[i].StartedAt + } + right := sessions[j].UpdatedAt + if right == "" { + right = sessions[j].StartedAt + } + leftTime, leftOK := parseTime(left) + rightTime, rightOK := parseTime(right) + if leftOK && rightOK { + return leftTime.After(rightTime) + } + if left != right { + return left > right + } + return sessions[i].ID > sessions[j].ID + }) +} + +func limitFromString(value string, fallback int) int { + if value == "" { + return fallback + } + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return fallback + } + if n > 1000 { + return 1000 + } + return n +} diff --git a/cli/codex_inspector/timeutil.go b/cli/codex_inspector/timeutil.go new file mode 100644 index 0000000..1f0fada --- /dev/null +++ b/cli/codex_inspector/timeutil.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +func parseTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + t, err := time.Parse(layout, value) + if err == nil { + return t, true + } + } + return time.Time{}, false +} + +func parseFilter(from string, to string, query string, limit int) QueryFilter { + filter := QueryFilter{ + From: strings.TrimSpace(from), + To: strings.TrimSpace(to), + Query: strings.TrimSpace(query), + Limit: limit, + } + if _, ok := parseTime(filter.From); ok { + filter.hasFrom = true + } + if _, ok := parseTime(filter.To); ok { + filter.hasTo = true + } + return filter +} + +func inRange(value string, filter QueryFilter) bool { + t, ok := parseTime(value) + if !ok { + return true + } + if filter.hasFrom { + from, _ := parseTime(filter.From) + if t.Before(startOfDay(from)) { + return false + } + } + if filter.hasTo { + to, _ := parseTime(filter.To) + if t.After(endOfDay(to)) { + return false + } + } + return true +} + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +func endOfDay(t time.Time) time.Time { + return startOfDay(t).AddDate(0, 0, 1).Add(-time.Nanosecond) +} + +func dayKey(value string) string { + t, ok := parseTime(value) + if !ok { + return "" + } + return t.Format("2006-01-02") +} + +func weekKey(value string) string { + t, ok := parseTime(value) + if !ok { + return "" + } + year, week := t.ISOWeek() + return fmt.Sprintf("%04d-W%02d", year, week) +} + +func monthKey(value string) string { + t, ok := parseTime(value) + if !ok { + return "" + } + return t.Format("2006-01") +} diff --git a/cli/codex_inspector/types.go b/cli/codex_inspector/types.go new file mode 100644 index 0000000..2eca9cc --- /dev/null +++ b/cli/codex_inspector/types.go @@ -0,0 +1,208 @@ +package main + +type SourceStatus struct { + Name string `json:"name"` + Path string `json:"path"` + Exists bool `json:"exists"` + Size int64 `json:"size"` + ModifiedAt string `json:"modifiedAt,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type QueryFilter struct { + From string + To string + Query string + Limit int + hasFrom bool + hasTo bool +} + +type SessionSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Path string `json:"path,omitempty"` + Cwd string `json:"cwd,omitempty"` + Model string `json:"model,omitempty"` + ModelProvider string `json:"modelProvider,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + EventCount int `json:"eventCount"` + UserMessages int `json:"userMessages"` + AssistantMessages int `json:"assistantMessages"` + ToolCalls int `json:"toolCalls"` + ToolResults int `json:"toolResults"` + SystemEvents int `json:"systemEvents"` + TokenStats TokenStats `json:"tokenStats"` + Preview string `json:"preview,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Enriched bool `json:"-"` +} + +type TokenUsage struct { + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningOutputTokens int64 `json:"reasoningOutputTokens"` + TotalTokens int64 `json:"totalTokens"` +} + +type RateLimitUsage struct { + UsedPercent float64 `json:"usedPercent,omitempty"` + WindowMinutes int64 `json:"windowMinutes,omitempty"` +} + +type TokenStats struct { + Total TokenUsage `json:"total"` + Last TokenUsage `json:"last"` + TokenEvents int `json:"tokenEvents"` + ModelContextWindow int64 `json:"modelContextWindow,omitempty"` + PrimaryRateLimit RateLimitUsage `json:"primaryRateLimit,omitempty"` + SecondaryRateLimit RateLimitUsage `json:"secondaryRateLimit,omitempty"` +} + +type DisplayEvent struct { + Line int `json:"line"` + Timestamp string `json:"timestamp,omitempty"` + Kind string `json:"kind"` + Role string `json:"role,omitempty"` + Label string `json:"label"` + Text string `json:"text,omitempty"` +} + +type RawLine struct { + Line int `json:"line"` + Text string `json:"text"` +} + +type SessionDetail struct { + Summary SessionSummary `json:"summary"` + Events []DisplayEvent `json:"events"` + RawLines []RawLine `json:"rawLines"` + EventOffset int `json:"eventOffset"` + EventLimit int `json:"eventLimit,omitempty"` + EventTotal int `json:"eventTotal"` + HasMore bool `json:"hasMore"` + Warnings []string `json:"warnings,omitempty"` +} + +type SessionDetailOptions struct { + IncludeEvents bool + IncludeRawLines bool + EventOffset int + EventLimit int + EventTotalHint int +} + +type RawLineResponse struct { + Line RawLine `json:"line"` + Warnings []string `json:"warnings,omitempty"` +} + +type CountPoint struct { + Key string `json:"key"` + Count int `json:"count"` +} + +type HeatmapCell struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type OverviewResponse struct { + CodexHome string `json:"codexHome"` + PrivacyNotice string `json:"privacyNotice"` + Sources []SourceStatus `json:"sources"` + Stats OverviewStats `json:"stats"` + RecentSessions []SessionSummary `json:"recentSessions"` + Warnings []string `json:"warnings,omitempty"` +} + +type OverviewStats struct { + TotalSessions int `json:"totalSessions"` + TotalEvents int `json:"totalEvents"` + ActiveDays int `json:"activeDays"` + UserMessages int `json:"userMessages"` + AssistantMessages int `json:"assistantMessages"` + ToolCalls int `json:"toolCalls"` + ToolResults int `json:"toolResults"` + TokenizedSessions int `json:"tokenizedSessions"` + InputTokens int64 `json:"inputTokens"` + CachedInputTokens int64 `json:"cachedInputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + TotalTokens int64 `json:"totalTokens"` + ByDay []CountPoint `json:"byDay"` + ByWeek []CountPoint `json:"byWeek"` + ByMonth []CountPoint `json:"byMonth"` + Heatmap []HeatmapCell `json:"heatmap"` +} + +type SessionsResponse struct { + Sessions []SessionSummary `json:"sessions"` + Warnings []string `json:"warnings,omitempty"` +} + +type MemoryFile struct { + Path string `json:"path"` + Kind string `json:"kind"` + Size int64 `json:"size"` + ModifiedAt string `json:"modifiedAt,omitempty"` + Title string `json:"title"` + Preview string `json:"preview,omitempty"` + Matches int `json:"matches,omitempty"` +} + +type MemoryResponse struct { + Files []MemoryFile `json:"files"` + Warnings []string `json:"warnings,omitempty"` +} + +type MemoryDetail struct { + File MemoryFile `json:"file"` + Content string `json:"content"` + Warnings []string `json:"warnings,omitempty"` +} + +type DiagnosticsResponse struct { + CodexHome string `json:"codexHome"` + Sources []SourceStatus `json:"sources"` + Schemas []SQLiteSchema `json:"schemas"` + Cache CacheStatus `json:"cache"` + Warnings []string `json:"warnings,omitempty"` +} + +type SQLiteSchema struct { + Name string `json:"name"` + Path string `json:"path"` + Exists bool `json:"exists"` + Loaded bool `json:"loaded"` + Schema string `json:"schema,omitempty"` + Error string `json:"error,omitempty"` + ReadMode string `json:"readMode"` +} + +type CacheStatus struct { + Status string `json:"status"` + Path string `json:"path,omitempty"` + Workers int `json:"workers"` + Reason string `json:"reason,omitempty"` + BackupPath string `json:"backupPath,omitempty"` + CanBuild bool `json:"canBuild"` + CanRebuild bool `json:"canRebuild"` + AutoFill bool `json:"autoFill"` + GeneratedAt string `json:"generatedAt"` + Job CacheJobState `json:"job"` +} + +type CacheJobState struct { + Running bool `json:"running"` + Total int `json:"total"` + Done int `json:"done"` + Cached int `json:"cached"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} diff --git a/docs/assets/codex_inspector/memory-768.png b/docs/assets/codex_inspector/memory-768.png new file mode 100644 index 0000000..a913c59 Binary files /dev/null and b/docs/assets/codex_inspector/memory-768.png differ diff --git a/docs/assets/codex_inspector/overview-1440.png b/docs/assets/codex_inspector/overview-1440.png new file mode 100644 index 0000000..a501150 Binary files /dev/null and b/docs/assets/codex_inspector/overview-1440.png differ diff --git a/docs/assets/codex_inspector/overview-390.png b/docs/assets/codex_inspector/overview-390.png new file mode 100644 index 0000000..0077633 Binary files /dev/null and b/docs/assets/codex_inspector/overview-390.png differ diff --git a/docs/assets/codex_inspector/sessions-1440.png b/docs/assets/codex_inspector/sessions-1440.png new file mode 100644 index 0000000..4035ade Binary files /dev/null and b/docs/assets/codex_inspector/sessions-1440.png differ diff --git a/docs/assets/emby_video_subtitle/control-page-1440.png b/docs/assets/emby_video_subtitle/control-page-1440.png new file mode 100644 index 0000000..74b94d9 Binary files /dev/null and b/docs/assets/emby_video_subtitle/control-page-1440.png differ diff --git a/docs/assets/file_share/overview-1440.png b/docs/assets/file_share/overview-1440.png new file mode 100644 index 0000000..3c01f90 Binary files /dev/null and b/docs/assets/file_share/overview-1440.png differ diff --git a/docs/assets/interview_timer/panel.png b/docs/assets/interview_timer/panel.png new file mode 100644 index 0000000..523898b Binary files /dev/null and b/docs/assets/interview_timer/panel.png differ diff --git a/docs/cli/codex_inspector.md b/docs/cli/codex_inspector.md new file mode 100644 index 0000000..ebb7dd8 --- /dev/null +++ b/docs/cli/codex_inspector.md @@ -0,0 +1,220 @@ +# codex_inspector 使用说明 + +`codex_inspector` 是本机 Codex Inspector。它启动一个只绑定本机地址的 HTTP 服务,用浏览器查看 `~/.codex` 里的历史 session、活跃度统计和记忆内容。 + +当前状态:实验工具。它会进入 tag Release 的 Go 二进制包,但不在根目录 `install.sh` 默认稳定安装清单中。 + +## 用途 + +| 场景 | 能力 | +|---|---| +| 查看 Codex 使用活跃度 | 统计 session 总量、事件数量、日/周/月维度和 heatmap | +| 回收 token 用量 | 从 `token_count` 事件统计每个 session 的累计 token、本轮 token、cached input、reasoning output、context window 和 rate limit | +| 排查历史对话 | 按时间范围浏览 session,按原始顺序展示 user、assistant、tool call、tool result 和 system event | +| 查看记忆内容 | 浏览 `MEMORY.md`、`memory_summary.md`、`rollout_summaries/`,支持关键词搜索和详情预览 | +| 诊断数据源 | 检查关键路径是否存在,只读探测 SQLite schema | + +## 启动方式 + +快速安装到用户级目录: + +```bash +./cli/codex_inspector/install.sh +codex_inspector -addr 127.0.0.1:8787 +``` + +默认安装到 `$HOME/.local/bin/codex_inspector`。脚本支持 macOS 和 Linux,默认只写当前用户目录;只有显式传 `--system` 或 `--allow-sudo` 时,才会尝试使用 `sudo` 写系统目录。 + +自定义安装目录: + +```bash +./cli/codex_inspector/install.sh --prefix "$HOME/.local" +``` + +系统级安装: + +```bash +./cli/codex_inspector/install.sh --system +``` + +从仓库根目录构建: + +```bash +go build -o output/codex_inspector ./cli/codex_inspector/... +``` + +启动本机服务: + +```bash +./output/codex_inspector -addr 127.0.0.1:8787 +``` + +指定 Codex home,适合用脱敏 fixture 做演示或截图: + +```bash +./output/codex_inspector -codex-home /tmp/codex-inspector-fixture -addr 127.0.0.1:8787 +``` + +历史 session summary 会缓存到本机 SQLite 文件,用于减少重复解析历史 rollout。默认路径放在系统临时目录下的用户隔离子目录,避免 macOS/Linux 上用户 cache 目录权限异常影响页面使用: + +| 系统 | 默认 cache | +|---|---| +| macOS | `${TMPDIR:-/tmp}/life_tools-codex-inspector-/session_summary_cache.sqlite` | +| Linux | `/tmp/life_tools-codex-inspector-/session_summary_cache.sqlite` | + +可以显式指定或禁用: + +```bash +./output/codex_inspector -cache-path /tmp/codex_inspector_cache.sqlite +./output/codex_inspector -no-cache +./output/codex_inspector -cache-workers 2 +./output/codex_inspector -cache-workers 0 +``` + +`-cache-workers` 控制启动后的后台自动补齐,默认是 2。设为 0 时不会自动补齐;Diagnostics 页面上的手动 build/rebuild 仍可执行一次。 + +打开浏览器访问: + +```text +http://127.0.0.1:8787 +``` + +## 数据源 + +| 数据源 | 读取内容 | 用途 | 写入 | +|---|---|---|---| +| `~/.codex/session_index.jsonl` | `id`、`thread_name`、`updated_at` | session 列表标题和更新时间 | 否 | +| `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | JSONL 事件流 | session 详情、消息统计、工具调用统计、token 用量统计 | 否 | +| `~/.codex/memories/MEMORY.md` | Markdown 文本 | 记忆索引总览和搜索 | 否 | +| `~/.codex/memories/memory_summary.md` | Markdown 文本 | 记忆摘要预览 | 否 | +| `~/.codex/memories/rollout_summaries/*.md` | Markdown 文本 | 相关 rollout 列表和详情预览 | 否 | +| `~/.codex/state_5.sqlite` | schema | Diagnostics 页面只读探测 | 否 | +| `~/.codex/goals_1.sqlite` | schema | Diagnostics 页面只读探测 | 否 | +| `~/.codex/memories_1.sqlite` | schema | Diagnostics 页面只读探测 | 否 | +| `~/.codex/auth.json` | 不读取内容 | Diagnostics 只标记为排除项 | 否 | +| 系统 tmp 目录下的 `life_tools-codex-inspector-/session_summary_cache.sqlite` | 脱敏 session summary、token 统计、文件大小和修改时间 | 历史数据缓存,不存 raw JSONL 或完整对话,不写 `~/.codex` | 是 | + +历史 session 详情页使用分页读取。若列表或 SQLite cache 已经提供可信 `SessionSummary`,后端会用其中的事件总数作为 hint,读取到当前页事件窗口后停止;今天仍在变化的 rollout 不使用该 hint,继续实时解析。 + +## 页面说明 + +| 页面 | 内容 | +|---|---| +| Overview | 统计卡片、默认最近一周时间筛选、快捷时间范围、最近半年活跃度 heatmap、月度趋势和最近 session | +| Sessions | 左侧 session 列表,右侧按原生顺序分页展示消息、工具调用、工具结果、系统事件和 token usage 摘要;raw JSONL 按行展开时再加载 | +| Memory | 左侧记忆文件列表,右侧详情预览;支持关键词搜索 | +| Diagnostics | 数据源状态、SQLite schema 探测结果和只读边界 | + +顶部菜单负责全局数据源状态和时间范围入口。左侧菜单只负责页面切换。小屏幕下左侧菜单会折叠成页面上方的两列导航。 + +右上角时间范围影响统计卡片、月度趋势、最近 session 和 Sessions 列表。Activity heatmap 不跟随该筛选,固定展示最近 183 天;鼠标悬停单元格时显示日期和当日对话次数。 + +大数字使用 `k`、`m`、`b` 简写,分别代表千、百万、十亿。精确值保留在指标悬浮提示中。 + +Diagnostics 的 Summary cache 面板展示派生 cache 生命周期: + +| 状态 | 页面动作 | 后端行为 | +|---|---|---| +| `missing` | `Create cache` | 新建 SQLite cache 并后台补齐今天以前的 rollout | +| `healthy` | `Fill missing cache` | 只补齐缺失或过期的历史 summary | +| `corrupt` | `Backup and rebuild cache`,二次确认 | 将损坏文件改名为 `.corrupt..bak` 后新建 cache | +| `rebuilding` | 展示进度 | 返回当前 job,不重复启动 | +| `disabled` | 无操作按钮 | `-no-cache` 下只实时解析 JSONL | +| `unavailable` | 无操作按钮 | 展示权限、busy timeout 或其他非损坏错误 | + +后端 API: + +| API | 方法 | 用途 | +|---|---|---| +| `/api/cache/status` | GET | 返回 cache 状态和 job 进度 | +| `/api/cache/build` | POST | 创建或补齐 cache;job 运行中返回当前 job | +| `/api/cache/rebuild` | POST | 仅用于 corrupt cache 的备份重建;job 运行中返回当前 job | + +## 架构 + +```mermaid +flowchart TD + A["Browser UI (127.0.0.1)"] --> B["Go HTTP server"] + B --> C["Session parser"] + B --> D["Memory reader"] + B --> E["Diagnostics reader"] + C --> F["session_index.jsonl"] + C --> G["sessions/YYYY/MM/DD/rollout-*.jsonl"] + C --> N["tool-owned summary cache.sqlite"] + D --> H["memories/MEMORY.md"] + D --> I["memories/memory_summary.md"] + D --> J["memories/rollout_summaries/*.md"] + E --> K["state_5.sqlite schema"] + E --> L["goals_1.sqlite schema"] + E --> M["memories_1.sqlite schema"] +``` + +渲染校验状态:当前机器未安装 `mmdc` 或 `mermaid` 命令;通过 `npx` 临时运行 `@mermaid-js/mermaid-cli` 需要下载并执行外部包,未被当前安全策略允许。因此该 Mermaid 图已按语法约束编写,但未完成本机实际渲染校验。 + +## 隐私边界 + +- 默认监听 `127.0.0.1`,只服务当前用户。 +- 不实现登录、账号或权限系统,不适合暴露到局域网或公网。 +- 安装脚本默认写 `$HOME/.local/bin`,不写 `/usr/local`、`/etc` 或 `/var`;系统级安装必须显式传 `--system` 或 `--allow-sudo`。 +- 不写入 `~/.codex`,不修改 session、memory、SQLite 或配置文件。 +- 不读取 `auth.json` 内容。 +- 历史 summary cache 默认写在系统 tmp 下的用户隔离目录,cache 目录权限为 `0700`,文件权限固定为 `0600`;也可以用 `-cache-path` 指定位置。缓存不包含 raw JSONL 或完整对话内容,只包含脱敏后的 `SessionSummary`、token 聚合、文件大小和修改时间。 +- 损坏 cache 不直接删除,只在用户触发 rebuild 时改名为 `.corrupt..bak` 后重建。 +- JSON 和文本展示层会脱敏常见 `auth`、`token`、`cookie`、`password`、`secret`、`api_key`、`access_key` 字段。 +- raw JSONL 展开能力按行读取并展示脱敏后的 JSONL,方便排查格式和事件顺序;页面不默认下载完整 raw JSONL,不用于导出完整敏感内容。 +- token usage 只读取 Codex JSONL 中的结构化 `token_count` 元数据,不读取或推断账单、账号或认证信息。 + +## 脱敏截图 + +以下截图来自脱敏 fixture,不包含真实对话、真实 token 或内部信息。 + +### Overview + +![Overview](../assets/codex_inspector/overview-1440.png) + +### Sessions + +![Sessions](../assets/codex_inspector/sessions-1440.png) + +### Memory + +![Memory](../assets/codex_inspector/memory-768.png) + +### Mobile + +![Mobile](../assets/codex_inspector/overview-390.png) + +## 故障排查 + +| 现象 | 原因 | 处理 | +|---|---|---| +| 页面显示没有 session | `session_index.jsonl` 或 `sessions/` 不存在,或时间筛选太窄 | 查看 Diagnostics,清空时间筛选后刷新 | +| 某个 session 没有详情 | 索引里有 session,但 rollout JSONL 不存在或无法匹配 | 查看 session 列表里的路径和 Diagnostics 数据源 | +| 点击 session 后一直 loading | 冷启动正在建立 session 索引,或目标 JSONL 文件很大 | 等待首次索引完成;详情接口会优先使用缓存中的 `id -> path`,首屏只返回有限事件,后续通过 `Load more events` 追加 | +| token 统计为 0 | 该历史 JSONL 没有 `event_msg.type=token_count`,或格式不同 | 这是兼容性缺失,不影响对话查看;Diagnostics 和 raw JSONL 可用于确认格式 | +| CPU 占用高 | 首次历史范围加载需要解析该范围内尚未缓存的历史 rollout;今天的数据实时解析;直接打开未缓存的超大 session 仍可能扫描目标文件 | 等待首次缓存写入;后续历史请求会复用 SQLite summary cache;历史详情页在有 summary hint 时只扫描当前事件窗口,raw JSONL 按行加载,避免浏览器一次性渲染完整会话 | +| cache 状态是 missing | cache 文件尚未创建 | 在 Diagnostics 点击 `Create cache` | +| cache 状态是 corrupt | SQLite 返回明确损坏错误 | 在 Diagnostics 二次确认后点击 `Backup and rebuild cache` | +| cache 状态是 unavailable | 权限、busy timeout 或其他非损坏错误 | 处理错误原因;页面会降级实时解析 JSONL | +| cache 文件权限不符合预期 | 旧版本或手动创建的 cache 文件权限过宽 | 删除 cache 文件后重启,或执行 `chmod 600 ` | +| raw JSONL 缺少部分内容 | 单行过大时后端会限制返回长度 | 到本机文件系统只读查看原文件 | +| SQLite schema 没有展示 | 系统没有 `sqlite3` 命令,或数据库不存在 | 安装 `sqlite3` 或忽略 schema 诊断 | +| 浏览器访问不到服务 | 服务未启动或地址不是 `127.0.0.1:8787` | 检查启动命令和终端输出 | + +## 验证 + +推荐验证命令: + +```bash +go test ./cli/codex_inspector +go build -o output/codex_inspector ./cli/codex_inspector/... +./output/codex_inspector -cache-workers 2 +``` + +页面验证需要启动服务后用 Chrome 检查: + +- Overview、Sessions、Memory、Diagnostics 四个页面能正常渲染。 +- Chrome console 没有相关 error。 +- `390x844`、`768x1024`、`1440x900` 三类视口没有文字重叠、按钮溢出、菜单遮挡或横向滚动失控。 + +功能回归样例见 [codex_inspector_regression.md](codex_inspector_regression.md)。修改页面、API、parser、redaction、SQLite summary cache 或响应式布局时,必须同步更新对应回归样例。 diff --git a/docs/cli/codex_inspector_regression.md b/docs/cli/codex_inspector_regression.md new file mode 100644 index 0000000..37d0825 --- /dev/null +++ b/docs/cli/codex_inspector_regression.md @@ -0,0 +1,205 @@ +# codex_inspector 功能回归测试样例 + +本文档记录 `codex_inspector` 的功能回归样例,供人工和 AI 工具在改动后执行。样例重点覆盖页面行为、API 合约、隐私边界、响应式布局和 SQLite summary cache 生命周期。 + +`codex_inspector` 只读展示本机 Codex 数据。回归测试默认使用脱敏 fixture,不直接依赖真实 `~/.codex`;需要真实数据验证时,只允许只读访问,不能把真实会话、token、auth、cookie、secret 或内部路径写入截图、日志和文档。 + +## 基本原则 + +| 原则 | 要求 | +|---|---| +| 数据安全 | 测试 fixture 必须脱敏;`auth.json`、token、cookie、secret、真实 API key 不得进入仓库。 | +| 只读边界 | 测试不得写入 `~/.codex`;cache 只能写到 `-cache-path` 指定的临时文件或系统 tmp 下的用户隔离目录。 | +| 可重复 | 每个样例必须说明前置数据、启动参数、操作步骤和断言点。 | +| 可自动化 | 页面样例需要给出可由 Chrome/Playwright 检查的 selector、网络请求或文本断言。 | +| 跨端 | 涉及 UI 的改动至少检查 `390x844`、`768x1024`、`1440x900` 三类视口。 | +| 降级可用 | cache 缺失、损坏、禁用或不可用时,页面仍应能通过实时 JSONL 解析展示核心数据。 | + +## 推荐验证命令 + +从仓库根目录执行: + +```bash +GOMODCACHE=/tmp/codex_inspector_mod_cache GOCACHE=/tmp/codex_inspector_go_cache go test ./cli/codex_inspector +GOMODCACHE=/tmp/codex_inspector_mod_cache GOCACHE=/tmp/codex_inspector_go_cache go build -o output/codex_inspector ./cli/codex_inspector/... +node --check cli/codex_inspector/static/app.js +``` + +启动服务时使用脱敏 fixture: + +```bash +./output/codex_inspector \ + -codex-home /tmp/codex-inspector-fixture \ + -cache-path /tmp/codex-inspector-cache/session_summary_cache.sqlite \ + -addr 127.0.0.1:8787 +``` + +禁用 cache 的启动方式: + +```bash +./output/codex_inspector \ + -codex-home /tmp/codex-inspector-fixture \ + -no-cache \ + -addr 127.0.0.1:8787 +``` + +## Fixture 要求 + +| Fixture | 目的 | 最小内容 | +|---|---|---| +| `base` | 正常功能回归 | `session_index.jsonl`、跨 3 天的 `sessions/YYYY/MM/DD/rollout-*.jsonl`、`memories/MEMORY.md`、`memory_summary.md`、`rollout_summaries/*.md`。 | +| `bad_jsonl` | 容错回归 | 至少一个 rollout 文件包含坏 JSONL 行、未知字段、缺少 `timestamp` 的事件。 | +| `missing_sources` | 缺文件回归 | 缺少 `session_index.jsonl` 或 `memories/`,但服务可启动。 | +| `token_usage` | token 聚合回归 | 至少两个 session 含 `event_msg.payload.type=token_count`,覆盖 input、cached input、output、reasoning、context window、rate limit。 | +| `privacy` | 脱敏回归 | JSONL 和 memory 中包含假 `token`、`cookie`、`secret`、`api_key` 字段,期望页面只显示脱敏值。 | +| `cache_missing` | cache 创建回归 | 指定一个不存在的 `-cache-path`。 | +| `cache_corrupt` | cache 损坏回归 | 指定一个内容为普通文本的 `.sqlite` 文件。 | +| `cache_disabled` | cache 禁用回归 | 使用 `-no-cache`。 | + +fixture 中的路径可使用 `/tmp/codex-demo/project`、`/tmp/demo-home/.codex` 等假路径,不得使用真实工作路径。 + +## API 回归样例 + +| 编号 | API | 前置条件 | 操作 | 断言 | +|---|---|---|---|---| +| API-001 | `GET /api/overview` | `base` fixture | 请求默认范围 | HTTP 200;包含 `stats.totalSessions`、`stats.byDay`、`stats.byWeek`、`stats.byMonth`、`stats.heatmap`、`recentSessions`。 | +| API-002 | `GET /api/overview?from=YYYY-MM-DD&to=YYYY-MM-DD` | `base` fixture | 请求一个只覆盖部分 session 的范围 | `stats.totalSessions` 只统计范围内 session;`stats.heatmap` 仍固定最近 183 天,不随范围缩短。 | +| API-003 | `GET /api/sessions?limit=300` | `base` fixture | 请求 session 列表 | HTTP 200;session 按更新时间倒序;每项包含 `id`、`title`、`updatedAt`、`eventCount`、`tokenStats`。 | +| API-004 | `GET /api/sessions?q=demo` | `base` fixture | 搜索标题、cwd 或 model | 只返回匹配项;大小写不应导致明显误判。 | +| API-005 | `GET /api/sessions/{id}?event_limit=300&raw=0` | `base` fixture | 请求一条存在的 session | HTTP 200;包含 `summary`、分页后的 `events`、`eventTotal`、`hasMore`;不默认返回 `rawLines`;events 顺序与 JSONL 行序一致。 | +| API-005A | `GET /api/sessions/{id}/raw?line=N` | `privacy` fixture | 请求单行 raw JSONL | HTTP 200;只返回目标行的脱敏 raw 内容;不存在的行返回结构化 404。 | +| API-006 | `GET /api/sessions/not-found` | `base` fixture | 请求不存在的 id | HTTP 404;返回结构化 `error`,服务不 panic。 | +| API-007 | `GET /api/memory` | `base` fixture | 请求 memory 列表 | HTTP 200;包含 `MEMORY.md`、`memory_summary.md`、rollout summary 条目。 | +| API-008 | `GET /api/memory?q=keyword` | `base` fixture | 搜索关键词 | 只展示匹配文件或匹配计数;不返回 auth/token 原文。 | +| API-009 | `GET /api/memory/file?path=...` | `base` fixture | 请求存在文件详情 | HTTP 200;返回 `file` 和 `content`;内容已脱敏。 | +| API-010 | `GET /api/diagnostics` | `base` fixture | 请求诊断 | HTTP 200;包含 `sources`、`schemas`;`auth.json` 只标记排除,不读取内容。 | +| API-011 | 所有 API | `bad_jsonl` fixture | 请求 Overview、Sessions、Session detail | HTTP 200 或目标 session 的可解释错误;坏行进入 warnings;服务不 panic。 | + +## Cache API 回归样例 + +当实现 cache lifecycle API 后,必须覆盖以下样例。cache 是派生数据,不是用户原始数据;失败时必须降级实时解析 JSONL。 + +| 编号 | API | 前置条件 | 操作 | 断言 | +|---|---|---|---|---| +| C-001 | `GET /api/cache/status` | `-no-cache` | 请求 cache 状态 | `status=disabled`;展示禁用原因;`canBuild=false`;页面不展示误导性重建按钮。 | +| C-002 | `GET /api/cache/status` | `cache_missing` | 请求 cache 状态 | `status=missing`;展示 cache 路径;允许 `Create cache`。 | +| C-003 | `POST /api/cache/build` | `cache_missing` | 点击或请求创建 cache | 返回当前 job;服务后台补齐今天以前的 rollout;今天的 rollout 不写入 cache。 | +| C-004 | `POST /api/cache/build` | job 正在运行 | 连续请求两次 build | 第二次不启动新 job;返回同一个 job 的进度状态。 | +| C-005 | `GET /api/cache/status` | build 运行中 | 轮询状态 | `status=rebuilding` 或等价运行态;`job.total`、`job.done`、`job.cached`、`job.skipped` 单调推进。 | +| C-006 | `GET /api/cache/status` | 健康 cache | 请求状态 | `status=healthy`;允许 `Fill missing cache`;不显示 rebuild 警告。 | +| C-007 | `POST /api/cache/build` | 健康 cache 且有历史缺口 | 请求补齐 | 只补齐缺失或过期历史 summary;SQLite 写入保持串行或低并发。 | +| C-008 | `GET /api/cache/status` | `cache_corrupt` | 请求状态 | `status=corrupt`;错误只在明确 `malformed`、`file is not a database`、`schema is corrupt` 时判定为损坏。 | +| C-009 | `POST /api/cache/rebuild` | `cache_corrupt`,用户已在应用内确认弹窗二次确认 | 请求重建 | 原 cache rename 为 `.corrupt..bak`;新建 cache;不直接删除损坏文件。 | +| C-010 | `POST /api/cache/rebuild` | 普通权限失败或 busy timeout | 请求重建 | 不 rename;状态为 `unavailable`;展示原因;页面仍通过 JSONL 实时解析可用。 | +| C-011 | Overview/Sessions | cache 损坏或不可用 | 打开页面 | 页面仍可展示统计和列表;warnings 可见;无未捕获 JS error。 | + +## Overview 页面回归样例 + +| 编号 | 前置条件 | 操作 | 断言 | +|---|---|---|---| +| O-001 | `base` fixture | 打开首页 | 顶部显示数据源状态;统计卡片展示 sessions、events、messages、tools、tokens、output。 | +| O-002 | `token_usage` fixture | 打开首页 | token 数字使用 `k`、`m`、`b` 简写;hover/title 或详情中保留可读精确值。 | +| O-003 | `base` fixture | 默认加载 | 时间范围默认为最近 7 天;From/To 有默认值,不为空。 | +| O-004 | `base` fixture | 切换 Last 24 hours、Last 7 days、Last 30 days、自定义日期 | 统计卡片、月度趋势、最近 session 跟随范围刷新。 | +| O-005 | `base` fixture | 连续设置 11 个自定义范围 | 下拉历史最多保留 10 条;重复范围去重。 | +| O-006 | `base` fixture | 查看 Activity heatmap | 固定展示最近 183 天;行数固定 7 行;宽窄视口下不改变行数。 | +| O-007 | `base` fixture | hover heatmap 单元格 | tooltip/title 或 aria label 包含日期和当日对话次数。 | +| O-008 | `missing_sources` fixture | 打开首页 | 页面展示空态或 warnings;不出现未捕获异常;菜单仍可切换。 | + +## Sessions 页面回归样例 + +| 编号 | 前置条件 | 操作 | 断言 | +|---|---|---|---| +| S-001 | `base` fixture | 打开 Sessions | 左侧列表展示 session title、时间、事件数、model、token 简写和 preview。 | +| S-002 | `base` fixture | 点击第一条 session | 右侧展示详情,不停留在 `Select a session to inspect.`。 | +| S-003 | `base` fixture | 查看详情事件流 | user、assistant、tool call、tool result、system event 按 JSONL 原始顺序展示。 | +| S-004 | `base` fixture | 展开 Raw JSONL | raw 内容按需加载且已脱敏;能看到行号;不能出现假 token/cookie/secret 原文。 | +| S-005 | `token_usage` fixture | 点击含 token_count 的 session | token usage 摘要展示 total、input、cached、output、reasoning、last turn、context、rate limit。 | +| S-006 | `base` fixture | 搜索 title、cwd、model | 列表过滤;清空搜索后恢复。 | +| S-007 | `bad_jsonl` fixture | 点击含坏行 session | 可展示有效事件;坏行进入 warnings 或 raw 脱敏内容;服务不 panic。 | +| S-008 | 大 session fixture | 点击事件数较多的历史 session | 首屏只渲染有限事件,展示总数和 `Load more events`;不默认下载全部 raw JSONL;有 summary hint 时后端只读取当前事件窗口,不触发浏览器慢标签提示。 | + +## Memory 页面回归样例 + +| 编号 | 前置条件 | 操作 | 断言 | +|---|---|---|---| +| M-001 | `base` fixture | 打开 Memory | 列表展示 summary、index、rollout summary 等分类。 | +| M-002 | `base` fixture | 点击 `MEMORY.md` | 右侧展示标题、路径、大小、更新时间和内容预览。 | +| M-003 | `base` fixture | 搜索关键词 | 匹配项保留;无匹配时展示清晰空态。 | +| M-004 | `privacy` fixture | 打开包含敏感假字段的 memory | token、cookie、secret、api_key 被脱敏;不展示原文。 | +| M-005 | `missing_sources` fixture | 缺少 `memories/` | 页面展示 warnings 或空态;不影响 Overview/Sessions。 | + +## Diagnostics 页面回归样例 + +| 编号 | 前置条件 | 操作 | 断言 | +|---|---|---|---| +| D-001 | `base` fixture | 打开 Diagnostics | Data sources 表展示 session index、sessions、memory、sqlite schema 等来源状态。 | +| D-002 | `base` fixture | 查看 SQLite schema | 只读展示 schema;不打开或展示 `auth.json` 内容。 | +| D-003 | 系统无 `sqlite3` 命令 | 打开 Diagnostics | schema 区域显示不可用原因;页面仍可使用。 | +| D-004 | cache missing | 打开 Diagnostics | cache 区域展示 `missing` 和 `Create cache` 按钮。 | +| D-005 | cache healthy | 打开 Diagnostics | cache 区域展示 `healthy` 和 `Fill missing cache` 按钮。 | +| D-006 | cache corrupt | 打开 Diagnostics 并点击 `Backup and rebuild cache` | cache 区域展示 `corrupt`;页面展示应用内二次确认 modal,不使用浏览器原生 confirm;取消关闭 modal,确认后才开始 rebuild。 | +| D-007 | cache rebuilding | 触发 build/rebuild 后轮询 | 展示 total、done、cached、skipped、failed、startedAt、lastError 或 finishedAt。 | +| D-008 | cache disabled/unavailable | 使用 `-no-cache` 或不可写 cache path | 展示明确原因;不展示可执行但必然失败的按钮。 | + +## 响应式 UI 回归样例 + +每个页面至少检查以下视口: + +| 编号 | 视口 | 页面 | 断言 | +|---|---|---|---| +| R-001 | `390x844` | Overview | 顶部范围控件不溢出;统计卡片可读;heatmap 不横向失控。 | +| R-002 | `390x844` | Sessions | 左侧列表和右侧详情可上下浏览;按钮和 raw 展开不遮挡内容。 | +| R-003 | `390x844` | Memory | 文件列表与详情不重叠;长路径换行或截断合理。 | +| R-004 | `390x844` | Diagnostics | 表格转换为移动端可读布局;cache 操作按钮不溢出。 | +| R-005 | `768x1024` | Overview/Sessions | iPad 尺寸下主内容列宽稳定;heatmap 仍为 7 行。 | +| R-006 | `1440x900` | 全页面 | 页面无大面积错位;顶部菜单与左侧菜单职责清楚,不重复堆砌。 | + +通用断言: + +- `document.documentElement.scrollWidth <= window.innerWidth + 1`。 +- Chrome console 无应用自身 error。 +- Network 面板没有 API 404/500,除非样例明确测试错误路径。 +- 文本不重叠,按钮文字不溢出,菜单不遮挡内容。 + +## 隐私与安全回归样例 + +| 编号 | 前置条件 | 操作 | 断言 | +|---|---|---|---| +| P-001 | `privacy` fixture | 打开所有页面 | 不出现原始 `token`、`cookie`、`secret`、`api_key` 值。 | +| P-002 | `privacy` fixture | 展开 raw JSONL | raw 行仍脱敏;只保留排查格式所需结构。 | +| P-003 | `base` fixture | 打开 Diagnostics | `auth.json` 只显示 excluded 或不读取说明,不显示内容。 | +| P-004 | 任意 fixture | 检查 cache 文件 | cache 不包含 raw JSONL 和完整对话内容,只包含脱敏 summary 和 token 聚合。 | +| P-005 | 任意 fixture | 服务启动参数 | 默认监听 `127.0.0.1`;文档和 UI 不鼓励绑定公网地址。 | + +## AI 回归执行清单 + +AI 工具执行回归时按以下顺序记录结果: + +1. 记录分支、commit、启动命令、fixture 路径和 cache path。 +2. 运行 Go 单测、构建和 `node --check`。 +3. 启动服务并用 API 检查 Overview、Sessions、Memory、Diagnostics。 +4. 用 Chrome 打开页面,检查四个页面和三类视口。 +5. 如果本次改动涉及 cache,执行 Cache API 和 Diagnostics cache 操作样例。 +6. 如果本次改动涉及 parser、redaction、memory 或 raw JSONL,执行隐私与坏 JSONL 样例。 +7. 输出 `通过 / 失败 / 未执行` 表格;未执行必须说明原因,不能写成已通过。 + +推荐结果表: + +| 样例编号 | 结果 | 证据 | 备注 | +|---|---|---|---| +| O-001 | 通过 | 截图或 API 摘要 | | +| S-002 | 失败 | 错误文本或 console error | | +| C-009 | 未执行 | cache rebuild 尚未实现 | | + +## 维护要求 + +新增或修改以下内容时,必须同步更新本文档: + +- 新增页面、页面区块、按钮、菜单或响应式布局。 +- 修改 `/api/overview`、`/api/sessions`、`/api/memory`、`/api/diagnostics` 或 cache API。 +- 修改 JSONL parser、session summary、token 统计、memory 读取、redaction 规则。 +- 修改 SQLite summary cache 的状态、补齐、重建、损坏识别或降级逻辑。 +- 修改默认时间范围、range history、heatmap、数字格式化、raw JSONL 展示。 + +如果一个改动不需要新增样例,提交说明或 PR 描述中必须说明原因,例如“只改文案,不改变行为;现有 O-001 覆盖”。 diff --git a/docs/install.md b/docs/install.md index 162125b..62ac636 100644 --- a/docs/install.md +++ b/docs/install.md @@ -11,6 +11,7 @@ - `InterviewTimer`:macOS 图形应用,需要 macOS 13+ 和 Swift/Apple 开发工具链,不由根目录 `install.sh` 默认安装。 - 默认安装路径:可执行文件放到 `/usr/local/bin`,Python 工具文件放到 `/usr/local/lib/life_tools`。 - 默认配置路径:`/etc/life_tools`,可用 `--config-dir` 改变安装脚本写入位置。 +- `codex_inspector` 是实验工具,使用 `cli/codex_inspector/install.sh` 单独安装;默认安装到 `$HOME/.local/bin`,不写系统目录。 写入 `/usr/local`、`/etc/life_tools` 和 Linux 的 `/var/log` 时可能需要 `sudo`。脚本会在需要时调用 `sudo`,不会覆盖已经存在的配置文件。 @@ -24,6 +25,7 @@ | `codex_hook_notify` | `codex_hook_notify` | Go | 是 | `/etc/life_tools/codex_hook_notify.json` | | `video_subtitle` | `video_subtitle` | Python | 是 | `/etc/life_tools/video_subtitle.json` | | `file_share` | `file_share` | Go | 是 | `/etc/life_tools/file_share.json` | +| `codex_inspector` | `codex_inspector` | Go | 否 | 无配置文件,默认只读 `~/.codex` | | `InterviewTimer` | `InterviewTimer.app` | SwiftPM macOS App | 否 | `~/Library/Application Support/InterviewTimer/` | ## 快速安装 @@ -82,6 +84,94 @@ file_share -config /etc/life_tools/file_share.json `file_share` 默认无认证,用于个人临时分享。不要把含敏感文件、隐藏文件或符号链接的目录暴露到不可信网络。 +## codex_inspector + +`codex_inspector` 是实验工具,用来在本机浏览器里只读查看 Codex 会话、活跃度统计和记忆内容。它不在 `install.sh` 默认稳定安装清单中,也不会写入 `~/.codex`。 + +快速安装: + +```bash +./cli/codex_inspector/install.sh +codex_inspector -addr 127.0.0.1:8787 +``` + +默认安装路径是: + +```text +$HOME/.local/bin/codex_inspector +``` + +macOS 和 Linux 权限策略一致:默认只写当前用户目录,不调用 `sudo`。如果目标目录不在 `PATH` 中,脚本会打印需要加入 shell profile 的提示。 + +安装到自定义用户目录: + +```bash +./cli/codex_inspector/install.sh --prefix "$HOME/.local" +``` + +系统级安装必须显式确认: + +```bash +./cli/codex_inspector/install.sh --system +``` + +`--system` 会安装到 `/usr/local/bin/codex_inspector`,并在目录不可写时使用 `sudo`。如果使用其他系统目录,可以组合 `--prefix` 和 `--allow-sudo`: + +```bash +./cli/codex_inspector/install.sh --prefix /opt/life_tools --allow-sudo +``` + +也可以不安装,直接构建和启动: + +```bash +go build -o output/codex_inspector ./cli/codex_inspector/... +./output/codex_inspector -addr 127.0.0.1:8787 +``` + +可指定脱敏 fixture 或其他 Codex home: + +```bash +./output/codex_inspector -codex-home /tmp/codex-fixture -addr 127.0.0.1:8787 +``` + +历史会话 summary 默认缓存到本机 SQLite 文件,用于加速重复浏览。可指定或禁用: + +```bash +./output/codex_inspector -cache-path /tmp/codex_inspector_cache.sqlite +./output/codex_inspector -no-cache +./output/codex_inspector -cache-workers 2 +./output/codex_inspector -cache-workers 0 +``` + +默认 cache 路径放在系统临时目录下的用户隔离子目录,避免 macOS/Linux 上用户 cache 目录权限异常影响页面使用: + +| 系统 | 默认 cache | +|---|---| +| macOS | `${TMPDIR:-/tmp}/life_tools-codex-inspector-/session_summary_cache.sqlite` | +| Linux | `/tmp/life_tools-codex-inspector-/session_summary_cache.sqlite` | + +cache 生命周期: + +| 状态 | 含义 | 操作 | +|---|---|---| +| `missing` | cache 文件不存在,页面实时解析 JSONL | Diagnostics 点击 `Create cache` | +| `healthy` | cache 可用,历史 summary 可复用 | Diagnostics 点击 `Fill missing cache` | +| `corrupt` | SQLite 明确报 `malformed`、`file is not a database` 或 `schema is corrupt` | Diagnostics 二次确认后点击 `Backup and rebuild cache` | +| `rebuilding` | 后台正在补齐或重建 | 查看 total/done/cached/skipped/failed | +| `disabled` | 使用 `-no-cache` 禁用 | 仅实时解析 JSONL | +| `unavailable` | 权限、busy timeout 或其他非损坏错误 | 页面实时解析 JSONL,先处理错误原因 | + +`-cache-workers` 只控制自动补齐。默认值是 2;设为 0 时不会在启动后自动补齐,但 Diagnostics 上的手动创建或重建仍可执行一次。 + +安全边界: + +- 默认监听 `127.0.0.1`,不要绑定到公网地址。 +- 只读取 `session_index.jsonl`、`sessions/`、`memories/` 和 SQLite schema。 +- 不写入 `~/.codex`;SQLite cache 默认写到系统 tmp 下的用户隔离目录,目录权限为 `0700`,文件权限为 `0600`;也可以用 `-cache-path` 指定位置。 +- cache 只保存脱敏后的 `SessionSummary`、token 聚合、文件大小和修改时间,不保存 raw JSONL 或完整对话内容。 +- 不读取 `auth.json` 内容,不展示 token、cookie、secret 类字段。 +- 详细说明见 [cli/codex_inspector.md](cli/codex_inspector.md)。 + ## InterviewTimer `InterviewTimer` 是 macOS 面试悬浮计时 App,源码位于: diff --git a/docs/release.md b/docs/release.md index 69522c7..1a0f28f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ # 发布说明 -本仓库通过 GitHub Actions 在推送 `v*` tag 时自动构建发布包,并把 zip 上传到 GitHub Release。PR 会执行发布包打包 dry-run,但不会创建 Release。Go 和 Python 单元测试在单独 workflow 里运行,失败只作为提醒,不阻塞发布包流程。Swift macOS App 使用独立 workflow 验证和上传未签名 `.app` 产物。 +本仓库通过 GitHub Actions 在推送 `v*` tag 时自动构建发布包,并把 zip 上传到 GitHub Release。PR 会执行 Go 单测和发布包打包 dry-run,但不会创建 Release。Python 单元测试在单独 workflow 里运行,失败只作为提醒,不阻塞发布包流程。Swift macOS App 使用独立 workflow 验证和上传未签名 `.app` 产物。 ## 触发方式 @@ -43,7 +43,7 @@ on: - 'v*' ``` -`pull_request` 在 release workflow 中只做 Python 编译检查、Emby 插件测试和打包 dry-run;只有 tag push 才执行 `gh release create` 或 `gh release upload`。Go 测试由 `.github/workflows/go-test.yml` 单独执行,Python 单元测试由 `.github/workflows/python-test.yml` 单独执行。测试失败时 reminder workflow 只写 GitHub warning 和 summary,本身仍返回成功,不阻塞发布包流程。 +`pull_request` 在 release workflow 中只做 Python 编译检查、Emby 插件测试和打包 dry-run;只有 tag push 才执行 `gh release create` 或 `gh release upload`。Go 测试由 `.github/workflows/go-test.yml` 单独执行,失败会阻塞 PR。Python 单元测试由 `.github/workflows/python-test.yml` 单独执行,失败时只写 GitHub warning 和 summary,本身仍返回成功,不阻塞发布包流程。 `swift-mac-app.yml` 使用 `macos-latest` runner。PR 和 `master` 推送会验证 `gui/interview_timer` 的 Swift 单测、可执行产物构建和 `.app` 打包;`v*` tag 会额外上传未签名的 `InterviewTimer.app` zip。 @@ -90,11 +90,14 @@ bin/check_keywords bin/retry_exec bin/codex_hook_notify bin/file_share +bin/codex_inspector install.sh sample/life_tools/*.json docs/** ``` +`codex_inspector` 虽然进入 Go 二进制发布包,但仍是实验工具,不进入根目录 `install.sh` 默认稳定安装清单。需要安装时优先使用 `cli/codex_inspector/install.sh` 或直接从 release zip 取 `bin/codex_inspector`。 + `video_subtitle` 发布包包含 Python 源码、prompts、`requirements.txt`、示例配置和文档。它不是纯二进制工具,使用前仍需要 Python 依赖、ffmpeg、TOS、ASR、LLM 配置。 Emby 插件发布包只包含部署需要的插件 DLL 和文档。安装到 Emby 插件目录时只复制: @@ -132,14 +135,19 @@ swift build --product InterviewTimerApp ./scripts/build_app.sh ``` -测试提示 workflow 会运行: +Go 测试 workflow 会运行: ```bash go test ./... +``` + +Python 测试提示 workflow 会运行: + +```bash python3 -m unittest cli/video_subtitle/video_subtitle_test.py ``` -这些 workflow 失败时只写 GitHub warning 和 summary,不阻塞 release workflow,也不阻止 tag 发布资产。 +Go 测试失败会阻塞 PR;Python 测试失败时只写 GitHub warning 和 summary,不阻塞 release workflow,也不阻止 tag 发布资产。 ## 权限 diff --git a/go.mod b/go.mod index 1e8c792..08cbc72 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,26 @@ require ( github.com/stretchr/testify v1.8.1 golang.org/x/sync v0.1.0 golang.org/x/term v0.7.0 + modernc.org/sqlite v1.29.10 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.7.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.19.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index 48ab916..05fb6a0 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,31 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mcoder2014/go_utils v0.0.6 h1:g7wdZXd3rREpTs4VHG9gOZhrLmP3yqIAVO0cuGlF8g8= github.com/mcoder2014/go_utils v0.0.6/go.mod h1:gRAhCQnSyHkjsGe524HSL2x8QmQqCZlngcF0sXRFSlU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -25,13 +37,18 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -39,3 +56,29 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=