diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 30fdfc2..8c71c93 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,15 @@ updates: commit-message: prefix: "deps(rust)" labels: [dependencies, rust] + # Tauri's plugins pin these to an older major, so bumping ours alone only adds a second copy. + # Drop each once Tauri moves. + ignore: + - dependency-name: zbus + update-types: [version-update:semver-major] + - dependency-name: dirs + update-types: [version-update:semver-major] + - dependency-name: sha2 + update-types: [version-update:semver-minor] # Groups match in the order listed, so `tauri` claims its crates before `rust-minor`. groups: tauri: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c580a82..65cf761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,19 @@ jobs: - name: Test run: cargo test --locked --workspace --no-fail-fast + # Days and weeks follow the local zone, so run the tests again in the two zones furthest from UTC. + - name: Test in UTC+14 + if: runner.os == 'Linux' + env: + TZ: Pacific/Kiritimati + run: cargo test --locked --workspace --no-fail-fast + + - name: Test in UTC-11 + if: runner.os == 'Linux' + env: + TZ: Pacific/Pago_Pago + run: cargo test --locked --workspace --no-fail-fast + frontend: name: frontend runs-on: ubuntu-24.04 @@ -105,6 +118,17 @@ jobs: - name: Tests with coverage run: pnpm --dir app test:coverage + # app/vitest.config.ts honors TZ; the default run above is pinned to UTC. + - name: Tests in UTC+14 + env: + TZ: Pacific/Kiritimati + run: pnpm --dir app exec vitest run + + - name: Tests in UTC-11 + env: + TZ: Pacific/Pago_Pago + run: pnpm --dir app exec vitest run + - name: Build run: pnpm --dir app build @@ -288,7 +312,7 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2 + - uses: taiki-e/install-action@1919c4a95903d2c33e4a221102edca87309fc14b # v2 with: tool: cargo-llvm-cov diff --git a/CHANGELOG.md b/CHANGELOG.md index 0313cad..76d9178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes are listed here. Format follows [Keep a Changelog](https://k ## [Unreleased] +## [0.5.7] - 2026-11-04 + +### Changed + +- Your history is scored again once, under the fixed rules below. +- Stored correction text is cleaned again once, with the wider redaction below. +- Lessons: clearer hints and examples for Evidence and Target, and the Context lesson says the checker can't spot it. + +### Fixed + +- Messages from other agents and scheduled tasks in Claude Code no longer count as yours. +- "No, that's all, thanks", "Nah, it's cool" and similar no longer count as corrections. +- "You're right" after you ask a question is no longer a correction. +- Adding context ("by the way…", "one other thing…") no longer counts against the AI. +- Messages typed while Claude was busy are counted even when they include an image. +- ChatGPT messages with an attachment are counted. +- Codex handoff and voice chats are counted. +- Large exports no longer use many times their size in memory. +- Redaction catches more secrets, such as `*_KEY=` values, `pass:`, and Google and Slack tokens. Dates are no longer hidden. +- Bedrock model names are recognized, and Opus 4 and 4.1 have prices. + ## [0.5.6] - 2026-11-01 ### Added @@ -273,7 +294,8 @@ First release. - Per-source consent: nothing is read until you allow it, and Forget deletes a source's data - `noactually` command-line tool (`sources`, `allow`, `sync`, `stats`) -[Unreleased]: https://github.com/HTMLGuyLLC/NoActually/compare/v0.5.6...HEAD +[Unreleased]: https://github.com/HTMLGuyLLC/NoActually/compare/v0.5.7...HEAD +[0.5.7]: https://github.com/HTMLGuyLLC/NoActually/compare/v0.5.6...v0.5.7 [0.5.6]: https://github.com/HTMLGuyLLC/NoActually/compare/v0.5.5...v0.5.6 [0.5.5]: https://github.com/HTMLGuyLLC/NoActually/compare/v0.5.4...v0.5.5 [0.5.4]: https://github.com/HTMLGuyLLC/NoActually/compare/v0.5.3...v0.5.4 diff --git a/Cargo.lock b/Cargo.lock index 80409b8..16a117f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" dependencies = [ "async-task", "concurrent-queue", @@ -497,6 +497,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -597,6 +608,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1379,6 +1399,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core", "wasm-bindgen", ] @@ -2233,9 +2254,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" [[package]] name = "mac-notification-sys" @@ -2402,7 +2423,7 @@ dependencies = [ [[package]] name = "noactually-app" -version = "0.5.6" +version = "0.5.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -2430,7 +2451,7 @@ dependencies = [ [[package]] name = "noactually-cli" -version = "0.5.6" +version = "0.5.7" dependencies = [ "anyhow", "chrono", @@ -2446,7 +2467,7 @@ dependencies = [ [[package]] name = "noactually-engine" -version = "0.5.6" +version = "0.5.7" dependencies = [ "regex", "serde", @@ -2456,7 +2477,7 @@ dependencies = [ [[package]] name = "noactually-sources" -version = "0.5.6" +version = "0.5.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -2471,7 +2492,7 @@ dependencies = [ [[package]] name = "noactually-store" -version = "0.5.6" +version = "0.5.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -3092,15 +3113,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3207,32 +3219,20 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "ppv-lite86", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "raw-window-handle" @@ -3369,9 +3369,9 @@ dependencies = [ [[package]] name = "rfd" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4fe518b6db89699d59862e4d1e3a0226316090f4e4ec09f7dd686be25634c7c" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ "block2", "dispatch2", @@ -3379,16 +3379,18 @@ dependencies = [ "gobject-sys", "gtk-sys", "js-sys", + "libc", "log", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "percent-encoding", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3838,7 +3840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4339,9 +4341,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" +checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5" dependencies = [ "log", "raw-window-handle", @@ -4402,9 +4404,9 @@ dependencies = [ [[package]] name = "tauri-plugin-notification" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b" +checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf" dependencies = [ "log", "notify-rust", @@ -4459,9 +4461,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561cd99484561bc39c954768e7b41a66cebbe1ac3d9d081c34361f740c489e2c" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" dependencies = [ "serde", "serde_json", @@ -5497,15 +5499,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5539,30 +5532,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -5593,12 +5569,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5611,12 +5581,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5629,24 +5593,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5659,12 +5611,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5677,12 +5623,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5695,12 +5635,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5713,12 +5647,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -5926,26 +5854,6 @@ dependencies = [ "serde", ] -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 9e248d6..6a6e983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/engine", "crates/sources", "crates/store", "crates/cli", "app/src-tauri"] [workspace.package] -version = "0.5.6" +version = "0.5.7" edition = "2021" # Oldest Rust that builds the whole workspace, app included. CI's `msrv` job checks it. rust-version = "1.90" diff --git a/app/package.json b/app/package.json index 4967abc..a247f1e 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "noactually-app", "private": true, - "version": "0.5.6", + "version": "0.5.7", "type": "module", "packageManager": "pnpm@10.32.1", "engines": { @@ -27,10 +27,10 @@ "devDependencies": { "@biomejs/biome": "2.6.4", "@tauri-apps/cli": "2.12.4", - "@vitest/coverage-v8": "5.0.5", + "@vitest/coverage-v8": "5.1.0", "happy-dom": "21.0.4", "typescript": "7.0.2", "vite": "8.3.5", - "vitest": "^5.0.5" + "vitest": "5.1.0" } } diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index f5cfb93..2c7f714 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -31,8 +31,8 @@ importers: specifier: 2.12.4 version: 2.12.4 '@vitest/coverage-v8': - specifier: 5.0.5 - version: 5.0.5(vitest@5.0.5) + specifier: 5.1.0 + version: 5.1.0(vitest@5.1.0) happy-dom: specifier: 21.0.4 version: 21.0.4 @@ -43,8 +43,8 @@ importers: specifier: 8.3.5 version: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2) vitest: - specifier: ^5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)) + specifier: 5.1.0 + version: 5.1.0(@types/node@26.6.5)(@vitest/coverage-v8@5.1.0)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)) packages: @@ -635,25 +635,25 @@ packages: cpu: [x64] os: [win32] - '@vitest/coverage-v8@5.0.5': - resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + '@vitest/coverage-v8@5.1.0': + resolution: {integrity: sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==} peerDependencies: - '@vitest/browser': 5.0.5 - vitest: 5.0.5 + '@vitest/browser': 5.1.0 + vitest: 5.1.0 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/istanbul-lib-coverage@1.0.2': - resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + '@vitest/istanbul-lib-coverage@2.1.0': + resolution: {integrity: sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==} engines: {node: '>=22'} - '@vitest/istanbul-lib-report@1.0.2': - resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + '@vitest/istanbul-lib-report@2.1.0': + resolution: {integrity: sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==} engines: {node: '>=22'} - '@vitest/mocker@5.0.5': - resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + '@vitest/mocker@5.1.0': + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} peerDependencies: msw: ^2.4.9 || ^3.0.0 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -663,8 +663,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.5': - resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -904,20 +904,20 @@ packages: yaml: optional: true - vitest@5.0.5: - resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + vitest@5.1.0: + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.5 - '@vitest/browser-preview': 5.0.5 + '@vitest/browser-playwright': 5.1.0 + '@vitest/browser-preview': 5.1.0 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.5 - '@vitest/coverage-v8': 5.0.5 - '@vitest/ui': 5.0.5 + '@vitest/coverage-istanbul': 5.1.0 + '@vitest/coverage-v8': 5.1.0 + '@vitest/ui': 5.1.0 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -1298,34 +1298,34 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + '@vitest/coverage-v8@5.1.0(vitest@5.1.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/istanbul-lib-report': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 + '@vitest/istanbul-lib-report': 2.1.0 ast-v8-to-istanbul: 1.0.7 magicast: 0.5.5 obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)) + vitest: 5.1.0(@types/node@26.6.5)(@vitest/coverage-v8@5.1.0)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)) - '@vitest/istanbul-lib-coverage@1.0.2': {} + '@vitest/istanbul-lib-coverage@2.1.0': {} - '@vitest/istanbul-lib-report@1.0.2': + '@vitest/istanbul-lib-report@2.1.0': dependencies: - '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 - '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2))': + '@vitest/mocker@5.1.0(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.5 + '@vitest/spy': 5.1.0 estree-walker: 3.0.3 magic-string: 1.4.5 optionalDependencies: vite: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2) - '@vitest/spy@5.0.5': {} + '@vitest/spy@5.1.0': {} assertion-error@2.0.1: {} @@ -1551,10 +1551,10 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 - vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)): + vitest@5.1.0(@types/node@26.6.5)(@vitest/coverage-v8@5.1.0)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)) + '@vitest/mocker': 5.1.0(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -1569,7 +1569,7 @@ snapshots: why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.5 - '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) + '@vitest/coverage-v8': 5.1.0(vitest@5.1.0) happy-dom: 21.0.4 transitivePeerDependencies: - msw diff --git a/app/src-tauri/src/report_cards.rs b/app/src-tauri/src/report_cards.rs index c36c15f..b37f922 100644 --- a/app/src-tauri/src/report_cards.rs +++ b/app/src-tauri/src/report_cards.rs @@ -111,6 +111,9 @@ pub struct Env { /// The `git` command, if there is one. Only used to look that a release tag is there before the /// plugin is moved to it; without git that look is skipped. pub git: Option, + /// `$CODEX_HOME`, as the process had it (read once, in [`Env::folders`]). Tests leave it `None` + /// so they never reach the real Codex folder. + pub codex_home: Option, } impl Env { @@ -138,7 +141,17 @@ impl Env { let windows = cfg!(windows); let git_bash = if windows { platform::git_bash_here() } else { None }; let exe = std::env::current_exe().ok(); - Ok(Env { home, data, claude: None, codex: None, exe, windows, git_bash, git: None }) + Ok(Env { + home, + data, + claude: None, + codex: None, + exe, + windows, + git_bash, + git: None, + codex_home: std::env::var_os("CODEX_HOME"), + }) } /// The file whose presence turns tips on (the session-start hook looks for it). diff --git a/app/src-tauri/src/report_cards/codex.rs b/app/src-tauri/src/report_cards/codex.rs index 2395380..da21714 100644 --- a/app/src-tauri/src/report_cards/codex.rs +++ b/app/src-tauri/src/report_cards/codex.rs @@ -51,7 +51,7 @@ fn shim_file(env: &Env) -> PathBuf { /// Codex's own folder: `$CODEX_HOME`, else `~/.codex`. A variable that isn't an absolute path counts as unset. pub(super) fn codex_dir(env: &Env) -> PathBuf { - codex_dir_in(std::env::var_os("CODEX_HOME"), &env.home) + codex_dir_in(env.codex_home.clone(), &env.home) } fn codex_dir_in(var: Option, home: &Path) -> PathBuf { diff --git a/app/src-tauri/src/report_cards/tests.rs b/app/src-tauri/src/report_cards/tests.rs index 0be7729..8087f95 100644 --- a/app/src-tauri/src/report_cards/tests.rs +++ b/app/src-tauri/src/report_cards/tests.rs @@ -85,7 +85,17 @@ impl Lab { fs::create_dir_all(home.join(".codex")).unwrap(); fs::create_dir_all(&data).unwrap(); Lab { - env: Env { home, data, claude: None, codex: None, exe: None, windows: false, git_bash: None, git: None }, + env: Env { + home, + data, + claude: None, + codex: None, + exe: None, + windows: false, + git_bash: None, + git: None, + codex_home: None, + }, dir, } } @@ -154,6 +164,7 @@ impl Lab { windows: e.windows, git_bash: e.git_bash.clone(), git: e.git.clone(), + codex_home: e.codex_home.clone(), } } diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 35deb2e..b4911d4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "No, Actually...", - "version": "0.5.6", + "version": "0.5.7", "identifier": "app.noactually", "build": { "beforeDevCommand": "pnpm dev", diff --git a/app/src/learn/lessons.json b/app/src/learn/lessons.json index c5c9d75..78bc57d 100644 --- a/app/src/learn/lessons.json +++ b/app/src/learn/lessons.json @@ -1,7 +1,7 @@ { "signal_hints": { "spot": "Target: name the file, function or page. 'In checkout.ts, the total is wrong.'", - "proof": "Evidence: paste the error, the output or an example. 'It says: Card declined.'", + "proof": "Evidence: paste the error, the output or an example. 'It says: TypeError: total is undefined.'", "done": "Finish line: say what done looks like. 'Done when the tests pass.'", "limits": "Guardrails: say what to leave alone. 'Don't change the API.'", "why": "Purpose: say who it's for or what it's part of. 'Our customers need this before tax season.'" @@ -30,7 +30,7 @@ "tips": [ "Use the real name of the file, the function or the doc.", "Wrap exact names in backticks so they stand out.", - "If it's on a screen, say which page and which button." + "If it's on a screen, paste the page's link and name the button." ], "quiz": [ { @@ -64,7 +64,7 @@ "text": "Checkout fails when I click Pay. The console says: TypeError: total is undefined. Please fix it in checkout.ts.", "signals": ["spot", "proof"] }, - "template": "When I , I get . Here it is: .", + "template": "When I , I get this: \"\".", "tips": [ "Paste the exact error. Don't retell it from memory.", "Show what you got and what you expected.", @@ -215,8 +215,8 @@ "template": ". I already know . I already tried .", "tips": [ "Say what you already tried and ruled out, and what happened, so nobody checks it twice.", - "Share facts the code can't show: what changed, and when it started.", - "Say facts about your setup and taste: “it's a Squarespace site”, “we use Tailwind”." + "The checker can't spot this one, so it won't light up. Say it anyway.", + "Say facts about your setup and taste: 'it's a small online shop', 'we use plain CSS'." ], "quiz": [ { @@ -321,7 +321,7 @@ "title": "Plan big tasks first", "reviewed": "2026-10", "teaches": { "steer": "redo" }, - "why": "A plan you can read in a minute corrections a wrong turn before hours of work get thrown away.", + "why": "A plan you can read in a minute catches a wrong turn before hours of work get thrown away.", "before": { "text": "Move the app from the old payment system to the new one.", "signals": [] diff --git a/app/src/test/fixtures.ts b/app/src/test/fixtures.ts index 09798f1..0ca5da5 100644 --- a/app/src/test/fixtures.ts +++ b/app/src/test/fixtures.ts @@ -1,4 +1,5 @@ // Shared test data, shaped like the Rust side's output. +import { expect } from "vitest"; import type { HallEntry, Meta, ModelRow, Overall, Progress, Prompts, RecentReportCard, ReportCardWeek, SourceInfo, Stats } from "../api"; export const meta: Meta = { @@ -87,6 +88,13 @@ export const prompts = (over: Partial = {}): Prompts => ({ export const HOSTILE = `&'"`; +/** Hostile markup must come out escaped: not live, and not silently dropped either. */ +export const safe = (markup: string) => { + expect(markup).not.toContain(" ({ provider, family, diff --git a/app/src/views/charts.test.ts b/app/src/views/charts.test.ts index b3d2d0c..995d3eb 100644 --- a/app/src/views/charts.test.ts +++ b/app/src/views/charts.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vitest"; -import { HOSTILE } from "../test/fixtures"; +import { HOSTILE, safe } from "../test/fixtures"; import { chartBox, lineChart, type Point, sparkline, stackChart } from "./charts"; -const safe = (markup: string) => { - expect(markup).not.toContain(" { - expect(markup).not.toContain(" = {}): LearnContext => ({ meta, prompts: null, done: [], lesson: null, draft: "", demo: false, ...over }); const home = (over: Partial = {}) => learnView(ctx(over)).value; const lesson = (id = "point-at-it", over: Partial = {}) => learnView(ctx({ lesson: id, ...over })).value; -const safe = (markup: string) => { - expect(markup).not.toContain(" { it("lists every track with its lessons and the lesson count", () => { const m = home(); @@ -113,7 +108,7 @@ describe("learnView lesson", () => { const m = lesson("say-what-you-know"); expect(m).toContain('

Context

'); expect(m).toContain("I already tried"); - expect(m).toContain("Say facts about your setup and taste: “it's a Squarespace site”, “we use Tailwind”."); + expect(m).toContain("Say facts about your setup and taste: 'it's a small online shop', 'we use plain CSS'."); expect(m).toContain('Target'); // only the signals the checker can see are chipped expect(m.split(" { - expect(markup).not.toContain(" { it("matches the Hmm. on screen", () => { diff --git a/app/vitest.config.ts b/app/vitest.config.ts index ab2229e..f5bedba 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ include: ["src/**/*.test.ts"], environment: "node", // Dates and numbers format by the machine's locale and timezone; pin both so a run is the same anywhere. - env: { TZ: "UTC", LC_ALL: "en_US.UTF-8", LANG: "en_US.UTF-8" }, + env: { TZ: process.env.TZ || "UTC", LC_ALL: "en_US.UTF-8", LANG: "en_US.UTF-8" }, // Tests that read the stylesheet's own text (its colors) get it; every other CSS file is left empty. css: { include: [/src\/style\.css/] }, restoreMocks: true, diff --git a/crates/cli/src/helper/tests.rs b/crates/cli/src/helper/tests.rs index 4f19808..6745adc 100644 --- a/crates/cli/src/helper/tests.rs +++ b/crates/cli/src/helper/tests.rs @@ -3,7 +3,7 @@ use super::chat::Look; use super::*; use serde_json::{json, Value}; -use sources::testing::line; +use sources::testing::{cc_prompt, cc_reply_with_usage, line}; use std::time::Instant; const MODEL: &str = "claude-opus-5-5"; @@ -14,8 +14,7 @@ fn at(minute: u32) -> String { /// A message the person typed, as Claude Code records it. `n` names it: uuid `u-n`, prompt `p-n`. fn user(n: &str, minute: u32, text: &str) -> String { - line(&json!({"type":"user","uuid":format!("u-{n}"),"promptId":format!("p-{n}"),"timestamp":at(minute), - "message":{"content":text}})) + cc_prompt(&format!("u-{n}"), &format!("p-{n}"), &at(minute), text) } /// Tokens as (new input, cache write, cache read, output). @@ -29,10 +28,14 @@ fn reply_from( text: &str, (input, cache_write, cache_read, output): Tokens, ) -> String { - line(&json!({"type":"assistant","uuid":format!("a-{n}"),"timestamp":at(minute),"message":{ - "id":format!("m-{n}"),"model":model,"content":[{"type":"text","text":text}], - "usage":{"input_tokens":input,"cache_creation_input_tokens":cache_write,"cache_read_input_tokens":cache_read, - "output_tokens":output}}})) + cc_reply_with_usage( + model, + &format!("a-{n}"), + &format!("m-{n}"), + &at(minute), + text, + (input, cache_write, cache_read, output), + ) } fn reply(n: &str, minute: u32, text: &str, tokens: Tokens) -> String { @@ -789,7 +792,10 @@ fn the_codex_prompt_hook_reads_the_end_of_a_long_rollout_not_all_of_it() { let t = Instant::now(); let out = handle(&Request::Hook(Hook::CodexPrompt), &fx.env, || input.to_string()).unwrap(); let took = t.elapsed(); - assert!(took.as_millis() < 2000, "a digest must come inside the hook's budget: {took:?}"); + assert!( + took.as_millis() < 20_000, + "a digest must come inside the hook's budget (10x the real 2s, for debug builds and busy machines): {took:?}" + ); let out: Value = serde_json::from_str(&out).unwrap(); let digest = out["hookSpecificOutput"]["additionalContext"].as_str().unwrap(); assert!(digest.contains("\nshowing the last 2 prompts\n"), "{digest}"); @@ -1207,14 +1213,14 @@ fn a_huge_transcript_is_not_read_to_say_nothing() { let message = tail::last_claude_message(&found, Some("p-9")).unwrap(); let looked_at = t.elapsed(); assert_eq!(message, "yes"); - assert!(looked_at.as_millis() < 500, "the end of {size} bytes, not all of them: {looked_at:?}"); + assert!(looked_at.as_millis() < 5_000, "the end of {size} bytes, not all of them: {looked_at:?}"); let t = Instant::now(); assert_eq!(fx.stop(&path, "9"), None); let whole = t.elapsed(); - // The bound is loose on purpose: a debug build compiles the rules' patterns here, which a - // release build does in milliseconds. Reading all 50 MB would take far longer. - assert!(whole.as_secs() < 5, "{whole:?}"); + // The bound is loose on purpose (10x): a debug build compiles the rules' patterns here, which a + // release build does in milliseconds. Reading all 50 MB would still take far longer. + assert!(whole.as_secs() < 50, "{whole:?}"); } // ---- long chats ---- @@ -1259,10 +1265,10 @@ fn a_slash_command_is_ruled_out_in_one_look_at_the_end_of_a_big_file() { let message = tail::last_claude_message(&found, Some("p-c")); let took = t.elapsed(); assert_eq!(message, None, "a command is not the person's message"); - assert!(took.as_millis() < 500, "one look at the first window: {took:?}"); + assert!(took.as_millis() < 5_000, "one look at the first window: {took:?}"); let t = Instant::now(); assert_eq!(fx.stop(&path, "c"), None); - assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed()); + assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed()); // The same when a long reply pushes the command out of the first window: wider, but only to find it. let path = write_long( @@ -1327,7 +1333,7 @@ fn a_sign_off_in_a_100_mb_transcript_is_decided_from_the_end() { let t = Instant::now(); assert_eq!(fx.stop(&path, "7"), Some(note("Want a report card on that? Type /grade:me"))); let took = t.elapsed(); - assert!(took.as_millis() < 2000, "a sign-off must decide inside the hook's budget: {took:?}"); + assert!(took.as_millis() < 20_000, "a sign-off must decide inside the hook's budget: {took:?}"); } #[test] @@ -1362,7 +1368,7 @@ fn a_task_longer_than_the_last_8_mb_is_left_alone_and_nothing_more_is_read() { let path = write_long(&fx, &[(&first(), 20), (&begin, 12), (&end, 0)]); let t = Instant::now(); assert_eq!(fx.stop(&path, "7"), None, "it can't tell what the task was without more than the limit"); - assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed()); + assert!(t.elapsed().as_secs() < 50, "{:?}", t.elapsed()); // What it could read is the last 8 MB: whole lines, the end of the chat, no task start. let from = tail_start(&path, 8 * 1024 * 1024).unwrap(); diff --git a/crates/cli/tests/helper.rs b/crates/cli/tests/helper.rs index d6f697d..a796496 100644 --- a/crates/cli/tests/helper.rs +++ b/crates/cli/tests/helper.rs @@ -2,7 +2,7 @@ //! transcript, an empty home folder and a throwaway database. Nothing real is read or written. use serde_json::{json, Value}; -use sources::testing::line; +use sources::testing::{cc_prompt, cc_reply_with_usage}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; @@ -12,14 +12,18 @@ fn at(minute: u32) -> String { } fn user(n: u32, minute: u32, text: &str) -> String { - line(&json!({"type":"user","uuid":format!("u-{n}"),"promptId":format!("p-{n}"),"timestamp":at(minute), - "message":{"content":text}})) + cc_prompt(&format!("u-{n}"), &format!("p-{n}"), &at(minute), text) } fn reply(n: u32, minute: u32, text: &str) -> String { - line(&json!({"type":"assistant","uuid":format!("a-{n}"),"timestamp":at(minute),"message":{ - "id":format!("m-{n}"),"model":"claude-opus-5-5","content":[{"type":"text","text":text}], - "usage":{"input_tokens":10_000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":5_000}}})) + cc_reply_with_usage( + "claude-opus-5-5", + &format!("a-{n}"), + &format!("m-{n}"), + &at(minute), + text, + (10_000, 0, 0, 5_000), + ) } /// Two rounds, then thanks. @@ -52,20 +56,34 @@ impl Home { p } + /// The binary with an empty environment but for this home, so no real Claude Code, Codex or + /// database folder (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_*`, `APPDATA`...) is ever reached. + fn command(&self) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_noactually")); + cmd.env_clear() + .env("HOME", self.dir.path()) + .env("USERPROFILE", self.dir.path()) + .env("NOACTUALLY_DB", self.dir.path().join("noactually.db")); + // Windows can't start a program without PATH and SystemRoot, and TZ lets CI run these in other zones. + // The rest of the real environment stays out. + for keep in ["PATH", "SystemRoot", "TZ"] { + if let Some(v) = std::env::var_os(keep) { + cmd.env(keep, v); + } + } + cmd + } + fn run(&self, args: &[&str], stdin: &str) -> Output { self.run_with(args, stdin, &[]) } fn run_with(&self, args: &[&str], stdin: &str, env: &[(&str, &str)]) -> Output { - let mut child = Command::new(env!("CARGO_BIN_EXE_noactually")) + let mut child = self + .command() .args(args) - .env("HOME", self.dir.path()) - .env("USERPROFILE", self.dir.path()) - .env("NOACTUALLY_DB", self.dir.path().join("noactually.db")) .env("NOACTUALLY_CLAUDE_PROJECTS", self.dir.path().join("projects")) .env("NOACTUALLY_CODEX_SESSIONS", self.dir.path().join("sessions")) - .env_remove("CLAUDE_CODE_ENTRYPOINT") - .env_remove("NOACTUALLY_DEBUG") .envs(env.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -129,15 +147,7 @@ fn an_argument_that_is_not_text_is_an_error_or_nothing_never_a_panic() { use std::os::unix::ffi::OsStringExt; let home = Home::new(); let bad = std::ffi::OsString::from_vec(vec![b'x', 0xff, 0xfe]); - let run = |args: &[&std::ffi::OsStr]| { - Command::new(env!("CARGO_BIN_EXE_noactually")) - .args(args) - .env("HOME", home.dir.path()) - .env("NOACTUALLY_DB", home.dir.path().join("noactually.db")) - .stdin(Stdio::null()) - .output() - .unwrap() - }; + let run = |args: &[&std::ffi::OsStr]| home.command().args(args).stdin(Stdio::null()).output().unwrap(); // A helper command prints nothing and succeeds. let out = run(&["digest".as_ref(), "--transcript".as_ref(), bad.as_ref()]); assert!(out.status.success()); @@ -170,10 +180,9 @@ fn the_stop_hook_answers_in_json_and_only_when_it_has_something_to_say() { assert!(quiet.status.success() && quiet.stdout.is_empty() && quiet.stderr.is_empty()); // `claude -p` has nobody to ask. - let mut headless = Command::new(env!("CARGO_BIN_EXE_noactually")); + let mut headless = home.command(); headless .args(["hook", "stop"]) - .env("NOACTUALLY_DB", home.dir.path().join("noactually.db")) .env("CLAUDE_CODE_ENTRYPOINT", "sdk-cli") .stdin(Stdio::piped()) .stdout(Stdio::piped()); @@ -187,9 +196,8 @@ fn the_stop_hook_answers_in_json_and_only_when_it_has_something_to_say() { fn the_session_start_hook_gives_a_chat_in_an_app_the_rules_when_tips_are_on() { let home = Home::new(); let run = |entrypoint: &str| { - let mut hook = Command::new(env!("CARGO_BIN_EXE_noactually")); + let mut hook = home.command(); hook.args(["hook", "session-start"]) - .env("NOACTUALLY_DB", home.dir.path().join("noactually.db")) .env("CLAUDE_CODE_ENTRYPOINT", entrypoint) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -347,10 +355,12 @@ fn stats_report_cards_reports_a_card_from_a_synced_chat_as_text_and_json() { assert!(text.find("Extra minutes") < text.find("Extra cost"), "{text}"); assert!(!text.contains("token"), "no counts of tokens in the text: {text}"); assert!(text.contains(" Most missed Finish line (1 of 1 task)\n"), "{text}"); + // The day is shown in local time, so it moves with the zone (the chat is at 10:00 UTC). + let day = chrono::DateTime::parse_from_rfc3339(&at(0)).unwrap().with_timezone(&chrono::Local).date_naive(); assert!( - text.contains( - "\n Latest\n 2026-10-01 claude-code B left out: finish line about 2 extra minutes · about $0.07 at pay-as-you-go prices" - ), + text.contains(&format!( + "\n Latest\n {day} claude-code B left out: finish line about 2 extra minutes · about $0.07 at pay-as-you-go prices" + )), "{text}" ); assert_eq!(String::from_utf8_lossy(&out.stderr), ""); @@ -391,17 +401,15 @@ fn stats_report_cards_reports_a_card_from_a_synced_chat_as_text_and_json() { } /// A hook whose input never ends (the pipe stays open, nothing is written) must still give up -/// on its own: exit 0 with nothing printed, inside the 2 second limit plus a little room. +/// on its own: exit 0 with nothing printed. The hook's own limit is 2 seconds; the wait here is 10x +/// that so a slow, busy machine doesn't fail it, while a hook that never gave up still does. #[test] fn a_hook_with_stdin_left_open_exits_quietly_in_time() { let home = Home::new(); - let mut child = Command::new(env!("CARGO_BIN_EXE_noactually")) + let mut child = home + .command() .args(["hook", "stop"]) - .env("HOME", home.dir.path()) - .env("USERPROFILE", home.dir.path()) - .env("NOACTUALLY_DB", home.dir.path().join("noactually.db")) .env("NOACTUALLY_CLAUDE_PROJECTS", home.dir.path().join("projects")) - .env_remove("CLAUDE_CODE_ENTRYPOINT") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -414,9 +422,9 @@ fn a_hook_with_stdin_left_open_exits_quietly_in_time() { if let Some(status) = child.try_wait().unwrap() { break status; } - if start.elapsed() > std::time::Duration::from_secs(3) { + if start.elapsed() > std::time::Duration::from_secs(30) { let _ = child.kill(); - panic!("the hook was still running after 3 seconds"); + panic!("the hook was still running after 30 seconds"); } std::thread::sleep(std::time::Duration::from_millis(20)); }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4050214..fe58605 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -42,6 +42,7 @@ pub use tier::{ pub use tip::{is_tip_rules, is_tip_stop, parse_tip, strip_tip, Tip, HAD_TO_ASK, TIP_RULES, TIP_STOP}; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; /// One AI message: its text and the raw model id that wrote it, when the source records one. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -213,7 +214,12 @@ impl Verdict { /// 11: a chat's first message follows no AI reply, so it is never a correction and keeps its task. /// 12: "I didn't catch the bug" admits again, but "I didn't catch that" (didn't hear you) doesn't; /// "I should have enough context now" and "my error handling" aren't admissions. -pub const RULES_VERSION: u32 = 12; +/// 13: polite closers ("No, that's all for now", "That's not needed") and "same thing for…" aren't +/// pushback; "you're right" after a plain question isn't an admission; "by the way" and the +/// prompt grader's other openers add context; a '?' in code isn't the AI asking; the AI's own +/// "I said" or "there is no" no longer picks the category; "because", "currently", "such as" +/// and "Next.js" no longer tick a signal on their own. +pub const RULES_VERSION: u32 = 13; /// Tools write placeholder ids for replies no model wrote (Claude Code's "" for errors, /// interruptions and "No response requested"). @@ -246,20 +252,24 @@ pub fn classify(turn: &Turn) -> Verdict { let ai_admit = rules::first(&rules::AI_ADMIT, next); let later = if ai_admit.is_none() { said_later(turn) } else { None }; let (ai_admit, admit_text) = match &later { - Some((_, text, m)) => (Some(*m), text.as_str()), + Some((_, text, m)) => (Some(*m), head_chars(text, AI_HEAD)), None => (ai_admit, next), }; let user_open = if pasted { None } else { rules::first(&rules::USER_OPEN, head) }; let user_any = if pasted { None } else { rules::first(&rules::USER_ANY, body) }; let context = rules::first(&rules::CONTEXT, head); - let answering = tail_chars(prev, ANSWER_TAIL).contains('?'); + let answering = asks_you(prev); + // "Is the default port 5432?" then "You're correct": it confirmed what you asked, it didn't + // own a mistake. "Are you sure?", "Can you fix it?" and a statement before the question still count. + let only_asked = rules::plain_question(t); + let agreed = ai_admit.is_some_and(|m| rules::YOU_WERE_RIGHT.contains(&m.rule)); // A chat's first message follows no AI reply, so there is nothing for it to correct: "still // broken: fix the login page" is a task, and keeps it. let label = match (ai_admit, user_open.or(user_any)) { _ if turn.prev_ai.is_none() => None, (Some(_), Some(_)) => Some(Label::Confirmed), - (Some(_), None) if context.is_none() => Some(Label::Admitted), + (Some(_), None) if context.is_none() && !(only_asked && agreed) => Some(Label::Admitted), (None, _) if user_open.is_some() && context.is_none() && !answering => Some(Label::Claimed), _ => None, }; @@ -294,6 +304,23 @@ pub fn classify(turn: &Turn) -> Verdict { Verdict::default() } +/// Code in a reply: fenced blocks (an unclosed fence runs to the end) and inline spans. +static CODE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(?s)```.*?(?:```|$)|`[^`\n]*`").expect("code")); + +/// A '?' that ends a sentence: after a word, before a space or the end (closing quotes, brackets +/// and emoji allowed). Not `read()?;`, `user?.name`, `?page=2` or `a ? b : c`. +static ASKS: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"[^\s?]\?+(?:["')\]*_]|\p{So}|\p{Sk}|\x{FE0F}|\x{200D})*(?:\s|$)"#).expect("asks") +}); + +/// True when the AI's reply ends by asking you something, so your "no" answers it. Code becomes a +/// word, so "Want me to run `cargo test`?" still asks. +fn asks_you(prev: &str) -> bool { + let prose = CODE.replace_all(prev, "code"); + ASKS.is_match(tail_chars(&prose, ANSWER_TAIL)) +} + /// The first later message that says the person was right: its index, its normalized text, and /// the match. "My mistake" doesn't count here: that far in, the AI may have found it on its own. fn said_later(turn: &Turn) -> Option<(usize, String, rules::Match)> { diff --git a/crates/engine/src/model.rs b/crates/engine/src/model.rs index 188a516..2858cca 100644 --- a/crates/engine/src/model.rs +++ b/crates/engine/src/model.rs @@ -29,6 +29,8 @@ impl Model { static DATE_SUFFIX: LazyLock = LazyLock::new(|| Regex::new(r"[-@](20\d{6}|\d{4}-\d{2}-\d{2}|latest|preview.*|exp.*)$").unwrap()); +static BEDROCK_PREFIX: LazyLock = LazyLock::new(|| Regex::new(r"^(?:[a-z]{2,6}\.)?anthropic\.").unwrap()); +static BEDROCK_REVISION: LazyLock = LazyLock::new(|| Regex::new(r"-v\d+(?::\d+)?$").unwrap()); static CLAUDE: LazyLock = LazyLock::new(|| Regex::new(r"^claude-(?:(\d+)(?:[-.](\d))?-)?([a-z]+)(?:-(\d+)(?:[-.](\d+))?)?").unwrap()); static GPT: LazyLock = @@ -49,7 +51,10 @@ pub fn normalize_model(raw: &str) -> Option { // Local runners prefix ids with an org or path ("lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF"). let base = raw.rsplit('/').next().unwrap_or(raw).to_ascii_lowercase(); let base = base.trim_start_matches("meta-"); - let id = DATE_SUFFIX.replace(base, ""); + // Bedrock ids: "us.anthropic.claude-sonnet-4-5-20250929-v1:0" is the plain id plus a region/vendor prefix and a revision. + let base = BEDROCK_PREFIX.replace(base, ""); + let base = BEDROCK_REVISION.replace(&base, ""); + let id = DATE_SUFFIX.replace(&base, ""); let id = id.as_ref(); let m = |provider: &str, family: String, version: String| Some(Model { provider: provider.into(), family, version }); @@ -63,6 +68,7 @@ pub fn normalize_model(raw: &str) -> Option { (None, None) => return m("Anthropic", format!("Claude {tier}"), String::new()), }; let version = match minor { + Some("0") => major.to_string(), // "claude-opus-4-0" is "claude-opus-4-20250514": version 4 Some(min) if min.len() < 3 => format!("{major}.{min}"), _ => major.to_string(), }; @@ -135,6 +141,20 @@ mod tests { assert_eq!(n("claude-3-opus-20240229"), t("Anthropic", "Claude Opus", "3")); } + #[test] + fn bedrock_ids_match_their_plain_forms() { + assert_eq!(n("us.anthropic.claude-sonnet-4-5-20250929-v1:0"), t("Anthropic", "Claude Sonnet", "4.5")); + assert_eq!(n("anthropic.claude-opus-5-5"), t("Anthropic", "Claude Opus", "5.5")); + assert_eq!(n("global.anthropic.claude-opus-4-6-v1"), t("Anthropic", "Claude Opus", "4.6")); + assert_eq!(n("eu.anthropic.claude-3-5-sonnet-20241022-v2:0"), t("Anthropic", "Claude Sonnet", "3.5")); + } + + #[test] + fn opus_4_has_one_version_name() { + assert_eq!(n("claude-opus-4-0"), t("Anthropic", "Claude Opus", "4")); + assert_eq!(n("claude-opus-4-20250514"), t("Anthropic", "Claude Opus", "4")); + } + #[test] fn openai_ids() { assert_eq!(n("gpt-5.1"), t("OpenAI", "GPT", "5.1")); diff --git a/crates/engine/src/pricing.rs b/crates/engine/src/pricing.rs index 9f9e612..ccb593b 100644 --- a/crates/engine/src/pricing.rs +++ b/crates/engine/src/pricing.rs @@ -104,7 +104,10 @@ pub fn price(model: &Model) -> Option { ("Claude Opus", "5" | "4.8" | "4.7" | "4.6" | "4.5") => (5.0, 25.0, 0.50), ("Claude Sonnet", "5.5" | "5") => (2.0, 10.0, 0.20), ("Claude Sonnet", "4.6" | "4.5" | "4") => (3.0, 15.0, 0.30), - // The rate for prompts up to 100K tokens; longer prompts cost 5x, so this can undercount. + ("Claude Opus", "4.1" | "4") => (15.0, 75.0, 1.50), + // The rate for requests up to 100K input tokens; larger ones cost 5x ($0.50 / $2.50). `Usage` is + // summed over many requests (a task, a session), so the size of each request is unknown here: + // we keep the low rate, and this can undercount. ("Claude Haiku", "5.5") => (0.10, 0.50, 0.01), ("Claude Haiku", "4.5") => (1.0, 5.0, 0.10), _ => return None, @@ -142,6 +145,16 @@ mod tests { assert!((usage().cost(&p) - 0.094).abs() < 1e-9); } + #[test] + fn opus_4_and_4_1_cost_fifteen_and_seventy_five() { + for id in ["claude-opus-4-20250514", "claude-opus-4-0", "claude-opus-4-1-20250805", "claude-opus-4-1"] { + let p = price(&normalize_model(id).unwrap()).unwrap_or_else(|| panic!("{id} has no price")); + assert_eq!(p, Price { input: 15.0, cache_write: 18.75, cache_read: 1.50, output: 75.0 }, "{id}"); + } + let bedrock = price(&normalize_model("us.anthropic.claude-opus-4-1-20250805-v1:0").unwrap()); + assert_eq!(bedrock.map(|p| p.input), Some(15.0)); + } + #[test] fn minus_is_the_step_from_an_earlier_total_and_starts_over_when_it_drops() { let a = Usage { input: 10, cache_write: 20, cache_read: 300, output: 40 }; diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index f3435cf..6c68034 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -5,7 +5,7 @@ //! A prompt's grade counts its [`Signal`]s, never its length, so padding a prompt earns nothing. //! Questions aren't prompts: asking how something works is not handing over a task. -use crate::rules::{first, rule, rule_unless, Rule}; +use crate::rules::{first, rule, rule_not_after, rule_unless, Rule, ADDED_FORGOT, ADDED_OPENER}; use crate::{head_chars, normalize_quotes, Label, Turn, Verdict}; use serde::{Deserialize, Serialize}; use std::sync::LazyLock; @@ -368,9 +368,11 @@ static QUESTION: LazyLock> = LazyLock::new(|| { }); static EXPLAIN: LazyLock> = LazyLock::new(|| { - vec![rule( + vec![rule_unless( "chat.explain", r"^(?:explain|tell me (?:about|what|why|how)|describe|define|summari[sz]e|tldr|eli5|(?:can|could|would) you (?:explain|tell me|help me understand|walk me through|describe|summari[sz]e|clarify))\b", + // "Summarize the changes into CHANGELOG.md" writes something: a task, not a question. + r"(?:(?:can|could|would) you )?summari[sz]e\b[^.?!\n]{0,80}\b(?:into|to|in|as)\s+(?:(?:a|an|the|my|our)\s+)?(?:[\w-]+\.[a-z]\w{0,4}\b|`|(?:new\s+)?(?:file|doc|document|changelog|readme|pr|commit|comment|issue|ticket|email|message|note|notes|table|list)\b)", )] }); @@ -409,15 +411,10 @@ static ADDED: LazyLock> = LazyLock::new(|| { vec![ rule_unless( "steer.added.also", - &format!( - r"{ACK}(?:also|oh,? and|ah,? and|and also|btw|by the way|fyi|one more thing|one other thing|another thing|additionally|in addition|plus,|p\.?s\.?)(?:\W|$)" - ), + &format!(r"{ACK}(?:{ADDED_OPENER})(?:\W|$)"), &format!(r"{ACK}(?:also|and also|plus,)\W*{WORKFLOW}"), ), - rule( - "steer.added.forgot", - r"\bi (?:forgot|neglected) to (?:mention|say|tell|add|include|note|specify)\b|\bforgot to mention\b|\bi should have (?:mentioned|said|told|added|specified)\b|\bi didn'?t mention\b", - ), + rule("steer.added.forgot", ADDED_FORGOT), rule_unless( "steer.added.make_sure", &format!( @@ -457,9 +454,11 @@ static NEW_TASK: LazyLock> = LazyLock::new(|| { /// Names the place. static SPOT: LazyLock> = LazyLock::new(|| { vec![ - rule( + rule_unless( "prompt.spot.file", r"\b[\w-]+\.(?:rs|ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|kts|swift|rb|php|cs|cpp|cc|c|h|hpp|css|scss|sass|less|html?|md|mdx|json|jsonl|ya?ml|toml|ini|cfg|conf|sql|sh|bash|zsh|ps1|vue|svelte|astro|lock|env|txt|csv|tsv|xml|gradle|tf|proto|graphql|gql|ipynb|plist|xcconfig|dart|lua|ex|exs|scala)\b|(?:^|[\s(`'])\.(?:env|gitignore|npmrc|nvmrc|eslintrc|prettierrc|editorconfig|github|vscode)\b|(?-i:\b(?:Dockerfile|Makefile|Justfile|justfile|Gemfile|Procfile|README|CHANGELOG)\b)", + // A framework's name ("Next.js", "Node.js") is a word, not a file. + r"(?:next|node|nuxt|nest|vue|react|preact|angular|ember|backbone|express|three|chart|d3|alpine|solid|knockout|meteor|moment|p5|pixi|leaflet|electron)\.js\b", ), rule("prompt.spot.dir", r"(?:^|[\s(\[`'])(?:~|\.{1,2})?/?[A-Za-z_][\w.-]*/[\w.-]+/"), rule("prompt.spot.mention", r"(?:^|\s)@[\w-]*[/.][\w./-]+"), @@ -499,7 +498,8 @@ static PROOF: LazyLock> = LazyLock::new(|| { ), rule( "prompt.proof.example", - r"\be\.g\.|\b(?:for example|for instance|such as|like this|example:|sample:|here'?s (?:an|the|my) (?:example|output|error|log|screenshot|trace)|here is (?:an|the|my) (?:example|output|error|log|screenshot|trace))", + // "such as login" lists, it doesn't show anything. + r"\be\.g\.|\b(?:for example|for instance|like this|example:|sample:|here'?s (?:an|the|my) (?:example|output|error|log|screenshot|trace)|here is (?:an|the|my) (?:example|output|error|log|screenshot|trace))", ), rule("prompt.proof.quote", r#""[^"\n]{6,200}""#), rule( @@ -575,7 +575,7 @@ static LIMITS: LazyLock> = LazyLock::new(|| { ), rule( "prompt.limits.no", - r"\bno (?:new |extra |additional |external |third[- ]party )?(?:dependencies|deps|packages|libraries|libs|frameworks|breaking changes|migrations|comments|emojis?|placeholders|mocks|todos?)\b|\b(?:must not|mustn'?t|should not|shouldn'?t|cannot|can'?t|may not) (?:change|break|touch|use|add|remove|affect|modify|rely|depend|exceed|call|import|be)\b|\b(?:out of scope|scope (?:it|this) to|limit(?:ed)? (?:it |this |the change )?to|stick (?:to|with)|stay within|no more than)\b", + r"\bno (?:new |extra |additional |external |third[- ]party )?(?:dependencies|deps|packages|libraries|libs|frameworks|breaking changes|migrations|comments|emojis?|placeholders|mocks|todos?)\b|\b(?:must not|mustn'?t|should not|shouldn'?t|cannot|can'?t|may not) (?:change|break|touch|use|add|remove|affect|modify|rely|depend|exceed|call|import)\b|\b(?:out of scope|scope (?:it|this) to|limit(?:ed)? (?:it |this |the change )?to|stick (?:to|with)|stay within|no more than)\b", ), ] }); @@ -583,9 +583,11 @@ static LIMITS: LazyLock> = LazyLock::new(|| { /// Gives the purpose. static WHY: LazyLock> = LazyLock::new(|| { vec![ - rule( + rule_not_after( "prompt.why.because", r"\b(?:because|since (?:we|i|they|users?|our \w+) (?:need|want|can'?t|cannot|don'?t|use|have|rely|depend|ship|support)|so that|in order to|that way)\b", + // "it fails because the cache is stale" says what's wrong, not what the work is for. + r"\b(?:fails?|failing|failed|breaks|broke|broken|crash(?:es|ed|ing)?|errors? out|hangs?|freezes|times? out|stuck|slow|empty|blank|stale|missing|wrong|(?:doesn'?t|does not|didn'?t|won'?t|can'?t) (?:work|load|show|run|build|start|open|compile)|(?:isn'?t|is not|not) (?:working|loading|showing|running|building|starting))\b(?:\s+(?:again|now|anymore|sometimes|often|randomly|here|there))?[\s,]*", ), rule( "prompt.why.goal", @@ -595,7 +597,12 @@ static WHY: LazyLock> = LazyLock::new(|| { "prompt.why.who", r"\b(?:our|my) (?:users?|customers?|clients?|team|boss|manager|designer|pm|stakeholders?|visitors|readers|audience)\b|\b(?:users?|customers?|people) (?:need|want|can'?t|cannot|keep|report|complain|expect)\b|\bfor (?:the|our|a|my) (?:demo|launch|release|client|customer|presentation|meeting|deadline|portfolio|interview)\b", ), - rule("prompt.why.now", r"\b(?:currently|at the moment|as of now)\b"), + // "Currently we're moving to Postgres" says what it's part of; "currently the button is + // red" only describes it. + rule( + "prompt.why.now", + r"\b(?:currently|at the moment|as of now|right now),? (?:i|we|they)(?:'?re|'?m| are| am)? (?:\w+ing|about to|in the middle of)\b", + ), ] }); diff --git a/crates/engine/src/prompt_tests.rs b/crates/engine/src/prompt_tests.rs index a6ec6ae..77fbfed 100644 --- a/crates/engine/src/prompt_tests.rs +++ b/crates/engine/src/prompt_tests.rs @@ -125,11 +125,25 @@ const EXAMPLES: &[(&str, &[Signal])] = &[ ("Don't work on main, use a branch", &[Limits]), // Purpose. ("cache the results because the API is rate limited", &[Why]), + ("cache the results because we're launching it on Friday", &[Why]), ("our users can't find the export button", &[Why]), ("cache it since we rely on a slow API", &[Why]), ("I'm trying to cut the bundle size for the launch", &[Why]), ("Goal: one place to see every invoice", &[Why]), - ("currently the report takes a minute to open", &[Why]), + ("currently we're moving the reports to Postgres", &[Why]), + // Saying what's wrong or how it is now isn't saying what it's for. + ("the login fails because the cache is stale", &[]), + ("the page is blank because the query times out", &[]), + ("it doesn't load, because the token expired", &[]), + ("Currently the button is red", &[]), + ("currently the report takes a minute to open", &[]), + // A framework's name isn't a file, a list isn't an example, and "can't be opened" isn't a rule. + ("move the site to Next.js", &[]), + ("the Node.js and Vue.js versions are old", &[]), + ("fix App.js", &[Spot]), + ("add social sign-in, such as login with email", &[]), + ("the PDF can't be opened on Windows", &[]), + ("this can't break the import", &[Limits]), // Specific: several at once. ( "In src/checkout.ts the total should include tax. Don't touch the cart API, our customers depend on it.", @@ -226,6 +240,8 @@ fn hellos_questions_and_payloads_are_not_prompts() { "is there a way to skip the hook?", "explain the retention levels", "can you explain what this regex matches", + "summarize the retention docs", + "summarize the changes in 2.0", "/clear", "/review", " ", @@ -240,6 +256,9 @@ fn hellos_questions_and_payloads_are_not_prompts() { "Can I get a script that renames files?", "Is it possible to add dark mode?", "/Users/me/app/src/a.ts is broken", + // Summing up into a file writes something. + "Summarize the changes into CHANGELOG.md", + "could you summarize the thread into a new issue", ] { assert!(matches!(first_message(p), Prompt::Task { .. }), "{p:?}"); } @@ -288,6 +307,10 @@ fn steering_that_is_on_you() { ("Done.", "let's try a different approach", Steer::Redo), ("Done.", "go back to the old version", Steer::Redo), ("Done.", "also make it work offline", Steer::Added), + ("Done.", "By the way, we deploy on Vercel", Steer::Added), + ("Done.", "oh, also the dates are in UTC", Steer::Added), + ("Done.", "p.s. it has to run on Node 18", Steer::Added), + ("Done.", "I should have mentioned it's for mobile", Steer::Added), ]; for (prev, human, want) in cases { assert_eq!(steer(prev, human), Some(want), "{human:?}"); diff --git a/crates/engine/src/redact.rs b/crates/engine/src/redact.rs index 20f3570..65ee1de 100644 --- a/crates/engine/src/redact.rs +++ b/crates/engine/src/redact.rs @@ -5,7 +5,7 @@ use regex::{Captures, Regex}; use std::sync::LazyLock; /// Bump when [`redact`] catches more, so text stored under older rules is cleaned once. -pub const REDACTION_VERSION: u32 = 2; +pub const REDACTION_VERSION: u32 = 3; /// The longest snippet kept, in chars. pub const SNIPPET_CHARS: usize = 140; @@ -13,6 +13,14 @@ pub const SNIPPET_CHARS: usize = 140; /// A value after a secret's name: escaped-quoted (in JSON inside a string), quoted (it may hold /// spaces), already redacted (so a second pass changes nothing), or bare. const VALUE: &str = r#"(?P\\"[^"]*?\\"|"[^"]*"|'[^']*'|\[secret\]|\S+)"#; +/// A value that only counts when [`looks_secret`] agrees: quoted, or long with a digit in it. +const SECRET_LIKE: &str = r#"(?P"[^"\s]{6,}"|'[^'\s]{6,}'|[A-Za-z0-9_+/=.-]+)"#; + +/// True for a quoted value, or a bare one of 8 or more characters with a digit (`abc123def456`). Plain +/// words and short ids (`id`, `users.id`, `5`) are code or prose. +fn looks_secret(value: &str) -> bool { + value.starts_with(['"', '\'']) || (value.len() >= 8 && value.contains(|c: char| c.is_ascii_digit())) +} /// What follows a home folder: more path, up to where prose or a list takes over again. /// A folder's name can hold a few spaces or commas when another folder follows it. @@ -91,6 +99,7 @@ const NOT_A_VALUE: &[&str] = &[ "please", "required", "reset", + "see", "should", "so", "some", @@ -164,7 +173,7 @@ static REDACTIONS: LazyLock> = LazyLock::new(|| { (r(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "[email]"), ( r( - r"\b(sk-[A-Za-z0-9_-]{16,}|(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{10,}|npm_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[0-9A-Z]{16}|hf_[A-Za-z0-9]{20,}|gsk_[A-Za-z0-9]{20,}|whsec_[A-Za-z0-9]{16,}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})", + r"\b(sk-[A-Za-z0-9_-]{16,}|(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{10,}|npm_[A-Za-z0-9]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|xapp-\d-[A-Za-z0-9-]{10,}|ya29\.[A-Za-z0-9_-]{20,}|GOCSPX-[A-Za-z0-9_-]{10,}|(?:AKIA|ASIA)[0-9A-Z]{16}|hf_[A-Za-z0-9]{20,}|gsk_[A-Za-z0-9]{20,}|whsec_[A-Za-z0-9]{16,}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})", ), "[secret]", ), @@ -196,9 +205,14 @@ static REDACTIONS: LazyLock> = LazyLock::new(|| { )), "$1=[secret]", ), + // Any other key name (OPENAI_KEY, license_key) only when the value looks like a key, so code such as + // `primary_key: id` is left alone. + (r(&format!(r#"(?i)\b([A-Za-z0-9_.-]*_key)(?:\\?["'])?\s*[:=]\s*{SECRET_LIKE}"#)), "$1=[secret]"), // Short names only count whole (`pw:`, `DB_PASS=`), so "bypass" and "compass" are left alone. (r(&format!(r#"(?i)\b((?:[A-Za-z0-9]+[_.-])*pw)(?:\\?["'])?\s*[:=]\s*{VALUE}"#)), "$1=[secret]"), (r(&format!(r#"(?i)\b((?:[A-Za-z0-9]+[_.-])*pass)(?:\\?["'])?\s*=\s*{VALUE}"#)), "$1=[secret]"), + // `pass:` is also a test count ("Pass: 5"), so it needs a value that looks like a password. + (r(&format!(r#"(?i)\b((?:[A-Za-z0-9]+[_.-])*pass)(?:\\?["'])?\s*:\s*{SECRET_LIKE}"#)), "$1=[secret]"), // "my password is hunter2" takes the next word, even when it is only "too" (better lost than leaked). (r(r"(?i)\b(passwd|pass(?:word|code|phrase))\s+(?:is|was)\s+\S+"), "$1 [secret]"), (r(&format!(r"(/Users|/home)/[^/\s]+{UNDER}")), "~/[path]"), @@ -208,7 +222,9 @@ static REDACTIONS: LazyLock> = LazyLock::new(|| { (r(r"(^|[^\w/.-])/root(?:/\S*|\b)"), "${1}~"), (r(r"/(private|var|tmp|Volumes|opt|mnt)/\S+"), "[path]"), (r(r"\b\d{1,3}(\.\d{1,3}){3}\b"), "[ip]"), - (r(r"\+?\d[\d\s().-]{8,}\d"), "[number]"), + // An ISO date ("2026-11-15") is not a phone number: the `keep` branch matches it first and leaves it be. + // It must end there, so a number that only starts like one ("0466-12-3456") is still caught. + (r(r"(?P\b\d{4}-\d{2}-\d{2})(?:[^\d-]|$)|\+?\d[\d\s().-]{8,}\d"), "[number]"), ]; rules.into_iter().map(|(re, with)| Rule { re, with }).collect() }); @@ -218,7 +234,10 @@ pub fn redact(text: &str) -> String { REDACTIONS.iter().fold(text.to_string(), |acc, rule| { rule.re .replace_all(&acc, |c: &Captures<'_>| { - if c.name("v").is_some_and(|v| not_a_value(v.as_str())) { + if c.name("keep").is_some() + || c.name("v").is_some_and(|v| not_a_value(v.as_str())) + || c.name("s").is_some_and(|s| !looks_secret(s.as_str())) + { return c[0].to_string(); } let mut out = String::new(); @@ -459,6 +478,44 @@ mod tests { assert_eq!(redact(prose), prose); } + #[test] + fn any_key_name_pass_colon_and_more_tokens_are_caught() { + // Split so secret scanners don't take these made-up samples for real keys. + for (name, value) in + [("OPENAI", "abc123def4"), ("STRIPE", concat!("rk_", "live_abc123def456")), ("LICENSE", "ABCD-1234-IJKL")] + { + assert_eq!(redact(&format!("{name}_KEY={value}")), format!("{name}_KEY=[secret]"), "{name}"); + } + assert_eq!(redact("license_key: 'ABCD-EFGH'"), "license_key=[secret]"); + assert_eq!(redact("pass: swordfish99"), "pass=[secret]"); + // Code and test output that only look like secrets. + for code in ["primary_key: id", "foreign_key: users.id", "sort_key=name", "Pass: 5", "pass: true, fail: 0"] { + assert_eq!(redact(code), code); + } + assert_eq!(redact("pass: 'hunter22'"), "pass=[secret]"); + // Split or built at runtime so secret scanners don't take these made-up samples for real tokens. + let ya29 = format!("ya29.{}", "a0AfH6SMBxabcdefghij0123456789"); + let gocspx = format!("GOCSPX-{}", "abcdefghijklmnop"); + let xoxs = format!("xox{}", "s-123456789012-abcdefghijkl"); + let xapp = format!("xapp-{}", "1-A0123456789-abcdefghijkl"); + for key in [ya29, gocspx, xoxs, xapp] { + assert_eq!(redact(&format!("key {key} end")), "key [secret] end", "{key}"); + } + // Near misses: prose after a key name, and a pass that is a verdict. + let prose = "the API_KEY: see docs, LICENSE_KEY is required, pass the test, a monkey=1"; + assert_eq!(redact(prose), prose); + } + + #[test] + fn iso_dates_stay_but_phone_numbers_go() { + assert_eq!(redact("due 2026-11-15 and 2026-11-16."), "due 2026-11-15 and 2026-11-16."); + assert_eq!(redact("at 2026-11-15T10:00:00Z"), "at 2026-11-15T10:00:00Z"); + assert_eq!(redact("call 555-123-4567 on 2026-11-15"), "call [number] on 2026-11-15"); + assert_eq!(redact("call 0466-12-3456 now"), "call [number] now"); + assert_eq!(redact("ref 2026-11-15-1234"), "ref [number]"); + assert_eq!(redact("card 4111 1111 1111 1111"), "card [number]"); + } + #[test] fn flags_with_a_space_before_the_value_are_caught() { assert_eq!(redact("run --password hunter2 now"), "run --password [secret] now"); diff --git a/crates/engine/src/rules.rs b/crates/engine/src/rules.rs index bf94bce..27be4e7 100644 --- a/crates/engine/src/rules.rs +++ b/crates/engine/src/rules.rs @@ -51,14 +51,40 @@ pub(crate) fn rule_unless(id: &'static str, pattern: &str, unless: &str) -> Rule Rule { id, re: re(id, pattern), unless: Some(re(id, &format!("^(?:{unless})"))), not_after: None } } +/// A rule that doesn't fire when the text before the match ends with `before`. +pub(crate) fn rule_not_after(id: &'static str, pattern: &str, before: &str) -> Rule { + Rule { id, re: re(id, pattern), unless: None, not_after: Some(re(id, &format!("(?:{before})$"))) } +} + pub fn first(rules: &[Rule], text: &str) -> Option { rules.iter().find_map(|r| r.find(text)) } +/// "not necessary", "not needed": nothing left to do, not a complaint. A macro so `concat!` can +/// build [`APPROVAL`] from it. +macro_rules! not_needed { + () => { + r"not\s+(really\s+|strictly\s+)?(necessary|needed|required|important|urgent)\b" + }; +} + /// "that's fine", "it works", "you're right": a subject plus an approval word. Appended to an /// opener like "no, " it marks approval ("No, that's fine, ship it"), not pushback. Present tense -/// only: "No, it worked before you changed it" and "it was fine until…" are corrections. -const APPROVAL: &str = r"(that|this|it|you)('s|'re|s|re|\s+is|\s+are|\s+looks?|\s+sounds?|\s+seems?)?(\s+(all|also|actually|just|totally|perfectly|pretty|really|now))?\s+(fine|good|great|ok|okay|perfect|right|correct|works?|working)\b"; +/// only: "No, it worked before you changed it" and "it was fine until…" are corrections. "That's +/// not necessary" and "that's all for now" close a task politely; "that's all wrong" doesn't. +const APPROVAL: &str = concat!( + r"(that|this|it|you)('s|'re|s|re|\s+is|\s+are|\s+looks?|\s+sounds?|\s+seems?)?(\s+(all|also|actually|just|totally|perfectly|pretty|really|now))?\s+((fine|good|great|ok|okay|perfect|right|correct|works?|working|alright|all right|cool)\b|", + not_needed!(), + r"|all(\s+for\s+now)?\s*([,.!;:—–-]|$|thanks\b|thank you\b))" +); + +/// Openers that add what the AI couldn't have known: "also", "by the way", "p.s.". Not a bare +/// "ps": "ps aux" and a "PS C:\>" prompt are pastes. Shared by +/// [`CONTEXT`] and the prompt grader's "you added more", so the two never disagree. +pub(crate) const ADDED_OPENER: &str = r"also|oh,? and|ah,? and|oh,? also|ah,? also|and also|btw|by the way|fyi|one more thing|one other thing|another thing|additionally|in addition|plus,|p\.s\.|ps[:,]"; + +/// "I forgot to mention", "I should have said": a requirement added late, on you. +pub(crate) const ADDED_FORGOT: &str = r"\bi (?:forgot|neglected) to (?:mention|say|tell|add|include|note|specify)\b|\bforgot to mention\b|\bi should have (?:mentioned|said|told|added|specified)\b|\bi didn'?t mention\b"; /// Right before an admission, these leave it open: "let me check if you're right", "I don't think /// you're right", "that might be a good catch". @@ -104,8 +130,9 @@ pub static AI_ADMIT: LazyLock> = LazyLock::new(|| { rule_unless( "ai.should_have", r"\bi should have\b", - // What the AI expects to have, not what it failed to do: "I should have enough context now". - r"i should have\s+(enough|everything|all|what|which|the (context|info|information|data|access|files?|details?|answer|results?|rest)|access|a (good|clear|better|full|complete)|more (time|context|info|information|data|details?|access)|some|plenty|sufficient|both|time|permission|it (now|soon|ready|done|working|fixed)|that (now|soon))\b", + // What the AI expects to have, not what it failed to do: "I should have enough context + // now", "I should have a fix ready in a minute", "I should have this done shortly". + r"i should have\s+(enough|everything|all|what|which|the (context|info|information|data|access|files?|details?|answer|results?|rest)|access|a (good|clear|better|full|complete)|more (time|context|info|information|data|details?|access)|some|plenty|sufficient|both|time|permission|(it|this|that|these|those|them|(a|the|an) (fix|patch|draft|pr|change|update|version|build|plan|answer))\s+((all|fully|mostly)\s+)?(now|soon|shortly|ready|done|working|fixed|finished|sorted|up|back|in a (minute|moment|sec|second|bit|few)))\b", ), rule("ai.incorrectly", r"\bi incorrectly\b"), rule("ai.that_was_wrong", r"\bthat was (wrong|incorrect|a mistake)\b"), @@ -123,7 +150,7 @@ pub static AI_ADMIT: LazyLock> = LazyLock::new(|| { /// Credits the person, rather than only owning up. "Good catch" isn't here: that far into a reply /// it's mostly about a test or the AI's own check. -const YOU_WERE_RIGHT: [&str; 2] = ["ai.youre_right", "ai.you_are_right"]; +pub(crate) const YOU_WERE_RIGHT: [&str; 2] = ["ai.youre_right", "ai.you_are_right"]; /// The admissions that say the person was right, for a later message in a reply. pub fn you_were_right(text: &str) -> Option { @@ -133,17 +160,26 @@ pub fn you_were_right(text: &str) -> Option { /// You open your message by pushing back. pub static USER_OPEN: LazyLock> = LazyLock::new(|| { vec![ + // "Not needed" is approval ("No, that's not necessary."), unless it says to undo the work. + rule( + "you.unneeded", + &format!( + r"^((no|nope|nah)\s*[,.!—–-]+\s*)?((that|this|it)('s|\s+is)?\s+)?{}[^\n]{{0,60}}?\b(remove|revert|undo|delete|take (it |that |this )?out|roll (it |that |this )?back|i (said|asked))\b", + not_needed!() + ), + ), rule_unless( "you.no_that", r"^(no|nope|nah)\s*[,.!—–-]+\s*(that|this|it|you|not|the|we|i said|still|wrong)\b", - &format!(r"(no|nope|nah)\s*[,.!—–-]+\s*{APPROVAL}"), + &format!(r"(no|nope|nah)\s*[,.!—–-]+\s*({APPROVAL}|{})", not_needed!()), ), rule("you.no", r"^(no|nope|nah|wrong)[.!]*$"), rule_unless( "you.wrong", r"^(wrong|incorrect|not quite|not true|that'?s not|that is not|that isn'?t|this is not|this isn'?t)\b", - // "that's not a problem", "this isn't bad": a negated complaint is approval. - r"(that'?s not|that is not|that isn'?t|this is not|this isn'?t)\s+(a (big )?deal|a problem|an issue|bad|wrong)(\s+at all)?\s*([,.!;:\n]|$)", + // "that's not a problem", "this isn't bad": a negated complaint is approval. "That's not + // needed" says there's nothing more to do. + r"(that'?s not|that is not|that isn'?t|this is not|this isn'?t)\s+((really|strictly)\s+)?(a (big )?deal|a problem|an issue|bad|wrong|necessary|needed|required|important|urgent)(\s+(at all|right now|now|yet|anymore|for now|today))?\s*([,.!;:\n]|$)", ), rule( "you.its_wrong", @@ -157,8 +193,18 @@ pub static USER_OPEN: LazyLock> = LazyLock::new(|| { rule("you.i_said", r"^(i said|i told you|like i said|as i said|i already (said|told|asked))\b"), rule("you.i_asked", r"^i asked (you )?(to|for|not)\b"), rule("you.why_did_you", r"^(why did you|why would you|what\??$|wtf|huh\??$)"), - rule("you.doesnt_work", r"^(doesn'?t|didn'?t|does not|did not|isn'?t|not) work"), - rule("you.same_error", r"^(same|still the same) (error|issue|problem|thing)"), + rule_unless( + "you.doesnt_work", + r"^(doesn'?t|didn'?t|does not|did not|isn'?t|not) work", + // "Not working on the API today": a plan, not a complaint. "Not working on Safari" still is one. + r"not working on\b[^.!?\n]*\b(today|tonight|this (week|morning|afternoon)|for now|focus)\b", + ), + rule_unless( + "you.same_error", + r"^(same|still the same) (error|issue|problem|thing)\b", + // "Same thing for the footer please" asks for more of the same. + r"(same|still the same) thing\s+(for|with|on|in|to|here|please)\b", + ), rule_unless( "you.actually", r"^actually,? (it|that|this|you|the|no)\b", @@ -187,8 +233,12 @@ pub static USER_ANY: LazyLock> = LazyLock::new(|| { /// You added information the AI couldn't have known, or you're just acknowledging. Never a correction. pub static CONTEXT: LazyLock> = LazyLock::new(|| { vec![ - rule("ctx.also", r"^(also|oh and|btw|fyi|one more thing|additionally|and also|plus)\b"), - rule("ctx.forgot_to_mention", r"\bi forgot to (mention|say|tell)\b"), + rule("ctx.also", &format!(r"^(?:{ADDED_OPENER})(?:\W|$)")), + // At the start only: "Why did you add a footer? I didn't mention a footer." is pushback. + rule( + "ctx.forgot_to_mention", + &format!(r"^(?:(?:oh|ah|oops|sorry|wait|also|and|ok|okay|btw)[,.!]*\s+)*(?:{ADDED_FORGOT})"), + ), rule("ctx.ack", r"^(ok|okay|great|thanks|perfect|cool|nice|yes|yep|sure)\b"), ] }); @@ -209,33 +259,69 @@ pub static AI_PUSHBACK: LazyLock> = LazyLock::new(|| { ] }); +/// Doubt inside a question: "are you sure?", "isn't it 5433?". That's pushing back, so a "you're +/// right" after it still admits a mistake; after a plain question it only confirms what you asked. +pub static DOUBT: LazyLock = LazyLock::new(|| { + re( + "doubt", + r"\breally\?|\b(are you (sure|certain|positive)|you sure|is (that|this|it) (really )?(right|correct|true|accurate)|did you (actually |really )?(check|test|verify|confirm|run|read|look)|(shouldn'?t|won'?t|can'?t|wouldn'?t|isn'?t|aren'?t|doesn'?t|don'?t|wasn'?t|didn'?t) \w+|what about|hold on|wait)\b", + ) +}); + +/// A request inside a question: "can you fix that?" asks for work, not information. +static REQUEST: LazyLock = LazyLock::new(|| re("request", r"\b((can|could|would|will) you|please|pls|plz)\b")); + +/// Sentence ends before the last one. +static SENTENCE_END: LazyLock = LazyLock::new(|| re("sentence", r"[.!?]+\s")); + +/// Longer than this (in chars), a question carries more than a quick check. +const PLAIN_QUESTION: usize = 120; + +/// One short question asking for information, and nothing else: "Is the default port 5432?". Not +/// a statement then a question ("It crashes. Can you fix it?"), a request, or doubt. +pub fn plain_question(t: &str) -> bool { + t.ends_with('?') + && t.chars().count() <= PLAIN_QUESTION + && !SENTENCE_END.is_match(t) + && !REQUEST.is_match(t) + && !DOUBT.is_match(t) +} + /// Short "are you done?" style questions: an AI "Not quite" there is a status, not a correction. pub static STATUS_Q: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\b(finish|done|left|still going|status|ready)\b").unwrap()); -static CATEGORIES: LazyLock> = LazyLock::new(|| { - let c = |cat, p: &str| (cat, Regex::new(&format!("(?i){p}")).unwrap()); +/// (category, words read in your message, words read in the AI's reply). +type CategoryWords = (Category, Option, Option); + +/// Each category's words, per side. "I said", "you missed" and "there is no" tell your side; in the +/// AI's reply they're about something else ("I already changed it", "there is no reason to keep +/// it"). "I misread" is the AI's side. +static CATEGORIES: LazyLock> = LazyLock::new(|| { + let re = |p: &str| (!p.is_empty()).then(|| Regex::new(&format!("(?i){p}")).unwrap()); + let c = |cat, yours: &str, ais: &str| (cat, re(yours), re(ais)); + let made_up = r"\b(doesn'?t|does not) exist\b|\bmade (that|it) up\b|\bhallucinat|\bno such\b|\bnot a real\b|\bthat'?s not a thing\b"; + let missing = r"\bmissing\b|\bwhat about\b|\bleft out\b"; + let still = + r"\bstill\b|\bsame (error|issue|problem)\b|\b(doesn'?t|didn'?t|not) work|\bbroken\b|\berror\b|\bcrash|\bfail"; vec![ - c( - Category::MadeItUp, - r"\b(doesn'?t|does not) exist\b|\bmade (that|it) up\b|\bhallucinat|\bno such\b|\bnot a real\b|\bthere is no\b|\bthat'?s not a thing\b", - ), + c(Category::MadeItUp, &format!(r"{made_up}|\bthere is no\b"), made_up), c( Category::IgnoredInstructions, r"\bi (said|told you|asked|already)\b|\blike i said\b|\bnot what i (asked|said)\b|\byou ignored\b|\bre-?read\b", + "", ), c( Category::Misread, - r"\bi (misread|misunderstood|misinterpreted)\b|\bi (read|took) .{0,40} as\b|\bnot what i (meant|wanted)\b|\bi meant\b", + r"\bnot what i (meant|wanted)\b|\bi meant\b", + r"\bi (misread|misunderstood|misinterpreted)\b|\bi (read|took) .{0,40} as\b", ), c( Category::MissedSomething, - r"\byou (missed|forgot|skipped|didn'?t|did not|never)\b|\bmissing\b|\bwhat about\b|\bleft out\b", - ), - c( - Category::StillBroken, - r"\bstill\b|\bsame (error|issue|problem)\b|\b(doesn'?t|didn'?t|not) work|\bbroken\b|\berror\b|\bcrash|\bfail", + &format!(r"\byou (missed|forgot|skipped|didn'?t|did not|never)\b|{missing}"), + missing, ), + c(Category::StillBroken, still, still), ] }); @@ -247,6 +333,10 @@ pub fn category(human: &str, ai: &str, admit: Option<(usize, usize)>) -> Categor Some((s, e)) => format!("{} {}", &ai[..s], &ai[e..]), None => ai.to_string(), }; - let text = format!("{human}\n{ai}"); - CATEGORIES.iter().find(|(_, re)| re.is_match(&text)).map_or(Category::JustWrong, |(c, _)| *c) + CATEGORIES + .iter() + .find(|(_, yours, ais)| { + yours.as_ref().is_some_and(|r| r.is_match(human)) || ais.as_ref().is_some_and(|r| r.is_match(&ai)) + }) + .map_or(Category::JustWrong, |(c, ..)| *c) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 346e95f..c78f6ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -246,6 +246,7 @@ const EXAMPLES: &[(&str, &str, &str)] = &[ ("ai.incorrectly", "I incorrectly assumed v2.", "It was incorrectly set upstream."), ("ai.that_was_wrong", "That was wrong of me.", "That was the wrong branch?"), ("ai.didnt_check", "I didn't check the logs.", "I didn't change the logs."), + ("you.unneeded", "No, that's not needed, remove it", "No, that's not needed, thanks"), ("you.no_that", "no, that's the wrong file", "no, that's fine, ship it"), ("you.no", "nope.", "no problem"), ("you.wrong", "not quite, the year is off", "that's not a problem"), @@ -256,7 +257,7 @@ const EXAMPLES: &[(&str, &str, &str)] = &[ ("you.i_asked", "I asked for a list", "I asked around and nobody knows"), ("you.why_did_you", "why did you delete that?", "why does this need a key?"), ("you.doesnt_work", "doesn't work on firefox", "doesn't matter, keep going"), - ("you.same_error", "same error as before", "same here"), + ("you.same_error", "same thing, it still crashes", "same thing for the footer please"), ("you.actually", "actually, that's the old API", "actually, that works now"), ("you.any.doesnt_exist", "the flag doesn't exist in v2", "the flag didn't exit cleanly"), ("you.any.not_working", "the login is not working", "I'm not worried about it"), @@ -464,3 +465,163 @@ fn expecting_to_have_something_is_not_an_admission() { assert!(rules::first(&rules::AI_ADMIT, reply).is_some(), "{reply}"); } } + +#[test] +fn should_have_it_ready_is_a_promise_not_an_admission() { + for reply in + ["I should have a fix ready in a minute.", "I should have this done shortly.", "I should have that now."] + { + assert_eq!(rules::first(&rules::AI_ADMIT, reply).map(|m| m.rule), None, "{reply}"); + } + for reply in ["I should have fixed this first.", "I should have tested that."] { + assert_eq!(rules::first(&rules::AI_ADMIT, reply).map(|m| m.rule), Some("ai.should_have"), "{reply}"); + } +} + +#[test] +fn polite_closers_and_routine_requests_are_not_claims() { + for h in [ + "No, that's not necessary.", + "No, that's all for now, thanks", + "No, that's all.", + "Nah, it's cool", + "No, that's alright", + "Nope, good to go", + "nope, not needed", + "That's not needed", + "that's not really necessary right now", + "Actually, that's not necessary", + "Same thing for the footer please", + "Not working on the API today, focus on the UI", + ] { + assert_eq!(label("Done. Anything else", h, "Sounds good."), None, "{h}"); + } + // Real corrections still count. + for h in [ + "No, that's wrong", + "No, that's not what I asked", + "No, that's all wrong", + "That's not right", + "same error", + "still the same issue", + "doesn't work", + "not working", + "not working at all", + "Not working on Safari", + "not working on my phone, the menu won't open", + "Not working on Safari anymore", + "Not working on mobile now", + "Not working on my machine right now either", + "Same thing", + "Same thing happens", + "Same thing, it still crashes", + // "Not needed" plus undoing the work is pushback. + "No, that's not needed, remove it", + "That's not needed, revert it", + "That's not necessary, I said only change the header", + ] { + assert_eq!(label("Done. Anything else", h, "Checking."), Some(Label::Claimed), "{h}"); + } +} + +#[test] +fn agreeing_with_a_plain_question_is_not_an_admission() { + for (h, reply) in [ + ("Is the default port 5432?", "You're correct, the default is 5432."), + ("does it run on node 20?", "You're right, it does."), + ("So I need to restart after editing .env?", "You're right, a restart is needed."), + ] { + assert_eq!(label("Set it to 5432.", h, reply), None, "{h}"); + } + // Doubt and pushback still make it a correction. + assert_eq!( + label("Set it to 5433.", "No, that's wrong. Why did you use 5433?", "You're right, it's 5432."), + Some(Label::Confirmed) + ); + assert_eq!(label("Set it to 5433.", "are you sure?", "You're right, it's 5432."), Some(Label::Admitted)); + assert_eq!(label("Set it to 5433.", "isn't it 5432?", "You're correct, it's 5432."), Some(Label::Admitted)); + // Owning a mistake after a question still counts: only agreement is ruled out. + assert_eq!(label("Set it to 5433.", "Is the default port 5432?", "My mistake, it is."), Some(Label::Admitted)); + // A statement before the question, a request, or doubt: still an admission. + for h in [ + "The function returns null when the list is empty. Can you fix it?", + "This crashes on empty input, can you fix that?", + "Shouldn't we handle errors here?", + "Shouldn't you check for null first?", + "Won't that break the build?", + "Can't you just use a map?", + "Isn't 5432 the default port?", + "Hold on, what about the empty case?", + "explain why you used 5432, it should be 5433", + ] { + assert_eq!(label("Set it to 5433.", h, "You're right, fixing it."), Some(Label::Admitted), "{h}"); + } + // Later messages follow the same rule. + assert_eq!(after_checking("Is it Globex?", &["You're right, it's Globex."]).label, None); +} + +#[test] +fn added_context_never_counts_against_the_ai() { + for h in [ + "By the way, we deploy on Vercel", + "oh, also we use pnpm", + "one other thing: it runs on Windows", + "Another thing, the API is v2", + "p.s. the dates are UTC", + "In addition, it must support Safari", + "I should have mentioned we use Postgres", + "I didn't mention it, but we deploy on Fly", + ] { + assert_eq!(label("Here's the config.", h, "Good catch! I'll add a vercel.json."), None, "{h}"); + } + // Pastes and pushback that only look like context. + for h in [ + "PS C:\\proj> npm run dev\nError: Cannot find module 'x'", + "ps aux shows it's still running", + "Why did you add a footer? I didn't mention a footer.", + ] { + assert!(rules::first(&rules::CONTEXT, h).is_none(), "{h}"); + } + assert_eq!( + label("Added a footer.", "Why did you add a footer? I didn't mention a footer.", "Removing it."), + Some(Label::Claimed) + ); +} + +#[test] +fn a_question_mark_in_code_is_not_the_ai_asking() { + for prev in [ + "Done:\n```rust\nlet f = File::open(p)?;\n```", + "It reads `user?.name` now.", + "It now calls `read()?` before parsing.", + "See https://example.com/docs?page=2 for the flags.", + "It returns ok ? a : b now.", + ] { + assert_eq!(label(prev, "no, that's the wrong file", "Switching."), Some(Label::Claimed), "{prev}"); + } + assert_eq!(label("Want me to run `cargo test`?", "No, the header is wrong", "Okay."), None); + for prev in [ + "Want me to ship it?", + "Should I keep `read()?` here?", + "Use \"v2\"?\n\nOr v3?", + "Want me to run `cargo test`?", + "Should I proceed?🙂", + "Should I proceed? 🙂 ", + ] { + assert_eq!(label(prev, "no, that's the wrong file", "Switching."), None, "{prev}"); + } +} + +#[test] +fn the_ais_incidental_words_do_not_pick_the_category() { + let cat = |h: &str, n: &str| classify(&turn("x", h, n)).category; + assert_eq!(cat("that's wrong", "You're right. There is no reason to keep that branch."), Some(Category::JustWrong)); + assert_eq!(cat("that's wrong", "You're right, I already changed the wrong file."), Some(Category::JustWrong)); + assert_eq!(cat("that's wrong", "You're right, let me re-read it."), Some(Category::JustWrong)); + // Your side still picks it. + assert_eq!(cat("there is no such page", "You're right."), Some(Category::MadeItUp)); + assert_eq!(cat("I already told you to keep it", "You're right."), Some(Category::IgnoredInstructions)); + // Only the start of a later message is read for the category too. + let late = format!("You're right. {} I said it doesn't exist.", "x".repeat(300)); + assert_eq!(after_checking("that's wrong", &[&late]).category, Some(Category::JustWrong)); +} diff --git a/crates/sources/src/chatgpt.rs b/crates/sources/src/chatgpt.rs index f10a5bb..d96695b 100644 --- a/crates/sources/src/chatgpt.rs +++ b/crates/sources/src/chatgpt.rs @@ -10,7 +10,8 @@ pub struct ChatGptExport; fn text_parts(msg: &Value) -> Option { let content = &msg["content"]; - if content["content_type"].as_str() != Some("text") { + // "multimodal_text" is a message with an attachment: its parts mix text with image records. + if !matches!(content["content_type"].as_str(), Some("text" | "multimodal_text")) { return None; } let parts: Vec<&str> = content["parts"].as_array()?.iter().filter_map(Value::as_str).collect(); @@ -128,6 +129,27 @@ mod tests { assert!(ChatGptExport.scan(&p, 0, false).err().unwrap().to_string().contains("not a valid export")); } + #[test] + fn messages_with_an_attachment_keep_their_text_parts() { + let multimodal = |role: &str, parts: Value| { + json!({"id":role,"author":{"role":role},"create_time":1.0, + "content":{"content_type":"multimodal_text","parts":parts},"metadata":{}}) + }; + let conv = json!({"conversation_id":"c1","current_node":"b","mapping":{ + "a": {"parent": null, "message": multimodal("user", json!([ + {"content_type":"image_asset_pointer","asset_pointer":"file-service://x","size_bytes":10}, + "no, that's the wrong button"]))}, + "b": {"parent": "a", "message": multimodal("assistant", json!(["You're right."]))} + }}); + let ev = events(&conv); + assert_eq!(ev.len(), 2); + assert!(matches!(&ev[0], Event::Human { text, .. } if text == "no, that's the wrong button")); + let image_only = + json!({"content":{"content_type":"multimodal_text","parts":[{"content_type":"image_asset_pointer"}]}}); + assert_eq!(text_parts(&image_only), None); + assert_eq!(text_parts(&json!({"content":{"content_type":"code","parts":["x"]}})), None); + } + #[test] fn hidden_messages_are_skipped_and_cycles_end() { let conv = json!({"current_node":"b","mapping":{ diff --git a/crates/sources/src/claude_code.rs b/crates/sources/src/claude_code.rs index 082f421..8bd8c7f 100644 --- a/crates/sources/src/claude_code.rs +++ b/crates/sources/src/claude_code.rs @@ -57,6 +57,11 @@ const NOISE: &[&str] = &[ ", attachment: Option, + origin: Option, +} + +/// Who a record came from: `human`, `peer` (another agent), `task-notification`. Older files have none. +#[derive(Deserialize)] +struct Origin { + kind: Option, +} + +/// True when the record says another agent, a scheduled task or a coordinator wrote it. Kinds not +/// listed are kept: a new way for the person to type (voice, a phone) mustn't vanish from their history. +fn not_human(origin: Option<&Origin>) -> bool { + origin.and_then(|o| o.kind.as_deref()).is_some_and(|k| matches!(k, "peer" | "task-notification" | "coordinator")) } #[derive(Deserialize)] @@ -111,6 +129,7 @@ struct Attachment { kind: Option, prompt: Option, content: Option, + origin: Option, } fn text_of(content: &Content) -> Option { @@ -137,6 +156,25 @@ fn is_noise(s: &str) -> bool { NOISE.iter().any(|n| t.starts_with(n)) } +/// Removes `…` blocks, keeping what was typed around them. +/// A block with no closing tag is left alone. +fn strip_pasted(text: &str) -> String { + const OPEN: &str = "') else { break }; + let close = format!(" &'static str { self.id @@ -238,6 +276,9 @@ struct Read { const SKILL: &str = "grade:me"; fn read_line(buf: &[u8], pos: u64, usage: bool) -> Read { + if buf.is_empty() { + return Read { skipped: true, ..Read::default() }; // a line over the cap (see `for_each_line`) + } // Cheap filters before any JSON work. Tool results are user-role records too. let is_user = contains(buf, br#""type":"user""#); let is_ai = contains(buf, br#""type":"assistant""#); @@ -333,6 +374,15 @@ fn sample(line: &Line, pos: u64) -> Option<(Option, Sample)> { Some((m.id.clone(), Sample { pos, ts, model: m.model.clone(), usage })) } +/// A person's message, unless it is injected context or only a pasted block. +fn human(text: String, ts: DateTime, line: &Line, pos: u64) -> Option { + if is_noise(&text) { + return None; + } + let text = if text.contains(" Option { if line.sidechain || line.meta { @@ -341,17 +391,24 @@ fn event(line: &Line, pos: u64) -> Option { let ts = line.timestamp.as_deref().and_then(parse_ts); match line.kind.as_deref()? { "user" => { + if not_human(line.origin.as_ref()) { + return None; + } let text = line.message.as_ref().and_then(|m| m.content.as_ref()).and_then(text_of)?; - (!is_noise(&text)).then_some(Event::Human { text, ts: ts?, key: line.uuid.clone(), pos }) + human(text, ts?, line, pos) } - // A message typed while Claude was busy, delivered later as an attachment. + // A message typed while Claude was busy, delivered later as an attachment. The prompt is + // text, or content blocks when an image came with it. "attachment" => { let a = line.attachment.as_ref()?; - let Some(serde_json::Value::String(text)) = &a.prompt else { return None }; - if a.kind.as_deref() != Some("queued_command") || is_noise(text) { + if a.kind.as_deref() != Some("queued_command") || not_human(line.origin.as_ref().or(a.origin.as_ref())) { return None; } - Some(Event::Human { text: text.clone(), ts: ts?, key: line.uuid.clone(), pos }) + let text = match a.prompt.as_ref()? { + serde_json::Value::String(t) => t.clone(), + other => Content::deserialize(other).ok().as_ref().and_then(text_of)?, + }; + human(text, ts?, line, pos) } "assistant" => { let m = line.message.as_ref()?; @@ -412,6 +469,12 @@ mod tests { assert_eq!((scan.turns.len(), scan.skipped_lines), (1, 2), "noise that no rule wants isn't counted"); } + #[test] + fn a_line_over_the_cap_is_counted_as_skipped() { + assert!(read_line(b"", 0, false).skipped); + assert!(!read_line(b"unrelated\n", 0, false).skipped); + } + #[test] fn notes_when_a_chat_was_given_the_tip_rules() { let scan = scan_str(&hook_context("hook_additional_context", engine::TIP_RULES, false), false); @@ -507,6 +570,97 @@ mod tests { assert_eq!(scan.turns[0].key.as_deref(), Some("q1")); } + #[test] + fn queued_commands_with_an_image_are_read_by_their_text() { + let queued = line(&json!({"type":"attachment","uuid":"q1","timestamp":TS,"attachment":{ + "type":"queued_command","prompt":[{"type":"text","text":"this looks off"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAAA"}}]}})); + let image_only = line(&json!({"type":"attachment","uuid":"q2","timestamp":TS,"attachment":{ + "type":"queued_command","prompt":[{"type":"image","source":{"type":"base64","data":"AAAA"}}]}})); + let body = + cc_ai("claude-opus-5-5", TS, "Done.") + &image_only + &queued + &cc_ai("claude-opus-5-5", TS, "Fixing it."); + let scan = scan_str(&body, true); + assert_eq!(scan.turns.len(), 1); + assert_eq!(scan.turns[0].turn.human, "this looks off"); + assert_eq!(scan.turns[0].key.as_deref(), Some("q1")); + } + + #[test] + fn messages_from_other_agents_and_tasks_are_not_the_persons() { + let peer_text = "Another Claude session sent a message:\nYou're right, it broke."; + let peer_queued = line(&json!({"type":"attachment","uuid":"q1","timestamp":TS,"origin":{"kind":"peer"}, + "attachment":{"type":"queued_command","prompt":"hi"}})); + let nested = line(&json!({"type":"attachment","uuid":"q2","timestamp":TS,"attachment":{ + "type":"queued_command","origin":{"kind":"task-notification"},"prompt":"anything at all"}})); + let tagged = line(&json!({"type":"user","uuid":"p1","timestamp":TS,"origin":{"kind":"peer"}, + "message":{"content":"plain text, tagged by origin"}})); + let human_tagged = line(&json!({"type":"user","uuid":"h1","timestamp":TS,"origin":{"kind":"human"}, + "message":{"content":"no, that's wrong"}})); + for body in [ + cc_user("p", TS, peer_text), + cc_user("p", TS, "run"), + line(&json!({"type":"attachment","uuid":"q","timestamp":TS,"attachment":{ + "type":"queued_command","prompt":"hi"}})), + peer_queued, + nested, + tagged, + ] { + let scan = scan_str(&(body.clone() + &cc_ai("claude-opus-5-5", TS, "ok")), true); + assert!(scan.turns.is_empty(), "{body}"); + } + let scan = scan_str(&(human_tagged + &cc_ai("claude-opus-5-5", TS, "You're right.")), true); + assert_eq!(scan.turns.len(), 1, "origin human is kept"); + let new_kind = line(&json!({"type":"user","uuid":"v1","timestamp":TS,"origin":{"kind":"voice"}, + "message":{"content":"no, that's wrong"}})); + let scan = scan_str(&(new_kind + &cc_ai("claude-opus-5-5", TS, "You're right.")), true); + assert_eq!(scan.turns.len(), 1, "a kind not known to be another writer is kept"); + } + + #[test] + fn a_chat_with_no_turns_is_still_listed_as_read() { + let body = cc_user("p", TS, "Another Claude session sent a message:\nhi") + &cc_ai("claude-opus-5-5", TS, "ok"); + let scan = scan_str(&body, true); + assert!(scan.turns.is_empty()); + assert_eq!(scan.sessions.len(), 1, "a rebuild must know this chat was read"); + } + + #[test] + fn a_peer_message_does_not_cut_the_replies_short() { + let peer = line(&json!({"type":"attachment","uuid":"q1","timestamp":TS,"origin":{"kind":"peer"}, + "attachment":{"type":"queued_command","prompt":"status"}})); + let body = cc_ai("claude-opus-5-5", TS, "I moved the header.") + + &cc_user("u1", TS, "the header still overlaps the menu") + + &cc_ai("claude-opus-5-5", TS, "Let me look.") + + &peer + + &cc_ai("claude-opus-5-5", TS, "You're right, my fix was wrong. Sorry about that."); + let scan = scan_str(&body, true); + assert_eq!(scan.turns.len(), 1); + let t = &scan.turns[0].turn; + assert_eq!(t.human, "the header still overlaps the menu"); + assert!(t.later_ai.iter().any(|r| r.contains("my fix was wrong")), "{:?}", t.later_ai); + assert_eq!(engine::classify(t).label, Some(engine::Label::Admitted)); + } + + #[test] + fn pasted_blocks_are_dropped_and_the_typed_text_kept() { + let pasted = |body: &str| format!("{body}"); + let humans = |text: String| { + let body = + cc_ai("claude-opus-5-5", TS, "Done.") + &cc_user("u1", TS, &text) + &cc_ai("claude-opus-5-5", TS, "ok"); + scan_str(&body, true).turns.iter().map(|t| t.turn.human.clone()).collect::>() + }; + assert_eq!(humans(pasted("error: x") + "\nno, that's wrong"), ["no, that's wrong"]); + assert_eq!(humans(format!("{}\n{}\nwhy?", pasted("a"), pasted("b"))), ["why?"]); + assert_eq!(humans(pasted("only a paste")).len(), 0); + let open = "never closed no, wrong"; + assert_eq!(humans(open.to_string()), [open]); + assert_eq!(strip_pasted("see x now"), "see now"); + // A paste that quotes another paste's closing tag ends only at its own. + let nested = + "log morestill wrong"; + assert_eq!(strip_pasted(nested), "still wrong"); + } + #[test] fn synthetic_replies_are_skipped() { let body = cc_ai("claude-opus-5-5", TS, "Use /admin.") diff --git a/crates/sources/src/codex.rs b/crates/sources/src/codex.rs index 07c11c6..165e637 100644 --- a/crates/sources/src/codex.rs +++ b/crates/sources/src/codex.rs @@ -24,6 +24,8 @@ const NOISE: &[&str] = &[ " bool { p.extension().is_some_and(|e| e == "zst") } +/// How a message with attachments begins. +const PREAMBLES: &[&str] = &["# Files mentioned by the user", "# Files pasted by the user", "Image attachment"]; + /// Drops the attachment preamble Codex puts before what you actually typed. fn strip_preamble(text: &str) -> &str { // "## My request for Codex:" in older builds, "## My request:" in newer ones. let mut t = text.trim_start(); - if let Some(i) = t.find("## My request") { - let rest = &t[i..]; - t = rest.find(':').map_or(rest, |c| &rest[c + 1..]).trim_start(); - return t; + // Only when the message opens with an attachment preamble: a person can write that heading too. + if PREAMBLES.iter().any(|p| t.starts_with(p)) { + if let Some(i) = t.find("## My request") { + let rest = &t[i..]; + t = rest.find(':').map_or(rest, |c| &rest[c + 1..]).trim_start(); + return t; + } } t = t.strip_prefix("# Files mentioned by the user:").unwrap_or(t).trim_start(); // Attachment lines look like "## Screenshot 1.png: /path" or "## repo: ~/code/repo/". @@ -85,12 +93,13 @@ fn strip_images(text: &str) -> String { out.trim().to_string() } -/// Only threads a person started. Guardian / subagent threads are model-to-model. +/// Only threads a person is in. Guardian / subagent threads are model-to-model. fn human_thread(meta: &Value) -> bool { if meta.get("source").is_some_and(|s| s.get("subagent").is_some()) { return false; } - meta.get("thread_source").and_then(Value::as_str).is_none_or(|s| s == "user") + // Handoffs from ChatGPT and voice chats are a person talking; only model-to-model threads are skipped. + meta.get("thread_source").and_then(Value::as_str).is_none_or(|s| !matches!(s, "guardian_review" | "subagent")) } impl Source for Codex { @@ -274,6 +283,10 @@ impl Rollout { fn line(&mut self, pos: u64, buf: &[u8]) { let fresh = pos >= self.from; + if buf.is_empty() { + self.skipped += usize::from(fresh); // a line over the cap (see `for_each_line`) + return; + } let meta = contains(buf, b"session_meta") || contains(buf, b"turn_context"); let counts = self.usage && contains(buf, b"token_count"); if !(meta || counts || (fresh && contains(buf, b"\"message\""))) { @@ -358,13 +371,48 @@ mod tests { ); assert_eq!(strip_images("poorly reviewed "), "poorly reviewed"); assert_eq!(strip_preamble("hello"), "hello"); + assert_eq!(strip_preamble("# Files pasted by the user:\n- a.png\n\n## My request:\nfix it"), "fix it"); + } + + #[test] + fn a_heading_a_person_wrote_keeps_their_full_text() { + let text = "no, that's wrong. Put it under the ## My request: heading"; + assert_eq!(strip_preamble(text), text); + } + + #[test] + fn skill_and_hook_bodies_are_not_the_persons_messages() { + let body = opening() + + &msg("assistant", "output_text", "Done.", "a2") + + &msg("user", "input_text", "\nx\nbody\n", "s1") + + &msg("user", "input_text", "keep going", "h1") + + &msg("assistant", "output_text", "ok", "a3"); + let (_d, p) = rollout(&body); + let scan = Codex.scan(&p, 0, true).unwrap(); + assert_eq!(scan.turns.len(), 1); + } + + #[test] + fn handoff_and_voice_threads_are_read() { + for source in ["chatgpt_handoff", "voice_chat"] { + let body = line(&json!({"type":"session_meta","payload":{"id":"x","thread_source":source}})) + + &msg("assistant", "output_text", "Plan.", "a") + + &msg("user", "input_text", "no, that's wrong", "u") + + &msg("assistant", "output_text", "You're right.", "b"); + let (_d, p) = rollout(&body); + assert_eq!(Codex.scan(&p, 0, true).unwrap().turns.len(), 1, "{source}"); + } } #[test] fn skips_guardian_threads() { assert!(!human_thread(&serde_json::json!({"source":{"subagent":{"other":"guardian"}}}))); assert!(human_thread(&serde_json::json!({"source":"vscode","thread_source":"user"}))); - assert!(!human_thread(&serde_json::json!({"thread_source":"agent"}))); + assert!(!human_thread(&serde_json::json!({"thread_source":"guardian_review"}))); + assert!(!human_thread(&serde_json::json!({"thread_source":"subagent"}))); + for s in ["chatgpt_handoff", "voice_chat"] { + assert!(human_thread(&serde_json::json!({"thread_source":s})), "{s}"); + } } fn msg(role: &str, kind: &str, text: &str, id: &str) -> String { @@ -556,7 +604,7 @@ mod tests { assert_eq!((light.session.as_str(), light.prompts.len()), ("rollout-2026-10-20-abc", 1)); assert_eq!(light.prompts[0].key, whole.prompts[1].key); assert_eq!(light.prompts[0].usage, Usage::default()); - let guardian = line(&json!({"type":"session_meta","payload":{"id":"abc","thread_source":"agent"}})); + let guardian = line(&json!({"type":"session_meta","payload":{"id":"abc","thread_source":"guardian_review"}})); let (_d2, p2) = rollout(&(guardian.clone() + &early + &late)); let skipped = Codex.view_from(&p2, (guardian.len() + early.len()) as u64, false).unwrap().unwrap(); assert!(skipped.prompts.is_empty(), "a model-to-model thread has no person's prompts"); diff --git a/crates/sources/src/export.rs b/crates/sources/src/export.rs index 8ca995a..7075d8c 100644 --- a/crates/sources/src/export.rs +++ b/crates/sources/src/export.rs @@ -1,53 +1,144 @@ -//! One-off data exports: a `conversations.json` array, read whole every time it changes. +//! One-off data exports: a `conversations.json` array, read in full every time it changes. use crate::{pair, Error, Event, FileScan, Result, MAX_READ_BYTES}; +use serde::de::{Deserializer, SeqAccess, Visitor}; use serde_json::Value; -use std::io::Read; +use std::io::{BufReader, Read}; use std::path::Path; -/// Reads `path` whole, or says it is over `limit` bytes without reading past that. -fn read_capped(path: &Path, limit: u64) -> Result> { - let mut bytes = Vec::new(); - let file = std::fs::File::open(path).map_err(Error::io(path))?; - file.take(limit + 1).read_to_end(&mut bytes).map_err(Error::io(path))?; - if bytes.len() as u64 > limit { - return Err(Error::TooBig { path: path.to_path_buf(), limit }); +/// Calls `each` with every element of the top-level array in `reader`, one at a time, so only one +/// conversation is held in memory (not the whole export as a tree of values). +fn each_element(reader: R, mut each: impl FnMut(Value)) -> serde_json::Result<()> { + struct Elements<'a, F>(&'a mut F); + impl<'de, F: FnMut(Value)> Visitor<'de> for Elements<'_, F> { + type Value = (); + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an array") + } + fn visit_seq>(self, mut seq: A) -> std::result::Result<(), A::Error> { + while let Some(v) = seq.next_element::()? { + (self.0)(v); + } + Ok(()) + } } - Ok(bytes) + let mut de = serde_json::Deserializer::from_reader(reader); + de.deserialize_seq(Elements(&mut each))?; + de.end() } /// Reads an export: each conversation's events (from `events`) become turns in the session /// `session(conv)` names. The checkpoint is the file size, since exports are never appended to. +/// The file is read as a stream and never past [`MAX_READ_BYTES`]. pub(crate) fn scan_export( source: &'static str, path: &Path, session: fn(&Value) -> &str, events: fn(&Value) -> Vec, ) -> Result { - let bytes = read_capped(path, MAX_READ_BYTES)?; - let convs: Vec = - serde_json::from_slice(&bytes).map_err(|source| Error::Json { path: path.to_path_buf(), source })?; - let mut scan = FileScan { checkpoint: bytes.len() as u64, ..FileScan::default() }; - for c in &convs { - let one = pair(source, session(c), events(c), 0, true); + scan_export_capped(source, path, MAX_READ_BYTES, session, events) +} + +fn scan_export_capped( + source: &'static str, + path: &Path, + limit: u64, + session: fn(&Value) -> &str, + events: fn(&Value) -> Vec, +) -> Result { + let file = std::fs::File::open(path).map_err(Error::io(path))?; + let mut counted = Counted { inner: BufReader::new(file.take(limit + 1)), read: 0 }; + let mut scan = FileScan::default(); + let parsed = each_element(&mut counted, |c| { + let one = pair(source, session(&c), events(&c), 0, true); scan.turns.extend(one.turns); + scan.sessions.extend(one.sessions); scan.cards.extend(one.cards); + }); + // Past the cap the stream is cut off, which also looks like a broken file: say which it was. + if counted.read > limit { + return Err(Error::TooBig { path: path.to_path_buf(), limit }); } + parsed.map_err(|source| Error::Json { path: path.to_path_buf(), source })?; + scan.checkpoint = counted.read; Ok(scan) } +/// A reader that counts the bytes taken through it. +struct Counted { + inner: R, + read: u64, +} + +impl Read for Counted { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buf)?; + self.read += n as u64; + Ok(n) + } +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + fn id(c: &Value) -> &str { + c["id"].as_str().unwrap_or_default() + } + + fn events(c: &Value) -> Vec { + let ts = chrono::DateTime::from_timestamp(1, 0).unwrap(); + let text = |k: &str| c[k].as_str().unwrap_or_default().to_string(); + vec![ + Event::Ai { text: text("ai"), model: None, ts: Some(ts), key: None, pos: 0 }, + Event::Human { text: text("human"), ts, key: Some(text("id")), pos: 1 }, + Event::Ai { text: "You're right.".into(), model: None, ts: Some(ts), key: None, pos: 2 }, + ] + } + + fn write(dir: &tempfile::TempDir, body: &[u8]) -> std::path::PathBuf { + let p = dir.path().join("conversations.json"); + std::fs::write(&p, body).unwrap(); + p + } #[test] fn an_export_over_the_cap_is_refused_with_a_plain_error() { let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("conversations.json"); - std::fs::write(&p, "[1, 2, 3, 4, 5]").unwrap(); - assert_eq!(read_capped(&p, 15).unwrap().len(), 15); - let err = read_capped(&p, 14).unwrap_err(); + let p = write(&dir, b"[1, 2, 3, 4, 5]"); + assert!(scan_export_capped("x", &p, 15, id, events).is_ok(), "exactly at the cap is fine"); + assert!(matches!(scan_export_capped("x", &p, 14, id, events), Err(Error::TooBig { .. }))); + let body = serde_json::to_vec(&json!([{"id":"a","ai":"Use x","human":"no, wrong"}])).unwrap(); + let p = write(&dir, &body); + assert_eq!(scan_export_capped("x", &p, body.len() as u64, id, events).unwrap().checkpoint, body.len() as u64); + let err = scan_export_capped("x", &p, body.len() as u64 - 1, id, events).unwrap_err(); assert!(matches!(err, Error::TooBig { .. })); assert!(err.to_string().contains("conversations.json: too large to read"), "{err}"); } + + #[test] + fn many_conversations_stream_through_one_at_a_time() { + let dir = tempfile::tempdir().unwrap(); + let convs: Vec = + (0..50).map(|i| json!({"id": format!("c{i}"), "ai": "Use x", "human": format!("no, wrong {i}")})).collect(); + let body = serde_json::to_vec_pretty(&convs).unwrap(); + let scan = scan_export("x", &write(&dir, &body), id, events).unwrap(); + assert_eq!(scan.checkpoint, body.len() as u64); + let want: Vec<_> = convs.iter().flat_map(|c| pair("x", id(c), events(c), 0, true).turns).collect(); + assert_eq!(scan.turns.len(), 50); + let key = |t: &crate::SourceTurn| (t.session.clone(), t.key.clone(), t.turn.human.clone()); + assert_eq!(scan.turns.iter().map(key).collect::>(), want.iter().map(key).collect::>()); + assert_eq!(scan.turns[49].session, "c49"); + } + + #[test] + fn a_broken_or_wrong_shaped_export_is_a_plain_json_error() { + let dir = tempfile::tempdir().unwrap(); + for body in [&b"{not json"[..], b"{}", b"[{\"id\":\"a\"}", b"[] trailing", b""] { + let err = scan_export("x", &write(&dir, body), id, events).unwrap_err(); + assert!(matches!(err, Error::Json { .. }), "{err}"); + } + assert!(scan_export("x", &write(&dir, b"[]"), id, events).unwrap().turns.is_empty()); + } } diff --git a/crates/sources/src/jsonl.rs b/crates/sources/src/jsonl.rs index 80f6bd1..a725b95 100644 --- a/crates/sources/src/jsonl.rs +++ b/crates/sources/src/jsonl.rs @@ -2,7 +2,7 @@ use crate::{Error, Result}; use std::fs::File; -use std::io::{self, BufRead, BufReader, Seek, SeekFrom}; +use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::Path; /// Opens `path` for line reading, positioned at byte `from`. @@ -12,16 +12,57 @@ pub(crate) fn open_at(path: &Path, from: u64) -> Result> { Ok(BufReader::with_capacity(1 << 16, f)) } +/// The longest line read, newline included. A longer one is a corrupt or hostile file, not a message. +const MAX_LINE: usize = 64 << 20; + /// Calls `f(line_pos, line)` for each complete line (newline included) of `reader`, whose first /// byte sits at offset `start`. Stops at EOF or at a last line still being written, and returns /// the offset just past the last complete line: where the next pass should resume. -pub(crate) fn for_each_line(mut reader: R, start: u64, mut f: impl FnMut(u64, &[u8])) -> io::Result { +/// +/// A line over [`MAX_LINE`] is skipped to its newline and reported as an empty `line`, which no real +/// line is. If the file ends inside one, the pass resumes at the end of the file, so the unfinished +/// line isn't read into memory again on every pass. +pub(crate) fn for_each_line(reader: R, start: u64, f: impl FnMut(u64, &[u8])) -> io::Result { + for_each_line_capped(reader, start, MAX_LINE, f) +} + +fn for_each_line_capped( + mut reader: R, + start: u64, + cap: usize, + mut f: impl FnMut(u64, &[u8]), +) -> io::Result { let mut pos = start; let mut buf = Vec::new(); loop { buf.clear(); - let n = reader.read_until(b'\n', &mut buf)?; - if n == 0 || buf.last() != Some(&b'\n') { + let n = Read::take(&mut reader, cap as u64 + 1).read_until(b'\n', &mut buf)?; + if n == 0 { + return Ok(pos); + } + if n > cap { + let mut ended = buf.last() == Some(&b'\n'); + drop(std::mem::take(&mut buf)); // give the memory back before reading on + let mut skipped = n as u64; + while !ended { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + break; + } + let (used, found) = + chunk.iter().position(|&b| b == b'\n').map_or((chunk.len(), false), |i| (i + 1, true)); + reader.consume(used); + skipped += used as u64; + ended = found; + } + f(pos, b""); + pos += skipped; + if !ended { + return Ok(pos); // EOF inside it + } + continue; + } + if buf.last() != Some(&b'\n') { return Ok(pos); // EOF, or a line still being written: resume here next time } f(pos, &buf); @@ -42,6 +83,29 @@ mod tests { assert_eq!(for_each_line(&b""[..], 4, |_, _| {}).unwrap(), 4); } + #[test] + fn a_line_over_the_cap_is_skipped_and_reported_empty() { + let mut seen = Vec::new(); + let body = &b"ab\nTOO-LONG-LINE\nok\n"[..]; + let end = for_each_line_capped(body, 0, 5, |pos, line| seen.push((pos, line.to_vec()))).unwrap(); + assert_eq!(seen, vec![(0, b"ab\n".to_vec()), (3, vec![]), (17, b"ok\n".to_vec())]); + assert_eq!(end, 20); + // Exactly at the cap is still a line; one more byte is not. + let mut seen = Vec::new(); + for_each_line_capped(&b"abcd\nabcde\n"[..], 0, 5, |pos, line| seen.push((pos, line.len()))).unwrap(); + assert_eq!(seen, vec![(0, 5), (5, 0)]); + } + + #[test] + fn an_unfinished_line_over_the_cap_is_skipped_once() { + let mut seen = Vec::new(); + let end = + for_each_line_capped(&b"ab\nXXXXXXXXXXXX"[..], 0, 5, |pos, line| seen.push((pos, line.len()))).unwrap(); + assert_eq!((seen, end), (vec![(0, 3), (3, 0)], 15), "resumes past it, not at its start"); + // A short unfinished line still waits for its newline. + assert_eq!(for_each_line_capped(&b"ab\nXX"[..], 0, 5, |_, _| {}).unwrap(), 3); + } + #[test] fn open_at_resumes_mid_file_and_names_missing_files() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/sources/src/lib.rs b/crates/sources/src/lib.rs index 46e3278..becfdd4 100644 --- a/crates/sources/src/lib.rs +++ b/crates/sources/src/lib.rs @@ -105,6 +105,9 @@ pub struct SourceTurn { pub struct FileScan { /// Complete turns, in file order. pub turns: Vec, + /// Every chat this pass read, turns or not. A chat whose only messages weren't the person's has + /// no turns, but it was still read, so a rebuild mustn't put back what older rules counted in it. + pub sessions: Vec, /// Where the next pass should resume. Only ever past turns that are complete. pub checkpoint: u64, /// Record blocks found in AI replies, in file order. A pass that re-reads a reply (after a diff --git a/crates/sources/src/pair.rs b/crates/sources/src/pair.rs index 10526fb..c8fb5dc 100644 --- a/crates/sources/src/pair.rs +++ b/crates/sources/src/pair.rs @@ -112,7 +112,7 @@ fn pair_at( let tips = read_tips(session, &events); // A tip on its own is no reply: what came before it is still what the person answered. events.retain(|e| !matches!(e, Event::Ai { text, .. } if only_a_tip(text))); - let mut scan = FileScan { checkpoint: end, tips, ..FileScan::default() }; + let mut scan = FileScan { checkpoint: end, tips, sessions: vec![session.to_string()], ..FileScan::default() }; // The chat's model near each message: the last real one before it, else the first after it. let real = |e: &Event| match e { Event::Ai { model: Some(m), .. } if engine::is_real_model(m) => Some(m.clone()), diff --git a/crates/sources/src/testing.rs b/crates/sources/src/testing.rs index 4a898c2..17b679c 100644 --- a/crates/sources/src/testing.rs +++ b/crates/sources/src/testing.rs @@ -15,6 +15,27 @@ pub fn cc_user(uuid: &str, ts: &str, text: &str) -> String { line(&json!({"type":"user","uuid":uuid,"timestamp":ts,"message":{"content":text}})) } +/// A Claude Code record the person typed, with the `promptId` the app's hooks and report cards go by. +pub fn cc_prompt(uuid: &str, prompt_id: &str, ts: &str, text: &str) -> String { + line(&json!({"type":"user","uuid":uuid,"promptId":prompt_id,"timestamp":ts,"message":{"content":text}})) +} + +/// A Claude Code reply from `model` with its own uuid and message id, and its token counts as +/// (new input, cache write, cache read, output). +pub fn cc_reply_with_usage( + model: &str, + uuid: &str, + message_id: &str, + ts: &str, + text: &str, + (input, cache_write, cache_read, output): (u64, u64, u64, u64), +) -> String { + line(&json!({"type":"assistant","uuid":uuid,"timestamp":ts,"message":{"id":message_id,"model":model, + "content":[{"type":"text","text":text}], + "usage":{"input_tokens":input,"cache_creation_input_tokens":cache_write,"cache_read_input_tokens":cache_read, + "output_tokens":output}}})) +} + /// A Claude Code reply from `model`. pub fn cc_ai(model: &str, ts: &str, text: &str) -> String { line(&json!({"type":"assistant","timestamp":ts,"message":{"model":model,"content":[{"type":"text","text":text}]}})) diff --git a/crates/store/src/card_tests.rs b/crates/store/src/card_tests.rs index ba07f50..305419e 100644 --- a/crates/store/src/card_tests.rs +++ b/crates/store/src/card_tests.rs @@ -7,7 +7,7 @@ use crate::rebuild::SCORE_TABLES; use chrono::{NaiveDate, SecondsFormat, TimeZone, Utc}; use engine::{habit_bit, seal, Usage}; use serde_json::{json, Value}; -use sources::testing::{cc_user, line, set_mtime_ago}; +use sources::testing::{cc_reply_with_usage, cc_user, line, set_mtime_ago}; use sources::{ChatView, Kind, Source}; use std::io::Write; use std::path::PathBuf; @@ -43,9 +43,7 @@ fn reply(uuid: &str, ts: &str, text: &str, usage: (u64, u64, u64, u64)) -> Strin /// The same, from `model`. fn reply_on(model: &str, uuid: &str, ts: &str, text: &str, usage: (u64, u64, u64, u64)) -> String { - line(&json!({"type":"assistant","uuid":uuid,"timestamp":ts,"message":{"id":format!("m-{uuid}"), - "model":model,"content":[{"type":"text","text":text}],"usage":{"input_tokens":usage.0, - "cache_creation_input_tokens":usage.1,"cache_read_input_tokens":usage.2,"output_tokens":usage.3}}})) + cc_reply_with_usage(model, uuid, &format!("m-{uuid}"), ts, text, usage) } /// Four prompts ten minutes apart, `p1` to `p4`: a request, two follow-ups and a sign-off. diff --git a/crates/store/src/ingest.rs b/crates/store/src/ingest.rs index 73c996c..092c8e0 100644 --- a/crates/store/src/ingest.rs +++ b/crates/store/src/ingest.rs @@ -139,6 +139,15 @@ impl Store { let rebuilding = self.rebuild_pending()?; let tx = self.conn.transaction()?; let (mut messages, mut corrections) = (0, 0); + if rebuilding { + // Read again, even with no turns of the person's in it: what older rules counted there goes. + for chat in &scan.sessions { + tx.execute( + "INSERT OR IGNORE INTO rebuild_read (id, source) VALUES (?1, ?2)", + params![hash(&[source, chat]), source], + )?; + } + } for st in scan.turns.iter().filter(|st| state.wants(st.ts)) { if let Some(corrected) = count_turn(&tx, source, st, level, rebuilding)? { messages += 1; diff --git a/crates/store/src/rebuild.rs b/crates/store/src/rebuild.rs index a1efd7a..000f17d 100644 --- a/crates/store/src/rebuild.rs +++ b/crates/store/src/rebuild.rs @@ -59,8 +59,9 @@ impl Store { } /// Ends a rebuild once history has been read again. A chat that wasn't read again comes back as - /// it was counted: its file is gone, or its source is stopped. Counts per day have no chat on - /// them, so a day with nothing read again comes back whole, and a day that a missing chat + /// it was counted: its file is gone, or its source is stopped. A chat read again with nothing of + /// the person's left in it stays gone. Counts per day have no chat on them, so a day with nothing + /// read again comes back whole if a missing chat (or no known chat) touched it, and a day that a missing chat /// touched keeps the larger message count and adds back that chat's corrections. Other days take /// the new count. Message keys of missing chats come back too, so a file that returns can't /// count twice. A correction that a fork already brought back isn't restored a second time. @@ -96,7 +97,16 @@ impl Store { ), [], )?; - tx.execute(&format!("INSERT INTO daily SELECT k.* FROM kept_daily k WHERE NOT {READ_DAY}"), [])?; + // A day with nothing read again comes back whole, unless every chat known on it was read again + // and simply holds nothing of the person's under the new rules. + tx.execute( + &format!( + "INSERT INTO daily SELECT k.* FROM kept_daily k WHERE NOT {READ_DAY} AND ({MISSING_CHAT_DAY} + OR NOT EXISTS (SELECT 1 FROM kept_sessions s WHERE s.source = k.source + AND k.day BETWEEN s.first_day AND s.last_day))" + ), + [], + )?; tx.execute( &format!( "INSERT INTO events SELECT NULL, ts, day, source, session, provider, family, version, diff --git a/crates/store/src/tests.rs b/crates/store/src/tests.rs index c11cb63..9a09c63 100644 --- a/crates/store/src/tests.rs +++ b/crates/store/src/tests.rs @@ -594,6 +594,29 @@ fn a_rebuild_keeps_what_it_cant_read_again() { assert_eq!(s.get_setting(REBUILDING_KEY).unwrap(), None); } +#[test] +fn a_rebuild_drops_a_chat_that_no_longer_has_any_of_your_messages() { + let dir = tempfile::tempdir().unwrap(); + let ts = (Utc::now() - Duration::hours(1)).to_rfc3339(); + let file = dir.path().join("a.jsonl"); + write_settled(&file, cc_ai("claude-opus-5-5", &ts, "hi") + &cc_correction("u1", &ts)); + let all = claude_code(&dir); + let mut s = Store::open_in_memory().unwrap(); + s.allow("claude-code", None).unwrap(); + sync(&mut s, &all).unwrap(); + assert_eq!(kept_counts(&s).1, 1); + + // Newer rules see the same chat's only message as another agent's, not the person's. + let agent = "Another Claude session sent a message:\nNo, that's wrong."; + write_settled( + &file, + cc_ai("claude-opus-5-5", &ts, "hi") + &cc_user("u1", &ts, agent) + &cc_ai("claude-opus-5-5", &ts, "ok"), + ); + s.set_setting("rules_version", "1").unwrap(); + assert!(sync(&mut s, &all).unwrap().rescored); + assert_eq!(kept_counts(&s), (0, 0, 0, 0), "the chat was read again, so what older rules counted goes"); +} + #[test] fn a_day_with_some_of_its_chats_gone_keeps_them() { let dir = tempfile::tempdir().unwrap(); @@ -1276,3 +1299,183 @@ fn a_file_that_cant_be_read_is_tried_again_only_when_it_changes() { std::fs::write(&file, "still not json!").unwrap(); assert_eq!(sync(&mut s, &all).unwrap().errors.len(), 1); } + +// ---- the privacy steps PRIVACY.md promises ---------------------------------------------------- + +#[test] +fn a_sync_steps_old_text_down_in_the_same_pass() { + let dir = tempfile::tempdir().unwrap(); + let ts = (Utc::now() - Duration::days(40)).to_rfc3339(); + write_settled(&dir.path().join("a.jsonl"), cc_ai("claude-opus-5-5", &ts, "hi") + &cc_correction("u1", &ts)); + let all = claude_code(&dir); + let mut s = Store::open_in_memory().unwrap(); + s.set_retention(Retention { level: Level::Full, full_days: 30, snippet_days: 365 }).unwrap(); + s.allow("claude-code", None).unwrap(); + assert_eq!(sync(&mut s, &all).unwrap().corrections, 1); + // Ingested at Full, so only the sweep at the end of the sync can have taken the full text away. + let (level, full, snip): (String, Option, Option) = s + .conn + .query_row("SELECT level, human_full, human_snip FROM events", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap(); + assert_eq!(level, "snippet"); + assert_eq!(full, None, "the full text is gone"); + assert!(snip.is_some(), "the snippet stays until it is older than its own limit"); +} + +#[test] +fn a_sweep_that_deletes_text_empties_the_write_ahead_log() { + let dir = tempfile::tempdir().unwrap(); + let wal_path = dir.path().join("t.db-wal"); + let wal = || std::fs::metadata(&wal_path).map_or(0, |m| m.len()); + let mut s = Store::open(&dir.path().join("t.db")).unwrap(); + s.set_retention(Retention { level: Level::Full, full_days: 30, snippet_days: 365 }).unwrap(); + ingest(&mut s, "f", vec![turn("no, that's wrong", "You're right.", "m", &now_ts(60), "old")]); + assert!(wal() > 0, "the text is in the log before the sweep"); + assert_eq!(s.sweep(Utc::now()).unwrap(), 1, "the full text was stepped down"); + assert_eq!(wal(), 0, "and no copy of it lingers in the log"); +} + +#[test] +fn redaction_runs_again_over_the_corrections_a_rebuild_set_aside() { + let mut s = Store::open_in_memory().unwrap(); + s.set_retention(Retention { level: Level::Full, ..Retention::default() }).unwrap(); + ingest(&mut s, "f", vec![turn("no, that's wrong", "You're right.", "m", &now_ts(1), "k")]); + // A rebuild's set-aside copy, written with an older, weaker redaction. + s.conn.execute("INSERT INTO kept_events SELECT * FROM events", []).unwrap(); + s.conn.execute("DELETE FROM events", []).unwrap(); + s.conn + .execute("UPDATE kept_events SET human_full = 'mail pat@example.com', human_snip = 'mail pat@example.com'", []) + .unwrap(); + s.set_setting(REDACTION_KEY, "1").unwrap(); + s.redact_stored_text().unwrap(); + let (full, snip): (String, String) = + s.conn.query_row("SELECT human_full, human_snip FROM kept_events", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!((full.as_str(), snip.as_str()), ("mail [email]", "mail [email]")); +} + +// ---- Forget touches only its own source ------------------------------------------------------- + +/// Every table with a `source` column (the sources table itself keeps it as `id`), with its columns +/// and declared types, read from the schema so a table added later is covered too. +fn tables_with_a_source(s: &Store) -> Vec<(String, Vec<(String, String)>)> { + let names: Vec = { + let mut q = s + .conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .unwrap(); + q.query_map([], |r| r.get(0)).unwrap().collect::>().unwrap() + }; + names + .into_iter() + .filter_map(|name| { + let mut q = s.conn.prepare(&format!("PRAGMA table_info({name})")).unwrap(); + let cols: Vec<(String, String)> = q + .query_map([], |r| Ok((r.get(1)?, r.get::<_, String>(2)?.to_uppercase()))) + .unwrap() + .collect::>() + .unwrap(); + cols.iter().any(|(c, _)| c == "source").then_some((name, cols)) + }) + .collect() +} + +/// One made-up row for `source` in every table that has a `source` column, each value different. +fn seed_every_source_table(s: &Store, source: &str) { + for (n, (table, cols)) in tables_with_a_source(s).into_iter().enumerate() { + let values: Vec = cols + .iter() + .map(|(col, ty)| match (col.as_str(), ty.contains("INT")) { + ("source", _) => format!("'{source}'"), + (_, true) => format!("{}", 1000 + n * 10 + usize::from(source == "b")), + _ => format!("'{col}-{table}-{source}'"), + }) + .collect(); + s.conn.execute(&format!("INSERT INTO {table} VALUES ({})", values.join(", ")), []).unwrap(); + } +} + +fn rows_of(s: &Store, table: &str, source: &str) -> i64 { + s.conn.query_row(&format!("SELECT COUNT(*) FROM {table} WHERE source = ?1"), [source], |r| r.get(0)).unwrap() +} + +#[test] +fn forgetting_one_source_leaves_every_row_of_the_other() { + let mut s = Store::open_in_memory().unwrap(); + for id in ["a", "b"] { + s.allow(id, None).unwrap(); + seed_every_source_table(&s, id); + } + let named = [ + "events", + "daily", + "sessions", + "tasks", + "seen", + "tips", + "tip_chats", + "report_cards", + "files", + "rebuild_read", + "kept_events", + "kept_daily", + "kept_sessions", + "kept_tasks", + "kept_seen", + "kept_tips", + "kept_tip_chats", + ]; + for table in named { + assert_eq!((rows_of(&s, table, "a"), rows_of(&s, table, "b")), (1, 1), "{table} was seeded"); + } + s.forget("a").unwrap(); + for table in named { + assert_eq!((rows_of(&s, table, "a"), rows_of(&s, table, "b")), (0, 1), "{table}"); + } + let ids: Vec = { + let mut q = s.conn.prepare("SELECT id FROM sources").unwrap(); + q.query_map([], |r| r.get(0)).unwrap().collect::>().unwrap() + }; + assert_eq!(ids, ["b"], "b's consent stays"); +} + +#[test] +fn forget_clears_the_source_in_every_table_that_has_one_and_nothing_else() { + let mut s = Store::open_in_memory().unwrap(); + for id in ["a", "b"] { + s.allow(id, None).unwrap(); + seed_every_source_table(&s, id); + } + let tables = tables_with_a_source(&s); + assert!( + tables.len() >= 15, + "the schema has many tables with a source: {:?}", + tables.iter().map(|t| &t.0).collect::>() + ); + s.forget("a").unwrap(); + for (table, _) in &tables { + assert_eq!(rows_of(&s, table, "a"), 0, "{table} still has rows from the forgotten source"); + assert_eq!(rows_of(&s, table, "b"), 1, "{table} lost rows from the other source"); + } +} + +// ---- consent cutoffs ---------------------------------------------------------------------------- + +#[test] +fn a_message_on_the_backfill_day_is_kept_and_one_just_before_it_is_not() { + let day = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); + let midnight = + Local.from_local_datetime(&day.and_hms_opt(0, 0, 0).unwrap()).earliest().unwrap().with_timezone(&Utc); + let state = SourceState { backfill_since: Some(day), ..test_source() }; + assert!(state.wants(midnight), "the first moment of the day counts"); + assert!(state.wants(midnight + Duration::hours(12))); + assert!(!state.wants(midnight - Duration::seconds(1)), "the last moment of the day before does not"); +} + +#[test] +fn a_message_at_the_from_now_on_moment_is_kept_and_one_just_before_it_is_not() { + let at = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap(); + let state = SourceState { start_after: Some(at), ..test_source() }; + assert!(!state.wants(at - Duration::seconds(1))); + assert!(state.wants(at), "exactly at the moment counts"); + assert!(state.wants(at + Duration::seconds(1))); +} diff --git a/plugins/grade/.claude-plugin/plugin.json b/plugins/grade/.claude-plugin/plugin.json index d272e54..293f702 100644 --- a/plugins/grade/.claude-plugin/plugin.json +++ b/plugins/grade/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "grade", - "version": "0.5.6", + "version": "0.5.7", "description": "Report cards on how you prompt: type /grade:me for a letter grade, why, and how to ask so it's clear the first time. Works with the No, Actually... app to track your progress.", "author": { "name": "HTMLGuy LLC",