From 51902613d4c2e2c24003d2110e625763fd2dcd88 Mon Sep 17 00:00:00 2001 From: HTMLGuy <2415447+HTMLGuyLLC@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:16:54 -0500 Subject: [PATCH 1/6] Fix the 2026-11-03 audit: rule misfires, adapters, redaction, tests Scoring: - Claude Code messages from other agents, scheduled tasks and coordinators no longer count as the person's (origin.kind, plus text markers for older files). A rebuild now drops what older rules counted in chats that hold none of the person's messages, instead of putting it back. - Polite closers ("No, that's all, thanks", "Nah, it's cool") and routine requests are no longer corrections. "You're right" after a plain question isn't an admission. Added context shares one list with the prompt grader. - A "?" in code isn't the AI asking. Mistake categories come from the right side of the chat. Several prompt signals fire less loosely. - RULES_VERSION 13. Sources: queued messages with images, ChatGPT attachments, Codex handoff and voice threads are read. Exports stream instead of loading whole. One line is capped at 64 MB. Pasted blocks are stripped before scoring. Redaction: any *_KEY with a key-like value, pass:, Google and Slack tokens. ISO dates are kept. REDACTION_VERSION 3. Bedrock model ids and Opus 4/4.1 prices. Lessons: Evidence and Target hints match what the checker credits, and the Context lesson says the checker can't spot it. Tests: no test reads or writes real CODEX_HOME or Claude history. New tests pin retention, WAL truncation, re-redaction, two-source Forget and consent cutoffs. CI also runs both suites in UTC+14 and UTC-11. Shared fixtures. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 24 +++ CHANGELOG.md | 19 +++ app/package.json | 4 +- app/pnpm-lock.yaml | 72 ++++----- app/src-tauri/src/report_cards.rs | 15 +- app/src-tauri/src/report_cards/codex.rs | 2 +- app/src-tauri/src/report_cards/tests.rs | 13 +- app/src/learn/lessons.json | 12 +- app/src/test/fixtures.ts | 8 + app/src/views/charts.test.ts | 6 +- app/src/views/improve.test.ts | 6 +- app/src/views/learn.test.ts | 9 +- app/src/views/views.test.ts | 7 +- app/vitest.config.ts | 2 +- crates/cli/src/helper/tests.rs | 38 +++-- crates/cli/tests/helper.rs | 80 +++++----- crates/engine/src/lib.rs | 35 +++- crates/engine/src/model.rs | 22 ++- crates/engine/src/pricing.rs | 15 +- crates/engine/src/prompt.rs | 35 ++-- crates/engine/src/prompt_tests.rs | 25 ++- crates/engine/src/redact.rs | 65 +++++++- crates/engine/src/rules.rs | 142 ++++++++++++++--- crates/engine/src/tests.rs | 163 ++++++++++++++++++- crates/sources/src/chatgpt.rs | 24 ++- crates/sources/src/claude_code.rs | 164 ++++++++++++++++++- crates/sources/src/codex.rs | 64 +++++++- crates/sources/src/export.rs | 131 ++++++++++++--- crates/sources/src/jsonl.rs | 72 ++++++++- crates/sources/src/lib.rs | 3 + crates/sources/src/pair.rs | 2 +- crates/sources/src/testing.rs | 21 +++ crates/store/src/card_tests.rs | 6 +- crates/store/src/ingest.rs | 9 ++ crates/store/src/rebuild.rs | 16 +- crates/store/src/tests.rs | 203 ++++++++++++++++++++++++ 36 files changed, 1313 insertions(+), 221 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c580a82..00f3d0b 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0313cad..8a4630e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes are listed here. Format follows [Keep a Changelog](https://k ## [Unreleased] +### 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 diff --git a/app/package.json b/app/package.json index 4967abc..b34505a 100644 --- a/app/package.json +++ b/app/package.json @@ -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/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))); +} From 490cae7a0111db020bca64689592d4510cc71433 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:25:20 +0000 Subject: [PATCH 2/6] ci: bump taiki-e/install-action Bumps the actions group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `taiki-e/install-action` from 2.87.32 to 2.89.0 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/5488ac5a951645ce95062214902ac4b6bc1a1037...1919c4a95903d2c33e4a221102edca87309fc14b) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.89.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00f3d0b..65cf761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,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 From f8457c6db9a2cc6fa93dcfd563febd1e3ba2ebbe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:26:22 +0000 Subject: [PATCH 3/6] deps(rust): bump the rust-minor group with 4 updates Bumps the rust-minor group with 4 updates: [log](https://github.com/rust-lang/log), [tauri-plugin-notification](https://github.com/tauri-apps/plugins-workspace), [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) and [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace). Updates `log` from 0.4.34 to 0.4.35 - [Release notes](https://github.com/rust-lang/log/releases) - [Changelog](https://github.com/rust-lang/log/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-lang/log/compare/0.4.34...v0.4.35) Updates `tauri-plugin-notification` from 2.7.0 to 2.8.0 - [Release notes](https://github.com/tauri-apps/plugins-workspace/releases) - [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.7.0...log-v2.8.0) Updates `tauri-plugin-dialog` from 2.8.3 to 2.8.4 - [Release notes](https://github.com/tauri-apps/plugins-workspace/releases) - [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.8.3...dialog-v2.8.4) Updates `tauri-plugin-single-instance` from 2.5.4 to 2.5.5 - [Release notes](https://github.com/tauri-apps/plugins-workspace/releases) - [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.5.4...http-v2.5.5) --- updated-dependencies: - dependency-name: log dependency-version: 0.4.35 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-minor - dependency-name: tauri-plugin-notification dependency-version: 2.8.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: rust-minor - dependency-name: tauri-plugin-dialog dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-minor - dependency-name: tauri-plugin-single-instance dependency-version: 2.5.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 176 +++++++++++++---------------------------------------- 1 file changed, 42 insertions(+), 134 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80409b8..752d3be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -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" From de666d825f437409aca6e6f3d6118b4ae0e181dd Mon Sep 17 00:00:00 2001 From: HTMLGuy <2415447+HTMLGuyLLC@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:24:13 -0500 Subject: [PATCH 4/6] Update the yanked async-executor so cargo-deny passes Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 752d3be..e64ef73 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", From 8d2de1c974006095fdb662bebe16f582b5a86ab5 Mon Sep 17 00:00:00 2001 From: HTMLGuy <2415447+HTMLGuyLLC@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:24:25 -0500 Subject: [PATCH 5/6] Tell Dependabot to skip majors that Tauri still pins zbus 6, dirs 7 and sha2 0.11 would each add a second copy, because Tauri's plugins still use the older versions. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 9 +++++++++ 1 file changed, 9 insertions(+) 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: From 8263f7a8683c4a847ae009a1a67ca40a55d62b0c Mon Sep 17 00:00:00 2001 From: HTMLGuy <2415447+HTMLGuyLLC@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:25:03 -0500 Subject: [PATCH 6/6] release: v0.5.7 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- app/package.json | 2 +- app/src-tauri/tauri.conf.json | 2 +- plugins/grade/.claude-plugin/plugin.json | 2 +- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4630e..76d9178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ 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. @@ -292,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 e64ef73..16a117f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2423,7 +2423,7 @@ dependencies = [ [[package]] name = "noactually-app" -version = "0.5.6" +version = "0.5.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -2451,7 +2451,7 @@ dependencies = [ [[package]] name = "noactually-cli" -version = "0.5.6" +version = "0.5.7" dependencies = [ "anyhow", "chrono", @@ -2467,7 +2467,7 @@ dependencies = [ [[package]] name = "noactually-engine" -version = "0.5.6" +version = "0.5.7" dependencies = [ "regex", "serde", @@ -2477,7 +2477,7 @@ dependencies = [ [[package]] name = "noactually-sources" -version = "0.5.6" +version = "0.5.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "noactually-store" -version = "0.5.6" +version = "0.5.7" dependencies = [ "chrono", "dirs 6.0.0", 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 b34505a..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": { 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/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",