cxs is a local-first CLI for searching Codex session logs. It is designed for progressive retrieval: find the right session first, then read
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(index): 修复 selector 覆盖同步语义

Entire-Checkpoint: f68e91e0cf0b

catoncat 4bfe8014 856d52a2

+2722 -685
+1
.gitignore
··· 2 2 dist/ 3 3 data/ 4 4 *.tgz 5 + .DS_Store 5 6 .claude/ 6 7 .entire/ 7 8 .intent/
+38 -6
AGENTS.md
··· 6 6 7 7 当前接受的产品边界: 8 8 9 - - 命令面固定为:`current`、`sync`、`find`、`read-range`、`read-page`、`list`、`stats` 10 - - 主工作流固定为:`sync -> find -> read-range/read-page` 9 + - 命令面固定为:`status`、`sync`、`find`、`read-range`、`read-page`、`list`、`stats` 10 + - 主工作流固定为:`status -> sync --selector -> find/list -> read-range/read-page` 11 11 - `sync` 是唯一会修改索引的命令;其余命令只读 SQLite 12 12 - 默认接受手动增量同步,不做 watcher / daemon / realtime sync 13 13 - 这个仓库可以作为其他 sidecar / GUI 的 retrieval engine,但本仓库自身不以 GUI 为目标 ··· 15 15 ## 当前实现真相 16 16 17 17 - 检索主链是 `message/session recall -> session heuristic rerank -> progressive read` 18 + - `status` 只返回执行上下文、source inventory、index 状态与 coverage 状态;它可以扫描 raw session metadata,但不写 index、不回答内容问题 19 + - 内容回答只能来自 cxs index;source inventory 只能用于构造 selector 和判断可能的同步范围,不能作为内容真相源 20 + - `sync --selector` 是建立 coverage 的唯一入口;只读命令不得隐式触发 sync 18 21 - 候选召回来自 `messages_fts` 与 `sessions_fts(title + summary_text + compact_text + reasoning_summary_text)`;极少数零 token CJK query 在 message 侧回退到 LIKE 19 22 - `summary_text`、`compact_text`、`reasoning_summary_text` 已持久化,也会通过 `sessions_fts` 参与 session-level recall 20 23 - session-level FTS 使用显式字段权重:title 8.0、compact 4.0、summary 3.0、reasoning summary 1.2 ··· 35 38 - [parser.ts](/Users/envvar/work/repos/cxs/parser.ts): Codex JSONL 解析与 `summary_text` 生成 36 39 - [db.ts](/Users/envvar/work/repos/cxs/db.ts): SQLite schema、会话/消息存取 37 40 - [query.ts](/Users/envvar/work/repos/cxs/query.ts): find / list / read-range / read-page 查询编排 41 + - [status.ts](/Users/envvar/work/repos/cxs/status.ts): status 输出编排 42 + - [selector.ts](/Users/envvar/work/repos/cxs/selector.ts): selector 解析与覆盖蕴含规则 43 + - [source-inventory.ts](/Users/envvar/work/repos/cxs/source-inventory.ts): raw sessions metadata inventory 44 + - [types.ts](/Users/envvar/work/repos/cxs/types.ts): CLI JSON contract 与核心类型 38 45 - [ranking.ts](/Users/envvar/work/repos/cxs/ranking.ts): session heuristic rerank 39 46 - [eval/](/Users/envvar/work/repos/cxs/eval): manual eval、batch compare 40 47 ··· 49 56 50 57 本仓库不维护项目级 `.agents/skills`。cxs 的 skill 是给用户安装后操作 CLI 的发行物,不是维护本仓库时给 Codex agent 自动加载的项目 workflow。 51 58 52 - skill package 源码放在: 59 + 本仓库维护两条 skill 通道: 60 + 61 + - `skill-packages/cxs`: 发布版 skill 源码,必须匹配将要发布的 `cxs` CLI 行为 62 + - `skill-packages/cxsd`: 本机开发版 skill,必须使用 `cxsd` / `CXSD_BIN`,用于 dogfood 当前 checkout 63 + 64 + ### 发布版 cxs 53 65 54 - - `skill-packages/cxs` 66 + `cxs` 永远代表线上安装版。不要把本地 dirty tree rsync 到全局 `cxs` skill。 55 67 56 - 对外推荐安装方式: 68 + 对外推荐安装方式,也是本机更新全局线上 skill 的方式: 57 69 58 70 ```bash 59 71 npx skills add catoncat/cxs --full-depth --skill cxs -g -a codex -y ··· 64 76 - 优先使用 `CXS_BIN` 65 77 - 未设置时回退到 `PATH` 里的 `cxs` 66 78 79 + ### 开发版 cxsd 80 + 81 + `cxsd` 永远代表本地 checkout。它用于验证未发布代码和未发布 skill,不用于验证 npm/npx 线上版本。 82 + 83 + 本机约定: 84 + 85 + - dev bin: `/Users/envvar/.local/bin/cxsd` 86 + - dev bin 指向:`/Users/envvar/work/repos/cxs/cli.ts` 87 + - global dev skill: `/Users/envvar/.agents/skills/cxsd -> /Users/envvar/work/repos/cxs/skill-packages/cxsd` 88 + - Claude exposure: `/Users/envvar/.claude/skills/cxsd -> /Users/envvar/.agents/skills/cxsd` 89 + 90 + 维护规则: 91 + 92 + - 改 CLI 行为时,先更新 `skill-packages/cxs`,再同步调整 `skill-packages/cxsd` 93 + - `skill-packages/cxsd` 只能把入口从 `cxs` 改成 `cxsd`,不能发明另一套产品语义 94 + - `skill-packages/cxsd` 内不要出现 `CXS_BIN`、`${CXS_BIN:-cxs}` 或指向发布版 `cxs` 的示例 95 + - 全局 `cxs` 通过 `npx skills add` 更新;全局 `cxsd` 通过 symlink 跟随本地 repo 96 + - 若 `cxs` 与 `cxsd` 行为不一致,先判断是“线上尚未发布”还是“dev skill 漂移”,不要直接覆盖任一通道 97 + 67 98 ## 默认验证 68 99 69 100 涉及实现或文档真相变更时,至少做与改动直接相关的验证: 70 101 71 102 - `npm run check` 72 - - 必要时补一条 CLI 烟测,例如 `npm run cxs -- stats --json` 或 `npm run cxs -- find "<query>" --json` 103 + - 必要时补一条 CLI 烟测,例如 `cxsd status --json` 或 `cxsd find "<query>" --json` 104 + - 涉及 skill 通道时,验证 `npx skills ls -g --json`、`readlink /Users/envvar/.agents/skills/cxsd` 和 `cxsd --help` 73 105 74 106 没有验证证据,不要声称“已对齐”“已完成”“文档正确”。 75 107
+130 -165
README.md
··· 1 1 # cxs 2 2 3 - `cxs` 是一个面向本机 Codex 会话日志的渐进式检索 CLI。 3 + `cxs` is a local-first CLI for searching Codex session logs. It is designed for 4 + progressive retrieval: find the right session first, then read only the relevant 5 + range or page. 4 6 5 - 它的目标不是“返回整场对话全文”,而是给 agent 或人一个低噪音的读取路径: 7 + Core workflow: 6 8 7 - `sync -> find -> read-range/read-page` 9 + ```text 10 + status -> sync --selector -> find/list -> read-range/read-page 11 + ``` 8 12 9 - ## 适用场景 13 + ## What It Is 10 14 11 - - 查“之前那个 session 里是怎么修的” 12 - - 按关键词找最近的 Codex 历史 13 - - 先拿候选 session,再围绕命中点局部展开 14 - - 给 sidecar / GUI 工具提供本地 session retrieval engine 15 + - A CLI for indexed search over local Codex JSONL sessions. 16 + - A retrieval backend for agents, sidecars, and local tools that need session recall. 17 + - A manual-sync tool: `sync` is the only command that writes the SQLite index. 15 18 16 - ## 非目标 19 + ## What It Is Not 17 20 18 - - 不做实时 watcher / daemon / 自动 sync 19 - - 不做 GUI 20 - - 不直接绑定 live in-flight thread 21 - - 不返回未裁剪的全文默认输出 22 - 23 - 当前命令面: 21 + - Not a GUI. 22 + - Not a watcher, daemon, or realtime sync service. 23 + - Not a live in-flight thread attachment layer. 24 + - Not a default full-transcript dumper. 24 25 25 - - `cxs sync` 26 - - `cxs find <query>` 27 - - `cxs read-range <sessionUuid>` 28 - - `cxs read-page <sessionUuid>` 29 - - `cxs list` 30 - - `cxs stats` 31 - - `cxs current` 26 + ## Install 32 27 33 - ## CLI Install Guide 28 + Requirements: 34 29 35 - > **平台支持**:**macOS / Linux only**(`darwin-arm64` / `darwin-x64` / `linux-x64` / `linux-arm64`)。Windows 走 WSL,我们没原生测过 Windows path。 30 + - macOS or Linux. Windows users should use WSL. 31 + - Node.js `>= 22`. 32 + - Read access to `~/.codex/sessions`. 36 33 37 - ### npm 全局安装(推荐,需要 Node 22+) 34 + Install the CLI globally: 38 35 39 36 ```bash 40 37 npm i -g @act0r/cxs 38 + cxs --help 41 39 ``` 42 40 43 - 装出来的命令是 `cxs`。当前唯一发布形态是 Node.js npm 包,不再发布 standalone binary。 41 + The installed command is `cxs`. The package is scoped because the unscoped 42 + `cxs` package name is already taken on npm. The current distribution is the npm 43 + package only; no standalone binary is published. 44 44 45 - 也可以一次性用 npx: 45 + For one-off usage: 46 46 47 47 ```bash 48 - npx @act0r/cxs --help 48 + npx @act0r/cxs@latest --help 49 + npx @act0r/cxs@latest status --json 49 50 ``` 50 51 51 - > 包名是 scoped 的,因为 npm 上 `cxs` 已被 css-in-js 库占用。 52 + ## Quick Start 52 53 53 - ### 从源码(开发者 / 需要 PR) 54 + Inspect available sources and index coverage: 54 55 55 56 ```bash 56 - git clone https://github.com/catoncat/cxs.git 57 - cd cxs 58 - npm install 59 - npm run cxs -- --version # 通过 tsx 直接跑 cli.ts 57 + cxs status --json 60 58 ``` 61 59 62 - 完整工程命令:`npm run check`(tsc + vitest)、`npm run build`(esbuild bundle 出 `dist/cli.js`)、`npm run eval:perf`(真实大库基准)。 63 - 64 - ### 首次使用建立索引 60 + Build coverage for a project: 65 61 66 62 ```bash 67 - cxs sync 68 - cxs stats --json 63 + cxs sync --selector '{"kind":"cwd","root":"/Users/you/.codex/sessions","cwd":"/Users/you/work/project"}' 69 64 ``` 70 65 71 - 没有单独的 `init` 命令;`sync` 会创建并更新索引。若直接用 `npx` 试跑: 66 + Replace the example paths with your own absolute paths. 67 + 68 + Search and read progressively: 72 69 73 70 ```bash 74 - npx @act0r/cxs sync 75 - npx @act0r/cxs find "health check" 71 + cxs find "health check" 72 + cxs read-range <sessionUuid> --seq <matchSeq> 73 + cxs read-page <sessionUuid> --offset 0 --limit 20 76 74 ``` 77 75 78 - `--help` 应列出 `sync` / `find` / `read-range` / `read-page` / `list` / `stats` / `current`。 79 - 80 - ### 数据目录 81 - 82 - 索引默认写到 `~/.local/state/cxs/index.sqlite`(XDG state 约定;`$XDG_STATE_HOME` 也尊重)。`CXS_DATA_DIR` 环境变量优先级最高: 76 + You can run the same flow without global installation: 83 77 84 78 ```bash 85 - export CXS_DATA_DIR="$HOME/.config/cxs" 79 + npx @act0r/cxs@latest sync --selector '{"kind":"all","root":"/Users/you/.codex/sessions"}' 80 + npx @act0r/cxs@latest find "health check" 86 81 ``` 87 82 88 - **自动迁移**:之前装过 cxs 0.2.0 及以下、索引在 `~/.cache/cxs/` 的用户,首次跑新版 `cxs sync` 会自动 `rename` 整个目录到 `~/.local/state/cxs/`,**不需要重 sync**(240 MB 索引不会重建)。如果新位置已有数据,迁移跳过,旧 cache 留在原地等用户手动处理。 83 + ## Commands 89 84 90 - ### 要求 91 - 92 - - 本机可读 `~/.codex/sessions` 93 - - Node.js `>= 22` 94 - 95 - ## 用法 85 + | Command | Purpose | 86 + | --- | --- | 87 + | `cxs status` | Show execution context, source inventory, index state, and coverage. Does not write the index. | 88 + | `cxs sync --selector <json>` | Scan selected Codex sessions and update the SQLite index. This is the only write command. | 89 + | `cxs find <query>` | Search indexed sessions and return ranked session candidates with minimal snippets. | 90 + | `cxs read-range <sessionUuid>` | Read a small message window around a matched sequence or in-session query. | 91 + | `cxs read-page <sessionUuid>` | Read a session page by offset and limit. | 92 + | `cxs list` | List indexed sessions without full-text search. | 93 + | `cxs stats` | Show index statistics. | 96 94 97 - 默认会读取: 95 + All commands that read indexed content support `--json`. Read commands fail 96 + cleanly if the index has not been created yet. 98 97 99 - - Codex sessions:`~/.codex/sessions` 100 - - 标题索引:`~/.codex/session_index.jsonl` 101 - - SQLite 索引:项目内 `./data/index.sqlite` 98 + ## Selectors 102 99 103 - 先建立索引: 100 + `sync` requires an explicit selector. Query commands can also accept selectors to 101 + constrain already-indexed results. 104 102 105 - ```bash 106 - cxs sync 103 + ```text 104 + {"kind":"all","root":"/Users/you/.codex/sessions"} 105 + {"kind":"date_range","root":"/Users/you/.codex/sessions","fromDate":"2026-04-01","toDate":"2026-04-30"} 106 + {"kind":"cwd","root":"/Users/you/.codex/sessions","cwd":"/Users/you/work/project"} 107 + {"kind":"cwd_date_range","root":"/Users/you/.codex/sessions","cwd":"/Users/you/work/project","fromDate":"2026-04-01","toDate":"2026-04-30"} 107 108 ``` 108 109 109 - `sync` 默认是严格模式:任一文件解析或写库失败都会带着 per-file 诊断非零退出,并且不会提交半截索引。只有显式传 `--best-effort` 时,才会继续写入成功部分。 110 - 111 - 搜索会话: 110 + Example list query scoped to one project: 112 111 113 112 ```bash 114 - cxs find "health check" 113 + cxs list --selector '{"kind":"cwd","root":"/Users/you/.codex/sessions","cwd":"/Users/you/work/project"}' --sort ended -n 10 115 114 ``` 116 115 117 - `find` 会返回标题、派生的 session summary,以及当前锚点 snippet,方便先做轻量筛选再决定是否 `read-range`。如果命中只来自 session-level title/summary/compact/reasoning summary,结果会标为 `matchSource = "session"`,这时先用 `read-page` 浏览整场会话。 116 + ## Sync And Storage 118 117 119 - 围绕命中点读取局部上下文: 118 + By default, `cxs` reads Codex sessions from `~/.codex/sessions` and stores its 119 + index at: 120 120 121 - ```bash 122 - cxs read-range <sessionUuid> --seq 12 123 - cxs read-range <sessionUuid> --query "health check" 121 + ```text 122 + ~/.local/state/cxs/index.sqlite 124 123 ``` 125 124 126 - 分页读取整场会话: 125 + `$XDG_STATE_HOME` is respected, and `CXS_DATA_DIR` has the highest priority: 127 126 128 127 ```bash 129 - cxs read-page <sessionUuid> --offset 0 --limit 20 128 + export CXS_DATA_DIR="$HOME/.config/cxs" 130 129 ``` 131 130 132 - 列出已索引 session(不做全文检索): 131 + Sync is strict by default. If any selected file fails to parse or write, `sync` 132 + exits non-zero with per-file diagnostics and does not commit partial coverage. 133 + On success, strict sync reconciles the selected index slice to the current source 134 + snapshot: selected sessions whose source JSONL no longer exists are removed 135 + before complete coverage is written. 136 + Pass `--best-effort` only when you explicitly want successful files written 137 + despite failures; best-effort sync does not record complete coverage. 133 138 134 - ```bash 135 - cxs list --limit 20 136 - cxs list --cwd hammerspoon --since 2026-04-01 --sort ended 137 - ``` 139 + Indexes created before `cxs-v6-selector-provenance` should be refreshed with 140 + `sync --selector` so date selectors and read coverage use the current 141 + `path_date` and source-root provenance fields. 138 142 139 - 索引状态: 143 + Older `cxs <= 0.2.0` indexes stored under `~/.cache/cxs/` are migrated 144 + automatically on first run when the new state directory is empty. If the new 145 + directory already has data, migration is skipped and the old cache is left in 146 + place. 140 147 141 - ```bash 142 - cxs stats 143 - ``` 148 + ## Retrieval Model 144 149 145 - ## 快速开始 146 - 147 - 下面以已安装的 `cxs` 命令为例;源码 checkout 中可把 `cxs` 替换成 `npm run cxs --`。 148 - 149 - 首次使用建议按下面顺序: 150 - 151 - ```bash 152 - cxs sync 153 - cxs find "health check" 154 - cxs read-range <sessionUuid> --seq <matchSeq> 155 - ``` 150 + The current retrieval chain is: 156 151 157 - 如果你已经知道当前项目路径,也可以先缩范围: 158 - 159 - ```bash 160 - cxs list --cwd /Users/you/work/project --sort ended -n 10 152 + ```text 153 + message/session recall -> session heuristic rerank -> read-range/read-page 161 154 ``` 162 155 163 - ## 当前实现边界 156 + Implemented recall surfaces: 164 157 165 - 当前 retrieval 主链是: 158 + - `messages_fts` over real user and assistant messages. 159 + - `sessions_fts` over `title + summary_text + compact_text + reasoning_summary_text`. 160 + - Session-level FTS weights: title `8.0`, compact `4.0`, summary `3.0`, reasoning summary `1.2`. 161 + - A small LIKE fallback for rare zero-token CJK message queries. 166 162 167 - `message/session recall -> session heuristic rerank -> read-range/read-page` 163 + `find` returns enough context to choose the next read step. If a result only 164 + matches session-level fields, it is marked with `matchSource = "session"` and 165 + has no message anchor; use `read-page` for those results. 168 166 169 - 已经落地的能力: 167 + Not implemented yet: 170 168 171 - - `messages_fts` 驱动的候选召回 172 - - `sessions_fts(title + summary_text + compact_text + reasoning_summary_text)` 驱动的 session-level 召回 173 - - `summary_text` 派生摘要 174 - - JSONL `type=compacted` handoff 与 `response_item.reasoning.summary` 低成本接入 175 - - session-level FTS 字段权重:title 8.0、compact 4.0、summary 3.0、reasoning summary 1.2 176 - - session 级 heuristic rerank 177 - - manual eval 导出与 batch compare 178 - 179 - 还没落地的能力: 169 + - Resource-level reranking. 170 + - Richer projection or event replay. 171 + - Range cache. 172 + - Duplicate-family collapse or diversity control. 173 + - Strong gold-set acceptance suite. 180 174 181 - - 真正的 resource-level reranker 182 - - duplicate collapse / diversity control 175 + More detail: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Roadmap: 176 + [docs/ROADMAP.md](docs/ROADMAP.md). 183 177 184 - 更完整的实现说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md),后续路线见 [docs/ROADMAP.md](docs/ROADMAP.md)。 178 + ## Development 185 179 186 - ## 常见问题 187 - 188 - ### 为什么 `find` 没搜到我刚刚的 session? 189 - 190 - 先看索引时间: 180 + Run from source: 191 181 192 182 ```bash 193 - cxs stats --json 183 + git clone https://github.com/catoncat/cxs.git 184 + cd cxs 185 + npm install 186 + npm run cxs -- --version 194 187 ``` 195 188 196 - 如果 `lastSyncAt` 很旧,先重新同步: 197 - 198 - ```bash 199 - cxs sync 200 - ``` 201 - 202 - ### 为什么有些中文短 query 命中不稳定? 203 - 204 - 当前主召回仍以 message FTS 为主,极少数零 token CJK query 才会回退到 LIKE。短 query 本身信息量低,建议换成更长的词组或加项目上下文。 205 - 206 - ### 为什么不做自动实时同步? 207 - 208 - 这是刻意的产品边界。当前接受“手动触发的增量 sync”,而不是 watcher/daemon。 209 - 210 - ## 开发 211 - 212 - 运行测试: 189 + Common checks: 213 190 214 191 ```bash 215 192 npm run check 216 - ``` 217 - 218 - 跑手工评测: 219 - 220 - ```bash 221 193 npm run eval:manual 194 + npm run eval:compare -- data/cxs-eval/<before-batch> data/cxs-eval/<after-batch> 222 195 ``` 223 196 224 - `eval:manual` 的 pass 判定现在采用 “Top-K 窗口内所有已配置 predicate 都必须命中” 的语义,并会把 predicate 级别结果写进导出 README/scorecard,避免单个弱命中把整条 query 误记为通过。 197 + `npm run check` runs TypeScript and Vitest. `eval:manual` exports manual eval 198 + results; `eval:compare` compares two eval batches. 225 199 226 - 对比两次评测批次的 Top1 变化: 200 + Project rules and contribution notes: 227 201 228 - ```bash 229 - npm run eval:compare -- data/cxs-eval/<before-batch> data/cxs-eval/<after-batch> 230 - ``` 231 - 232 - ## 开源协作 233 - 234 - - 项目规则见 [AGENTS.md](AGENTS.md) 235 - - 协作说明见 [CONTRIBUTING.md](CONTRIBUTING.md) 236 - - 当前公开目标是“可接手、可验证、可继续演进”的源码仓库;发布流程以 npm 包为唯一分发面 202 + - [AGENTS.md](AGENTS.md) 203 + - [CONTRIBUTING.md](CONTRIBUTING.md) 237 204 238 - ## 可安装 Skill Package 205 + ## Agent Skill Package 239 206 240 - 仓库内保留一个发行用 skill package,刻意不放在 `.agents/skills` 下,避免 clone 本仓库后被当前项目的 agent runtime 当成本项目 workflow 自动加载: 207 + This repository also publishes an installable agent skill package: 241 208 242 - - `skill-packages/cxs` 209 + ```text 210 + skill-packages/cxs 211 + ``` 243 212 244 - 推荐用 `npx skills add` 安装,而不是手动复制: 213 + Install or update it with: 245 214 246 215 ```bash 247 216 npx skills add catoncat/cxs --full-depth --skill cxs -g -a codex -y 248 217 ``` 249 218 250 - 如果只想先看仓库里有哪些 skill: 219 + List available skills in the repository: 251 220 252 221 ```bash 253 222 npx skills add catoncat/cxs --full-depth --list 254 223 ``` 255 224 256 - CLI install guide: 257 - 258 - `https://github.com/catoncat/cxs#cli-install-guide` 225 + Important boundaries: 259 226 260 - 注意: 261 - 262 - - `npx skills add` 只安装 agent skill,不安装 CLI 本体 263 - - 安装或更新 skill 后,需要重启 Codex / 开新 session 才会被 agent 发现 264 - - 推荐先按 CLI install guide 让 `cxs` 可执行,或设置 `CXS_BIN=/absolute/path/to/bin/cxs` 227 + - `npx skills add` installs the agent skill only; it does not install the `cxs` CLI. 228 + - Install the CLI with `npm i -g @act0r/cxs`, use `npx @act0r/cxs@latest`, or set `CXS_BIN` for the skill. 229 + - Restart Codex or open a new session after installing or updating the skill.
+101 -74
cli.test.ts
··· 24 24 }); 25 25 26 26 describe("cxs cli", () => { 27 - test("help only shows current/sync/find/read-range/read-page/list/stats", async () => { 27 + test("help only shows status/sync/find/read-range/read-page/list/stats", async () => { 28 28 const result = await runCli(["--help"]); 29 29 expect(result.exitCode).toBe(0); 30 - expect(result.stdout).toContain("current"); 30 + expect(result.stdout).toContain("status"); 31 31 expect(result.stdout).toContain("sync"); 32 32 expect(result.stdout).toContain("find"); 33 33 expect(result.stdout).toContain("read-range"); 34 34 expect(result.stdout).toContain("read-page"); 35 35 expect(result.stdout).toContain("list"); 36 36 expect(result.stdout).toContain("stats"); 37 + expect(result.stdout).not.toContain("current"); 37 38 expect(result.stdout).not.toContain("window"); 38 39 expect(result.stdout).not.toContain("\n session "); 39 40 }); 40 41 41 - test("current returns candidate sessions for cwd from state db", async () => { 42 - const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-")); 42 + test("status returns source inventory without an index", async () => { 43 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-status-")); 43 44 tempDirs.push(base); 44 - const stateDbPath = join(base, "state.sqlite"); 45 - const db = new Database(stateDbPath); 46 - db.exec(` 47 - CREATE TABLE threads ( 48 - id TEXT PRIMARY KEY, 49 - rollout_path TEXT NOT NULL, 50 - cwd TEXT NOT NULL, 51 - title TEXT NOT NULL, 52 - updated_at_ms INTEGER 53 - ) 54 - `); 55 - const insertThread = db.prepare( 56 - "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 45 + const sessionsRoot = join(base, "sessions", "2026", "04", "20"); 46 + mkdirSync(sessionsRoot, { recursive: true }); 47 + writeFileSync( 48 + join(sessionsRoot, "rollout-2026-04-20T10-00-00-11111111-1111-4111-8111-111111111111.jsonl"), 49 + [ 50 + line("session_meta", { id: "11111111-1111-4111-8111-111111111111", cwd: "/tmp/alpha" }), 51 + line("event_msg", { type: "user_message", message: "alpha private content must not be needed" }), 52 + ].join("\n"), 57 53 ); 58 - insertThread.run("aaaa1111-1111-4111-8111-111111111111", "/tmp/one.jsonl", "/tmp/picc", "older", 100); 59 - insertThread.run("bbbb2222-2222-4222-8222-222222222222", "/tmp/two.jsonl", "/tmp/picc", "newer", 200); 60 - db.close(); 61 54 62 55 const result = await runCli([ 63 - "current", 64 - "--cwd", 65 - "/tmp/picc", 66 - "--state-db", 67 - stateDbPath, 56 + "status", 57 + "--root", 58 + join(base, "sessions"), 59 + "--db", 60 + join(base, "missing.sqlite"), 68 61 "--json", 69 62 ]); 70 63 expect(result.exitCode).toBe(0); 71 64 const payload = JSON.parse(result.stdout) as { 72 - cwd: string; 73 - candidates: Array<{ sessionUuid: string; filePath: string }>; 65 + sourceInventory: { 66 + totalFiles: number; 67 + pathDateRange: { from: string | null; to: string | null }; 68 + cwdGroups: Array<{ cwd: string; fileCount: number; pathDateRange: { from: string | null; to: string | null } }>; 69 + }; 70 + index: { exists: boolean }; 74 71 }; 75 - expect(payload.cwd).toBe("/tmp/picc"); 76 - expect(payload.candidates.map((candidate) => candidate.sessionUuid)).toEqual([ 77 - "bbbb2222-2222-4222-8222-222222222222", 78 - "aaaa1111-1111-4111-8111-111111111111", 72 + expect(payload.index.exists).toBe(false); 73 + expect(payload.sourceInventory.totalFiles).toBe(1); 74 + expect(payload.sourceInventory.pathDateRange).toEqual({ from: "2026-04-20", to: "2026-04-20" }); 75 + expect(payload.sourceInventory.cwdGroups).toEqual([ 76 + { cwd: "/tmp/alpha", fileCount: 1, pathDateRange: { from: "2026-04-20", to: "2026-04-20" } }, 79 77 ]); 80 - expect(payload.candidates[0]?.filePath).toBe("/tmp/two.jsonl"); 81 78 }); 82 79 83 - test("current --json emits structured error when state db file is missing", async () => { 84 - const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-missing-")); 80 + test("sync requires an explicit selector", async () => { 81 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-sync-selector-required-")); 85 82 tempDirs.push(base); 86 - const stateDbPath = join(base, "does-not-exist.sqlite"); 87 83 88 - const result = await runCli(["current", "--state-db", stateDbPath, "--json"]); 84 + const result = await runCli(["sync", "--db", join(base, "index.sqlite"), "--json"]); 89 85 expect(result.exitCode).toBe(1); 90 86 const payload = JSON.parse(result.stdout) as { 91 87 error: { code: string; message: string }; 92 88 }; 93 - expect(payload.error.code).toBe("state_db_unavailable"); 94 - expect(payload.error.message).toContain(stateDbPath); 89 + expect(payload.error.code).toBe("selector_required"); 90 + expect(payload.error.message).toContain("--selector"); 95 91 }); 96 92 97 - test("current --json emits structured error when state db schema is unexpected", async () => { 98 - const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-schema-")); 93 + test("sync with cwd selector writes coverage and find stays scoped", async () => { 94 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-selector-")); 99 95 tempDirs.push(base); 100 - const stateDbPath = join(base, "state.sqlite"); 101 - const db = new Database(stateDbPath); 102 - db.exec("CREATE TABLE other (id INTEGER PRIMARY KEY)"); 103 - db.close(); 96 + const root = join(base, "sessions"); 97 + const day = join(root, "2026", "04", "21"); 98 + mkdirSync(day, { recursive: true }); 99 + writeFileSync( 100 + join(day, "rollout-2026-04-21T10-00-00-22222222-2222-4222-8222-222222222222.jsonl"), 101 + [ 102 + line("session_meta", { id: "22222222-2222-4222-8222-222222222222", cwd: "/tmp/alpha" }), 103 + line("event_msg", { type: "user_message", message: "shared needle alpha" }), 104 + ].join("\n"), 105 + ); 106 + writeFileSync( 107 + join(day, "rollout-2026-04-21T11-00-00-33333333-3333-4333-8333-333333333333.jsonl"), 108 + [ 109 + line("session_meta", { id: "33333333-3333-4333-8333-333333333333", cwd: "/tmp/beta" }), 110 + line("event_msg", { type: "user_message", message: "shared needle beta" }), 111 + ].join("\n"), 112 + ); 104 113 105 - const result = await runCli(["current", "--state-db", stateDbPath, "--json"]); 106 - expect(result.exitCode).toBe(1); 107 - const payload = JSON.parse(result.stdout) as { 108 - error: { code: string; message: string }; 114 + const dbPath = join(base, "index.sqlite"); 115 + const selector = JSON.stringify({ kind: "cwd", root, cwd: "/tmp/alpha" }); 116 + const synced = await runCli(["sync", "--selector", selector, "--db", dbPath, "--json"]); 117 + expect(synced.exitCode).toBe(0); 118 + const syncPayload = JSON.parse(synced.stdout) as { coverage: { written: boolean; selector: { kind: string; cwd?: string } } }; 119 + expect(syncPayload.coverage.written).toBe(true); 120 + expect(syncPayload.coverage.selector).toMatchObject({ kind: "cwd", cwd: "/tmp/alpha" }); 121 + 122 + const found = await runCli(["find", "shared needle", "--selector", selector, "--db", dbPath, "--json"]); 123 + expect(found.exitCode).toBe(0); 124 + const findPayload = JSON.parse(found.stdout) as { 125 + results: Array<{ sessionUuid: string; cwd: string }>; 126 + coverage: { complete: boolean; freshness: string }; 109 127 }; 110 - expect(payload.error.code).toBe("state_db_unavailable"); 111 - expect(payload.error.message).toContain("threads"); 128 + expect(findPayload.coverage.complete).toBe(true); 129 + expect(findPayload.coverage.freshness).toBe("not_checked"); 130 + expect(findPayload.results.map((result) => result.cwd)).toEqual(["/tmp/alpha"]); 112 131 }); 113 132 114 - test("current --json emits structured error when 'threads' is missing required columns", async () => { 115 - const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-cols-")); 133 + test("status marks coverage stale when selected source files change", async () => { 134 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-coverage-stale-")); 116 135 tempDirs.push(base); 117 - const stateDbPath = join(base, "state.sqlite"); 118 - const db = new Database(stateDbPath); 119 - db.exec(` 120 - CREATE TABLE threads ( 121 - id TEXT PRIMARY KEY, 122 - cwd TEXT NOT NULL, 123 - title TEXT NOT NULL 124 - ) 125 - `); 126 - db.close(); 136 + const root = join(base, "sessions"); 137 + const day = join(root, "2026", "04", "21"); 138 + mkdirSync(day, { recursive: true }); 139 + const filePath = join(day, "rollout-2026-04-21T10-00-00-12121212-1212-4212-8212-121212121212.jsonl"); 140 + writeFileSync( 141 + filePath, 142 + [ 143 + line("session_meta", { id: "12121212-1212-4212-8212-121212121212", cwd: "/tmp/stale" }), 144 + line("event_msg", { type: "user_message", message: "stale before" }), 145 + ].join("\n"), 146 + ); 127 147 128 - const result = await runCli(["current", "--state-db", stateDbPath, "--json"]); 129 - expect(result.exitCode).toBe(1); 130 - const payload = JSON.parse(result.stdout) as { 131 - error: { code: string; message: string }; 132 - }; 133 - expect(payload.error.code).toBe("state_db_unavailable"); 134 - expect(payload.error.message).toContain("rollout_path"); 135 - // Crucially, raw SQLite errors should never reach stdout — exit 1 with a 136 - // structured payload is the contract. 137 - expect(result.stdout).not.toContain("SQLiteError"); 148 + const dbPath = join(base, "index.sqlite"); 149 + const selector = JSON.stringify({ kind: "cwd", root, cwd: "/tmp/stale" }); 150 + const synced = await runCli(["sync", "--selector", selector, "--db", dbPath, "--json"]); 151 + expect(synced.exitCode).toBe(0); 152 + 153 + writeFileSync( 154 + filePath, 155 + [ 156 + line("session_meta", { id: "12121212-1212-4212-8212-121212121212", cwd: "/tmp/stale" }), 157 + line("event_msg", { type: "user_message", message: "stale after" }), 158 + ].join("\n"), 159 + ); 160 + 161 + const status = await runCli(["status", "--root", root, "--db", dbPath, "--json"]); 162 + expect(status.exitCode).toBe(0); 163 + const payload = JSON.parse(status.stdout) as { coverage: Array<{ freshness: string }> }; 164 + expect(payload.coverage[0]?.freshness).toBe("stale"); 138 165 }); 139 166 140 167 test("find text output points to read-range", async () => { ··· 348 375 349 376 const result = await runCli([ 350 377 "sync", 351 - "--root", 352 - join(base, "sessions"), 378 + "--selector", 379 + JSON.stringify({ kind: "all", root: join(base, "sessions") }), 353 380 "--db", 354 381 join(base, "index.sqlite"), 355 382 ]);
+43 -35
cli.ts
··· 1 - import { existsSync } from "node:fs"; 2 1 import { Command } from "commander"; 3 2 import packageJson from "./package.json" with { type: "json" }; 4 3 import { 5 - DEFAULT_CODEX_STATE_DB_PATH, 6 4 DEFAULT_DB_PATH, 7 5 migrateLegacyCacheDirIfNeeded, 8 - resolveCodexDir, 9 6 } from "./env"; 10 7 import { IndexUnavailableError } from "./db"; 11 8 ··· 14 11 // `cxs sync`. Idempotent + silent on failure (worst case is a re-sync). 15 12 migrateLegacyCacheDirIfNeeded(); 16 13 import { 17 - printCurrentSessions, 18 14 printFindResults, 19 15 printReadPage, 20 16 printReadRangeResult, 21 17 printSessionList, 22 18 printStats, 19 + printStatus, 23 20 printSyncSummary, 24 21 } from "./format"; 25 22 import { SyncError, syncSessions } from "./indexer"; 26 23 import { 27 24 collectStats, 28 - CurrentStateDbError, 29 25 findSessions, 30 - getCurrentSessions, 31 26 getMessagePage, 32 27 getMessageRange, 33 28 listSessionSummaries, 34 29 } from "./query"; 30 + import { parseSelectorJson, SelectorParseError } from "./selector"; 31 + import { collectStatus } from "./status"; 35 32 import { SyncLockTimeoutError } from "./sync-lock"; 36 - import type { SessionListSort } from "./types"; 33 + import type { Selector, SessionListSort } from "./types"; 37 34 38 35 const program = new Command(); 39 36 ··· 43 40 .version(packageJson.version); 44 41 45 42 program 46 - .command("current") 47 - .description("按 cwd 返回当前候选 session,不做全文检索") 48 - .option("--cwd <path>", "显式指定 cwd,默认当前工作目录") 49 - .option("-n, --limit <n>", "返回条数上限", "100") 50 - .option("--state-db <path>", "覆盖默认 Codex state SQLite 路径", DEFAULT_CODEX_STATE_DB_PATH) 43 + .command("status") 44 + .description("返回执行上下文、source inventory、index 与 coverage 状态") 45 + .option("--root <dir>", "覆盖默认 sessions 根目录") 46 + .option("--db <path>", "覆盖默认数据库路径", DEFAULT_DB_PATH) 51 47 .option("--json", "输出 JSON") 52 48 .action((options) => { 53 - const cwd = options.cwd ?? process.cwd(); 54 - const jsonMode = Boolean(options.json); 55 - try { 56 - if (!existsSync(options.stateDb)) { 57 - throw new CurrentStateDbError(`state db not found: ${options.stateDb}`); 58 - } 59 - const result = getCurrentSessions(options.stateDb, cwd, parsePositiveInt(options.limit, 100)); 60 - if (jsonMode) { 61 - console.log(JSON.stringify(result, null, 2)); 62 - return; 63 - } 64 - printCurrentSessions(result.cwd, result.candidates); 65 - } catch (error) { 66 - if (error instanceof CurrentStateDbError) { 67 - emitCurrentError(error, jsonMode); 68 - return; 69 - } 70 - throw error; 49 + const status = collectStatus({ rootDir: options.root, dbPath: options.db, cwd: process.cwd() }); 50 + if (options.json) { 51 + console.log(JSON.stringify(status, null, 2)); 52 + return; 71 53 } 54 + printStatus(status); 72 55 }); 73 56 74 57 program 75 58 .command("sync") 76 59 .description("扫描并同步本地 Codex sessions 到 SQLite 索引") 77 - .option("--root <dir>", "覆盖默认 sessions 根目录") 60 + .option("--selector <json>", "结构化同步范围 JSON") 78 61 .option("--db <path>", "覆盖默认数据库路径", DEFAULT_DB_PATH) 79 62 .option("--best-effort", "即使部分文件失败也继续写入可成功部分") 80 63 .option("--json", "输出 JSON") 81 64 .action(async (options) => { 82 65 try { 66 + const selector = requireSelector(options.selector); 83 67 const summary = await syncSessions({ 84 68 dbPath: options.db, 85 - rootDir: resolveCodexDir(options.root), 69 + selector, 86 70 bestEffort: options.bestEffort, 87 71 }); 88 72 if (options.json) { ··· 109 93 process.exitCode = 1; 110 94 return; 111 95 } 96 + if (error instanceof SelectorParseError) { 97 + emitSelectorError(error, Boolean(options.json)); 98 + return; 99 + } 112 100 throw error; 113 101 } 114 102 }); ··· 117 105 .command("find <query>") 118 106 .description("搜索相关 session,返回最小必要命中") 119 107 .option("-n, --limit <n>", "返回条数", "10") 108 + .option("--selector <json>", "结构化查询范围 JSON") 120 109 .option("--db <path>", "覆盖默认数据库路径", DEFAULT_DB_PATH) 121 110 .option("--json", "输出 JSON") 122 111 .action((query, options) => { 123 112 runReadCommand(Boolean(options.json), () => { 124 113 const limit = parsePositiveInt(options.limit, 10); 125 - const result = findSessions(options.db, query, limit); 114 + const selector = optionalSelector(options.selector); 115 + const result = findSessions(options.db, query, limit, selector); 126 116 if (options.json) { 127 117 console.log(JSON.stringify(result, null, 2)); 128 118 return; ··· 197 187 .description("列出已索引的 session(不做全文检索)") 198 188 .option("--cwd <needle>", "cwd 子串过滤(大小写不敏感)") 199 189 .option("--since <iso>", "只看 ended_at >= 指定时间的 session") 190 + .option("--selector <json>", "结构化查询范围 JSON") 200 191 .option("--sort <key>", "排序键:ended|started|messages", "ended") 201 192 .option("-n, --limit <n>", "返回条数", "20") 202 193 .option("--db <path>", "覆盖默认数据库路径", DEFAULT_DB_PATH) ··· 204 195 .action((options) => { 205 196 runReadCommand(Boolean(options.json), () => { 206 197 const sort = normalizeListSort(options.sort); 198 + const selector = optionalSelector(options.selector); 207 199 const result = listSessionSummaries(options.db, { 208 200 cwd: options.cwd, 209 201 since: options.since, 202 + selector: selector ?? undefined, 210 203 sort, 211 204 limit: parsePositiveInt(options.limit, 20), 212 205 }); ··· 264 257 emitIndexUnavailableError(error, jsonMode); 265 258 return; 266 259 } 260 + if (error instanceof SelectorParseError) { 261 + emitSelectorError(error, jsonMode); 262 + return; 263 + } 267 264 throw error; 268 265 } 269 266 } ··· 292 289 process.exitCode = 1; 293 290 } 294 291 295 - function emitCurrentError(error: CurrentStateDbError, jsonMode: boolean): void { 292 + function emitSelectorError(error: SelectorParseError, jsonMode: boolean): void { 296 293 if (jsonMode) { 297 294 console.log( 298 295 JSON.stringify( 299 - { error: { code: "state_db_unavailable", message: error.message } }, 296 + { error: { code: error.message.includes("requires --selector") ? "selector_required" : "invalid_selector", message: error.message } }, 300 297 null, 301 298 2, 302 299 ), ··· 306 303 } 307 304 process.exitCode = 1; 308 305 } 306 + 307 + function requireSelector(value: string | undefined): Selector { 308 + if (!value) { 309 + throw new SelectorParseError("sync requires --selector with an explicit selector JSON object"); 310 + } 311 + return parseSelectorJson(value); 312 + } 313 + 314 + function optionalSelector(value: string | undefined): Selector | null { 315 + return value ? parseSelectorJson(value) : null; 316 + }
+237 -5
db.ts
··· 1 1 import { existsSync } from "node:fs"; 2 2 import Database from "better-sqlite3"; 3 3 import { tokenizedText } from "./tokenize"; 4 + import { INDEX_VERSION } from "./env"; 4 5 import type { 6 + CoverageRecord, 5 7 CwdCount, 6 8 MessageRecord, 7 9 ParsedSession, 10 + Selector, 8 11 SessionListEntry, 9 12 SessionListQuery, 10 13 SessionRecord, 11 14 } from "./types"; 15 + import { selectorImplies, selectorStorageKey } from "./selector"; 12 16 13 17 type Db = Database.Database; 14 18 type SqlParams = unknown[]; ··· 63 67 id INTEGER PRIMARY KEY AUTOINCREMENT, 64 68 session_uuid TEXT NOT NULL UNIQUE, 65 69 file_path TEXT NOT NULL UNIQUE, 70 + source_root TEXT NOT NULL DEFAULT '', 66 71 title TEXT NOT NULL DEFAULT '', 67 72 summary_text TEXT NOT NULL DEFAULT '', 68 73 compact_text TEXT NOT NULL DEFAULT '', ··· 71 76 model TEXT NOT NULL DEFAULT '', 72 77 started_at TEXT NOT NULL, 73 78 ended_at TEXT NOT NULL, 79 + path_date TEXT NOT NULL DEFAULT '', 74 80 message_count INTEGER NOT NULL DEFAULT 0, 75 81 raw_file_mtime INTEGER NOT NULL DEFAULT 0, 76 82 raw_file_size INTEGER NOT NULL DEFAULT 0, ··· 82 88 ensureTextColumn(db, "sessions", "summary_text"); 83 89 ensureTextColumn(db, "sessions", "compact_text"); 84 90 ensureTextColumn(db, "sessions", "reasoning_summary_text"); 91 + ensureTextColumn(db, "sessions", "path_date"); 92 + ensureTextColumn(db, "sessions", "source_root"); 85 93 86 94 db.exec(` 87 95 CREATE TABLE IF NOT EXISTS messages ( ··· 112 120 `); 113 121 114 122 ensureSessionsFtsTable(db); 123 + ensureCoverageTable(db); 115 124 116 125 dropLegacyTrigramTable(db); 126 + } 127 + 128 + function ensureCoverageTable(db: Db): void { 129 + db.exec(` 130 + CREATE TABLE IF NOT EXISTS coverage ( 131 + id INTEGER PRIMARY KEY AUTOINCREMENT, 132 + selector_key TEXT NOT NULL UNIQUE, 133 + selector_json TEXT NOT NULL, 134 + selector_kind TEXT NOT NULL, 135 + root TEXT NOT NULL, 136 + cwd TEXT, 137 + from_date TEXT, 138 + to_date TEXT, 139 + source_fingerprint TEXT NOT NULL, 140 + source_file_count INTEGER NOT NULL, 141 + indexed_session_count INTEGER NOT NULL, 142 + completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 143 + index_version TEXT NOT NULL 144 + ) 145 + `); 146 + 147 + db.exec("CREATE INDEX IF NOT EXISTS idx_coverage_root ON coverage(root)"); 117 148 } 118 149 119 150 function dropLegacyTrigramTable(db: Db): void { ··· 190 221 rawFileMtime: number, 191 222 rawFileSize: number, 192 223 indexVersion: string, 224 + pathDate: string, 225 + sourceRoot = sessionRootFromFile(session.filePath), 193 226 ): void { 194 227 const tx = db.transaction(() => { 195 228 const existing = db ··· 200 233 db.prepare( 201 234 ` 202 235 UPDATE sessions 203 - SET session_uuid = ?, file_path = ?, title = ?, summary_text = ?, compact_text = ?, reasoning_summary_text = ?, 204 - cwd = ?, model = ?, started_at = ?, ended_at = ?, 236 + SET session_uuid = ?, file_path = ?, source_root = ?, title = ?, summary_text = ?, compact_text = ?, reasoning_summary_text = ?, 237 + cwd = ?, model = ?, started_at = ?, ended_at = ?, path_date = ?, 205 238 message_count = ?, raw_file_mtime = ?, raw_file_size = ?, index_version = ?, updated_at = CURRENT_TIMESTAMP 206 239 WHERE id = ? 207 240 `, 208 241 ).run( 209 242 session.sessionUuid, 210 243 session.filePath, 244 + sourceRoot, 211 245 session.title, 212 246 session.summaryText, 213 247 session.compactText ?? "", ··· 216 250 session.model, 217 251 session.startedAt, 218 252 session.endedAt, 253 + pathDate, 219 254 session.messages.length, 220 255 rawFileMtime, 221 256 rawFileSize, ··· 226 261 db.prepare( 227 262 ` 228 263 INSERT INTO sessions ( 229 - session_uuid, file_path, title, summary_text, compact_text, reasoning_summary_text, 230 - cwd, model, started_at, ended_at, 264 + session_uuid, file_path, source_root, title, summary_text, compact_text, reasoning_summary_text, 265 + cwd, model, started_at, ended_at, path_date, 231 266 message_count, raw_file_mtime, raw_file_size, index_version 232 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 267 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 233 268 `, 234 269 ).run( 235 270 session.sessionUuid, 236 271 session.filePath, 272 + sourceRoot, 237 273 session.title, 238 274 session.summaryText, 239 275 session.compactText ?? "", ··· 242 278 session.model, 243 279 session.startedAt, 244 280 session.endedAt, 281 + pathDate, 245 282 session.messages.length, 246 283 rawFileMtime, 247 284 rawFileSize, ··· 314 351 SELECT 315 352 session_uuid AS sessionUuid, 316 353 file_path AS filePath, 354 + source_root AS sourceRoot, 317 355 title, 318 356 summary_text AS summaryText, 319 357 cwd, 320 358 model, 321 359 started_at AS startedAt, 322 360 ended_at AS endedAt, 361 + path_date AS pathDate, 323 362 message_count AS messageCount 324 363 FROM sessions 325 364 WHERE session_uuid = ? ··· 388 427 export function listSessions(db: Db, query: SessionListQuery): SessionListEntry[] { 389 428 const conditions: string[] = []; 390 429 const params: SqlParams = []; 430 + if (query.selector) { 431 + const selectorWhere = selectorWhereSql(query.selector, "sessions"); 432 + conditions.push(...selectorWhere.conditions); 433 + params.push(...selectorWhere.params); 434 + } 391 435 if (query.cwd) { 392 436 // Substring match rather than prefix/equality: agent callers often pass 393 437 // the trailing segment of a project path, not the full canonical path. ··· 417 461 cwd, 418 462 started_at AS startedAt, 419 463 ended_at AS endedAt, 464 + path_date AS pathDate, 420 465 message_count AS messageCount 421 466 FROM sessions 422 467 ${where} ··· 464 509 LIMIT ? 465 510 `) 466 511 .all(limit) as CwdCount[]; 512 + } 513 + 514 + export function replaceCoverage( 515 + db: Db, 516 + selector: Selector, 517 + sourceFingerprint: string, 518 + sourceFileCount: number, 519 + indexedSessionCount: number, 520 + indexVersion: string, 521 + ): CoverageRecord { 522 + const key = selectorStorageKey(selector); 523 + const stmt = db.prepare(` 524 + INSERT INTO coverage ( 525 + selector_key, selector_json, selector_kind, root, cwd, from_date, to_date, 526 + source_fingerprint, source_file_count, indexed_session_count, index_version 527 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 528 + ON CONFLICT(selector_key) DO UPDATE SET 529 + selector_json = excluded.selector_json, 530 + selector_kind = excluded.selector_kind, 531 + root = excluded.root, 532 + cwd = excluded.cwd, 533 + from_date = excluded.from_date, 534 + to_date = excluded.to_date, 535 + source_fingerprint = excluded.source_fingerprint, 536 + source_file_count = excluded.source_file_count, 537 + indexed_session_count = excluded.indexed_session_count, 538 + completed_at = CURRENT_TIMESTAMP, 539 + index_version = excluded.index_version 540 + `); 541 + stmt.run( 542 + key, 543 + JSON.stringify(selector), 544 + selector.kind, 545 + selector.root, 546 + "cwd" in selector ? selector.cwd : null, 547 + "fromDate" in selector ? selector.fromDate : null, 548 + "toDate" in selector ? selector.toDate : null, 549 + sourceFingerprint, 550 + sourceFileCount, 551 + indexedSessionCount, 552 + indexVersion, 553 + ); 554 + return getCoverageRecordByKey(db, key)!; 555 + } 556 + 557 + export function listCoverageRecords(db: Db): CoverageRecord[] { 558 + if (!tableExists(db, "coverage")) return []; 559 + const rows = db.prepare("SELECT * FROM coverage ORDER BY completed_at DESC, id DESC").all() as CoverageRow[]; 560 + return rows.map(rowToCoverageRecord); 561 + } 562 + 563 + export function coverageStatusForSelector(db: Db, requested: Selector | null): { 564 + complete: boolean; 565 + coveringSelectors: CoverageRecord[]; 566 + } { 567 + if (!requested) return { complete: false, coveringSelectors: [] }; 568 + const entries = listCoverageRecords(db).filter((entry) => 569 + entry.indexVersion === requestedIndexVersion(db) && selectorImplies(entry.selector, requested) 570 + ); 571 + return { 572 + complete: entries.length > 0, 573 + coveringSelectors: entries, 574 + }; 575 + } 576 + 577 + export function countSessionsForSelector(db: Db, selector: Selector): number { 578 + const where = selectorWhereSql(selector, "sessions"); 579 + const row = db 580 + .prepare<typeof where.params, { count: number }>(` 581 + SELECT COUNT(*) AS count 582 + FROM sessions 583 + WHERE ${where.conditions.join(" AND ")} 584 + `) 585 + .get(...where.params) as { count: number }; 586 + return row.count; 587 + } 588 + 589 + export function deleteSessionsForSelectorExceptFilePaths( 590 + db: Db, 591 + selector: Selector, 592 + retainedFilePaths: Set<string>, 593 + ): number { 594 + const where = selectorWhereSql(selector, "sessions"); 595 + const params = [...where.params]; 596 + const retained = [...retainedFilePaths]; 597 + const retainedClause = retained.length > 0 598 + ? ` AND sessions.file_path NOT IN (${retained.map(() => "?").join(", ")})` 599 + : ""; 600 + params.push(...retained); 601 + const rows = db 602 + .prepare(` 603 + SELECT session_uuid AS sessionUuid 604 + FROM sessions 605 + WHERE ${where.conditions.join(" AND ")}${retainedClause} 606 + `) 607 + .all(...params) as Array<{ sessionUuid: string }>; 608 + 609 + for (const row of rows) { 610 + deleteSessionByUuid(db, row.sessionUuid); 611 + } 612 + return rows.length; 613 + } 614 + 615 + export function selectorWhereSql(selector: Selector, alias: string): { conditions: string[]; params: SqlParams } { 616 + const conditions = [`(${alias}.file_path = ? OR ${alias}.file_path LIKE ? ESCAPE '\\')`]; 617 + const params: SqlParams = [selector.root, `${escapeLike(selector.root)}/%`]; 618 + if (selector.kind === "cwd" || selector.kind === "cwd_date_range") { 619 + conditions.push(`${alias}.cwd = ?`); 620 + params.push(selector.cwd); 621 + } 622 + if (selector.kind === "date_range" || selector.kind === "cwd_date_range") { 623 + conditions.push(`${alias}.path_date >= ?`); 624 + conditions.push(`${alias}.path_date <= ?`); 625 + params.push(selector.fromDate, selector.toDate); 626 + } 627 + return { conditions, params }; 628 + } 629 + 630 + export function coverageEntriesForSession(db: Db, session: SessionRecord): CoverageRecord[] { 631 + const root = session.sourceRoot || sessionRootFromFile(session.filePath); 632 + const sessionSelectors: Selector[] = [ 633 + { kind: "all", root }, 634 + { kind: "cwd", root, cwd: session.cwd }, 635 + ]; 636 + if (session.pathDate) { 637 + sessionSelectors.push({ 638 + kind: "date_range", 639 + root, 640 + fromDate: session.pathDate, 641 + toDate: session.pathDate, 642 + }); 643 + sessionSelectors.push({ 644 + kind: "cwd_date_range", 645 + root, 646 + cwd: session.cwd, 647 + fromDate: session.pathDate, 648 + toDate: session.pathDate, 649 + }); 650 + } 651 + return listCoverageRecords(db).filter((entry) => 652 + sessionSelectors.some((selector) => selectorImplies(entry.selector, selector)) 653 + ); 654 + } 655 + 656 + type CoverageRow = { 657 + id: number; 658 + selector_json: string; 659 + source_fingerprint: string; 660 + source_file_count: number; 661 + indexed_session_count: number; 662 + completed_at: string; 663 + index_version: string; 664 + }; 665 + 666 + function getCoverageRecordByKey(db: Db, key: string): CoverageRecord | null { 667 + const row = db.prepare<[string], CoverageRow>("SELECT * FROM coverage WHERE selector_key = ? LIMIT 1").get(key); 668 + return row ? rowToCoverageRecord(row) : null; 669 + } 670 + 671 + function rowToCoverageRecord(row: CoverageRow): CoverageRecord { 672 + return { 673 + id: row.id, 674 + selector: JSON.parse(row.selector_json) as Selector, 675 + sourceFingerprint: row.source_fingerprint, 676 + sourceFileCount: row.source_file_count, 677 + indexedSessionCount: row.indexed_session_count, 678 + completedAt: row.completed_at, 679 + indexVersion: row.index_version, 680 + }; 681 + } 682 + 683 + function tableExists(db: Db, tableName: string): boolean { 684 + const row = db.prepare<[string], unknown>("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1").get(tableName); 685 + return Boolean(row); 686 + } 687 + 688 + function requestedIndexVersion(_db: Db): string { 689 + // Kept as a function so coverage matching has one place for future index 690 + // compatibility policy; current policy is exact index version equality. 691 + return INDEX_VERSION; 692 + } 693 + 694 + function sessionRootFromFile(filePath: string): string { 695 + const marker = "/sessions/"; 696 + const index = filePath.indexOf(marker); 697 + if (index >= 0) return filePath.slice(0, index + marker.length - 1); 698 + return filePath.slice(0, Math.max(0, filePath.lastIndexOf("/"))); 467 699 } 468 700 469 701 function escapeLike(value: string): string {
+15 -3
docs/ARCHITECTURE.md
··· 4 4 5 5 `cxs` 是一个面向本机 Codex session 日志的渐进式检索 CLI,当前架构是: 6 6 7 - `sync -> message/session recall -> session heuristic rerank -> read-range/read-page` 7 + `status -> sync --selector -> message/session recall -> session heuristic rerank -> read-range/read-page` 8 8 9 9 它已经可用,但仍是轻量 retrieval 后端,不是完整的 resource-level retrieval 系统。 10 10 11 11 ## 当前命令面 12 12 13 13 - `cxs sync` 14 + - `cxs status` 14 15 - `cxs find <query>` 15 16 - `cxs read-range <sessionUuid>` 16 17 - `cxs read-page <sessionUuid>` 17 18 - `cxs list` 18 19 - `cxs stats` 19 - - `cxs current` 20 20 21 21 这套命令面已经定型,不再保留 `window/session` 旧别名语义。 22 22 ··· 24 24 25 25 ### 1. 同步 26 26 27 - [indexer.ts](/Users/envvar/work/repos/cxs/indexer.ts) 扫描 `~/.codex/sessions` 下的 JSONL session 文件,按文件 `mtime`、`size` 和 `indexVersion` 做增量判断。 27 + [status.ts](/Users/envvar/work/repos/cxs/status.ts) 返回执行上下文、source inventory、index 状态与 coverage 状态。它可以扫描 raw sessions 的 metadata,但不回答内容问题。 28 + 29 + [indexer.ts](/Users/envvar/work/repos/cxs/indexer.ts) 按显式 selector 扫描 `~/.codex/sessions` 下的 JSONL session 文件,按文件 `mtime`、`size` 和 `indexVersion` 做增量判断。 30 + 31 + strict sync 在写 complete coverage 前会 reconcile selector 范围:当前 source snapshot 中不存在、被过滤或不能解析成 session 的旧 index row 会被删除。 28 32 29 33 [parser.ts](/Users/envvar/work/repos/cxs/parser.ts) 只抽取 `event_msg` 里的: 30 34 ··· 39 43 40 44 - `sessions` 41 45 - `messages` 46 + - `coverage` 47 + 48 + `sessions.source_root` 持久化该 session 被同步时使用的 selector root,read-range / read-page 的 coverage attribution 基于这个字段,而不是从文件路径命名约定反推。 42 49 43 50 以及两个全文索引: 44 51 ··· 51 58 52 59 - `sync` 走 writer 连接,负责 schema ensure、WAL 初始化与写入事务 53 60 - `find` / `read-range` / `read-page` / `list` / `stats` 走只读连接 61 + - `status` 不写 index;它可以读取 raw metadata 和只读 SQLite 54 62 - 读路径默认设置 `busy_timeout`,避免并发 agent 多查时把瞬时锁竞争直接暴露成 `SQLITE_BUSY` 55 63 - `sync` 额外有文件级 single-writer lock;遇到活跃 writer 会等待,遇到 dead pid 残留锁会自动清理 56 64 ··· 97 105 - `reasoning_summary_text` 解析 `response_item.reasoning.summary` 98 106 - `sessions_fts(title + summary_text + compact_text + reasoning_summary_text)` session-level recall 99 107 - strict / best-effort 两种 sync 语义 108 + - explicit selector sync 109 + - source inventory 110 + - complete coverage 记录 100 111 - manual eval 导出 101 112 - eval batch compare 102 113 ··· 108 119 - richer projection / event replay / range cache 109 120 - duplicate collapse / diversity control 110 121 - 强约束 gold set / rubric / error taxonomy 122 + - watcher / daemon / realtime sync 111 123 112 124 ## 为什么当前文档改成这版 113 125
+314
docs/INDEX_COVERAGE_DESIGN.md
··· 1 + # cxs Index Coverage Design 2 + 3 + ## 目标 4 + 5 + cxs 是面向 agent 的本地 Codex session retrieval backend。 6 + 7 + 目标态: 8 + 9 + - 内容回答只来自 cxs index。 10 + - 原始 sessions 只用于盘点资源与制定同步计划。 11 + - 同步范围必须显式、结构化、可验证。 12 + - 查询结果必须能够声明 index 覆盖边界。 13 + - 首次安装后,agent 可以通过有限同步快速进入可用状态。 14 + 15 + ## 设计原则 16 + 17 + ### 单一回答真相 18 + 19 + cxs index 是唯一可用于回答内容问题的真相源。 20 + 21 + 原始 sessions 只能用于: 22 + 23 + - 盘点可同步资源 24 + - 推断可选同步范围 25 + - 生成同步计划 26 + 27 + 原始 sessions 不能直接参与 `find`、`list`、`read-range`、`read-page` 的结果生成。 28 + 29 + ### 显式同步 30 + 31 + `sync` 是唯一写入口。 32 + 33 + 只读命令不得隐式触发同步。 34 + 35 + 只读命令不得修改 index。 36 + 37 + 内容读取命令不得扫描原始 sessions。 38 + 39 + ### 结构化状态优先 40 + 41 + CLI 面向 agent 输出 facts,不输出解释性标签。 42 + 43 + 禁止使用需要自然语言解释的 shortcut selector 或 shortcut status。 44 + 45 + 所有状态必须能还原为明确 selector。 46 + 47 + ### Coverage First 48 + 49 + index 不只记录已有数据,还要记录数据覆盖范围。 50 + 51 + `lastSyncAt` 不是覆盖证明。 52 + 53 + `earliestStartedAt` / `latestEndedAt` 不是覆盖证明。 54 + 55 + coverage 才是覆盖证明。 56 + 57 + coverage 必须表达: 58 + 59 + - sessions root 60 + - selector 61 + - 完整性状态 62 + - 扫描文件数 63 + - 成功索引数 64 + - 完成时间 65 + - index version 66 + 67 + ### 渐进式可用 68 + 69 + cxs 不要求首次使用前完成 full sync。 70 + 71 + agent 应基于 source inventory 和 index coverage,选择最小充分同步范围。 72 + 73 + 系统允许局部回答,但局部回答必须携带覆盖边界。 74 + 75 + ## 核心概念 76 + 77 + ### Source Inventory 78 + 79 + Source inventory 是对原始 Codex session 文件的轻量盘点。 80 + 81 + 它回答: 82 + 83 + - 原始 session 文件有多少 84 + - 文件路径日期范围是什么 85 + - 存在哪些 cwd 86 + - 某个 cwd 对应哪些日期范围 87 + 88 + Source inventory 不回答: 89 + 90 + - 某个主题是否出现过 91 + - 某个 session 的具体内容是什么 92 + - 某个历史结论是否成立 93 + 94 + ### Index 95 + 96 + Index 是 cxs 的检索数据库。 97 + 98 + 它回答: 99 + 100 + - 哪些 session 已可检索 101 + - 哪些 message 已可读取 102 + - 哪些 session-level 字段已参与召回 103 + - 哪些范围已完成同步 104 + 105 + ### Selector 106 + 107 + Selector 是同步范围的结构化描述。 108 + 109 + 允许的基础维度: 110 + 111 + - sessions root 112 + - cwd 113 + - date range 114 + 115 + 目标态 selector: 116 + 117 + ```text 118 + all(root) 119 + date_range(root, fromDate, toDate) 120 + cwd(root, cwd) 121 + cwd_date_range(root, cwd, fromDate, toDate) 122 + ``` 123 + 124 + selector 是 agent 和 CLI 之间的同步契约。 125 + 126 + ### Coverage 127 + 128 + Coverage 是 index 对 selector 的完成记录。 129 + 130 + 只有完整成功的同步才能产生 complete coverage。 131 + 132 + 部分成功不能记录为 complete coverage。 133 + 134 + Coverage 绑定 source snapshot。 135 + 136 + 当 raw sessions 中属于该 selector 的文件集合、文件大小、mtime、cwd metadata 或 index version 改变时,既有 complete coverage 对当前 source snapshot 不再 fresh。 137 + 138 + Freshness 只能由 planning command 扫描 raw sessions 后判断。 139 + 140 + 内容读取命令只能报告 index 中已有 coverage,不得为了判断 freshness 扫描 raw sessions。 141 + 142 + ### Coverage Implication 143 + 144 + Coverage 可以蕴含更窄 selector。 145 + 146 + 规则: 147 + 148 + - `all(root)` 蕴含同 root 下任意 selector 149 + - `date_range(root, from, to)` 蕴含同 root 下被完全包含的 date range selector 150 + - `cwd(root, cwd)` 蕴含同 root、同 cwd 下任意 date range selector 151 + - `cwd_date_range(root, cwd, from, to)` 只蕴含同 root、同 cwd、且日期范围被完全包含的 selector 152 + 153 + ## 命令职责 154 + 155 + ### status 156 + 157 + `status` 是 planning command,不属于内容读取命令。 158 + 159 + 职责: 160 + 161 + - 返回当前执行上下文 162 + - 返回 source inventory 163 + - 返回 index 状态 164 + - 返回 coverage 状态 165 + 166 + 约束: 167 + 168 + - 不写 index 169 + - 不回答内容问题 170 + - 不读取 Codex state DB 171 + - 不执行 sync 172 + - 可扫描 raw sessions 的 metadata 173 + - 不读取 raw message content 174 + 175 + ### sync 176 + 177 + `sync` 是唯一写入口。 178 + 179 + 职责: 180 + 181 + - 根据 selector 扫描 raw sessions 182 + - 更新 index 183 + - 写入 coverage 184 + - 输出同步结果 185 + 186 + 约束: 187 + 188 + - selector 必须显式 189 + - 无 selector 是错误 190 + - 严格成功才写 complete coverage 191 + - strict sync 必须把 selector 范围内的 index 收敛成当前 source snapshot 的投影;source 中已不存在、已被过滤或已不再可解析成 session 的旧 row 必须在写 coverage 前删除 192 + - best-effort 不能产生 complete coverage 193 + 194 + ### find 195 + 196 + `find` 是内容召回命令。 197 + 198 + 职责: 199 + 200 + - 从 index 中召回相关 session 201 + - 返回可继续读取的锚点或 session-level 命中 202 + - 返回当前 index coverage 摘要 203 + 204 + 约束: 205 + 206 + - 不扫描 raw sessions 207 + - 不触发 sync 208 + - 不使用 source inventory 作为召回来源 209 + 210 + ### list 211 + 212 + `list` 是 index metadata 查询命令。 213 + 214 + 职责: 215 + 216 + - 基于 index 列出 session 217 + - 支持 cwd / time 等过滤 218 + - 返回当前 index coverage 摘要 219 + 220 + 约束: 221 + 222 + - 不扫描 raw sessions 223 + - 不触发 sync 224 + 225 + ### read-range / read-page 226 + 227 + 读取命令只读取 index 中已存在 session。 228 + 229 + 职责: 230 + 231 + - 返回可回读 transcript 232 + - 保持 message stream 语义纯净 233 + 234 + 约束: 235 + 236 + - 不读取 raw JSONL 237 + - 不触发 sync 238 + - 不合成虚拟 message 239 + 240 + ## Agent 工作模型 241 + 242 + agent 使用 cxs 的标准流程: 243 + 244 + ```text 245 + status 246 + select sync selector 247 + sync 248 + find or list 249 + read-range or read-page 250 + answer with coverage boundary 251 + ``` 252 + 253 + agent 不应直接假设 index 完整。 254 + 255 + agent 不应把 partial coverage 当作 full history。 256 + 257 + agent 不应在未声明覆盖范围时回答全历史问题。 258 + 259 + `current` 不是目标命令。 260 + 261 + 不得通过 Codex state DB 或其他外部 session registry 获取内容候选。 262 + 263 + ## 查询语义 264 + 265 + ### 当前项目问题 266 + 267 + 当前项目问题应优先使用 cwd selector。 268 + 269 + 如果 source inventory 显示当前 cwd 有 raw sessions,agent 应同步该 cwd 对应范围,再查询 index。 270 + 271 + ### 主题历史问题 272 + 273 + 主题历史问题应基于已有 coverage 判断可回答范围。 274 + 275 + 如果 coverage 不足,agent 可以选择扩大 selector。 276 + 277 + 回答必须区分: 278 + 279 + - 已覆盖范围内未发现 280 + - 全历史未发现 281 + 282 + ### 全历史问题 283 + 284 + 全历史问题需要 full coverage 才能给出完整结论。 285 + 286 + 未完成 full coverage 时,只能回答已覆盖 selector 内的发现结果,并声明覆盖范围。 287 + 288 + ## 非目标 289 + 290 + v1 不做: 291 + 292 + - watcher 293 + - daemon 294 + - realtime sync 295 + - 隐式 sync 296 + - Codex state DB fallback 297 + - 自然语言 selector 298 + - human-friendly shortcut labels 299 + - raw sessions 直接检索 300 + - GUI-specific behavior 301 + 302 + ## 文档要求 303 + 304 + 设计文档只记录目标态、原则、模型和约束。 305 + 306 + 不得写入: 307 + 308 + - 讨论过程 309 + - 临时权衡记录 310 + - 实现步骤 311 + - 代码文件清单 312 + - 面向人类的糖语法说明 313 + 314 + 实现计划必须单独成文,在设计确认后再写。
+1 -3
env.ts
··· 33 33 34 34 export const DEFAULT_DB_PATH = resolve(DATA_DIR, "index.sqlite"); 35 35 export const DEFAULT_CODEX_DIR = resolve(homedir(), ".codex", "sessions"); 36 - export const CODEX_TITLE_INDEX_PATH = resolve(homedir(), ".codex", "session_index.jsonl"); 37 - export const DEFAULT_CODEX_STATE_DB_PATH = resolve(homedir(), ".codex", "state_5.sqlite"); 38 - export const INDEX_VERSION = "cxs-v5-session-field-weights"; 36 + export const INDEX_VERSION = "cxs-v6-selector-provenance"; 39 37 40 38 export function ensureDataDir(): void { 41 39 migrateLegacyCacheDir(LEGACY_CACHE_DIR, DATA_DIR);
+24 -18
format.ts
··· 1 1 import chalk from "chalk"; 2 2 import type { 3 - CurrentSessionCandidate, 4 3 CwdCount, 5 4 FindResult, 6 5 MessageRecord, 7 6 SessionListEntry, 8 7 SessionRecord, 9 8 StatsSummary, 9 + StatusSummary, 10 10 SyncSummary, 11 11 } from "./types"; 12 12 ··· 17 17 console.log(`updated: ${summary.updated}`); 18 18 console.log(`skipped: ${summary.skipped}`); 19 19 console.log(`filtered: ${summary.filtered}`); 20 + console.log(`removed: ${summary.removed}`); 20 21 console.log(`errors: ${summary.errors}`); 22 + console.log(`coverage: ${summary.coverage.written ? "written" : `not written (${summary.coverage.reason ?? "unknown"})`}`); 21 23 if (summary.errorDetails.length > 0) { 22 24 console.log(); 23 25 console.log(chalk.bold.red("sync errors")); ··· 107 109 } 108 110 } 109 111 110 - export function printCurrentSessions(cwd: string, results: CurrentSessionCandidate[]): void { 111 - console.log(chalk.bold.cyan("cxs current")); 112 - console.log(chalk.gray(`cwd=${cwd || "-"}`)); 113 - if (results.length === 0) { 114 - console.log(chalk.yellow("没有匹配的 session")); 115 - return; 116 - } 117 - 118 - for (const [index, entry] of results.entries()) { 119 - console.log(); 120 - console.log(chalk.bold(`[${index + 1}] ${entry.title || "(no title)"}`)); 121 - console.log(chalk.gray(`${entry.cwd || "-"} · updated_at_ms=${entry.updatedAtMs}`)); 122 - console.log(chalk.gray(`uuid=${entry.sessionUuid}`)); 123 - console.log(chalk.gray(`file=${entry.filePath}`)); 124 - } 125 - } 126 - 127 112 export function printStats(stats: StatsSummary): void { 128 113 console.log(chalk.bold.cyan(`cxs stats`)); 129 114 console.log(`sessions: ${stats.sessionCount}`); ··· 134 119 console.log(`index_version: ${stats.indexVersion}`); 135 120 console.log(`db_path: ${stats.dbPath}`); 136 121 console.log(`db_size_bytes: ${stats.dbSizeBytes}`); 122 + console.log(`coverage_count: ${stats.coverage.length}`); 137 123 if (stats.topCwds.length > 0) { 138 124 console.log(); 139 125 console.log(chalk.bold("top cwds")); 140 126 const width = Math.max(...stats.topCwds.map((row: CwdCount) => row.cwd.length)); 141 127 for (const row of stats.topCwds) { 142 128 console.log(` ${row.cwd.padEnd(width)} ${row.count}`); 129 + } 130 + } 131 + } 132 + 133 + export function printStatus(status: StatusSummary): void { 134 + console.log(chalk.bold.cyan("cxs status")); 135 + console.log(`cwd: ${status.context.cwd}`); 136 + console.log(`root: ${status.context.root}`); 137 + console.log(`db_path: ${status.context.dbPath}`); 138 + console.log(`source_files: ${status.sourceInventory.totalFiles}`); 139 + console.log(`source_dates: ${status.sourceInventory.pathDateRange.from ?? "-"}..${status.sourceInventory.pathDateRange.to ?? "-"}`); 140 + console.log(`index_exists: ${status.index.exists}`); 141 + console.log(`sessions: ${status.index.sessionCount}`); 142 + console.log(`messages: ${status.index.messageCount}`); 143 + console.log(`coverage_count: ${status.coverage.length}`); 144 + if (status.sourceInventory.cwdGroups.length > 0) { 145 + console.log(); 146 + console.log(chalk.bold("source cwd groups")); 147 + for (const group of status.sourceInventory.cwdGroups.slice(0, 10)) { 148 + console.log(` ${group.fileCount.toString().padStart(4)} ${group.cwd}`); 143 149 } 144 150 } 145 151 }
+140 -4
indexer.test.ts
··· 3 3 import { tmpdir } from "node:os"; 4 4 import { join } from "node:path"; 5 5 import { spawn } from "node:child_process"; 6 - import { openReadDb } from "./db"; 6 + import { openReadDb, openWriteDb } from "./db"; 7 7 import { SyncError, syncSessions } from "./indexer"; 8 + import { findSessions } from "./query"; 8 9 import { syncLockPath } from "./sync-lock"; 9 10 10 11 const tempDirs: string[] = []; ··· 47 48 test("can opt into best-effort sync and still returns failure diagnostics", async () => { 48 49 const { dbPath, sessionsRoot, badFilePath } = createFixture(); 49 50 50 - const summary = await syncSessions({ dbPath, rootDir: sessionsRoot, bestEffort: true }); 51 + const summary = await syncSessions({ 52 + dbPath, 53 + selector: { kind: "all", root: sessionsRoot }, 54 + bestEffort: true, 55 + }); 51 56 expect(summary.added).toBe(1); 52 57 expect(summary.errors).toBe(1); 53 58 expect(summary.errorDetails).toHaveLength(1); 54 59 expect(summary.errorDetails[0]?.filePath).toBe(badFilePath); 60 + expect(summary.coverage.written).toBe(false); 55 61 56 62 const db = openReadDb(dbPath); 57 63 const row = db.prepare("SELECT COUNT(*) AS count FROM sessions").get() as { count: number }; 64 + const coverage = db.prepare("SELECT COUNT(*) AS count FROM coverage").get() as { count: number }; 58 65 db.close(); 59 66 expect(row.count).toBe(1); 67 + expect(coverage.count).toBe(0); 68 + }); 69 + 70 + test("strict sync writes complete coverage for the selector", async () => { 71 + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-coverage-")); 72 + tempDirs.push(base); 73 + const root = join(base, "sessions"); 74 + const sessionsRoot = join(root, "2026", "04", "22"); 75 + mkdirSync(sessionsRoot, { recursive: true }); 76 + 77 + writeFileSync( 78 + join(sessionsRoot, "rollout-2026-04-22T12-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"), 79 + [ 80 + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/covered" }), 81 + line("event_msg", { type: "user_message", message: "covered session" }), 82 + ].join("\n"), 83 + ); 84 + 85 + const dbPath = join(base, "index.sqlite"); 86 + const summary = await syncSessions({ 87 + dbPath, 88 + selector: { kind: "cwd_date_range", root, cwd: "/tmp/covered", fromDate: "2026-04-22", toDate: "2026-04-22" }, 89 + }); 90 + 91 + expect(summary.added).toBe(1); 92 + expect(summary.coverage.written).toBe(true); 93 + expect(summary.coverage.selector).toMatchObject({ 94 + kind: "cwd_date_range", 95 + cwd: "/tmp/covered", 96 + fromDate: "2026-04-22", 97 + toDate: "2026-04-22", 98 + }); 99 + 100 + const db = openReadDb(dbPath); 101 + const coverage = db.prepare("SELECT selector_kind AS kind, cwd, from_date AS fromDate, to_date AS toDate, source_file_count AS sourceFileCount FROM coverage").get() as { 102 + kind: string; 103 + cwd: string; 104 + fromDate: string; 105 + toDate: string; 106 + sourceFileCount: number; 107 + }; 108 + db.close(); 109 + 110 + expect(coverage).toEqual({ 111 + kind: "cwd_date_range", 112 + cwd: "/tmp/covered", 113 + fromDate: "2026-04-22", 114 + toDate: "2026-04-22", 115 + sourceFileCount: 1, 116 + }); 117 + }); 118 + 119 + test("strict sync reconciles deleted source files before writing coverage", async () => { 120 + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-reconcile-")); 121 + tempDirs.push(base); 122 + const root = join(base, "sessions"); 123 + const day = join(root, "2026", "04", "22"); 124 + mkdirSync(day, { recursive: true }); 125 + 126 + const deletedPath = join(day, "rollout-2026-04-22T10-00-00-11111111-1111-4111-8111-111111111111.jsonl"); 127 + const keptPath = join(day, "rollout-2026-04-22T11-00-00-22222222-2222-4222-8222-222222222222.jsonl"); 128 + writeFileSync( 129 + deletedPath, 130 + [ 131 + line("session_meta", { id: "11111111-1111-4111-8111-111111111111", cwd: "/tmp/reconcile" }), 132 + line("event_msg", { type: "user_message", message: "needle deleted" }), 133 + ].join("\n"), 134 + ); 135 + writeFileSync( 136 + keptPath, 137 + [ 138 + line("session_meta", { id: "22222222-2222-4222-8222-222222222222", cwd: "/tmp/reconcile" }), 139 + line("event_msg", { type: "user_message", message: "needle kept" }), 140 + ].join("\n"), 141 + ); 142 + 143 + const dbPath = join(base, "index.sqlite"); 144 + const selector = { kind: "all" as const, root }; 145 + await syncSessions({ dbPath, selector }); 146 + 147 + rmSync(deletedPath); 148 + const summary = await syncSessions({ dbPath, selector }); 149 + 150 + expect(summary.removed).toBe(1); 151 + expect(summary.coverage.sourceFileCount).toBe(1); 152 + expect(summary.coverage.indexedSessionCount).toBe(1); 153 + 154 + const found = findSessions(dbPath, "needle", 10, selector); 155 + expect(found.results.map((result) => result.sessionUuid)).toEqual([ 156 + "22222222-2222-4222-8222-222222222222", 157 + ]); 158 + }); 159 + 160 + test("strict sync rebuilds legacy rows missing path_date before date-range coverage", async () => { 161 + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-legacy-path-date-")); 162 + tempDirs.push(base); 163 + const root = join(base, "sessions"); 164 + const day = join(root, "2026", "04", "22"); 165 + mkdirSync(day, { recursive: true }); 166 + 167 + writeFileSync( 168 + join(day, "rollout-2026-04-22T12-00-00-33333333-3333-4333-8333-333333333333.jsonl"), 169 + [ 170 + line("session_meta", { id: "33333333-3333-4333-8333-333333333333", cwd: "/tmp/legacy-path-date" }), 171 + line("event_msg", { type: "user_message", message: "dated needle" }), 172 + ].join("\n"), 173 + ); 174 + 175 + const dbPath = join(base, "index.sqlite"); 176 + const allSelector = { kind: "all" as const, root }; 177 + await syncSessions({ dbPath, selector: allSelector }); 178 + 179 + const writeDb = openWriteDb(dbPath); 180 + writeDb 181 + .prepare("UPDATE sessions SET path_date = '', index_version = ? WHERE session_uuid = ?") 182 + .run("cxs-v5-session-field-weights", "33333333-3333-4333-8333-333333333333"); 183 + writeDb.close(); 184 + 185 + const dateSelector = { kind: "date_range" as const, root, fromDate: "2026-04-22", toDate: "2026-04-22" }; 186 + const summary = await syncSessions({ dbPath, selector: dateSelector }); 187 + 188 + expect(summary.updated).toBe(1); 189 + expect(summary.coverage.indexedSessionCount).toBe(1); 190 + 191 + const found = findSessions(dbPath, "dated needle", 5, dateSelector); 192 + expect(found.coverage.complete).toBe(true); 193 + expect(found.results.map((result) => result.sessionUuid)).toEqual([ 194 + "33333333-3333-4333-8333-333333333333", 195 + ]); 60 196 }); 61 197 62 198 test("waits for an existing sync writer lock before opening the database", async () => { ··· 77 213 const dbPath = join(base, "index.sqlite"); 78 214 const blocker = await holdSyncLock(syncLockPath(dbPath), 350); 79 215 const startedAt = Date.now(); 80 - const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 216 + const summary = await syncSessions({ dbPath, selector: { kind: "all", root: join(base, "sessions") } }); 81 217 const elapsedMs = Date.now() - startedAt; 82 218 await blocker.done; 83 219 ··· 107 243 JSON.stringify({ pid: 999_999, createdAt: new Date("2026-04-22T00:00:00.000Z").toISOString() }), 108 244 ); 109 245 110 - const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 246 + const summary = await syncSessions({ dbPath, selector: { kind: "all", root: join(base, "sessions") } }); 111 247 112 248 expect(summary.added).toBe(1); 113 249 expect(existsSync(lockPath)).toBe(false);
+104 -45
indexer.ts
··· 1 - import { readdirSync, statSync } from "node:fs"; 2 - import type { Dirent } from "node:fs"; 3 - import { join } from "node:path"; 4 1 import { DEFAULT_DB_PATH, INDEX_VERSION, ensureDataDir, resolveCodexDir } from "./env"; 5 - import { deleteSessionByFilePath, getIndexedSessionMeta, openWriteDb, replaceSession } from "./db"; 2 + import { 3 + countSessionsForSelector, 4 + deleteSessionsForSelectorExceptFilePaths, 5 + deleteSessionByFilePath, 6 + getIndexedSessionMeta, 7 + openWriteDb, 8 + replaceCoverage, 9 + replaceSession, 10 + } from "./db"; 6 11 import { parseCodexSession } from "./parser"; 12 + import { canonicalizeSelector } from "./selector"; 13 + import { collectSourceSnapshot } from "./source-inventory"; 7 14 import { withSyncLock } from "./sync-lock"; 8 - import type { ParsedSession, SyncErrorDetail, SyncSummary } from "./types"; 15 + import type { CoverageWriteSummary, ParsedSession, Selector, SyncErrorDetail, SyncSummary } from "./types"; 9 16 10 17 interface SyncOptions { 11 18 dbPath?: string; 12 19 rootDir?: string; 20 + selector?: Selector; 13 21 bestEffort?: boolean; 14 22 } 15 23 ··· 20 28 session: ParsedSession; 21 29 rawFileMtime: number; 22 30 rawFileSize: number; 31 + pathDate: string; 23 32 isUpdate: boolean; 24 33 } 25 34 | { ··· 40 49 export async function syncSessions(options: SyncOptions = {}): Promise<SyncSummary> { 41 50 ensureDataDir(); 42 51 const dbPath = options.dbPath ?? DEFAULT_DB_PATH; 43 - const rootDir = resolveCodexDir(options.rootDir); 52 + const selector = canonicalizeSelector(options.selector ?? { kind: "all", root: resolveCodexDir(options.rootDir) }); 44 53 return withSyncLock(dbPath, async () => { 45 54 const db = openWriteDb(dbPath); 46 - const files = collectJsonlFiles(rootDir); 55 + const sourceSnapshot = collectSourceSnapshot(selector); 47 56 const operations: SyncOperation[] = []; 57 + const unchangedFilePaths = new Set<string>(); 48 58 49 59 const summary: SyncSummary = { 50 - scanned: files.length, 60 + scanned: sourceSnapshot.fileCount, 51 61 added: 0, 52 62 updated: 0, 53 63 skipped: 0, 54 64 filtered: 0, 65 + removed: 0, 55 66 errors: 0, 56 67 errorDetails: [], 68 + selector, 69 + coverage: skippedCoverage(selector, sourceSnapshot.fingerprint, sourceSnapshot.fileCount, "not_written"), 57 70 }; 58 71 59 72 try { 60 - for (const filePath of files) { 73 + for (const file of sourceSnapshot.files) { 74 + const filePath = file.filePath; 61 75 try { 62 - const stats = statSync(filePath); 63 76 const indexed = getIndexedSessionMeta(db, filePath); 64 - if (isUnchanged(indexed, stats.mtimeMs, stats.size)) { 77 + if (isUnchanged(indexed, file.mtimeMs, file.size)) { 65 78 summary.skipped += 1; 79 + unchangedFilePaths.add(filePath); 66 80 continue; 67 81 } 68 82 ··· 80 94 kind: "replace", 81 95 filePath, 82 96 session: parsed.session, 83 - rawFileMtime: stats.mtimeMs, 84 - rawFileSize: stats.size, 97 + rawFileMtime: file.mtimeMs, 98 + rawFileSize: file.size, 99 + pathDate: file.pathDate ?? "", 85 100 isUpdate: Boolean(indexed), 86 101 }); 87 102 } catch (error) { ··· 93 108 throw new SyncError(summary); 94 109 } 95 110 96 - applyOperations(db, operations, summary, Boolean(options.bestEffort)); 111 + if (!options.bestEffort) { 112 + const afterSnapshot = collectSourceSnapshot(selector); 113 + if (afterSnapshot.fingerprint !== sourceSnapshot.fingerprint) { 114 + recordSyncError(summary, "(selector)", new Error("source changed during strict sync")); 115 + throw new SyncError(summary); 116 + } 117 + } 118 + 119 + const bestEffort = Boolean(options.bestEffort); 120 + const retainedFilePaths = retainedIndexedFilePaths(unchangedFilePaths, operations); 121 + summary.coverage = applyOperations( 122 + db, 123 + operations, 124 + summary, 125 + bestEffort, 126 + selector, 127 + sourceSnapshot, 128 + retainedFilePaths, 129 + ); 97 130 if (summary.errors > 0 && !options.bestEffort) { 98 131 throw new SyncError(summary); 99 132 } ··· 116 149 && indexed.indexVersion === INDEX_VERSION; 117 150 } 118 151 119 - function collectJsonlFiles(rootDir: string): string[] { 120 - const files: string[] = []; 121 - walk(rootDir, files); 122 - files.sort(); 123 - return files; 124 - } 125 - 126 - function walk(currentDir: string, files: string[]): void { 127 - let entries: Dirent<string>[]; 128 - try { 129 - entries = readdirSync(currentDir, { withFileTypes: true }); 130 - } catch { 131 - return; 132 - } 133 - 134 - for (const entry of entries) { 135 - const fullPath = join(currentDir, entry.name); 136 - if (entry.isDirectory()) { 137 - walk(fullPath, files); 138 - continue; 139 - } 140 - if (entry.isFile() && entry.name.endsWith(".jsonl")) { 141 - files.push(fullPath); 142 - } 143 - } 144 - } 145 - 146 152 function applyOperations( 147 153 db: ReturnType<typeof openWriteDb>, 148 154 operations: SyncOperation[], 149 155 summary: SyncSummary, 150 156 bestEffort: boolean, 151 - ): void { 157 + selector: Selector, 158 + sourceSnapshot: { fingerprint: string; fileCount: number }, 159 + retainedFilePaths: Set<string>, 160 + ): CoverageWriteSummary { 152 161 if (bestEffort) { 153 162 for (const operation of operations) { 154 163 try { ··· 158 167 recordSyncError(summary, operation.filePath, error); 159 168 } 160 169 } 161 - return; 170 + return skippedCoverage(selector, sourceSnapshot.fingerprint, sourceSnapshot.fileCount, "best_effort"); 162 171 } 163 172 164 173 let currentFilePath = ""; 174 + let coverage: CoverageWriteSummary | null = null; 165 175 const tx = db.transaction(() => { 166 176 for (const operation of operations) { 167 177 currentFilePath = operation.filePath; 168 - applyOperation(db, operation); 178 + applyOperation(db, operation, selector.root); 169 179 } 180 + summary.removed += deleteSessionsForSelectorExceptFilePaths(db, selector, retainedFilePaths); 181 + const indexedSessionCount = countSessionsForSelector(db, selector); 182 + const record = replaceCoverage( 183 + db, 184 + selector, 185 + sourceSnapshot.fingerprint, 186 + sourceSnapshot.fileCount, 187 + indexedSessionCount, 188 + INDEX_VERSION, 189 + ); 190 + coverage = { 191 + written: true, 192 + selector: record.selector, 193 + sourceFingerprint: record.sourceFingerprint, 194 + sourceFileCount: record.sourceFileCount, 195 + indexedSessionCount: record.indexedSessionCount, 196 + }; 170 197 }); 171 198 172 199 try { ··· 179 206 for (const operation of operations) { 180 207 recordAppliedOperation(summary, operation); 181 208 } 209 + return coverage ?? skippedCoverage(selector, sourceSnapshot.fingerprint, sourceSnapshot.fileCount, "not_written"); 182 210 } 183 211 184 - function applyOperation(db: ReturnType<typeof openWriteDb>, operation: SyncOperation): void { 212 + function applyOperation(db: ReturnType<typeof openWriteDb>, operation: SyncOperation, sourceRoot?: string): void { 185 213 if (operation.kind === "filtered") { 186 214 deleteSessionByFilePath(db, operation.filePath); 187 215 return; ··· 193 221 operation.rawFileMtime, 194 222 operation.rawFileSize, 195 223 INDEX_VERSION, 224 + operation.pathDate, 225 + sourceRoot, 196 226 ); 197 227 } 198 228 229 + function retainedIndexedFilePaths( 230 + unchangedFilePaths: Set<string>, 231 + operations: SyncOperation[], 232 + ): Set<string> { 233 + const retained = new Set(unchangedFilePaths); 234 + for (const operation of operations) { 235 + if (operation.kind === "replace") { 236 + retained.add(operation.filePath); 237 + } 238 + } 239 + return retained; 240 + } 241 + 199 242 function recordAppliedOperation(summary: SyncSummary, operation: SyncOperation): void { 200 243 if (operation.kind === "filtered") { 201 244 summary.filtered += 1; ··· 229 272 ); 230 273 return `sync failed with ${summary.errors} error(s)\n${details.join("\n")}`; 231 274 } 275 + 276 + function skippedCoverage( 277 + selector: Selector, 278 + sourceFingerprint: string, 279 + sourceFileCount: number, 280 + reason: string, 281 + ): CoverageWriteSummary { 282 + return { 283 + written: false, 284 + selector, 285 + sourceFingerprint, 286 + sourceFileCount, 287 + indexedSessionCount: 0, 288 + reason, 289 + }; 290 + }
+2 -28
parser.ts
··· 1 - import { createReadStream, existsSync, readFileSync } from "node:fs"; 1 + import { createReadStream } from "node:fs"; 2 2 import { basename } from "node:path"; 3 3 import { createInterface } from "node:readline"; 4 - import { CODEX_TITLE_INDEX_PATH } from "./env"; 5 4 import type { ParsedMessage, ParseSessionResult } from "./types"; 6 5 7 6 const INTERNAL_MARKERS = [ ··· 87 86 if (filteredMessageCount > 0 && eventMessages.length === 0) return { kind: "filtered" }; 88 87 if (!sessionUuid || eventMessages.length === 0) return { kind: "skipped" }; 89 88 90 - const title = loadCodexTitle(sessionUuid) ?? firstUserMessage(eventMessages) ?? "(no title)"; 89 + const title = firstUserMessage(eventMessages) ?? "(no title)"; 91 90 const timestamps = eventMessages.map((message) => message.timestamp).sort(); 92 91 93 92 return { ··· 183 182 function isRecord(value: unknown): value is Record<string, unknown> { 184 183 return typeof value === "object" && value !== null; 185 184 } 186 - 187 - let titleIndex: Map<string, string> | null = null; 188 - 189 - function loadCodexTitle(sessionUuid: string): string | null { 190 - if (!titleIndex) { 191 - titleIndex = new Map(); 192 - if (existsSync(CODEX_TITLE_INDEX_PATH)) { 193 - const raw = readFileSync(CODEX_TITLE_INDEX_PATH, "utf8"); 194 - for (const line of raw.split("\n")) { 195 - const trimmed = line.trim(); 196 - if (!trimmed) continue; 197 - try { 198 - const record = JSON.parse(trimmed) as { id?: string; thread_name?: string }; 199 - if (record.id && record.thread_name) { 200 - titleIndex.set(record.id, record.thread_name); 201 - } 202 - } catch { 203 - continue; 204 - } 205 - } 206 - } 207 - } 208 - 209 - return titleIndex.get(sessionUuid) ?? null; 210 - }
+34 -88
query.test.ts
··· 1 1 import { afterEach, describe, expect, test } from "vitest"; 2 - import Database from "better-sqlite3"; 3 2 import { spawn } from "node:child_process"; 4 3 import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; 5 4 import { tmpdir } from "node:os"; ··· 10 9 import { syncSessions } from "./indexer"; 11 10 import { 12 11 classifyQueryProfile, 13 - CurrentStateDbError, 14 12 findSessions, 15 - getCurrentSessions, 16 13 getMessagePage, 17 14 getMessageRange, 18 15 } from "./query"; ··· 26 23 }); 27 24 28 25 describe("cxs retrieval flow", () => { 29 - test("current returns latest thread candidates for cwd from Codex state db", () => { 30 - const base = mkdtempSync(join(tmpdir(), "cxs-current-")); 31 - tempDirs.push(base); 32 - const stateDbPath = join(base, "state.sqlite"); 33 - const db = new Database(stateDbPath); 34 - db.exec(` 35 - CREATE TABLE threads ( 36 - id TEXT PRIMARY KEY, 37 - rollout_path TEXT NOT NULL, 38 - cwd TEXT NOT NULL, 39 - title TEXT NOT NULL, 40 - updated_at_ms INTEGER 41 - ) 42 - `); 43 - const insertThread = db.prepare( 44 - "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 45 - ); 46 - insertThread.run("11111111-1111-4111-8111-111111111111", "/tmp/a.jsonl", "/tmp/project", "older", 100); 47 - insertThread.run("22222222-2222-4222-8222-222222222222", "/tmp/b.jsonl", "/tmp/project", "newer", 200); 48 - insertThread.run("33333333-3333-4333-8333-333333333333", "/tmp/c.jsonl", "/tmp/other", "other", 300); 49 - db.close(); 50 - 51 - const result = getCurrentSessions(stateDbPath, "/tmp/project", 10); 52 - expect(result.cwd).toBe("/tmp/project"); 53 - expect(result.candidates.map((candidate) => candidate.sessionUuid)).toEqual([ 54 - "22222222-2222-4222-8222-222222222222", 55 - "11111111-1111-4111-8111-111111111111", 56 - ]); 57 - expect(result.candidates[0]?.filePath).toBe("/tmp/b.jsonl"); 58 - }); 59 - 60 - test("current throws CurrentStateDbError when state db lacks 'threads' table", () => { 61 - const base = mkdtempSync(join(tmpdir(), "cxs-current-schema-")); 62 - tempDirs.push(base); 63 - const stateDbPath = join(base, "state.sqlite"); 64 - const db = new Database(stateDbPath); 65 - db.exec("CREATE TABLE other (id INTEGER PRIMARY KEY)"); 66 - db.close(); 67 - 68 - let caught: unknown = null; 69 - try { 70 - getCurrentSessions(stateDbPath, "/tmp/project", 10); 71 - } catch (error) { 72 - caught = error; 73 - } 74 - expect(caught).toBeInstanceOf(CurrentStateDbError); 75 - expect((caught as Error).message).toContain("threads"); 76 - }); 77 - 78 - test("current throws CurrentStateDbError when 'threads' is missing required columns", () => { 79 - const base = mkdtempSync(join(tmpdir(), "cxs-current-cols-")); 80 - tempDirs.push(base); 81 - const stateDbPath = join(base, "state.sqlite"); 82 - const db = new Database(stateDbPath); 83 - // Table exists but lacks rollout_path & updated_at_ms — simulates an 84 - // upstream rename of the columns we SELECT in getCurrentSessions. 85 - db.exec(` 86 - CREATE TABLE threads ( 87 - id TEXT PRIMARY KEY, 88 - cwd TEXT NOT NULL, 89 - title TEXT NOT NULL 90 - ) 91 - `); 92 - db.close(); 93 - 94 - let caught: unknown = null; 95 - try { 96 - getCurrentSessions(stateDbPath, "/tmp/project", 10); 97 - } catch (error) { 98 - caught = error; 99 - } 100 - expect(caught).toBeInstanceOf(CurrentStateDbError); 101 - const message = (caught as Error).message; 102 - expect(message).toContain("rollout_path"); 103 - expect(message).toContain("updated_at_ms"); 104 - }); 105 - 106 26 test("sync -> find -> read-range -> read-page works on fixture sessions", async () => { 107 27 const base = mkdtempSync(join(tmpdir(), "cxs-test-")); 108 28 tempDirs.push(base); ··· 186 106 expect(range.messages.map((message) => message.seq)).toEqual([1, 2]); 187 107 }); 188 108 109 + test("read-page reports coverage for sessions synced from a nonstandard root", async () => { 110 + const base = mkdtempSync(join(tmpdir(), "cxs-nonstandard-root-")); 111 + tempDirs.push(base); 112 + const root = join(base, "rawroot"); 113 + const day = join(root, "2026", "04", "22"); 114 + mkdirSync(day, { recursive: true }); 115 + 116 + writeFileSync( 117 + join(day, "rollout-2026-04-22T10-00-00-45454545-4545-4545-8545-454545454545.jsonl"), 118 + [ 119 + line("session_meta", { id: "45454545-4545-4545-8545-454545454545", cwd: "/tmp/nonstandard-root" }), 120 + line("event_msg", { type: "user_message", message: "root attribution needle" }), 121 + ].join("\n"), 122 + ); 123 + 124 + const dbPath = join(base, "index.sqlite"); 125 + await syncSessions({ dbPath, selector: { kind: "all", root } }); 126 + 127 + const page = getMessagePage(dbPath, "45454545-4545-4545-8545-454545454545", 0, 10); 128 + 129 + expect(page.coverage.entries).toHaveLength(1); 130 + expect(page.coverage.entries[0]?.selector).toEqual({ kind: "all", root }); 131 + }); 132 + 189 133 test("session title hit outranks broad incidental mentions", async () => { 190 134 const base = mkdtempSync(join(tmpdir(), "cxs-rank-")); 191 135 tempDirs.push(base); ··· 332 276 1, 333 277 1, 334 278 INDEX_VERSION, 279 + "", 335 280 ); 336 281 db.close(); 337 282 ··· 375 320 sessionUuid: "10101010-1010-4010-8010-101010101010", 376 321 filePath: join(base, "title.jsonl"), 377 322 title: "handoffneedle title", 378 - }, 1, 1, INDEX_VERSION); 323 + }, 1, 1, INDEX_VERSION, ""); 379 324 replaceSession(db, { 380 325 ...common, 381 326 sessionUuid: "20202020-2020-4020-8020-202020202020", 382 327 filePath: join(base, "compact.jsonl"), 383 328 compactText: "handoffneedle compact handoff", 384 - }, 1, 1, INDEX_VERSION); 329 + }, 1, 1, INDEX_VERSION, ""); 385 330 replaceSession(db, { 386 331 ...common, 387 332 sessionUuid: "30303030-3030-4030-8030-303030303030", 388 333 filePath: join(base, "summary.jsonl"), 389 334 summaryText: "handoffneedle derived summary", 390 - }, 1, 1, INDEX_VERSION); 335 + }, 1, 1, INDEX_VERSION, ""); 391 336 replaceSession(db, { 392 337 ...common, 393 338 sessionUuid: "40404040-4040-4040-8040-404040404040", 394 339 filePath: join(base, "reasoning.jsonl"), 395 340 reasoningSummaryText: "handoffneedle reasoning summary", 396 - }, 1, 1, INDEX_VERSION); 341 + }, 1, 1, INDEX_VERSION, ""); 397 342 db.close(); 398 343 399 344 const found = findSessions(dbPath, "handoffneedle", 10); ··· 470 415 1, 471 416 1, 472 417 INDEX_VERSION, 418 + "", 473 419 ); 474 420 db.close(); 475 421 ··· 517 463 sourceKind: "event_msg", 518 464 }, 519 465 ], 520 - }, 1, 1, INDEX_VERSION); 466 + }, 1, 1, INDEX_VERSION, ""); 521 467 522 468 // Message-only control: query appears only in a message body, neither 523 469 // title nor any session-level field carries it. ··· 541 487 sourceKind: "event_msg", 542 488 }, 543 489 ], 544 - }, 1, 1, INDEX_VERSION); 490 + }, 1, 1, INDEX_VERSION, ""); 545 491 546 492 db.close(); 547 493 ··· 600 546 sourceKind: "event_msg", 601 547 }, 602 548 ], 603 - }, 1, 1, INDEX_VERSION); 549 + }, 1, 1, INDEX_VERSION, ""); 604 550 605 551 db.close(); 606 552 ··· 645 591 sourceKind: "event_msg", 646 592 }, 647 593 ], 648 - }, 1, 1, INDEX_VERSION); 594 + }, 1, 1, INDEX_VERSION, ""); 649 595 650 596 db.close(); 651 597
+86 -87
query.ts
··· 1 1 import Database from "better-sqlite3"; 2 2 import { statSync } from "node:fs"; 3 3 import { 4 + coverageEntriesForSession, 5 + coverageStatusForSelector, 4 6 getMessagesForPage, 5 7 getMessagesForRange, 6 8 getSessionRecord, 7 9 getStatsCounts, 8 10 getTopCwds, 11 + listCoverageRecords, 9 12 listSessions, 13 + selectorWhereSql, 10 14 withReadDb, 11 15 } from "./db"; 12 16 import { INDEX_VERSION } from "./env"; ··· 14 18 import type { RawHitRow } from "./ranking"; 15 19 import { hasCjk, isCjkToken, queryTerms } from "./tokenize"; 16 20 import type { 17 - CurrentSessionCandidate, 21 + CoverageStatus, 18 22 FindResult, 23 + Selector, 19 24 SessionListEntry, 20 25 SessionListQuery, 21 26 SessionRecord, ··· 26 31 type Db = Database.Database; 27 32 type SqlParams = unknown[]; 28 33 29 - // Why: Codex state db lives outside cxs's control — its schema can drift 30 - // across upstream releases. CLI translates this into a structured --json 31 - // error instead of leaking SQLite's raw exception. 32 - export class CurrentStateDbError extends Error { 33 - constructor(message: string) { 34 - super(message); 35 - this.name = "CurrentStateDbError"; 36 - } 37 - } 38 - 39 - // Hard-coded identifier list — getCurrentSessions's SELECT references each of 40 - // these. Keep this in sync with the SELECT below. 41 - const THREADS_REQUIRED_COLUMNS = ["id", "rollout_path", "cwd", "title", "updated_at_ms"] as const; 42 - 43 34 export function findSessions( 44 35 dbPath: string, 45 36 query: string, 46 37 limit: number, 47 - ): { query: string; results: FindResult[] } { 38 + selector: Selector | null = null, 39 + ): { query: string; results: FindResult[]; coverage: CoverageStatus } { 48 40 return withReadDb(dbPath, (db) => { 49 41 const recallLimit = Math.max(limit * 12, 50); 50 42 const rawRows = [ 51 - ...searchMessageHits(db, query, recallLimit), 52 - ...searchSessionHits(db, query, recallLimit), 43 + ...searchMessageHits(db, query, recallLimit, undefined, selector), 44 + ...searchSessionHits(db, query, recallLimit, selector), 53 45 ]; 54 46 const results = rerankHits(rawRows, query, limit); 55 - return { query, results }; 47 + return { query, results, coverage: buildCoverageStatus(db, selector) }; 56 48 }); 57 49 } 58 50 ··· 66 58 rangeStartSeq: number; 67 59 rangeEndSeq: number; 68 60 messages: ReturnType<typeof getMessagesForRange>; 61 + coverage: { entries: ReturnType<typeof coverageEntriesForSession> }; 69 62 } { 70 63 return withReadDb(dbPath, (db) => { 71 64 const anchorSeq = resolveAnchorSeq(db, sessionUuid, options.seq, options.query); ··· 75 68 const rangeStartSeq = Math.max(0, anchorSeq - options.before); 76 69 const rangeEndSeq = anchorSeq + options.after; 77 70 const messages = getMessagesForRange(db, sessionUuid, rangeStartSeq, rangeEndSeq); 78 - return { session, anchorSeq, rangeStartSeq, rangeEndSeq, messages }; 71 + return { 72 + session, 73 + anchorSeq, 74 + rangeStartSeq, 75 + rangeEndSeq, 76 + messages, 77 + coverage: { entries: coverageEntriesForSession(db, session) }, 78 + }; 79 79 }); 80 80 } 81 81 ··· 91 91 totalCount: number; 92 92 hasMore: boolean; 93 93 messages: ReturnType<typeof getMessagesForPage>; 94 + coverage: { entries: ReturnType<typeof coverageEntriesForSession> }; 94 95 } { 95 96 return withReadDb(dbPath, (db) => { 96 97 const session = getSessionRecord(db, sessionUuid); ··· 98 99 const messages = getMessagesForPage(db, sessionUuid, offset, limit); 99 100 const totalCount = session.messageCount; 100 101 const hasMore = offset + messages.length < totalCount; 101 - return { session, offset, limit, totalCount, hasMore, messages }; 102 + return { 103 + session, 104 + offset, 105 + limit, 106 + totalCount, 107 + hasMore, 108 + messages, 109 + coverage: { entries: coverageEntriesForSession(db, session) }, 110 + }; 102 111 }); 103 112 } 104 113 105 114 export function listSessionSummaries( 106 115 dbPath: string, 107 116 query: SessionListQuery, 108 - ): { query: SessionListQuery; results: SessionListEntry[] } { 117 + ): { query: SessionListQuery; results: SessionListEntry[]; coverage: CoverageStatus } { 109 118 return withReadDb(dbPath, (db) => { 110 119 const results = listSessions(db, query); 111 - return { query, results }; 120 + return { query, results, coverage: buildCoverageStatus(db, query.selector ?? null) }; 112 121 }); 113 122 } 114 123 115 - export function getCurrentSessions( 116 - stateDbPath: string, 117 - cwd: string, 118 - limit: number, 119 - ): { cwd: string; candidates: CurrentSessionCandidate[] } { 120 - const normalizedCwd = cwd.trim(); 121 - if (!normalizedCwd) { 122 - return { cwd: normalizedCwd, candidates: [] }; 123 - } 124 - 125 - const db = new Database(stateDbPath, { readonly: true }); 126 - try { 127 - if (!tableExists(db, "threads")) { 128 - throw new CurrentStateDbError( 129 - `unexpected codex state db schema: missing 'threads' table at ${stateDbPath}`, 130 - ); 131 - } 132 - const missingColumns = findMissingColumns(db, "threads", THREADS_REQUIRED_COLUMNS); 133 - if (missingColumns.length > 0) { 134 - throw new CurrentStateDbError( 135 - `unexpected codex state db schema: 'threads' missing column(s) ${missingColumns.join(", ")} at ${stateDbPath}`, 136 - ); 137 - } 138 - const candidates = db 139 - .prepare<[string, number], CurrentSessionCandidate>(` 140 - SELECT 141 - id AS sessionUuid, 142 - title, 143 - cwd, 144 - rollout_path AS filePath, 145 - COALESCE(updated_at_ms, 0) AS updatedAtMs 146 - FROM threads 147 - WHERE cwd = ? 148 - ORDER BY updated_at_ms DESC 149 - LIMIT ? 150 - `) 151 - .all(normalizedCwd, limit) as CurrentSessionCandidate[]; 152 - return { cwd: normalizedCwd, candidates }; 153 - } finally { 154 - db.close(); 155 - } 156 - } 157 - 158 124 export function collectStats(dbPath: string): StatsSummary { 159 - const { counts, topCwds } = withReadDb(dbPath, (db) => ({ 125 + const { counts, topCwds, coverage } = withReadDb(dbPath, (db) => ({ 160 126 counts: getStatsCounts(db), 161 127 topCwds: getTopCwds(db, 10), 128 + coverage: listCoverageRecords(db), 162 129 })); 163 130 164 131 let dbSizeBytes = 0; ··· 178 145 dbPath, 179 146 dbSizeBytes, 180 147 lastSyncAt: counts.lastSyncAt, 148 + coverage, 181 149 }; 182 150 } 183 151 ··· 205 173 return result ?? null; 206 174 } 207 175 208 - function searchMessageHits(db: Db, query: string, limit: number, sessionUuid?: string): RawHitRow[] { 176 + function searchMessageHits( 177 + db: Db, 178 + query: string, 179 + limit: number, 180 + sessionUuid?: string, 181 + selector: Selector | null = null, 182 + ): RawHitRow[] { 209 183 const normalized = query.trim(); 210 184 if (!normalized) return []; 211 185 ··· 215 189 // back to a bounded LIKE scan so single-character CJK probes still work 216 190 // even though they are discouraged. 217 191 if (terms.length === 0) { 218 - if (hasCjk(normalized)) return searchByLike(db, normalized, limit, sessionUuid); 192 + if (hasCjk(normalized)) return searchByLike(db, normalized, limit, sessionUuid, selector); 219 193 return []; 220 194 } 221 195 222 - return searchByFts(db, terms, limit, sessionUuid); 196 + return searchByFts(db, terms, limit, sessionUuid, selector); 223 197 } 224 198 225 - function searchSessionHits(db: Db, query: string, limit: number): RawHitRow[] { 199 + function searchSessionHits(db: Db, query: string, limit: number, selector: Selector | null): RawHitRow[] { 226 200 const normalized = query.trim(); 227 201 if (!normalized || !tableExists(db, "sessions_fts")) return []; 228 202 229 203 const terms = queryTerms(normalized); 230 204 if (terms.length === 0) return []; 231 205 232 - return searchSessionsByFts(db, normalized, terms, limit); 206 + return searchSessionsByFts(db, normalized, terms, limit, selector); 233 207 } 234 208 235 209 function searchByFts( ··· 237 211 terms: string[], 238 212 limit: number, 239 213 sessionUuid?: string, 214 + selector: Selector | null = null, 240 215 ): RawHitRow[] { 241 216 const matchExpr = buildFtsMatch(terms); 242 217 const conditions = [`messages_fts MATCH ?`]; 243 218 const params: SqlParams = [matchExpr]; 244 219 220 + if (selector) { 221 + const selectorWhere = selectorWhereSql(selector, "s"); 222 + conditions.push(...selectorWhere.conditions); 223 + params.push(...selectorWhere.params); 224 + } 245 225 if (sessionUuid) { 246 226 conditions.push("m.session_uuid = ?"); 247 227 params.push(sessionUuid); ··· 279 259 query: string, 280 260 terms: string[], 281 261 limit: number, 262 + selector: Selector | null, 282 263 ): RawHitRow[] { 283 264 const matchExpr = buildFtsMatch(terms); 265 + const conditions = ["sessions_fts MATCH ?"]; 266 + const params: SqlParams = [matchExpr]; 267 + if (selector) { 268 + const selectorWhere = selectorWhereSql(selector, "s"); 269 + conditions.push(...selectorWhere.conditions); 270 + params.push(...selectorWhere.params); 271 + } 272 + params.push(limit); 284 273 const rows = db 285 - .prepare<[string, number], RawHitRow>(` 274 + .prepare<typeof params, RawHitRow>(` 286 275 SELECT 287 276 s.session_uuid AS sessionUuid, 288 277 s.title AS title, ··· 299 288 bm25(sessions_fts, 8.0, 3.0, 4.0, 1.2) AS score 300 289 FROM sessions_fts 301 290 JOIN sessions s ON s.id = sessions_fts.rowid 302 - WHERE sessions_fts MATCH ? 291 + WHERE ${conditions.join(" AND ")} 303 292 ORDER BY score 304 293 LIMIT ? 305 294 `) 306 - .all(matchExpr, limit) as RawHitRow[]; 295 + .all(...params) as RawHitRow[]; 307 296 308 297 return rows.map((row) => ({ 309 298 ...row, ··· 311 300 })); 312 301 } 313 302 314 - function searchByLike(db: Db, query: string, limit: number, sessionUuid?: string): RawHitRow[] { 303 + function searchByLike( 304 + db: Db, 305 + query: string, 306 + limit: number, 307 + sessionUuid?: string, 308 + selector: Selector | null = null, 309 + ): RawHitRow[] { 315 310 const conditions = ["lower(m.content_text) LIKE ? ESCAPE '\\'"]; 316 311 const params: SqlParams = [`%${escapeLike(query.toLowerCase())}%`]; 312 + if (selector) { 313 + const selectorWhere = selectorWhereSql(selector, "s"); 314 + conditions.push(...selectorWhere.conditions); 315 + params.push(...selectorWhere.params); 316 + } 317 317 if (sessionUuid) { 318 318 conditions.push("m.session_uuid = ?"); 319 319 params.push(sessionUuid); ··· 359 359 return Boolean(row); 360 360 } 361 361 362 - // Why: PRAGMA table_info doesn't accept bound parameters, so callers MUST 363 - // pass a hard-coded identifier. Returns required columns that the table is 364 - // missing, in input order; empty array means schema is good. 365 - function findMissingColumns(db: Db, tableName: string, required: readonly string[]): string[] { 366 - const rows = db 367 - .prepare<[], { name: string }>(`PRAGMA table_info(${tableName})`) 368 - .all() as Array<{ name: string }>; 369 - const present = new Set(rows.map((row) => row.name)); 370 - return required.filter((column) => !present.has(column)); 371 - } 372 - 373 362 /** 374 363 * Build an FTS5 MATCH expression from already-tokenized terms. Each term is 375 364 * quoted and ANDed, giving us intersection semantics across CJK bigrams and ··· 391 380 // empty-token queries fall through to this branch now. 392 381 function escapeLike(value: string): string { 393 382 return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); 383 + } 384 + 385 + function buildCoverageStatus(db: Db, selector: Selector | null): CoverageStatus { 386 + const status = coverageStatusForSelector(db, selector); 387 + return { 388 + requested: selector, 389 + complete: status.complete, 390 + freshness: "not_checked", 391 + coveringSelectors: status.coveringSelectors, 392 + }; 394 393 } 395 394 396 395 function makeLikeSnippet(content: string, query: string): string {
+39
selector.test.ts
··· 1 + import { describe, expect, test } from "vitest"; 2 + import { canonicalizeSelector, selectorImplies } from "./selector"; 3 + 4 + describe("selector", () => { 5 + test("canonicalizes selector roots to absolute paths", () => { 6 + expect(canonicalizeSelector({ kind: "all", root: "/tmp/../tmp/cxs-root" })).toEqual({ 7 + kind: "all", 8 + root: "/tmp/cxs-root", 9 + }); 10 + }); 11 + 12 + test("rejects date ranges with fromDate after toDate", () => { 13 + expect(() => 14 + canonicalizeSelector({ 15 + kind: "date_range", 16 + root: "/tmp/cxs-root", 17 + fromDate: "2026-04-23", 18 + toDate: "2026-04-22", 19 + }) 20 + ).toThrow("fromDate must be <= toDate"); 21 + }); 22 + 23 + test("computes selector implication for root cwd and date scopes", () => { 24 + const root = "/tmp/cxs-root"; 25 + expect(selectorImplies({ kind: "all", root }, { kind: "cwd", root, cwd: "/tmp/project" })).toBe(true); 26 + expect(selectorImplies( 27 + { kind: "cwd", root, cwd: "/tmp/project" }, 28 + { kind: "cwd_date_range", root, cwd: "/tmp/project", fromDate: "2026-04-21", toDate: "2026-04-22" }, 29 + )).toBe(true); 30 + expect(selectorImplies( 31 + { kind: "date_range", root, fromDate: "2026-04-01", toDate: "2026-04-30" }, 32 + { kind: "date_range", root, fromDate: "2026-04-10", toDate: "2026-04-20" }, 33 + )).toBe(true); 34 + expect(selectorImplies( 35 + { kind: "cwd", root, cwd: "/tmp/other" }, 36 + { kind: "cwd_date_range", root, cwd: "/tmp/project", fromDate: "2026-04-21", toDate: "2026-04-22" }, 37 + )).toBe(false); 38 + }); 39 + });
+120
selector.ts
··· 1 + import { resolve } from "node:path"; 2 + import type { Selector } from "./types"; 3 + 4 + const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; 5 + 6 + export function parseSelectorJson(value: string): Selector { 7 + let raw: unknown; 8 + try { 9 + raw = JSON.parse(value) as unknown; 10 + } catch (error) { 11 + throw new SelectorParseError(`invalid selector JSON: ${describeError(error)}`); 12 + } 13 + return canonicalizeSelector(raw); 14 + } 15 + 16 + export class SelectorParseError extends Error { 17 + constructor(message: string) { 18 + super(message); 19 + this.name = "SelectorParseError"; 20 + } 21 + } 22 + 23 + export function canonicalizeSelector(value: unknown): Selector { 24 + if (!isRecord(value)) throw new SelectorParseError("selector must be an object"); 25 + const kind = value.kind; 26 + const root = requireString(value.root, "root"); 27 + const base = { root: resolve(root) }; 28 + 29 + if (kind === "all") { 30 + return { kind, ...base }; 31 + } 32 + if (kind === "date_range") { 33 + const fromDate = requireDate(value.fromDate, "fromDate"); 34 + const toDate = requireDate(value.toDate, "toDate"); 35 + assertDateOrder(fromDate, toDate); 36 + return { kind, ...base, fromDate, toDate }; 37 + } 38 + if (kind === "cwd") { 39 + return { kind, ...base, cwd: requireString(value.cwd, "cwd") }; 40 + } 41 + if (kind === "cwd_date_range") { 42 + const fromDate = requireDate(value.fromDate, "fromDate"); 43 + const toDate = requireDate(value.toDate, "toDate"); 44 + assertDateOrder(fromDate, toDate); 45 + return { kind, ...base, cwd: requireString(value.cwd, "cwd"), fromDate, toDate }; 46 + } 47 + 48 + throw new SelectorParseError("selector kind must be all, date_range, cwd, or cwd_date_range"); 49 + } 50 + 51 + export function selectorImplies(covering: Selector, requested: Selector): boolean { 52 + if (covering.root !== requested.root) return false; 53 + if (covering.kind === "all") return true; 54 + 55 + if (covering.kind === "date_range") { 56 + if (requested.kind === "date_range") return containsDateRange(covering, requested); 57 + if (requested.kind === "cwd_date_range") return containsDateRange(covering, requested); 58 + return false; 59 + } 60 + 61 + if (covering.kind === "cwd") { 62 + if (requested.kind === "cwd") return covering.cwd === requested.cwd; 63 + if (requested.kind === "cwd_date_range") return covering.cwd === requested.cwd; 64 + return false; 65 + } 66 + 67 + if (requested.kind !== "cwd_date_range") return false; 68 + return covering.cwd === requested.cwd && containsDateRange(covering, requested); 69 + } 70 + 71 + export function selectorContainsFile(selector: Selector, file: { pathDate: string | null; cwd: string }): boolean { 72 + if (selector.kind === "all") return true; 73 + if (selector.kind === "cwd") return file.cwd === selector.cwd; 74 + if (!file.pathDate) return false; 75 + if (selector.kind === "date_range") return dateInRange(file.pathDate, selector.fromDate, selector.toDate); 76 + return file.cwd === selector.cwd && dateInRange(file.pathDate, selector.fromDate, selector.toDate); 77 + } 78 + 79 + export function selectorStorageKey(selector: Selector): string { 80 + return JSON.stringify(selector); 81 + } 82 + 83 + function containsDateRange( 84 + covering: Extract<Selector, { fromDate: string; toDate: string }>, 85 + requested: Extract<Selector, { fromDate: string; toDate: string }>, 86 + ): boolean { 87 + return covering.fromDate <= requested.fromDate && covering.toDate >= requested.toDate; 88 + } 89 + 90 + function dateInRange(date: string, fromDate: string, toDate: string): boolean { 91 + return date >= fromDate && date <= toDate; 92 + } 93 + 94 + function requireString(value: unknown, field: string): string { 95 + if (typeof value !== "string" || !value.trim()) { 96 + throw new SelectorParseError(`selector.${field} must be a non-empty string`); 97 + } 98 + return value.trim(); 99 + } 100 + 101 + function requireDate(value: unknown, field: string): string { 102 + const date = requireString(value, field); 103 + if (!DATE_RE.test(date)) { 104 + throw new SelectorParseError(`selector.${field} must be YYYY-MM-DD`); 105 + } 106 + return date; 107 + } 108 + 109 + function assertDateOrder(fromDate: string, toDate: string): void { 110 + if (fromDate > toDate) throw new SelectorParseError("fromDate must be <= toDate"); 111 + } 112 + 113 + function isRecord(value: unknown): value is Record<string, unknown> { 114 + return typeof value === "object" && value !== null; 115 + } 116 + 117 + function describeError(error: unknown): string { 118 + if (error instanceof Error) return error.message; 119 + return String(error); 120 + }
+11 -11
skill-packages/cxs/SKILL.md
··· 15 15 16 16 ```bash 17 17 "${CXS_BIN:-cxs}" --version # 应输出 cxs 版本号 18 - "${CXS_BIN:-cxs}" --help # 应列出 sync/find/read-range/read-page/list/stats/current 18 + "${CXS_BIN:-cxs}" --help # 应列出 status/sync/find/read-range/read-page/list/stats 19 19 ``` 20 20 21 21 如果 `cxs` 不在 PATH 里,设 `export CXS_BIN=/absolute/path/to/bin/cxs`。 ··· 35 35 36 36 | 场景 | 起手 | 原因 | 37 37 | --- | --- | --- | 38 - | 用户问"之前 / 上次 / 我记得 / 我们讨论过" | `cxs find` | 先拿 `sessionUuid + matchSeq` | 39 - | 用户问"本项目最近的对话",且不确定是否 sync 过 | `cxs current` | 直读 Codex state DB,零索引依赖 | 40 - | 用户给项目名 / cwd / 时间窗,且 cxs 已 sync | `cxs list --cwd ... --since ...` | cwd/since 缩范围比全文搜更稳 | 38 + | 用户问"之前 / 上次 / 我记得 / 我们讨论过" | `cxs status --json` | 先拿 source inventory 和 coverage | 39 + | 用户问"本项目最近的对话" | 构造 `{"kind":"cwd",...}` selector 后 `sync` / `list` | 内容只从 cxs index 出来 | 40 + | 用户给项目名 / cwd / 时间窗 | 显式构造 selector | cwd/date selector 比全文搜更稳 | 41 41 | 已锁定某 session,需要局部上下文 | `cxs read-range --seq` 或 `--query` | 局部扩窗,不冷启 `read-page` | 42 42 43 43 **反例**(应该用别的工具): ··· 50 50 51 51 ## 工作流心法 52 52 53 - - **find → read-range → read-page**:永远先 `find` 拿候选,不要冷启 `read-page` 53 + - **status → sync selector → find/list → read-range → read-page**:先确定覆盖边界,再回答内容问题 54 54 - `matchSource = "session"` 时 `matchSeq = null`;这种命中先 `read-page` 抽样,**不要伪造 `read-range --seq`** 55 - - 用户给 cwd 但不确定 sync 状态 → `current`(零索引依赖);cxs 已 sync → `list`(全索引) 55 + - 用户给 cwd 但不确定 sync 状态 → `status --json`;根据 source inventory 构造 cwd selector;再 `sync --selector` 56 56 - `cwd` 只是候选过滤,不是主题真相;还要再看 `title`、`summaryText`、开头几条 message 57 57 - 同主题可能多个 uuid;按 `cwd / startedAt / matchCount` 选,不要按 title 脑补去重 58 58 59 59 ## 前置 60 60 61 - - 先 `stats --json` 看 `dbPath / lastSyncAt / sessionCount` 62 - - 索引不存在、读命令返回 `index_unavailable`、或 `lastSyncAt` 很旧 → `sync`(默认严格模式;只有用户接受部分成功才加 `--best-effort`) 63 - - `current` 不依赖 cxs 索引,即使 sync 没跑过也能用 61 + - 先 `status --json` 看 `context / sourceInventory / coverage` 62 + - 索引不存在、读命令返回 `index_unavailable`、或 coverage 不覆盖目标范围 → `sync --selector '<json>'` 63 + - `sync` 默认严格模式;只有用户接受部分成功才加 `--best-effort`;best-effort 不写 complete coverage 64 64 - 从别的 cwd 调用时,若默认 db 不对,显式传 `--db` 65 65 66 66 ## 参考 ··· 70 70 - [`references/cli-surface.md`](references/cli-surface.md) — 每个子命令的 options + Example 71 71 - [`references/progressive-workflow.md`](references/progressive-workflow.md) — 4 个 worked scenarios 72 72 - [`references/json-schema.md`](references/json-schema.md) — 完整 JSON 字段 73 - - [`references/failure-cookbook.md`](references/failure-cookbook.md) — 错误症状速查 / state_db_unavailable 处理 / `--json` error shape 速查 73 + - [`references/failure-cookbook.md`](references/failure-cookbook.md) — 错误症状速查 / `--json` error shape 速查 74 74 - [`references/advanced-queries.md`](references/advanced-queries.md) — query 语义 / CJK 行为 / snippet 高亮 75 75 76 - # skill-sync: distributable cxs skill package, PATH-or-CXS_BIN mode, 2026-04-28 76 + # skill-sync: distributable cxs skill package, selector-status workflow, 2026-04-28
+22 -28
skill-packages/cxs/references/cli-surface.md
··· 12 12 export CXS_BIN=/absolute/path/to/bin/cxs 13 13 ``` 14 14 15 - 没有单独的 `init` 命令。首次安装后先跑 `sync`;如果是一次性 npx 调用,先跑 `npx @act0r/cxs sync`。 15 + 没有单独的 `init` 命令。首次安装后先跑 `status --json`,根据返回的 `context.root`、`sourceInventory.cwdGroups` 和问题范围构造 selector,再跑 `sync --selector '<json>'`。 16 16 17 17 缺少 cxs 索引时,`find` / `read-range` / `read-page` / `list` / `stats --json` 返回: 18 18 ··· 20 20 { "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "..." } } 21 21 ``` 22 22 23 - ## current 23 + ## status 24 24 25 - Purpose: 直读 Codex state SQLite,按 cwd 拿候选 session,**不依赖 cxs 自己的索引**(适合 sync 没跑过或刚换机器的场景)。 26 - 27 - Notes: 28 - 29 - - 默认 state DB 路径 `~/.codex/state.sqlite`,可用 `--state-db` 覆盖 30 - - `--cwd` 缺省走 `process.cwd()`,直接拿当前 repo 候选 31 - - 顶层 `{ cwd, candidates: CurrentSessionCandidate[] }`;每个 candidate 含 `sessionUuid / title / cwd / filePath / updatedAtMs` 32 - - state DB 不存在/缺 `threads` 表/缺必需列时,`--json` 输出结构化 `{ error: { code: "state_db_unavailable", message } }`,exit code 1 33 - - 拿到 `sessionUuid` 后通常直接 `read-page` 抽样确认,不需要再走 `find` 34 - 35 - Options: 36 - 37 - | option | 说明 | 38 - | --- | --- | 39 - | `--cwd <path>` | 指定 cwd,默认 `process.cwd()` | 40 - | `-n, --limit <n>` | 候选条数上限,默认 100 | 41 - | `--state-db <path>` | 覆盖默认 Codex state SQLite 路径 | 42 - | `--json` | 输出 JSON | 25 + Purpose: 返回执行上下文、source inventory、index 状态和 coverage 状态。`status` 可以扫描 raw session metadata,但不回答内容问题、不写 index。 43 26 44 27 Example: 45 28 46 29 ```bash 47 - "${CXS_BIN:-cxs}" current --json 48 - "${CXS_BIN:-cxs}" current --cwd /Users/me/work/foo --json 30 + "${CXS_BIN:-cxs}" status --json 31 + ``` 32 + 33 + Selector shapes: 34 + 35 + ```json 36 + {"kind":"all","root":"/Users/me/.codex/sessions"} 37 + {"kind":"date_range","root":"/Users/me/.codex/sessions","fromDate":"2026-04-01","toDate":"2026-04-30"} 38 + {"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"} 39 + {"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo","fromDate":"2026-04-01","toDate":"2026-04-30"} 49 40 ``` 50 41 51 42 ## sync 52 43 53 - Purpose: 扫描本地 `~/.codex/sessions` 并同步到 SQLite 索引。 44 + Purpose: 按显式 selector 扫描本地 sessions 并同步到 SQLite 索引。 54 45 55 46 Options: 56 47 57 48 | option | 说明 | 58 49 | --- | --- | 59 - | `--root <dir>` | 覆盖 sessions 根目录 | 50 + | `--selector <json>` | 必填;结构化同步范围 | 60 51 | `--db <path>` | 覆盖默认数据库 | 61 - | `--best-effort` | 即使部分文件失败也继续写入成功部分 | 52 + | `--best-effort` | 即使部分文件失败也继续写入成功部分;不写 complete coverage | 62 53 | `--json` | 成功时把 `SyncSummary` 打到 stdout | 54 + 55 + 严格模式成功时,`sync` 会先把 selector 范围内的 index 与当前 source snapshot 对齐;源文件已删除、被过滤或不再能解析成 session 的旧 row 会被移除,并计入 `removed`。 63 56 64 57 Example: 65 58 66 59 ```bash 67 - "${CXS_BIN:-cxs}" sync --json 68 - "${CXS_BIN:-cxs}" sync --json 2>&1 60 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"}' --json 61 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' --json 2>&1 69 62 ``` 70 63 71 64 ## find ··· 76 69 77 70 ```bash 78 71 "${CXS_BIN:-cxs}" find "cf tunnel" --json -n 5 72 + "${CXS_BIN:-cxs}" find "cf tunnel" --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"}' --json -n 5 79 73 ``` 80 74 81 75 ## read-range ··· 111 105 Example: 112 106 113 107 ```bash 114 - "${CXS_BIN:-cxs}" list --cwd hammerspoon --since 2026-04-15 --sort ended --json 108 + "${CXS_BIN:-cxs}" list --selector '{"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo","fromDate":"2026-04-15","toDate":"2026-04-30"}' --sort ended --json 115 109 ``` 116 110 117 111 ## stats
+34 -47
skill-packages/cxs/references/failure-cookbook.md
··· 4 4 5 5 | 症状 | 先跑 | 处理 | 6 6 | --- | --- | --- | 7 - | `find` 零结果但用户坚持存在 | `stats --json` | 看 `lastSyncAt`;必要时 `sync`;再试 `list --cwd` | 8 - | `sync` 非零退出带 per-file errors | `sync --json 2>&1` | 看 `errorDetails[]`;默认严格模式;只在允许部分成功时加 `--best-effort` | 9 - | `find/list/stats/read-*` 输出 `index_unavailable` | `sync` | cxs 索引还没建立;没有单独 `init`,`sync` 就是建库入口 | 7 + | `find` 零结果但用户坚持存在 | `status --json` | 看目标 selector 是否有 coverage;必要时 `sync --selector`;再带 selector 查询 | 8 + | `sync` 非零退出带 per-file errors | `sync --selector '<json>' --json 2>&1` | 看 `errorDetails[]`;默认严格模式;只在允许部分成功时加 `--best-effort` | 9 + | `sync` 返回 `selector_required` | 原命令补 `--selector` | selector 必须显式,不存在默认范围 | 10 + | `find/list/stats/read-*` 输出 `index_unavailable` | `status --json` | 索引还没建立;选择 selector 后 `sync --selector` | 10 11 | `stats/list/find` 报 `database is locked` | 原命令重试一次 | 多半是 SQLite 忙;仍失败就先跳过 `stats` 直接读 | 11 12 | 同一主题多条 uuid | `find -n 10 --json` | 按 `startedAt`、`cwd`、`matchCount` 选 | 12 - | 中文/CJK 零结果 | 无 | 换至少两字中文、英文关键词,或先 `list --since` | 13 - | 用户问“最近本项目讨论了什么” | `list --cwd <current-repo> --sort ended --json` | `cwd` 先圈候选,再抽样读头尾确认主题 | 14 - | 用户说“在 X 项目里” | `list --cwd X --json` | 先按 cwd 缩范围,再在候选里 `read-range --query` | 13 + | 中文/CJK 零结果 | 无 | 换至少两字中文、英文关键词,或先用 selector 缩范围 | 14 + | 用户问“最近本项目讨论了什么” | `status --json` | 用当前 repo cwd 构造 selector,同步后 `list --selector` | 15 + | 用户说“在 X 项目里” | `status --json` | 从 `sourceInventory.cwdGroups` 选择 cwd selector | 15 16 | 从其他 cwd 调用找不到 db | `stats --json` | 看 `dbPath`;必要时显式传 `--db` | 16 - | `current --json` 输出 `state_db_unavailable` | 看 message | Codex state DB 问题,不是 cxs 本身坏;**不要重跑 sync** | 17 17 18 18 ## Find zero results but user insists it exists 19 19 20 20 ```bash 21 - "${CXS_BIN:-cxs}" stats --json 21 + "${CXS_BIN:-cxs}" status --json 22 22 ``` 23 23 24 - 如果 `lastSyncAt` 很旧,先: 24 + 如果目标范围没有 coverage,先同步明确 selector: 25 25 26 26 ```bash 27 - "${CXS_BIN:-cxs}" sync 27 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"}' 28 28 ``` 29 29 30 + 然后查询时继续带同一个 selector。 31 + 30 32 ## Sync non-zero with per-file errors 31 33 32 34 ```bash 33 - "${CXS_BIN:-cxs}" sync --json 2>&1 35 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' --json 2>&1 34 36 ``` 35 37 36 38 处理规则: 37 39 38 - - 默认不要忽略,先看是坏 JSONL、权限问题还是别的解析失败 39 - - 只有用户明确接受 partial index 时,才用 `--best-effort` 40 + - 默认不要忽略,先看是坏 JSONL、权限问题还是别的解析失败。 41 + - 只有用户明确接受 partial index 时,才用 `--best-effort`。 42 + - `--best-effort` 不写 complete coverage。 40 43 41 44 ## index_unavailable 42 45 43 - `find` / `read-range` / `read-page` / `list` / `stats` 都读 cxs 自己的 SQLite 索引。第一次安装后还没跑过 `sync` 时,这些命令会在 `--json` 模式下返回: 46 + `find` / `read-range` / `read-page` / `list` / `stats` 都读 cxs 自己的 SQLite 索引。第一次安装后还没跑过 `sync --selector` 时,这些命令会在 `--json` 模式下返回: 44 47 45 48 ```json 46 49 { ··· 56 59 处理方式: 57 60 58 61 ```bash 59 - "${CXS_BIN:-cxs}" sync 62 + "${CXS_BIN:-cxs}" status --json 63 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' 60 64 ``` 61 65 62 - 没有单独 `init` 命令;`sync` 会创建并更新索引。如果用户是一次性 `npx @act0r/cxs find ...`,提示他先跑 `npx @act0r/cxs sync`。 66 + 没有单独 `init` 命令;`sync --selector` 会创建并更新索引。 63 67 64 68 ## Database is locked or SQLITE_BUSY 65 69 66 - - 先重试原命令一次 67 - - 如果只是想读取历史,不一定非得先拿 `stats` 68 - - 如果你刚跑过 `sync` 或怀疑别的进程正占着 db,先等一下再重试 70 + - 先重试原命令一次。 71 + - 如果只是想读取历史,不一定非得先拿 `stats`。 72 + - 如果你刚跑过 `sync` 或怀疑别的进程正占着 db,先等一下再重试。 69 73 70 74 ## Current project discussion query 71 75 72 - 用户问“最近本项目讨论了什么”时,默认先用当前 repo 绝对路径: 76 + 用户问“最近本项目讨论了什么”时,默认先用当前 repo 绝对路径构造 cwd selector: 73 77 74 78 ```bash 75 - "${CXS_BIN:-cxs}" list --cwd /absolute/path/to/current/repo --sort ended -n 8 --json 79 + "${CXS_BIN:-cxs}" status --json 80 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --json 81 + "${CXS_BIN:-cxs}" list --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --sort ended -n 8 --json 76 82 ``` 77 83 78 84 然后至少再看: ··· 82 88 - `read-page` 开头 6 到 8 条 83 89 - `read-page` 结尾 6 到 8 条 84 90 85 - ## state_db_unavailable 86 - 87 - `cxs current` 直读 Codex state SQLite,跟 cxs 自身索引无关。`--json` 模式下,所有 state DB 不可用都被收口成: 88 - 89 - ```json 90 - { "error": { "code": "state_db_unavailable", "message": "..." } } 91 - ``` 92 - 93 - `message` 三类: 94 - 95 - | message 关键词 | 真实原因 | 处理 | 96 - | --- | --- | --- | 97 - | `state db not found: <path>` | Codex state DB 文件不存在 | 用户没装 codex 或 `--state-db` 路径错 | 98 - | `missing 'threads' table` | DB 存在但缺核心表 | Codex 版本异常或库被截断 | 99 - | `missing column(s) ...` | `threads` 表缺必需列(`id` / `rollout_path` / `cwd` / `title` / `updated_at_ms`) | 上游 Codex 改了 schema,cxs 需要适配新版 | 100 - 101 - **关键**:这是 **Codex 端**的问题,**不要尝试 `cxs sync` 修复**——`sync` 写的是 cxs 自己的索引,跟 state DB 毫无关系。直接告知用户检查 codex 安装/版本即可。 102 - 103 91 ## --json error shape 速查 104 92 105 - 不同子命令在 `--json` 下的 error 形状不一致,解析时按命令分流: 93 + 不同子命令在 `--json` 下的 error 形状不一致,解析时按命令分流: 106 94 107 95 | 命令 | error 出口 | 形状 | 108 96 | --- | --- | --- | 109 - | `sync`(per-file 错) | stderr | `SyncSummary`,看 `errors / errorDetails[]` | 110 - | `sync`(锁超时 `SyncLockTimeoutError`) | stderr | `{ "error": <message string> }` | 111 - | `current`(state DB 问题) | stdout | `{ "error": { "code": "state_db_unavailable", "message": "..." } }` | 112 - | `find / read-range / read-page / list / stats`(索引不存在) | stdout | `{ "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "..." } }` | 113 - | `find / read-range / read-page / list / stats`(其他异常) | 进程异常退出 | 直接非零退出 | 114 - 115 - **实务**:解析前先看 exit code;非零再判断是结构化(`current` / 缺索引读命令)还是字符串(`sync` 锁超时)还是 summary(`sync` per-file)。 97 + | `sync` 缺 selector | stdout | `{ "error": { "code": "selector_required", "message": "..." } }` | 98 + | `sync` invalid selector | stdout | `{ "error": { "code": "invalid_selector", "message": "..." } }` | 99 + | `sync` per-file 错 | stderr | `SyncSummary`,看 `errors / errorDetails[]` | 100 + | `sync` 锁超时 | stderr | `{ "error": <message string> }` | 101 + | `find / read-range / read-page / list / stats` 索引不存在 | stdout | `{ "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "..." } }` | 102 + | `find / read-range / read-page / list / stats` 其他异常 | 进程异常退出 | 直接非零退出 | 116 103 117 104 ## Schema drift 118 105
+87
skill-packages/cxs/references/json-schema.md
··· 8 8 { 9 9 query: string; 10 10 results: FindResult[]; 11 + coverage: CoverageStatus; 11 12 } 12 13 ``` 13 14 ··· 43 44 rangeStartSeq: number; 44 45 rangeEndSeq: number; 45 46 messages: MessageRecord[]; 47 + coverage: { entries: CoverageRecord[] }; 46 48 } 47 49 ``` 48 50 ··· 56 58 totalCount: number; 57 59 hasMore: boolean; 58 60 messages: MessageRecord[]; 61 + coverage: { entries: CoverageRecord[] }; 59 62 } 60 63 ``` 61 64 ··· 66 69 query: { 67 70 cwd?: string; 68 71 since?: string; 72 + selector?: Selector; 69 73 sort: "ended" | "started" | "messages"; 70 74 limit: number; 71 75 }; 72 76 results: SessionListEntry[]; 77 + coverage: CoverageStatus; 73 78 } 74 79 ``` 75 80 ··· 86 91 dbPath: string; 87 92 dbSizeBytes: number; 88 93 lastSyncAt: string | null; 94 + coverage: CoverageInventoryStatus[]; 95 + } 96 + ``` 97 + 98 + ## status 99 + 100 + ```ts 101 + { 102 + context: { 103 + cwd: string; 104 + root: string; 105 + dbPath: string; 106 + indexVersion: string; 107 + }; 108 + sourceInventory: SourceInventory; 109 + index: { 110 + exists: boolean; 111 + sessionCount: number; 112 + messageCount: number; 113 + earliestStartedAt: string | null; 114 + latestEndedAt: string | null; 115 + dbSizeBytes: number; 116 + lastSyncAt: string | null; 117 + }; 118 + coverage: CoverageRecord[]; 89 119 } 90 120 ``` 91 121 ··· 98 128 updated: number; 99 129 skipped: number; 100 130 filtered: number; 131 + removed: number; 101 132 errors: number; 102 133 errorDetails: Array<{ 103 134 filePath: string; 104 135 message: string; 105 136 }>; 137 + selector: Selector; 138 + coverage: { 139 + written: boolean; 140 + selector: Selector; 141 + sourceFingerprint: string; 142 + sourceFileCount: number; 143 + indexedSessionCount: number; 144 + reason?: string; 145 + }; 106 146 } 107 147 ``` 108 148 ··· 114 154 { 115 155 sessionUuid: string; 116 156 filePath: string; 157 + sourceRoot: string; 117 158 title: string; 118 159 summaryText: string; 119 160 cwd: string; 120 161 model: string; 121 162 startedAt: string; 122 163 endedAt: string; 164 + pathDate: string; 123 165 messageCount: number; 124 166 } 125 167 ``` ··· 134 176 contentText: string; 135 177 timestamp: string; 136 178 sourceKind: string; 179 + } 180 + ``` 181 + 182 + `Selector`: 183 + 184 + ```ts 185 + type Selector = 186 + | { kind: "all"; root: string } 187 + | { kind: "date_range"; root: string; fromDate: string; toDate: string } 188 + | { kind: "cwd"; root: string; cwd: string } 189 + | { kind: "cwd_date_range"; root: string; cwd: string; fromDate: string; toDate: string }; 190 + ``` 191 + 192 + `CoverageStatus`: 193 + 194 + ```ts 195 + { 196 + requested: Selector | null; 197 + complete: boolean; 198 + freshness: "not_checked"; 199 + coveringSelectors: CoverageRecord[]; 200 + } 201 + ``` 202 + 203 + `CoverageRecord`: 204 + 205 + ```ts 206 + { 207 + id: number; 208 + selector: Selector; 209 + sourceFingerprint: string; 210 + sourceFileCount: number; 211 + indexedSessionCount: number; 212 + completedAt: string; 213 + indexVersion: string; 214 + } 215 + ``` 216 + 217 + `CoverageInventoryStatus`: 218 + 219 + ```ts 220 + CoverageRecord & { 221 + freshness: "fresh" | "stale"; 222 + currentSourceFingerprint: string; 223 + currentSourceFileCount: number; 137 224 } 138 225 ``` 139 226
+15 -30
skill-packages/cxs/references/progressive-workflow.md
··· 1 1 # Progressive Workflow 2 2 3 - ## 默认三步 3 + ## 默认流程 4 4 5 - 1. `find` 拿候选 session 和命中锚点 6 - 2. `read-range` 在最佳候选周围扩局部上下文 7 - 3. `read-page` 只在局部窗口仍不够时翻整页 5 + 1. `status --json` 拿 source inventory 和 coverage 6 + 2. 选择明确 selector 并 `sync --selector` 7 + 3. `find` 或 `list` 拿候选 session 和命中锚点 8 + 4. `read-range` 在最佳候选周围扩局部上下文 9 + 5. `read-page` 只在局部窗口仍不够时翻整页 8 10 9 11 硬规则: 10 12 11 13 - 没有 `sessionUuid` 时,不要冷启动 `read-page` 12 - - 用户给了 `cwd` 但**不知道是否 sync 过**:先 `current --cwd ...`(直读 state DB,零索引依赖) 13 - - 用户给了 `cwd` 或时间窗口且 cxs 已 sync,优先 `list` 14 + - 用户给了 `cwd` 或时间窗口:构造同范围 selector;先同步这个 selector;查询时继续带同一个 selector 14 15 - 已锁定 session 但锚点不对时,用 `read-range --query` 15 16 - `cwd` 只是候选过滤,不是主题真相;还要再看 `title`、`summaryText` 和开头几条 message 16 17 ··· 19 20 用户说:`上次我配 cf tunnel 是怎么弄的` 20 21 21 22 ```bash 23 + "${CXS_BIN:-cxs}" status --json 24 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' --json 22 25 "${CXS_BIN:-cxs}" find "cf tunnel" --json -n 5 23 26 ``` 24 27 ··· 41 44 先按 cwd + 时间缩范围: 42 45 43 46 ```bash 44 - "${CXS_BIN:-cxs}" list --cwd hammerspoon --since 2026-04-15 --json 47 + "${CXS_BIN:-cxs}" status --json 48 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/hammerspoon","fromDate":"2026-04-15","toDate":"2026-04-30"}' --json 49 + "${CXS_BIN:-cxs}" list --selector '{"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/hammerspoon","fromDate":"2026-04-15","toDate":"2026-04-30"}' --json 45 50 ``` 46 51 47 52 再在候选 session 内局部重定位: ··· 57 62 先按当前 repo 路径列最近 session: 58 63 59 64 ```bash 60 - "${CXS_BIN:-cxs}" list --cwd /absolute/path/to/current/repo --sort ended -n 8 --json 65 + "${CXS_BIN:-cxs}" status --json 66 + "${CXS_BIN:-cxs}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --json 67 + "${CXS_BIN:-cxs}" list --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --sort ended -n 8 --json 61 68 ``` 62 69 63 70 不要把 `cwd` 直接当主题真相。至少再看: ··· 66 73 - `summaryText` 67 74 - 开头几条 message 68 75 - 结尾几条 message 69 - 70 - ## Worked Scenario 4 71 - 72 - 用户说:`本项目最近的对话` 或 `刚换的机器还没来得及跑 sync`,但你需要立刻拿到当前 repo 的候选 session。 73 - 74 - 先 `current` 直读 Codex state DB(不需要 cxs 自己的索引): 75 - 76 - ```bash 77 - "${CXS_BIN:-cxs}" current --json 78 - ``` 79 - 80 - 输出顶层是 `{ cwd, candidates: [...] }`,每个 candidate 已经按 `updatedAtMs` 倒序。拿最近一条的 `sessionUuid` 直接抽样: 81 - 82 - ```bash 83 - "${CXS_BIN:-cxs}" read-page <sessionUuid> --offset 0 --limit 20 --json 84 - ``` 85 - 86 - **何时选 current 而不是 list**: 87 - 88 - - `cxs sync` 没跑过或久没 sync(`stats.lastSyncAt` 很旧/为 null)时,`list` 会拿到陈旧或空结果;`current` 直读 state DB,反映的是 Codex 当前的 thread 列表 89 - - 只想要"当前 cwd 下最新的 session",不需要全文检索 90 - - state DB 不存在/缺 `threads` 表/缺必需列时,`current --json` 会返回结构化 `{ error: { code: "state_db_unavailable", message } }`,**不要重跑 sync 试图修复**——那是 Codex 端的问题,提示用户检查 codex 安装即可 91 76 92 77 ## 来源 93 78
+69
skill-packages/cxsd/SKILL.md
··· 1 + --- 2 + name: cxsd 3 + description: "Use when the user wants to dogfood, verify, debug, or compare unpublished local cxs changes from /Users/envvar/work/repos/cxs; mentions cxsd, dev cxs, local cxs, current checkout, or latest local code. Do not use for published-version checks; use cxs there." 4 + --- 5 + 6 + # cxsd 7 + 8 + 用 `cxsd` 跑当前本机 checkout 的 cxs 开发版。`cxsd` 只改变 CLI 实现来源,内容回答仍然只来自 cxs index。 9 + 10 + ## 本机入口 11 + 12 + `cxsd` 是这台机器的开发版 wrapper,不等同于发布版 `cxs`: 13 + 14 + - repo: `/Users/envvar/work/repos/cxs` 15 + - bin: `/Users/envvar/.local/bin/cxsd` 16 + - override: `CXSD_BIN=/absolute/path/to/bin/cxsd` 17 + 18 + ```bash 19 + "${CXSD_BIN:-cxsd}" --version 20 + "${CXSD_BIN:-cxsd}" --help # 应列出 status/sync/find/read-range/read-page/list/stats,不应列 current 21 + ``` 22 + 23 + 如果用户要验证 npm/npx 已发布版本,改用 `cxs` skill 和发布版 `cxs` 命令。 24 + 25 + ## 什么时候用 cxsd 26 + 27 + | 场景 | 起手 | 原因 | 28 + | --- | --- | --- | 29 + | 验证当前 checkout 的 cxs 行为 | `"${CXSD_BIN:-cxsd}" status --json` | 用本地源码,不碰发布版 | 30 + | 用户问"之前 / 上次 / 我记得 / 我们讨论过"且要试 dev 版 | `"${CXSD_BIN:-cxsd}" status --json` | 先拿 source inventory 和 coverage | 31 + | 用户问"本项目最近的对话" | 构造 `{"kind":"cwd",...}` selector 后用 `"${CXSD_BIN:-cxsd}" sync` / `"${CXSD_BIN:-cxsd}" list` | 内容只从 cxs index 出来 | 32 + | 用户给项目名 / cwd / 时间窗 | 显式构造 selector | cwd/date selector 比全文搜更稳 | 33 + | 已锁定某 session,需要局部上下文 | `"${CXSD_BIN:-cxsd}" read-range --seq` 或 `--query` | 局部扩窗,不冷启 `read-page` | 34 + 35 + **反例**(应该用别的工具): 36 + 37 + - 用户要验证最新发布版 / npx 行为 → `cxs` skill 38 + - 当前 repo 代码/字符串搜索 → 代码搜索工具 39 + - 当前文件或已知路径阅读 → 文件读取工具 40 + - 外部文档/网页 → WebFetch / WebSearch 41 + - 今日提交/日报 → `commit-daily-summary` 42 + - 当前会话收尾 → `session-wrap` 43 + 44 + ## 工作流心法 45 + 46 + - **status → sync selector → find/list → read-range → read-page**:先确定覆盖边界,再回答内容问题 47 + - `matchSource = "session"` 时 `matchSeq = null`;这种命中先 `read-page` 抽样,**不要伪造 `read-range --seq`** 48 + - 用户给 cwd 但不确定 sync 状态 → `"${CXSD_BIN:-cxsd}" status --json`;根据 source inventory 构造 cwd selector;再 `"${CXSD_BIN:-cxsd}" sync --selector` 49 + - `cwd` 只是候选过滤,不是主题真相;还要再看 `title`、`summaryText`、开头几条 message 50 + - 同主题可能多个 uuid;按 `cwd / startedAt / matchCount` 选,不要按 title 脑补去重 51 + 52 + ## 前置 53 + 54 + - 先 `status --json` 看 `context / sourceInventory / coverage` 55 + - 索引不存在、读命令返回 `index_unavailable`、或 coverage 不覆盖目标范围 → `"${CXSD_BIN:-cxsd}" sync --selector '<json>'` 56 + - `sync` 默认严格模式;只有用户接受部分成功才加 `--best-effort`;best-effort 不写 complete coverage 57 + - 从别的 cwd 调用时,若默认 db 不对,显式传 `--db` 58 + 59 + ## 参考 60 + 61 + 详细命令面、字段、流程、错误处理: 62 + 63 + - [`references/cli-surface.md`](references/cli-surface.md) — 每个子命令的 options + Example 64 + - [`references/progressive-workflow.md`](references/progressive-workflow.md) — 4 个 worked scenarios 65 + - [`references/json-schema.md`](references/json-schema.md) — 完整 JSON 字段 66 + - [`references/failure-cookbook.md`](references/failure-cookbook.md) — 错误症状速查 / `--json` error shape 速查 67 + - [`references/advanced-queries.md`](references/advanced-queries.md) — query 语义 / CJK 行为 / snippet 高亮 68 + 69 + # skill-sync: local cxsd dev skill, selector-status workflow, 2026-04-28
+86
skill-packages/cxsd/references/advanced-queries.md
··· 1 + # Advanced Queries 2 + 3 + ## 实际 query 语义 4 + 5 + `cxs` 不是把用户输入原样透传给 SQLite FTS。当前行为是: 6 + 7 + - 先把 query 做 tokenizer 处理 8 + - 每个 term 都会被双引号包住 9 + - term 与 term 之间一律用 `AND` 10 + 11 + 这意味着: 12 + 13 + - 不要指望用户输入里的 `OR`、`NEAR`、`*`、引号按原生 FTS 运算符生效 14 + - 空格分开的多词查询,本质上是“这些词都要进候选” 15 + - 原始整句仍会参与 rerank,所以像 `health check` 这种自然短语仍然值得原样查询 16 + 17 + 对 agent 的含义: 18 + 19 + - 先用自然关键词查询 20 + - 关键词太宽时,增加第二个稳定词,而不是发明 FTS 运算符 21 + 22 + ## CJK / 中文行为 23 + 24 + 当前 tokenizer 对 CJK 的策略是: 25 + 26 + - 中文/日文/韩文连续串按重叠 bigram 切分 27 + - 单个 CJK 字不会成为有效 FTS term 28 + - 如果 query 含 CJK 但 token 结果为空,会回退到有界 LIKE 扫描 29 + 30 + 实务建议: 31 + 32 + - 单个汉字命中不稳,尽量用至少两字中文词 33 + - 更稳的是“中文短词 + 英文标识符/报错” 34 + - 中文零结果时,先换: 35 + - 至少两字中文 36 + - 英文关键词 37 + - 项目名 / cwd 过滤 38 + 39 + ## 缩范围:什么时候 `list` 胜过 `find` 40 + 41 + 优先 `list` 的情况: 42 + 43 + - 用户给了项目名、repo、cwd 44 + - 用户给了大致日期或“前几天 / 那周 / 昨天” 45 + - 用户只记得“在那个项目里做过”,却没有强关键词 46 + 47 + 典型做法: 48 + 49 + ```bash 50 + "${CXSD_BIN:-cxsd}" list --cwd hammerspoon --since 2026-04-15 --json 51 + ``` 52 + 53 + 然后对候选 session 再跑: 54 + 55 + ```bash 56 + "${CXSD_BIN:-cxsd}" read-range <sessionUuid> --query "IME" --json 57 + ``` 58 + 59 + ## 同 title 的多变体 session 60 + 61 + Codex resume/fork 可能产生多个 title 很像、但 `sessionUuid` 不同的 session。当前 `find` 会保留这些 distinct sessions,不会按 title 折叠。 62 + 63 + 不要做的事: 64 + 65 + - 不要假设 title 一样就是同一场会话 66 + - 不要自己先按 title 去重再看内容 67 + 68 + 应该做的事: 69 + 70 + - 按 `cwd` 71 + - 按 `startedAt` / `endedAt` 72 + - 按 `matchCount` 73 + - 再决定是否继续 `read-range` 74 + 75 + ## `snippet` 高亮 76 + 77 + - FTS path 的 `snippet` 会带 `<mark>...</mark>` 78 + - LIKE fallback 也会自己补 `<mark>...</mark>` 79 + - 如果下游需要纯文本,自己 strip 80 + - 如果你在回答里要引用命中词,高亮保留也可以 81 + 82 + ## 来源 83 + 84 + - 仓库内 `query.ts` 85 + - 仓库内 `tokenize.ts` 86 + - 仓库内 `ranking.ts`
+125
skill-packages/cxsd/references/cli-surface.md
··· 1 + # cxsd CLI Surface 2 + 3 + 命令默认写法: 4 + 5 + ```bash 6 + "${CXSD_BIN:-cxsd}" <subcommand> ... 7 + ``` 8 + 9 + 如果你没有把 `cxsd` 放进 `PATH`,先: 10 + 11 + ```bash 12 + export CXSD_BIN=/absolute/path/to/bin/cxsd 13 + ``` 14 + 15 + 没有单独的 `init` 命令。首次安装后先跑 `status --json`,根据返回的 `context.root`、`sourceInventory.cwdGroups` 和问题范围构造 selector,再跑 `sync --selector '<json>'`。 16 + 17 + 缺少 cxs 索引时,`find` / `read-range` / `read-page` / `list` / `stats --json` 返回: 18 + 19 + ```json 20 + { "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "..." } } 21 + ``` 22 + 23 + ## status 24 + 25 + Purpose: 返回执行上下文、source inventory、index 状态和 coverage 状态。`status` 可以扫描 raw session metadata,但不回答内容问题、不写 index。 26 + 27 + Example: 28 + 29 + ```bash 30 + "${CXSD_BIN:-cxsd}" status --json 31 + ``` 32 + 33 + Selector shapes: 34 + 35 + ```json 36 + {"kind":"all","root":"/Users/me/.codex/sessions"} 37 + {"kind":"date_range","root":"/Users/me/.codex/sessions","fromDate":"2026-04-01","toDate":"2026-04-30"} 38 + {"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"} 39 + {"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo","fromDate":"2026-04-01","toDate":"2026-04-30"} 40 + ``` 41 + 42 + ## sync 43 + 44 + Purpose: 按显式 selector 扫描本地 sessions 并同步到 SQLite 索引。 45 + 46 + Options: 47 + 48 + | option | 说明 | 49 + | --- | --- | 50 + | `--selector <json>` | 必填;结构化同步范围 | 51 + | `--db <path>` | 覆盖默认数据库 | 52 + | `--best-effort` | 即使部分文件失败也继续写入成功部分;不写 complete coverage | 53 + | `--json` | 成功时把 `SyncSummary` 打到 stdout | 54 + 55 + 严格模式成功时,`sync` 会先把 selector 范围内的 index 与当前 source snapshot 对齐;源文件已删除、被过滤或不再能解析成 session 的旧 row 会被移除,并计入 `removed`。 56 + 57 + Example: 58 + 59 + ```bash 60 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"}' --json 61 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' --json 2>&1 62 + ``` 63 + 64 + ## find 65 + 66 + Purpose: 搜索相关 session,返回最小必要命中。 67 + 68 + Example: 69 + 70 + ```bash 71 + "${CXSD_BIN:-cxsd}" find "cf tunnel" --json -n 5 72 + "${CXSD_BIN:-cxsd}" find "cf tunnel" --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"}' --json -n 5 73 + ``` 74 + 75 + ## read-range 76 + 77 + Purpose: 围绕命中点读取局部上下文。 78 + 79 + Notes: 80 + 81 + - 必须显式传 `<sessionUuid>` 82 + - 必须二选一提供 `--seq` 或 `--query` 83 + 84 + Example: 85 + 86 + ```bash 87 + "${CXSD_BIN:-cxsd}" read-range <sessionUuid> --seq 12 --before 4 --after 8 --json 88 + "${CXSD_BIN:-cxsd}" read-range <sessionUuid> --query "IME" --before 4 --after 8 --json 89 + ``` 90 + 91 + ## read-page 92 + 93 + Purpose: 顺序分页读取某个 session 的消息。 94 + 95 + Example: 96 + 97 + ```bash 98 + "${CXSD_BIN:-cxsd}" read-page <sessionUuid> --offset 0 --limit 40 --json 99 + ``` 100 + 101 + ## list 102 + 103 + Purpose: 列出已索引 session,不做全文检索。 104 + 105 + Example: 106 + 107 + ```bash 108 + "${CXSD_BIN:-cxsd}" list --selector '{"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo","fromDate":"2026-04-15","toDate":"2026-04-30"}' --sort ended --json 109 + ``` 110 + 111 + ## stats 112 + 113 + Purpose: 展示索引状态统计。 114 + 115 + Example: 116 + 117 + ```bash 118 + "${CXSD_BIN:-cxsd}" stats --json 119 + ``` 120 + 121 + ## 来源 122 + 123 + - 仓库内 `cli.ts` 124 + - 仓库内 `env.ts` 125 + - 仓库内 `README.md`
+122
skill-packages/cxsd/references/failure-cookbook.md
··· 1 + # Failure Cookbook 2 + 3 + ## 快速表 4 + 5 + | 症状 | 先跑 | 处理 | 6 + | --- | --- | --- | 7 + | `find` 零结果但用户坚持存在 | `status --json` | 看目标 selector 是否有 coverage;必要时 `sync --selector`;再带 selector 查询 | 8 + | `sync` 非零退出带 per-file errors | `sync --selector '<json>' --json 2>&1` | 看 `errorDetails[]`;默认严格模式;只在允许部分成功时加 `--best-effort` | 9 + | `sync` 返回 `selector_required` | 原命令补 `--selector` | selector 必须显式,不存在默认范围 | 10 + | `find/list/stats/read-*` 输出 `index_unavailable` | `status --json` | 索引还没建立;选择 selector 后 `sync --selector` | 11 + | `stats/list/find` 报 `database is locked` | 原命令重试一次 | 多半是 SQLite 忙;仍失败就先跳过 `stats` 直接读 | 12 + | 同一主题多条 uuid | `find -n 10 --json` | 按 `startedAt`、`cwd`、`matchCount` 选 | 13 + | 中文/CJK 零结果 | 无 | 换至少两字中文、英文关键词,或先用 selector 缩范围 | 14 + | 用户问“最近本项目讨论了什么” | `status --json` | 用当前 repo cwd 构造 selector,同步后 `list --selector` | 15 + | 用户说“在 X 项目里” | `status --json` | 从 `sourceInventory.cwdGroups` 选择 cwd selector | 16 + | 从其他 cwd 调用找不到 db | `stats --json` | 看 `dbPath`;必要时显式传 `--db` | 17 + 18 + ## Find zero results but user insists it exists 19 + 20 + ```bash 21 + "${CXSD_BIN:-cxsd}" status --json 22 + ``` 23 + 24 + 如果目标范围没有 coverage,先同步明确 selector: 25 + 26 + ```bash 27 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/foo"}' 28 + ``` 29 + 30 + 然后查询时继续带同一个 selector。 31 + 32 + ## Sync non-zero with per-file errors 33 + 34 + ```bash 35 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' --json 2>&1 36 + ``` 37 + 38 + 处理规则: 39 + 40 + - 默认不要忽略,先看是坏 JSONL、权限问题还是别的解析失败。 41 + - 只有用户明确接受 partial index 时,才用 `--best-effort`。 42 + - `--best-effort` 不写 complete coverage。 43 + 44 + ## index_unavailable 45 + 46 + `find` / `read-range` / `read-page` / `list` / `stats` 都读 cxs 自己的 SQLite 索引。第一次安装后还没跑过 `sync --selector` 时,这些命令会在 `--json` 模式下返回: 47 + 48 + ```json 49 + { 50 + "error": { 51 + "code": "index_unavailable", 52 + "message": "index not found: ...", 53 + "dbPath": "...", 54 + "hint": "Run `cxsd sync` first ..." 55 + } 56 + } 57 + ``` 58 + 59 + 处理方式: 60 + 61 + ```bash 62 + "${CXSD_BIN:-cxsd}" status --json 63 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' 64 + ``` 65 + 66 + 没有单独 `init` 命令;`sync --selector` 会创建并更新索引。 67 + 68 + ## Database is locked or SQLITE_BUSY 69 + 70 + - 先重试原命令一次。 71 + - 如果只是想读取历史,不一定非得先拿 `stats`。 72 + - 如果你刚跑过 `sync` 或怀疑别的进程正占着 db,先等一下再重试。 73 + 74 + ## Current project discussion query 75 + 76 + 用户问“最近本项目讨论了什么”时,默认先用当前 repo 绝对路径构造 cwd selector: 77 + 78 + ```bash 79 + "${CXSD_BIN:-cxsd}" status --json 80 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --json 81 + "${CXSD_BIN:-cxsd}" list --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --sort ended -n 8 --json 82 + ``` 83 + 84 + 然后至少再看: 85 + 86 + - `title` 87 + - `summaryText` 88 + - `read-page` 开头 6 到 8 条 89 + - `read-page` 结尾 6 到 8 条 90 + 91 + ## --json error shape 速查 92 + 93 + 不同子命令在 `--json` 下的 error 形状不一致,解析时按命令分流: 94 + 95 + | 命令 | error 出口 | 形状 | 96 + | --- | --- | --- | 97 + | `sync` 缺 selector | stdout | `{ "error": { "code": "selector_required", "message": "..." } }` | 98 + | `sync` invalid selector | stdout | `{ "error": { "code": "invalid_selector", "message": "..." } }` | 99 + | `sync` per-file 错 | stderr | `SyncSummary`,看 `errors / errorDetails[]` | 100 + | `sync` 锁超时 | stderr | `{ "error": <message string> }` | 101 + | `find / read-range / read-page / list / stats` 索引不存在 | stdout | `{ "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "..." } }` | 102 + | `find / read-range / read-page / list / stats` 其他异常 | 进程异常退出 | 直接非零退出 | 103 + 104 + ## Schema drift 105 + 106 + source of truth 永远是: 107 + 108 + - 仓库内 `types.ts` 109 + - 仓库内 `cli.ts` 110 + 111 + 如果字段、命令、flag 变了: 112 + 113 + - 先更新 `references/*.md` 114 + - 再更新 `SKILL.md` 115 + - 最后 bump `skill-sync` 日期 116 + 117 + ## 来源 118 + 119 + - 仓库内 `cli.ts` 120 + - 仓库内 `types.ts` 121 + - 仓库内 `env.ts` 122 + - 仓库内 `query.ts`
+231
skill-packages/cxsd/references/json-schema.md
··· 1 + # cxs JSON Schema 2 + 3 + ## find 4 + 5 + Top-level shape: 6 + 7 + ```ts 8 + { 9 + query: string; 10 + results: FindResult[]; 11 + coverage: CoverageStatus; 12 + } 13 + ``` 14 + 15 + `FindResult`: 16 + 17 + ```ts 18 + { 19 + rank: number; 20 + sessionUuid: string; 21 + title: string; 22 + summaryText: string; 23 + cwd: string; 24 + startedAt: string; 25 + endedAt: string; 26 + matchCount: number; 27 + matchSource: "message" | "session"; 28 + matchSeq: number | null; 29 + matchRole: "user" | "assistant" | "session"; 30 + matchTimestamp: string | null; 31 + score: number; 32 + snippet: string; 33 + } 34 + ``` 35 + 36 + `matchSource = "session"` means the hit came from session-level fields such as title, derived summary, compact handoff, or reasoning summary rather than a concrete message. In that case `matchSeq` is `null`; use `read-page` first instead of fabricating a `read-range --seq` anchor. 37 + 38 + ## read-range 39 + 40 + ```ts 41 + { 42 + session: SessionRecord; 43 + anchorSeq: number; 44 + rangeStartSeq: number; 45 + rangeEndSeq: number; 46 + messages: MessageRecord[]; 47 + coverage: { entries: CoverageRecord[] }; 48 + } 49 + ``` 50 + 51 + ## read-page 52 + 53 + ```ts 54 + { 55 + session: SessionRecord; 56 + offset: number; 57 + limit: number; 58 + totalCount: number; 59 + hasMore: boolean; 60 + messages: MessageRecord[]; 61 + coverage: { entries: CoverageRecord[] }; 62 + } 63 + ``` 64 + 65 + ## list 66 + 67 + ```ts 68 + { 69 + query: { 70 + cwd?: string; 71 + since?: string; 72 + selector?: Selector; 73 + sort: "ended" | "started" | "messages"; 74 + limit: number; 75 + }; 76 + results: SessionListEntry[]; 77 + coverage: CoverageStatus; 78 + } 79 + ``` 80 + 81 + ## stats 82 + 83 + ```ts 84 + { 85 + sessionCount: number; 86 + messageCount: number; 87 + earliestStartedAt: string | null; 88 + latestEndedAt: string | null; 89 + topCwds: Array<{ cwd: string; count: number }>; 90 + indexVersion: string; 91 + dbPath: string; 92 + dbSizeBytes: number; 93 + lastSyncAt: string | null; 94 + coverage: CoverageInventoryStatus[]; 95 + } 96 + ``` 97 + 98 + ## status 99 + 100 + ```ts 101 + { 102 + context: { 103 + cwd: string; 104 + root: string; 105 + dbPath: string; 106 + indexVersion: string; 107 + }; 108 + sourceInventory: SourceInventory; 109 + index: { 110 + exists: boolean; 111 + sessionCount: number; 112 + messageCount: number; 113 + earliestStartedAt: string | null; 114 + latestEndedAt: string | null; 115 + dbSizeBytes: number; 116 + lastSyncAt: string | null; 117 + }; 118 + coverage: CoverageRecord[]; 119 + } 120 + ``` 121 + 122 + ## sync 123 + 124 + ```ts 125 + { 126 + scanned: number; 127 + added: number; 128 + updated: number; 129 + skipped: number; 130 + filtered: number; 131 + removed: number; 132 + errors: number; 133 + errorDetails: Array<{ 134 + filePath: string; 135 + message: string; 136 + }>; 137 + selector: Selector; 138 + coverage: { 139 + written: boolean; 140 + selector: Selector; 141 + sourceFingerprint: string; 142 + sourceFileCount: number; 143 + indexedSessionCount: number; 144 + reason?: string; 145 + }; 146 + } 147 + ``` 148 + 149 + ## Shared Records 150 + 151 + `SessionRecord`: 152 + 153 + ```ts 154 + { 155 + sessionUuid: string; 156 + filePath: string; 157 + sourceRoot: string; 158 + title: string; 159 + summaryText: string; 160 + cwd: string; 161 + model: string; 162 + startedAt: string; 163 + endedAt: string; 164 + pathDate: string; 165 + messageCount: number; 166 + } 167 + ``` 168 + 169 + `MessageRecord`: 170 + 171 + ```ts 172 + { 173 + sessionUuid: string; 174 + seq: number; 175 + role: "user" | "assistant"; 176 + contentText: string; 177 + timestamp: string; 178 + sourceKind: string; 179 + } 180 + ``` 181 + 182 + `Selector`: 183 + 184 + ```ts 185 + type Selector = 186 + | { kind: "all"; root: string } 187 + | { kind: "date_range"; root: string; fromDate: string; toDate: string } 188 + | { kind: "cwd"; root: string; cwd: string } 189 + | { kind: "cwd_date_range"; root: string; cwd: string; fromDate: string; toDate: string }; 190 + ``` 191 + 192 + `CoverageStatus`: 193 + 194 + ```ts 195 + { 196 + requested: Selector | null; 197 + complete: boolean; 198 + freshness: "not_checked"; 199 + coveringSelectors: CoverageRecord[]; 200 + } 201 + ``` 202 + 203 + `CoverageRecord`: 204 + 205 + ```ts 206 + { 207 + id: number; 208 + selector: Selector; 209 + sourceFingerprint: string; 210 + sourceFileCount: number; 211 + indexedSessionCount: number; 212 + completedAt: string; 213 + indexVersion: string; 214 + } 215 + ``` 216 + 217 + `CoverageInventoryStatus`: 218 + 219 + ```ts 220 + CoverageRecord & { 221 + freshness: "fresh" | "stale"; 222 + currentSourceFingerprint: string; 223 + currentSourceFileCount: number; 224 + } 225 + ``` 226 + 227 + ## 来源 228 + 229 + - 仓库内 `types.ts` 230 + - 仓库内 `cli.ts` 231 + - 仓库内 `query.ts`
+81
skill-packages/cxsd/references/progressive-workflow.md
··· 1 + # Progressive Workflow 2 + 3 + ## 默认流程 4 + 5 + 1. `status --json` 拿 source inventory 和 coverage 6 + 2. 选择明确 selector 并 `sync --selector` 7 + 3. `find` 或 `list` 拿候选 session 和命中锚点 8 + 4. `read-range` 在最佳候选周围扩局部上下文 9 + 5. `read-page` 只在局部窗口仍不够时翻整页 10 + 11 + 硬规则: 12 + 13 + - 没有 `sessionUuid` 时,不要冷启动 `read-page` 14 + - 用户给了 `cwd` 或时间窗口:构造同范围 selector;先同步这个 selector;查询时继续带同一个 selector 15 + - 已锁定 session 但锚点不对时,用 `read-range --query` 16 + - `cwd` 只是候选过滤,不是主题真相;还要再看 `title`、`summaryText` 和开头几条 message 17 + 18 + ## Worked Scenario 1 19 + 20 + 用户说:`上次我配 cf tunnel 是怎么弄的` 21 + 22 + ```bash 23 + "${CXSD_BIN:-cxsd}" status --json 24 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"all","root":"/Users/me/.codex/sessions"}' --json 25 + "${CXSD_BIN:-cxsd}" find "cf tunnel" --json -n 5 26 + ``` 27 + 28 + 然后: 29 + 30 + ```bash 31 + "${CXSD_BIN:-cxsd}" read-range <sessionUuid> --seq <matchSeq> --before 4 --after 8 --json 32 + ``` 33 + 34 + 只有 `read-range` 还缺前情后果时,再: 35 + 36 + ```bash 37 + "${CXSD_BIN:-cxsd}" read-page <sessionUuid> --offset 0 --limit 40 --json 38 + ``` 39 + 40 + ## Worked Scenario 2 41 + 42 + 用户说:`我记得前几天在 hammerspoon 那个 repo 里试过 IME 切换` 43 + 44 + 先按 cwd + 时间缩范围: 45 + 46 + ```bash 47 + "${CXSD_BIN:-cxsd}" status --json 48 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/hammerspoon","fromDate":"2026-04-15","toDate":"2026-04-30"}' --json 49 + "${CXSD_BIN:-cxsd}" list --selector '{"kind":"cwd_date_range","root":"/Users/me/.codex/sessions","cwd":"/Users/me/work/hammerspoon","fromDate":"2026-04-15","toDate":"2026-04-30"}' --json 50 + ``` 51 + 52 + 再在候选 session 内局部重定位: 53 + 54 + ```bash 55 + "${CXSD_BIN:-cxsd}" read-range <sessionUuid> --query "IME" --before 4 --after 8 --json 56 + ``` 57 + 58 + ## Worked Scenario 3 59 + 60 + 用户说:`最近本项目有做过什么讨论` 61 + 62 + 先按当前 repo 路径列最近 session: 63 + 64 + ```bash 65 + "${CXSD_BIN:-cxsd}" status --json 66 + "${CXSD_BIN:-cxsd}" sync --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --json 67 + "${CXSD_BIN:-cxsd}" list --selector '{"kind":"cwd","root":"/Users/me/.codex/sessions","cwd":"/absolute/path/to/current/repo"}' --sort ended -n 8 --json 68 + ``` 69 + 70 + 不要把 `cwd` 直接当主题真相。至少再看: 71 + 72 + - `title` 73 + - `summaryText` 74 + - 开头几条 message 75 + - 结尾几条 message 76 + 77 + ## 来源 78 + 79 + - 仓库内 `README.md` 80 + - 仓库内 `query.ts` 81 + - 仓库内 `types.ts`
+74
source-inventory.test.ts
··· 1 + import { afterEach, describe, expect, test } from "vitest"; 2 + import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { collectSourceInventory, collectSourceSnapshot } from "./source-inventory"; 6 + 7 + const tempDirs: string[] = []; 8 + 9 + afterEach(() => { 10 + for (const dir of tempDirs.splice(0)) { 11 + rmSync(dir, { recursive: true, force: true }); 12 + } 13 + }); 14 + 15 + describe("source inventory", () => { 16 + test("returns path dates and cwd groups without indexing content", () => { 17 + const base = mkdtempSync(join(tmpdir(), "cxs-source-inventory-")); 18 + tempDirs.push(base); 19 + const root = join(base, "sessions"); 20 + const day = join(root, "2026", "04", "22"); 21 + mkdirSync(day, { recursive: true }); 22 + writeFileSync( 23 + join(day, "rollout-2026-04-22T12-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl"), 24 + [ 25 + line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/alpha" }), 26 + line("event_msg", { type: "user_message", message: "do not use me for inventory" }), 27 + ].join("\n"), 28 + ); 29 + 30 + const inventory = collectSourceInventory(root); 31 + 32 + expect(inventory.totalFiles).toBe(1); 33 + expect(inventory.pathDateRange).toEqual({ from: "2026-04-22", to: "2026-04-22" }); 34 + expect(inventory.cwdGroups).toEqual([ 35 + { cwd: "/tmp/alpha", fileCount: 1, pathDateRange: { from: "2026-04-22", to: "2026-04-22" } }, 36 + ]); 37 + }); 38 + 39 + test("builds selector snapshots from raw source metadata", () => { 40 + const base = mkdtempSync(join(tmpdir(), "cxs-source-snapshot-")); 41 + tempDirs.push(base); 42 + const root = join(base, "sessions"); 43 + const day = join(root, "2026", "04", "22"); 44 + mkdirSync(day, { recursive: true }); 45 + writeFileSync( 46 + join(day, "rollout-2026-04-22T12-00-00-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"), 47 + [ 48 + line("session_meta", { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", cwd: "/tmp/alpha" }), 49 + line("event_msg", { type: "user_message", message: "alpha" }), 50 + ].join("\n"), 51 + ); 52 + writeFileSync( 53 + join(day, "rollout-2026-04-22T13-00-00-cccccccc-cccc-4ccc-8ccc-cccccccccccc.jsonl"), 54 + [ 55 + line("session_meta", { id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", cwd: "/tmp/beta" }), 56 + line("event_msg", { type: "user_message", message: "beta" }), 57 + ].join("\n"), 58 + ); 59 + 60 + const snapshot = collectSourceSnapshot({ kind: "cwd", root, cwd: "/tmp/alpha" }); 61 + 62 + expect(snapshot.fileCount).toBe(1); 63 + expect(snapshot.files[0]?.cwd).toBe("/tmp/alpha"); 64 + expect(snapshot.fingerprint).toMatch(/^[a-f0-9]{64}$/); 65 + }); 66 + }); 67 + 68 + function line(type: string, payload: Record<string, unknown>): string { 69 + return JSON.stringify({ 70 + timestamp: new Date("2026-04-22T00:00:00.000Z").toISOString(), 71 + type, 72 + payload, 73 + }); 74 + }
+166
source-inventory.ts
··· 1 + import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs"; 2 + import type { Dirent } from "node:fs"; 3 + import { relative, resolve } from "node:path"; 4 + import { createHash } from "node:crypto"; 5 + import { canonicalizeSelector, selectorContainsFile } from "./selector"; 6 + import type { 7 + DateRange, 8 + Selector, 9 + SourceFileMeta, 10 + SourceInventory, 11 + SourceInventoryCwdGroup, 12 + SourceSnapshot, 13 + } from "./types"; 14 + 15 + const CWD_SCAN_BYTES = 64 * 1024; 16 + 17 + export function collectSourceInventory(root: string): SourceInventory { 18 + const resolvedRoot = resolve(root); 19 + const files = collectSourceFiles(resolvedRoot); 20 + return { 21 + root: resolvedRoot, 22 + totalFiles: files.length, 23 + pathDateRange: dateRange(files.map((file) => file.pathDate)), 24 + cwdGroups: buildCwdGroups(files), 25 + }; 26 + } 27 + 28 + export function collectSourceSnapshot(selector: Selector): SourceSnapshot { 29 + const canonical = canonicalizeSelector(selector); 30 + const files = collectSourceFiles(canonical.root).filter((file) => selectorContainsFile(canonical, file)); 31 + return { 32 + selector: canonical, 33 + fingerprint: fingerprintFiles(canonical.root, files), 34 + fileCount: files.length, 35 + files, 36 + }; 37 + } 38 + 39 + export function collectSourceFiles(root: string): SourceFileMeta[] { 40 + const files: SourceFileMeta[] = []; 41 + walk(resolve(root), files); 42 + files.sort((a, b) => a.filePath.localeCompare(b.filePath)); 43 + return files; 44 + } 45 + 46 + export function extractPathDate(filePath: string): string | null { 47 + const pathMatch = filePath.match(/(?:^|\/)(\d{4})\/(\d{2})\/(\d{2})(?:\/|$)/); 48 + if (pathMatch) return `${pathMatch[1]}-${pathMatch[2]}-${pathMatch[3]}`; 49 + const nameMatch = filePath.match(/rollout-(\d{4})-(\d{2})-(\d{2})T/); 50 + if (nameMatch) return `${nameMatch[1]}-${nameMatch[2]}-${nameMatch[3]}`; 51 + return null; 52 + } 53 + 54 + function walk(currentDir: string, files: SourceFileMeta[]): void { 55 + let entries: Dirent<string>[]; 56 + try { 57 + entries = readdirSync(currentDir, { withFileTypes: true }); 58 + } catch { 59 + return; 60 + } 61 + 62 + for (const entry of entries) { 63 + const fullPath = `${currentDir}/${entry.name}`; 64 + if (entry.isDirectory()) { 65 + walk(fullPath, files); 66 + continue; 67 + } 68 + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; 69 + 70 + try { 71 + const stats = statSync(fullPath); 72 + files.push({ 73 + filePath: fullPath, 74 + pathDate: extractPathDate(fullPath), 75 + cwd: readCwdMetadata(fullPath), 76 + mtimeMs: stats.mtimeMs, 77 + size: stats.size, 78 + }); 79 + } catch { 80 + continue; 81 + } 82 + } 83 + } 84 + 85 + function readCwdMetadata(filePath: string): string { 86 + let fd: number | null = null; 87 + try { 88 + fd = openSync(filePath, "r"); 89 + const buffer = Buffer.allocUnsafe(CWD_SCAN_BYTES); 90 + const bytesRead = readSync(fd, buffer, 0, CWD_SCAN_BYTES, 0); 91 + const prefix = buffer.subarray(0, bytesRead).toString("utf8"); 92 + for (const rawLine of prefix.split("\n")) { 93 + const line = rawLine.trim(); 94 + if (!line) continue; 95 + let record: Record<string, unknown>; 96 + try { 97 + record = JSON.parse(line) as Record<string, unknown>; 98 + } catch { 99 + continue; 100 + } 101 + const payload = isRecord(record.payload) ? record.payload : null; 102 + if (!payload) continue; 103 + if ((record.type === "session_meta" || record.type === "turn_context") && typeof payload.cwd === "string") { 104 + return payload.cwd; 105 + } 106 + } 107 + } catch { 108 + return ""; 109 + } finally { 110 + if (fd !== null) { 111 + try { 112 + closeSync(fd); 113 + } catch { 114 + // Ignore inventory-only close failures. 115 + } 116 + } 117 + } 118 + return ""; 119 + } 120 + 121 + function buildCwdGroups(files: SourceFileMeta[]): SourceInventoryCwdGroup[] { 122 + const groups = new Map<string, SourceFileMeta[]>(); 123 + for (const file of files) { 124 + if (!file.cwd) continue; 125 + const group = groups.get(file.cwd) ?? []; 126 + group.push(file); 127 + groups.set(file.cwd, group); 128 + } 129 + return [...groups.entries()] 130 + .map(([cwd, groupFiles]) => ({ 131 + cwd, 132 + fileCount: groupFiles.length, 133 + pathDateRange: dateRange(groupFiles.map((file) => file.pathDate)), 134 + })) 135 + .sort((a, b) => b.fileCount - a.fileCount || a.cwd.localeCompare(b.cwd)); 136 + } 137 + 138 + function dateRange(values: Array<string | null>): DateRange { 139 + const dates = values.filter((value): value is string => Boolean(value)).sort(); 140 + return { 141 + from: dates[0] ?? null, 142 + to: dates[dates.length - 1] ?? null, 143 + }; 144 + } 145 + 146 + function fingerprintFiles(root: string, files: SourceFileMeta[]): string { 147 + const hash = createHash("sha256"); 148 + hash.update(resolve(root)); 149 + for (const file of files) { 150 + hash.update("\0"); 151 + hash.update(relative(root, file.filePath)); 152 + hash.update("\0"); 153 + hash.update(String(file.mtimeMs)); 154 + hash.update("\0"); 155 + hash.update(String(file.size)); 156 + hash.update("\0"); 157 + hash.update(file.pathDate ?? ""); 158 + hash.update("\0"); 159 + hash.update(file.cwd); 160 + } 161 + return hash.digest("hex"); 162 + } 163 + 164 + function isRecord(value: unknown): value is Record<string, unknown> { 165 + return typeof value === "object" && value !== null; 166 + }
+69
status.ts
··· 1 + import { existsSync, statSync } from "node:fs"; 2 + import { collectSourceInventory, collectSourceSnapshot } from "./source-inventory"; 3 + import { INDEX_VERSION, DEFAULT_DB_PATH, resolveCodexDir } from "./env"; 4 + import { getStatsCounts, listCoverageRecords, withReadDb } from "./db"; 5 + import type { CoverageInventoryStatus, CoverageRecord, StatusSummary } from "./types"; 6 + 7 + export function collectStatus(options: { rootDir?: string; dbPath?: string; cwd?: string } = {}): StatusSummary { 8 + const root = resolveCodexDir(options.rootDir); 9 + const dbPath = options.dbPath ?? DEFAULT_DB_PATH; 10 + const sourceInventory = collectSourceInventory(root); 11 + const index = collectIndexStatus(dbPath); 12 + const coverage = existsSync(dbPath) ? withReadDb(dbPath, (db) => listCoverageRecords(db)) : []; 13 + return { 14 + context: { 15 + cwd: options.cwd ?? process.cwd(), 16 + root, 17 + dbPath, 18 + indexVersion: INDEX_VERSION, 19 + }, 20 + sourceInventory, 21 + index, 22 + coverage: coverage.map(toCoverageInventoryStatus), 23 + }; 24 + } 25 + 26 + function collectIndexStatus(dbPath: string): StatusSummary["index"] { 27 + if (!existsSync(dbPath)) { 28 + return { 29 + exists: false, 30 + sessionCount: 0, 31 + messageCount: 0, 32 + earliestStartedAt: null, 33 + latestEndedAt: null, 34 + dbSizeBytes: 0, 35 + lastSyncAt: null, 36 + }; 37 + } 38 + 39 + const counts = withReadDb(dbPath, (db) => getStatsCounts(db)); 40 + let dbSizeBytes = 0; 41 + try { 42 + dbSizeBytes = statSync(dbPath).size; 43 + } catch { 44 + dbSizeBytes = 0; 45 + } 46 + 47 + return { 48 + exists: true, 49 + sessionCount: counts.sessionCount, 50 + messageCount: counts.messageCount, 51 + earliestStartedAt: counts.earliestStartedAt, 52 + latestEndedAt: counts.latestEndedAt, 53 + dbSizeBytes, 54 + lastSyncAt: counts.lastSyncAt, 55 + }; 56 + } 57 + 58 + function toCoverageInventoryStatus(record: CoverageRecord): CoverageInventoryStatus { 59 + const snapshot = collectSourceSnapshot(record.selector); 60 + const fresh = snapshot.fingerprint === record.sourceFingerprint 61 + && snapshot.fileCount === record.sourceFileCount 62 + && record.indexVersion === INDEX_VERSION; 63 + return { 64 + ...record, 65 + freshness: fresh ? "fresh" : "stale", 66 + currentSourceFingerprint: snapshot.fingerprint, 67 + currentSourceFileCount: snapshot.fileCount, 68 + }; 69 + }
+101 -8
types.ts
··· 34 34 message: string; 35 35 } 36 36 37 + export type Selector = 38 + | { kind: "all"; root: string } 39 + | { kind: "date_range"; root: string; fromDate: string; toDate: string } 40 + | { kind: "cwd"; root: string; cwd: string } 41 + | { kind: "cwd_date_range"; root: string; cwd: string; fromDate: string; toDate: string }; 42 + 43 + export type SelectorKind = Selector["kind"]; 44 + 45 + export interface DateRange { 46 + from: string | null; 47 + to: string | null; 48 + } 49 + 50 + export interface SourceInventoryCwdGroup { 51 + cwd: string; 52 + fileCount: number; 53 + pathDateRange: DateRange; 54 + } 55 + 56 + export interface SourceInventory { 57 + root: string; 58 + totalFiles: number; 59 + pathDateRange: DateRange; 60 + cwdGroups: SourceInventoryCwdGroup[]; 61 + } 62 + 63 + export interface SourceFileMeta { 64 + filePath: string; 65 + pathDate: string | null; 66 + cwd: string; 67 + mtimeMs: number; 68 + size: number; 69 + } 70 + 71 + export interface SourceSnapshot { 72 + selector: Selector; 73 + fingerprint: string; 74 + fileCount: number; 75 + files: SourceFileMeta[]; 76 + } 77 + 78 + export interface CoverageRecord { 79 + id: number; 80 + selector: Selector; 81 + sourceFingerprint: string; 82 + sourceFileCount: number; 83 + indexedSessionCount: number; 84 + completedAt: string; 85 + indexVersion: string; 86 + } 87 + 88 + export interface CoverageInventoryStatus extends CoverageRecord { 89 + freshness: "fresh" | "stale"; 90 + currentSourceFingerprint: string; 91 + currentSourceFileCount: number; 92 + } 93 + 94 + export interface CoverageWriteSummary { 95 + written: boolean; 96 + selector: Selector; 97 + sourceFingerprint: string; 98 + sourceFileCount: number; 99 + indexedSessionCount: number; 100 + reason?: string; 101 + } 102 + 103 + export interface CoverageStatus { 104 + requested: Selector | null; 105 + complete: boolean; 106 + freshness: "not_checked"; 107 + coveringSelectors: CoverageRecord[]; 108 + } 109 + 37 110 export interface SessionRecord { 38 111 sessionUuid: string; 39 112 filePath: string; 113 + sourceRoot: string; 40 114 title: string; 41 115 summaryText: string; 42 116 cwd: string; 43 117 model: string; 44 118 startedAt: string; 45 119 endedAt: string; 120 + pathDate: string; 46 121 messageCount: number; 47 122 } 48 123 ··· 78 153 updated: number; 79 154 skipped: number; 80 155 filtered: number; 156 + removed: number; 81 157 errors: number; 82 158 errorDetails: SyncErrorDetail[]; 159 + selector: Selector; 160 + coverage: CoverageWriteSummary; 83 161 } 84 162 85 163 export interface SessionListEntry { ··· 89 167 cwd: string; 90 168 startedAt: string; 91 169 endedAt: string; 170 + pathDate: string; 92 171 messageCount: number; 93 172 } 94 173 95 - export interface CurrentSessionCandidate { 96 - sessionUuid: string; 97 - title: string; 98 - cwd: string; 99 - filePath: string; 100 - updatedAtMs: number; 101 - } 102 - 103 174 export type SessionListSort = "ended" | "started" | "messages"; 104 175 105 176 export interface SessionListQuery { 106 177 cwd?: string; 107 178 since?: string; 179 + selector?: Selector; 108 180 sort: SessionListSort; 109 181 limit: number; 110 182 } ··· 124 196 dbPath: string; 125 197 dbSizeBytes: number; 126 198 lastSyncAt: string | null; 199 + coverage: CoverageRecord[]; 200 + } 201 + 202 + export interface StatusSummary { 203 + context: { 204 + cwd: string; 205 + root: string; 206 + dbPath: string; 207 + indexVersion: string; 208 + }; 209 + sourceInventory: SourceInventory; 210 + index: { 211 + exists: boolean; 212 + sessionCount: number; 213 + messageCount: number; 214 + earliestStartedAt: string | null; 215 + latestEndedAt: string | null; 216 + dbSizeBytes: number; 217 + lastSyncAt: string | null; 218 + }; 219 + coverage: CoverageInventoryStatus[]; 127 220 }