diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..33a99f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug report +description: Report something in clens that is broken or behaves incorrectly. +title: "[bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug. Please fill out the sections below so we can reproduce and fix it quickly. + + clens is **local-first** — it never sends your session data anywhere. When sharing logs or output, please redact anything sensitive. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear and concise description of the bug. + placeholder: When I run `clens ...`, I see ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + placeholder: I expected ... + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The exact commands and actions that trigger the bug. + value: | + 1. Run `clens ...` + 2. ... + 3. See error + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant output or logs + description: Paste the terminal output, error message, or stack trace. This will be rendered as code, so no backticks needed. + render: shell + - type: input + id: clens-version + attributes: + label: clens version + description: Output of `clens --version`. + placeholder: 0.3.0 + validations: + required: true + - type: input + id: env + attributes: + label: Environment + description: OS, runtime (Bun/Node) version, and Claude Code version if relevant. + placeholder: macOS 14.5, Bun 1.1.20, Claude Code 1.x + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else that might help — screenshots, related issues, etc. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3d571bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & discussion + url: https://github.com/silouone/clens/discussions + about: Ask questions, share workflows, or discuss ideas with the community. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e4fc465 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: Feature request +description: Suggest an idea or improvement for clens. +title: "[feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! clens is a precision instrument for Claude Code session observability — proposals that keep it focused, honest, and local-first are most likely to land. + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the pain point or gap. What can't you do today? + placeholder: I'm always trying to ... but clens doesn't ... + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would you like clens to do? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches, workarounds, or existing commands you have tried. + - type: textarea + id: context + attributes: + label: Additional context + description: Mockups, examples from other tools, or anything else that helps. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..59956ed --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ + + +## Summary + + + +## Related issue + + + +## Type of change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that changes existing behavior) +- [ ] Documentation +- [ ] Refactor / chore (no functional change) + +## Checklist + +- [ ] `bun run typecheck` passes +- [ ] `bun test` passes +- [ ] I added or updated tests where it makes sense +- [ ] I updated the docs / README / CHANGELOG where relevant +- [ ] My changes preserve the locked metric semantics (status, wall-clock duration, exact event counts, read-time pricing, honesty caveats) + +## Notes for reviewers + + diff --git a/.github/assets/dashboard-detail.png b/.github/assets/dashboard-detail.png new file mode 100644 index 0000000..0bcfd1d Binary files /dev/null and b/.github/assets/dashboard-detail.png differ diff --git a/.github/assets/dashboard-insights.png b/.github/assets/dashboard-insights.png new file mode 100644 index 0000000..afd8449 Binary files /dev/null and b/.github/assets/dashboard-insights.png differ diff --git a/.github/assets/dashboard-mobile.png b/.github/assets/dashboard-mobile.png new file mode 100644 index 0000000..17cb5eb Binary files /dev/null and b/.github/assets/dashboard-mobile.png differ diff --git a/.github/assets/dashboard-sessions-light.png b/.github/assets/dashboard-sessions-light.png new file mode 100644 index 0000000..04f72c0 Binary files /dev/null and b/.github/assets/dashboard-sessions-light.png differ diff --git a/.github/assets/dashboard-sessions.png b/.github/assets/dashboard-sessions.png new file mode 100644 index 0000000..3246fa4 Binary files /dev/null and b/.github/assets/dashboard-sessions.png differ diff --git a/.github/assets/dashboard-usage.png b/.github/assets/dashboard-usage.png new file mode 100644 index 0000000..b3048ed Binary files /dev/null and b/.github/assets/dashboard-usage.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0e21da..5e4f313 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,14 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: check: runs-on: ubuntu-latest + # TEST-10: pin the timezone so time-relative analytics window tests are deterministic + # across runners. ubuntu-latest already defaults to UTC; this makes it explicit. + env: + TZ: UTC steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -17,7 +20,16 @@ jobs: - run: bun install - name: Typecheck run: bun run typecheck - - name: Test - run: bun test --coverage + # TEST-5: run BOTH package suites explicitly so the web suite can never be + # silently dropped (root `bun test` recursion is incidental). Per D5-RESOLVED + # there is no vitest harness — the web tests are bun:test and run here. + - name: Test CLI + run: bun run test:cli + - name: Test web + run: bun run test:web - name: Lint run: bun run lint + - name: Build + run: bun run build + - name: Verify pack contents (web bundle + types) + run: node scripts/verify-pack.mjs packages/cli diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e9bfaef..ae58883 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,6 +10,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + # OIDC token for npm publish provenance (--provenance). An explicit + # permissions block replaces the default set, so id-token must be listed + # alongside contents or provenance is silently disabled. + id-token: write steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -20,6 +24,15 @@ jobs: node-version: '20' registry-url: 'https://registry.npmjs.org' - run: bun install + - name: Verify tag matches package version + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./packages/cli/package.json').version")" + if [ "$TAG" != "$PKG" ]; then + echo "::error::Tag '$GITHUB_REF_NAME' does not match packages/cli version '$PKG'" + exit 1 + fi + echo "Tag and version agree: $PKG" - name: Typecheck run: bun run typecheck - name: Test @@ -28,7 +41,10 @@ jobs: run: bun run lint - name: Build run: bun run build - - name: Publish - run: npm publish + - name: Verify pack contents (web bundle + types) + run: node scripts/verify-pack.mjs packages/cli + - name: Publish (packages/cli only — root is private) + working-directory: packages/cli + run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index e3b4457..fe63f87 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ ai_docs/ docs/ cli-review/ +# Internal deliberation / self-criticism (not part of the open source project) +deliberation/ + # cLens local capture data .clens/ @@ -51,3 +54,14 @@ specs/ # Internal analytics scripts/ report_kpi_33_sessions.md + +# Test artifacts +.playwright-cli/ +screenshots/ +ai_review/ +.verify.json + +# Generated/placeholder image assets (not real product assets) +**/Gemini_Generated_Image* +# Stray root-level asset dump (real product assets live in packages/web/src/client/assets) +/assets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1ee0a..e6cf407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,116 @@ All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.3.0] - 2026-03-08 + +### Breaking Changes + +#### Monorepo Migration +- Project restructured into Bun workspaces: `packages/cli` + `packages/web` +- All existing source moved to `packages/cli/src/`, tests to `packages/cli/test/` +- CLI package remains **zero runtime dependencies** +- Web package is a new `@clens/web` workspace with its own dependency tree + +### Added + +#### `clens web` — Browser-Based Session Explorer +- New CLI command: `clens web [--port ] [--no-open]` +- Opens a full-featured browser UI at `http://127.0.0.1:3700` +- Dynamic import of `@clens/web/server` avoids circular workspace dependency + +#### Hono API Server (9 endpoints) +- `GET /api/sessions` — paginated session list with filtering and sorting +- `GET /api/sessions/:id` — distilled session detail (404/202 for missing/undistilled) +- `GET /api/sessions/:id/events` — raw events with LRU cache (10 sessions, ~17MB max) +- `GET /api/sessions/:id/conversation` — merged ConversationEntry timeline (paginated) +- `GET /api/sessions/:id/agents/:agentId/conversation` — agent-scoped conversation +- `GET /api/sessions/:id/diff/:filePath` — unified diff via diffLinesToUnified() +- `POST /api/sessions/:id/distill` — async distill trigger with SSE notification +- `GET /api/events/stream` — SSE with ring buffer replay (1000 events), 30s heartbeat +- `GET /` — SPA static assets with immutable cache headers (production mode) + +#### Security +- Random 256-bit auth token per server session (query param or Bearer header) +- Bound to `127.0.0.1` only — no network exposure +- UUID regex validation on session IDs (path traversal protection) +- CORS: `localhost:5173` in dev, same-origin in production + +#### SolidJS SPA +- **Session list**: table with status badges, duration, cost, branch; search, filters, pagination +- **Split-screen hero view**: ConversationPanel (left) + DiffPanel (right) with resizable SplitPane +- **ConversationPanel**: 6 entry types (user prompts, thinking blocks, tool calls, tool results, backtracks, phase boundaries); collapsible thinking with intent badges; consecutive tool call collapse; jump-to navigation; minimap scrollbar; virtual scrolling for 500+ entries +- **DiffPanel**: diff2html rendering, A/M/D/R badges, +N/-M line counts, expandable file cards, per-file lazy loading, abandoned edit markers +- **Bidirectional linking**: click tool call → scroll to diff, click file → scroll to tool call (flash highlight animation) +- **SessionHeader**: metadata bar with phase timeline visualization (click phase → scroll to boundary) +- **Bottom panel tabs**: Backtracks (click to scroll), Timeline (13 type filters), Edits (chain visualization), Communication (multi-agent) + +#### Multi-Agent Views +- Agent tree sidebar for team sessions (collapsible, color-coded by agent type) +- Agent-scoped conversation view with stats sidebar (tools, cost, duration, files) +- Communication timeline: swim-lane visualization of inter-agent messages +- Solo session detection — no agent UI clutter + +#### Risk Scoring +- `computeFileRiskScores()` — per-file risk based on backtracks, abandoned edits, failure rate +- Risk badges (green/amber/red) on DiffPanel file list with tooltips +- Risk levels: low (clean), medium (1-2 backtracks), high (3+ backtracks or >50% abandoned) + +#### Plan Drift View +- Expected vs actual files side-by-side with match/missing/unexpected badges +- Drift score percentage with color coding + +#### Live Updates +- `fs.watch` on `.clens/sessions/` and `.clens/distilled/` with 100ms debounce +- Per-file byte offset tracking for incremental JSONL reads +- SSE ring buffer (1000 events) with `Last-Event-ID` reconnect replay +- Polling fallback via `CLENS_POLL=1` env var +- Session list auto-updates with connection status indicator + +#### Dark/Light Theme +- System preference detection as default, toggle in header +- Persisted in localStorage, flash-of-wrong-theme prevention +- All components support `dark:` variants + +#### Keyboard Navigation +- `j`/`k` — scroll entries / session rows +- `Enter` — drill into session or agent +- `Escape` — go back +- `[`/`]` — switch panel focus +- `?` — keyboard shortcut help overlay + +#### Responsive Layout +- Below 1024px: stacked layout (conversation above diffs) +- Above 1024px: side-by-side split (default) +- Error boundaries on every major panel with retry fallback + +#### New CLI Modules +- `ConversationEntry` discriminated union type (6 variants) +- `buildConversation(distilled, events)` — pure function merging distilled data + raw events into sorted timeline +- `diffLinesToUnified(filePath, lines)` — converts DiffLine[] to standard unified diff format +- `computeFileRiskScores(distilled)` — per-file risk scoring (pure function, no I/O) +- `FileRiskScore` and `RiskLevel` types + +#### Production Build +- Vite with SolidJS plugin, Tailwind CSS purging +- Asset fingerprinting with immutable cache headers (1 year) +- Output: 150KB JS (47KB gzip), 31KB CSS (6KB gzip) + +### Changed +- `package.json` now uses Bun workspaces (`"workspaces": ["packages/*"]`) +- Root scripts: `build:cli`, `build:web`, `build`, `test:cli`, `test:web`, `test`, `dev:api`, `dev:web` +- `tsconfig.json` uses project references with shared `tsconfig.base.json` +- Hook tests updated with absolute paths for monorepo compatibility +- 1,373 CLI tests + 72 web tests = 1,445 total (0 regressions) + +### Dependencies (web package only) +- Runtime: hono, solid-js, @solidjs/router, @kobalte/core, diff2html +- Dev: vite, vite-plugin-solid, tailwindcss, postcss, autoprefixer, vitest, @solidjs/testing-library + ## [0.2.1] - 2026-02-25 ### Added @@ -170,3 +280,9 @@ All notable changes to this project will be documented in this file. - Cost estimation (token heuristic + model pricing) - Zero-dependency, local-first architecture - Compiled binary support via `bun build --compile` + +[Unreleased]: https://github.com/silouone/clens/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/silouone/clens/compare/v0.2.1...v0.3.0 +[0.2.1]: https://github.com/silouone/clens/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/silouone/clens/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/silouone/clens/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c6a7304 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +silouane.galinou.dev@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3684238..523a870 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,43 +1,122 @@ # Contributing to cLens -Thank you for your interest in contributing to clens! +Thanks for your interest in contributing to cLens! + +cLens is a Bun monorepo with two packages: + +- **`packages/cli`** — the published `clens` package (CLI, hooks, distillers). This is what `npm install -g clens` installs. +- **`packages/web`** — `@clens/web`, the private web dashboard (SolidJS + Hono) served by `clens web`. Not published to npm; it ships bundled inside the CLI package. ## Prerequisites -- [Bun](https://bun.sh) >= 1.0 +- [Bun](https://bun.sh) >= 1.0 (runtime, bundler, and test runner) ## Development Setup -1. Clone the repository -2. Install dependencies: `bun install` -3. Run tests: `bun test` +1. Clone the repository. +2. Install all workspace dependencies from the repo root: `bun install` +3. Run the test suite: `bun test` + +`bun install` installs dependencies for every workspace; you do not need to install per package. ## Project Structure ``` -src/ — Source code (CLI, hooks, format definitions, session management) -test/ — Test files (mirrors src/ structure) -.clens/ — Local session data directory (not committed) -specs/ — Implementation plans +packages/ + cli/ — the published `clens` package + src/ + cli.ts — CLI entry point (thin dispatch) + hook.ts — universal hook handler (JSONL append, ~2ms budget) + types/ — type definitions (leaf modules) + capture/ — hook-time I/O (2ms budget) + session/ — CLI-time session I/O (read/clean/export/transcript) + distill/ — pure extractors (20+ extractors + orchestrator) + commands/ — one file per CLI command + test/ — unit + e2e tests (mirrors src/) + web/ — `@clens/web` dashboard (private, SolidJS + Hono) + src/ + client/ — SolidJS UI (pages, components, charts, stores) + server/ — Hono API server + test/ — api / gate / integration / unit tests +specs/ — implementation plans and brainstorms +``` + +## Commands + +Run these from the repo root. Each delegates to the relevant workspace(s). + +| Command | What it does | +|---|---| +| `bun test` | Run all tests (CLI + web API) | +| `bun run typecheck` | TypeScript type checking across both packages | +| `bun run lint` | Lint with Biome (CLI + web) | +| `bun run build` | Build both packages | + +### Targeting a single package + +| Command | What it does | +|---|---| +| `bun run test:cli` / `bun run test:web` | Test only the CLI / web package | +| `bun run lint:cli` / `bun run lint:web` | Lint only the CLI / web package | +| `bun run build:cli` / `bun run build:web` | Build only the CLI / web package | + +You can also work inside a package directly, e.g. `bun run --filter clens lint:fix` to auto-fix CLI lint violations, or `bun run --filter @clens/web typecheck`. + +### Building the CLI binary + +The CLI compiles to standalone binaries: + +```sh +bun run --filter clens build:bin ``` -## Development Commands +Note: `bun run build` (the JS bundle) is **not** the same as the live compiled binary. To exercise the binary locally you must run `build:bin` and use the produced `packages/cli/bin/clens`. + +## Web dashboard development + +The dashboard is a SolidJS client served by a Hono API. + +Run both together from the repo root with live reload: + +```sh +bun run dev # API (global mode) + Vite dev server +``` + +Or run the pieces individually: + +```sh +bun run dev:api # Hono API, current project only +bun run dev:api:global # Hono API across all registered projects +bun run dev:web # Vite dev server (SolidJS client) +``` + +To run the dashboard the way end users do (against the compiled CLI): + +```sh +clens web # serves on http://localhost:3700, opens a browser +clens web --port 4000 # custom port +clens web --no-open # do not auto-open a browser +clens web --global # aggregate sessions across all registered projects +``` + +### UI styling + +The dashboard follows the INSTRUMENT design system (IBM Plex Sans/Mono, square corners, 1px hairlines, a single signal-green accent). Design tokens live in `packages/web/src/client/index.css` and `tailwind.config.js` and are guarded by `packages/web/test/gate/design-tokens.test.ts` — keep token changes in sync or that gate will fail. + +### UI review gate -- `bun test` — Run all tests -- `bun run typecheck` — TypeScript type checking -- `bun run lint` — Lint with Biome -- `bun run lint:fix` — Auto-fix lint violations -- `bun run build` — Compile binary +There is no client-side component-test harness. Client UI changes are validated by the **web-review** gate (it starts the server and drives the dashboard in a browser). Run it before opening a PR that touches the web client, in addition to `bun run test:web` for the API layer. ## Pull Request Guidelines -- Branch from `main` -- Keep PRs focused — one feature or fix per PR -- All tests must pass (`bun test`) -- TypeScript must compile cleanly (`bun run typecheck`) -- Code must pass lint (`bun run lint`) -- Run `bun run lint:fix` before committing +- Branch from `main`. +- Keep PRs focused — one feature or fix per PR. +- All tests must pass: `bun test`. +- TypeScript must compile cleanly: `bun run typecheck`. +- Code must pass lint: `bun run lint`. +- Run `bun run --filter clens lint:fix` (and/or the web equivalent) before committing. +- For web client changes, run the web-review UI gate. ## Code Style -Code style is enforced by [Biome](https://biomejs.dev). Run `bun run lint:fix` before committing to auto-fix formatting and lint issues. +Code style is enforced by [Biome](https://biomejs.dev) (config in `biome.json`). Run `lint:fix` before committing to auto-fix formatting and lint issues. The codebase favors a functional style: immutable data, pure functions, and composition. diff --git a/LICENSE b/LICENSE index e882ad8..f53fd1b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 cLens contributors +Copyright (c) 2025-2026 Silouane Galinou Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5ab1362..a10d4b9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,59 @@ # cLens — Session Observability for Claude Code -[![npm](https://img.shields.io/npm/v/@silou/clens)](https://www.npmjs.com/package/@silou/clens) +[![npm](https://img.shields.io/npm/v/clens)](https://www.npmjs.com/package/clens) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-1151%20passing-brightgreen)]() +[![CI](https://github.com/silouone/clens/actions/workflows/ci.yml/badge.svg)](https://github.com/silouone/clens/actions/workflows/ci.yml) -**Local-first session capture and analysis for Claude Code agents.** See what your agent actually did — every tool call, backtrack, decision, and reasoning step — without any network dependencies. +**Local-first session capture and analysis for Claude Code agents.** See what your agent actually did — every tool call, backtrack, decision, and reasoning step — then explore it all in a browser dashboard. Zero network, zero server, zero telemetry. + +## 30-second quickstart + +```sh +npm install -g clens # or: bun install -g clens +``` + +![cLens web dashboard](.github/assets/dashboard-sessions.png) + +```sh +clens init # 1. install hooks into this project +# ... use Claude Code normally ... +clens distill --last # 2. analyze your latest session +clens web # 3. open the dashboard in your browser +``` + +That's it. `clens init` wires the capture hooks, your sessions land as local JSONL, and `clens web` launches the full dashboard at `http://127.0.0.1:3700`. Prefer the terminal? `clens explore` opens the interactive TUI instead. + +## Web Dashboard + +`clens web` serves a browser dashboard for everything cLens captures — session list, per-session detail, multi-agent trees, decision and backtrack timelines, edit chains, plan drift, and cross-session insights. The web server is bundled into the CLI binary at build time, so npm consumers need nothing extra to run it. + +![Session detail](.github/assets/dashboard-detail.png) + +```sh +clens web # launch on http://127.0.0.1:3700, opens your browser +clens web --port 8080 # custom port +clens web --no-open # don't auto-open the browser +clens web --global # aggregate sessions across all your repos +``` + +The dashboard is served locally and gated behind a per-launch token printed to your terminal — nothing is exposed to the network and no data leaves your machine. A per-session Config / Environment panel surfaces the git context, model, and pricing tier captured for each run. + +> Honest by design: cost and token figures are **mostly estimated** (cache reads dominate real usage and aren't always itemized), "outcome" is derived from the session's completion flag plus files modified (not from commits), and "decisions" are **structural** signals (timing gaps, phase boundaries, tool pivots) rather than semantic judgments. cLens measures; it doesn't editorialize. ## What it does -cLens hooks into Claude Code to capture complete session traces as local JSONL files. Every tool call, session lifecycle event, and agent message is appended to a flat file in your project — no network, no server, no external dependencies. After a session, the `distill` command runs 23 extractors to surface decision points, backtracks, reasoning patterns, edit chains, plan drift, multi-agent communication, and more for post-hoc analysis. +cLens hooks into Claude Code to capture complete session traces as local JSONL files. Every tool call, session lifecycle event, and agent message is appended to a flat file in your project — no network, no server, no external dependencies. After a session, the `distill` command runs 20+ extractors to surface decision points, backtracks, reasoning patterns, edit chains, plan drift, multi-agent communication, and more for post-hoc analysis in the CLI, TUI, or web dashboard. ### Key capabilities - **Session capture** — zero-config hooks, ~2ms overhead per event +- **Web dashboard** — `clens web` for a full browser UI over every session - **Backtrack detection** — find where agents reversed course and why -- **Decision analysis** — trace decision points through agent reasoning +- **Decision analysis** — trace structural decision points through agent reasoning - **Edit chains** — link thinking blocks to the code changes they produced - **Plan drift** — compare intended spec vs actual execution - **Multi-agent tracing** — communication graphs, team metrics, agent trees - **Interactive TUI** — explore sessions with keyboard-navigable tabs -- **OpenTelemetry export** — bridge to existing observability stacks ## Prerequisites @@ -34,14 +68,17 @@ npm install -g clens # or: bun install -g clens Then in any project: ```sh -clens init # install hooks +clens init # install hooks (local, per-project) +clens init --global # or install hooks globally (all projects) # use Claude Code normally clens list # see captured sessions clens distill --last # analyze latest session +clens distill --global # analyze every session across all your repos clens what --last # quick summary: request, outcome, cost clens report --last # detailed report clens report --last backtracks # drill into backtracks clens agents --last # agent overview +clens web # browser dashboard clens explore # interactive TUI explorer ``` @@ -51,65 +88,84 @@ clens explore # interactive TUI explorer | Command | Description | |---|---| -| `init` | Install hooks into `.claude/settings.json` | -| `init --remove` | Remove hooks, restore original settings | -| `init --status` | Show installation status (hooks, plugin, data) | +| `init` | Install hooks into `.claude/settings.json` (local, per-project) | +| `init --global` | Install hooks globally for all projects | +| `init --remove` | Remove hooks from all active tiers (`--legacy` to also clean legacy hooks) | +| `init --status` | Show installation status across all tiers | | `init plugin` | Install agentic plugin into `~/.claude/` | | `init plugin --remove` | Remove agentic plugin | | `init plugin --dev` | Dev mode (symlink from source) | +| `config` | View current configuration | +| `config --pricing ` | Set pricing tier (`api`, `max`, or `auto`) | +| `config --global-mode ` | Set discovery mode: `repository` (git root) or `project` (each `.clens/`) | ### Sessions | Command | Description | |---|---| | `list` | List captured sessions with duration, events, team, type, status | +| `list --global` | List sessions across all registered projects | +| `name [label]` | Set/clear a session's label and color (`--color `, `--clear`; no args prints current) | | `distill [id]` | Extract insights: backtracks, decisions, file map, reasoning, edit chains | +| `distill --global` | Distill every session across all registered projects (incremental; `--force` to re-distill) | | `what [id]` | Quick summary: request, outcome, cost, issues, files changed | -| `report [id]` | Session summary -- backtrack severity, high-risk files, top tools | +| `report [id]` | Session summary — backtrack severity, high-risk files, top tools | | `report [id] backtracks` | Backtrack analysis (add `--detail` for per-backtrack breakdown) | | `report [id] drift [spec]` | Plan drift analysis (spec vs actual files) | | `report [id] reasoning` | Reasoning analysis (add `--full` for full text, `--intent` to filter) | | `agents [id]` | Agent table overview (or `agents [id] ` for detail) | | `agents [id] --comms` | Communication timeline | +| `web` | Launch the browser dashboard (`--port`, `--no-open`, `--global`) | | `explore` | Interactive TUI explorer (dynamic tabs, scroll, keyboard nav) | ### Data | Command | Description | |---|---| -| `clean [id]` | Remove raw session data (preserves distilled artifacts) | -| `export [id]` | Export session as archive (supports `--otel` for OTLP format) | +| `clean ` / `clean --last` | Remove **one** session's raw data (preserves distilled artifacts) | +| `clean --all` | **Destructive:** remove the raw data for **every** session in this project. Prompts for confirmation; requires `--yes` when non-interactive. There is no undo. | +| `export [id]` | Export session as a `.zip` archive | + +> ⚠️ `clean` and `clean --all` permanently delete raw session JSONL for the whole project. `clean --all` always asks before deleting in a terminal and refuses to run non-interactively unless you pass `--yes`. Distilled artifacts (`.clens/distilled/`) are always preserved. By default, sessions that have **not** been distilled yet are skipped so you don't lose un-analyzed raw data; pass `--force` to delete those too. ## Flags | Flag | Applies to | Description | |---|---|---| -| `--last` | Most commands | Use most recent session | -| `--json` | Analysis commands | Output structured JSON | -| `--all` | `distill`, `clean` | Apply to all sessions | -| `--deep` | `distill` | Enrich agents with transcript data | -| `--force` | `clean` | Skip safety checks | +| `--last` | `distill`, `report`, `agents`, `what`, `clean`, `export` | Use most recent session | +| `--json` | `list`, `distill`, `report`, `agents`, `what`, `config`, `name` | Output structured JSON | +| `--all` | `distill`, `clean` | Apply to all sessions in the project | +| `--global` | `init`, `list`, `distill`, `what`, `web` | Operate across all registered projects (for `init`, install hooks globally) | +| `--deep` | `distill` | Enrich with git history and unified diffs (spawns git) | +| `--force` | `clean`, `distill` | For `clean`, also delete not-yet-distilled raw sessions; for `distill`, re-distill fresh sessions | +| `--yes`, `-y` | `clean` | Skip the confirmation prompt (required for `clean --all` when non-interactive) | | `--detail` | `report backtracks` | Per-backtrack breakdown | | `--full` | `report reasoning` | Show full thinking text | | `--intent ` | `report reasoning` | Filter by intent type | | `--comms` | `agents` | Show communication timeline | -| `--otel` | `export` | Export in OTLP format | +| `--port ` | `web` | Dashboard port (default 3700) | +| `--no-open` | `web` | Don't auto-open the browser | +| `--pricing ` | `distill`, `what`, `config` | Pricing tier: `api`, `max`, or `auto` | +| `--color ` | `name` | Session color flag | +| `--clear` | `name` | Clear a session's label and color | +| `--global-mode ` | `config` | Discovery mode: `repository` or `project` | | `--remove` | `init` | Remove hooks/plugin | | `--status` | `init` | Show installation status | | `--dev` | `init plugin` | Dev mode (symlink from source) | +| `--legacy` | `init --remove` | Also remove legacy hooks | ## Why cLens | | cLens | OTel-based tools | Usage trackers | Session viewers | |---|---|---|---|---| | Capture method | Native hooks (2ms) | Proxy/middleware | Log parsing | Transcript reading | -| Backtrack detection | 23 extractors | -- | -- | -- | +| Backtrack detection | 20+ extractors | -- | -- | -- | | Decision analysis | Built-in | -- | -- | -- | | Edit chain tracking | Built-in | -- | -- | -- | | Plan drift analysis | Built-in | -- | -- | -- | | Multi-agent support | Full (comms, trees) | Partial | -- | -- | | Network required | No | Yes | No | No | -| Interactive explorer | TUI | Dashboard | -- | Web UI | +| Explorers | TUI + local web dashboard | Dashboard | -- | Web UI | | Self-analysis plugin | Yes (agents analyze own sessions) | -- | -- | -- | ## How it works @@ -120,7 +176,7 @@ Two-layer architecture: **Layer 2 -- Transcript Enrichment**: At distill time, the Claude Code transcript is parsed for thinking blocks and user messages, providing context for why the agent made the choices it did. -The `distill` command runs 23 extractors covering: stats, backtracks, decisions, file-map, git-diff, reasoning, user-messages, summary, timeline, plan-drift, edit-chains, active-duration, aggregate, comm-graph, comm-sequence, agent-tree, agent-distill, agent-enrich, team, decisions-team, summary-team, journey, and agent-lifetimes. Output is written as structured JSON to `.clens/distilled/`. +The `distill` command runs 20+ pure extractors covering stats, backtracks, decisions, file-map, git-diff, reasoning, user-messages, summary, timeline, plan-drift, edit-chains, active-duration, aggregate, comm-graph, comm-sequence, agent-tree, agent-distill, agent-enrich, team, decisions-team, summary-team, journey, and more. Output is written as structured JSON to `.clens/distilled/`. ## Agentic Plugin @@ -149,12 +205,14 @@ The plugin provides: ## Privacy -All data is local. No network calls. No telemetry. Full tool call payloads -- including arguments and outputs -- are written to JSONL. Be aware of this if sessions involve credentials, API keys, or sensitive file contents. +All data is local. No network calls. No telemetry. Full tool call payloads -- including arguments and outputs -- are written to JSONL. Be aware of this if sessions involve credentials, API keys, or sensitive file contents. The `clens web` dashboard is served only on localhost and gated by a per-launch token. ## Development +This is a Bun workspace monorepo (`packages/cli` + `packages/web`). + ```sh -bun test # 1151 tests across 49 files +bun test # run the test suite bun run typecheck bun run build ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5ead321 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities **privately** — do not open a public +GitHub issue for security problems. + +- **Preferred:** open a [GitHub private security advisory](https://github.com/silouone/clens/security/advisories/new) + (Security → Advisories → "Report a vulnerability"). +- **Alternative:** email **silouane.galinou.dev@gmail.com** with the subject + line `clens security`. + +Please include a description of the issue, reproduction steps, affected +version(s), and any relevant logs or proof-of-concept. We aim to acknowledge +reports within 5 business days and will keep you updated on remediation +progress. Please give us a reasonable window to ship a fix before any public +disclosure. + +## Supported Versions + +cLens is pre-1.0; only the latest released version receives security fixes. + +## Data Handling (important) + +cLens is **local-first**: all capture data stays on your machine, there are no +network calls and no telemetry. However, capture writes the **full JSONL +payload of every hook event** to `.clens/sessions/` — including complete tool +call arguments and outputs. + +**This means secrets can be persisted to disk.** If a session involves +credentials, API keys, tokens, or sensitive file contents, those values are +written verbatim into the local JSONL files. Treat the `.clens/` directory as +sensitive: + +- Keep `.clens/` out of version control (it is gitignored by default). +- Do not share raw session files or exports without reviewing them for secrets. +- Rotate any credential you suspect was captured before sharing a trace. + +This is a deliberate, documented trade-off — cLens captures everything so the +analysis is faithful. There is no inline redaction at capture time. diff --git a/bun.lock b/bun.lock index 4afca70..1df3268 100644 --- a/bun.lock +++ b/bun.lock @@ -3,18 +3,85 @@ "workspaces": { "": { "name": "clens", - "dependencies": { + }, + "packages/cli": { + "name": "clens", + "version": "0.3.0", + "bin": { + "clens": "dist/cli.js", + "clens-hook": "dist/hook.js", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.3", "bun-types": "^1.3.9", + "typescript": "^5.7.0", "undici-types": "^7.18.2", }, + }, + "packages/web": { + "name": "@clens/web", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@kobalte/core": "^0.13.0", + "@solidjs/router": "^0.15.0", + "clens": "workspace:*", + "diff2html": "^3.4.0", + "hono": "^4.0.0", + "lucide-solid": "^0.577.0", + "snarkdown": "^2.0.0", + "solid-js": "^1.9.0", + }, "devDependencies": { - "@biomejs/biome": "^2.4.3", - "@types/bun": "latest", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", "typescript": "^5.7.0", + "vite": "^6.0.0", + "vite-plugin-solid": "^2.10.0", }, }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], @@ -33,16 +100,424 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@clens/web": ["@clens/web@workspace:packages/web"], + + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@fontsource-variable/ibm-plex-sans": ["@fontsource-variable/ibm-plex-sans@5.2.8", "", {}, "sha512-n5PF2iFa0CZT0QYTPzxvZ39opC9LnU0zdoRccoADbs+Dtsd+lbXOZF7RNuIPHcQX1dKjF63sxnRImQIB5eD0Ag=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], + + "@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], + + "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@profoundlogic/hogan": ["@profoundlogic/hogan@3.0.4", "", { "dependencies": { "nopt": "1.0.10" }, "bin": { "hulk": "bin/hulk" } }, "sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], + + "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], + + "@solid-primitives/media": ["@solid-primitives/media@2.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA=="], + + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], + + "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], + + "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], + + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], + + "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="], + + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "clens": ["clens@workspace:packages/cli"], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "diff2html": ["diff2html@3.4.56", "", { "dependencies": { "@profoundlogic/hogan": "^3.0.4", "diff": "^8.0.3" }, "optionalDependencies": { "highlight.js": "11.11.1" } }, "sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + + "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], + + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-solid": ["lucide-solid@0.577.0", "", { "peerDependencies": { "solid-js": "^1.4.7" } }, "sha512-r/rsauBlyNjFlUhXCkD544tOH1GgcFFupw9oP2zZT4BiFkHoO3MTr12QfKBrS5zCRIhktc/qY2tRr925hFlNuQ=="], + + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "nopt": ["nopt@1.0.10", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "./bin/nopt.js" } }, "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + + "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + + "snarkdown": ["snarkdown@2.0.0", "", {}, "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A=="], + + "solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="], + + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + + "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], + + "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.22.0", "", {}, "sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="], + + "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], } } diff --git a/landing/assets/README.md b/landing/assets/README.md new file mode 100644 index 0000000..ca6a2f4 --- /dev/null +++ b/landing/assets/README.md @@ -0,0 +1,72 @@ +# landing/assets — marketing screenshots + +Static image assets for the clens.dev landing page. These files live inside the +landing site root so the deployed Cloudflare Pages bundle is self-contained +(a deployed static site cannot reach `../.github/assets/`). The canonical +capture location is `../.github/assets/`; these are the gate-cleared copies. + +## Screenshot gate (LAND-4) + +> **Screenshot capture must start only after the FE credibility fixes and the +> Work Units cut have landed.** A capture taken before them bakes +> credibility-killers (internal "Fable" codename on Model Breakdown, a +> "Quality Score 0/100" headline, the Work Units surface) into the OG image and +> feature section. + +### Source-fix gate: CLEARED (verified 2026-06-27) + +| Gate | Fix | Evidence (working tree) | +|---|---|---| +| **NUM-6** — internal codename "Fable" | Raw model ids humanized for display | `packages/web/src/client/lib/format.ts` — `humanizeModelId` maps `claude-fable-5` → display name; used by `pages/UsagePage.tsx` Model Breakdown. | +| **NUM-9** — "Quality Score" headline | Quality Score **cut** for launch (DECISIONS D3) | `packages/web/src/server/routes/analytics.ts` + `pages/InsightsPage.tsx` — headline, `agent_quality_score` field, and dead second implementation removed. | +| **FE-1** — Work Units | Work Units surface **cut** | No live source references under `packages/web/src` / `packages/cli/src` (only historical `.clens` session logs). | + +### Per-image visual verification (each PNG opened and inspected) + +The source fix landing is necessary but not sufficient — what renders depends on +the dogfooded session. Each candidate was opened and checked against the gate: + +| File | Fable? | Quality Score? | Work Units? | Verdict | +|---|---|---|---|---| +| `dashboard-detail.png` | no (model = Opus 4.8) | no headline | no | **clean — ship** | +| `dashboard-insights.png` | no | no | no | **clean — ship** | +| `dashboard-sessions.png` | no | no | no | ship with caveat ¹ | +| `dashboard-sessions-light.png` | no | no | no | ship with caveat ¹ | +| `dashboard-mobile.png` | no | no | no | ship with caveat ¹ | +| ~~`dashboard-usage.png`~~ | **YES — "CLAUDE FABLE 5"** | no | no | **REMOVED — fails NUM-6** ² | + +¹ **Caveat (outside LAND-4's three gates, do not bury):** the SESSIONS header on +all three sessions shots shows a cumulative `SPAN 22051h` (~22k h) KPI — the same +credibility-killer class landing.md §1.5 flags ("TOTAL TIME 22245h"). It is a +time-aggregation issue (NUM-time), not one of LAND-4's three gates, so these are +not blocked here — but they should NOT be certified fully credibility-clean for +the hero/OG until that KPI is fixed or excluded from the crop. Surface to Silou. + +² **`dashboard-usage.png` removed.** NUM-6 humanizes the id but the output still +contains the word "Fable" ("Claude Fable 5" in Model Breakdown), and the honesty +gate (_positioning-synthesis §4 / §3.7) bars the codename "Fable" from any public +**screenshot**, humanized or not. The dogfooded session used the `claude-fable-5` +model. To use a usage/model-breakdown shot, re-capture from a session that did +not touch that model (or after a public-name relabel lands). + +## Files + alt text (gate-cleared set) + +Reference these from `index.html` with the alt text below (honesty-safe: cost is +"estimated", no overclaims). + +| File | Used as | Alt text | +|---|---|---| +| `dashboard-detail.png` | Hero / feature: decision trace | "cLens session detail — agent timeline with backtracks, decision points, and reasoning trace; cost shown is estimated." | +| `dashboard-insights.png` | Feature: cross-session analytics | "cLens insights — cross-session analytics including backtrack rate, edit survival, and plan-drift." | +| `dashboard-sessions.png` | Sessions list (dark) | "cLens dashboard session list — local Claude Code sessions with status, span, events, and agents." | +| `dashboard-sessions-light.png` | Light-mode variant | "cLens dashboard session list in light mode." | +| `dashboard-mobile.png` | Responsive proof | "cLens dashboard on a mobile viewport, reflowed to a single column." | + +## Re-capture / refresh + +1. Confirm the source-fix gate table above still holds in the working tree. +2. Run the dashboard against a real dogfooded session — for the usage/model + shot, one that did **not** use the `claude-fable-5` model. +3. Capture into `../.github/assets/` (canonical), then copy the gate-cleared + files here. Keep filenames identical so `index.html` references stay valid. +4. Re-open each PNG and re-run the visual verification table before shipping. diff --git a/landing/assets/dashboard-detail.png b/landing/assets/dashboard-detail.png new file mode 100644 index 0000000..0bcfd1d Binary files /dev/null and b/landing/assets/dashboard-detail.png differ diff --git a/landing/assets/dashboard-insights.png b/landing/assets/dashboard-insights.png new file mode 100644 index 0000000..afd8449 Binary files /dev/null and b/landing/assets/dashboard-insights.png differ diff --git a/landing/assets/dashboard-mobile.png b/landing/assets/dashboard-mobile.png new file mode 100644 index 0000000..17cb5eb Binary files /dev/null and b/landing/assets/dashboard-mobile.png differ diff --git a/landing/assets/dashboard-sessions-light.png b/landing/assets/dashboard-sessions-light.png new file mode 100644 index 0000000..a296f90 Binary files /dev/null and b/landing/assets/dashboard-sessions-light.png differ diff --git a/landing/assets/dashboard-sessions.png b/landing/assets/dashboard-sessions.png new file mode 100644 index 0000000..3246fa4 Binary files /dev/null and b/landing/assets/dashboard-sessions.png differ diff --git a/landing/index.html b/landing/index.html new file mode 100644 index 0000000..d3449e0 --- /dev/null +++ b/landing/index.html @@ -0,0 +1,644 @@ + + + + + + cLens — See how your coding agent actually reasoned + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ Local-first · Claude Code · MIT +

See how your coding agent actually reasoned.

+

Local-first observability for Claude Code. Zero instrumentation, never phones home — cLens reconstructs any session from the JSONL Claude Code already writes.

+ + + +
+ $ npm i -g clens && clens init + +
+ +

clens is the public package name — npm publish is pending; verify it installs before relying on it.

+ +
+
+ + clens — session detail +
+ cLens session detail — agent timeline with backtracks, decision points, and reasoning trace. +
+
+
+ + +
+
+

What it is

+
+ MIT + 100% LOCAL + ZERO NETWORK + ~2ms / EVENT + 20+ EXTRACTORS + CLI · TUI · DASHBOARD +
+

+ cLens reads the JSONL Claude Code already writes via native hooks, stores everything in a local + .clens/ folder in your repo, and distills it offline. Nothing is uploaded, ever. +

+
+
+ + +
+
+
+

Why cLens

+
+
+
+
+

How, not just how much

+

decision trace

+

Other tools meter cost and tokens. cLens reconstructs the decision trace — backtracks, decision points, edit-chains, and plan-drift.

+
+
+

100% local

+

~2ms / event

+

No SDK to wire up, no collector, no server, no account. Native hooks append events to flat JSONL. Your traces never leave your machine.

+
+
+

Honest by design

+

estimated, labeled

+

Cost is labeled estimated, decisions are labeled structural, outcomes by completion + files touched. cLens never invents precision it doesn't have.

+
+
+
+
+ + +
+
+
+

The transcript tells you what your agent typed. Not why.

+
+
+

+ When a Claude Code agent runs 40 minutes, edits a dozen files, backtracks twice and spawns sub-agents, + the chat log is a wall of text. You can't see where it changed its mind, which reasoning produced which + edit, or where it drifted from the plan. Production LLM-observability tools don't help — they're built to + meter tokens in deployed apps, not to reconstruct a coding session. So you scroll, and guess. +

+
+
+ + +
+
+
+

What it surfaces

+
+
+
+
+
+

Decision trace

+

See where it changed course.

+

Backtracks and structural decision points laid out on a timeline, traced to the reasoning text behind them. cLens surfaces where the run pivoted — it doesn't judge it.

+
+
+ cLens dashboard session list — local Claude Code sessions with duration, tool counts, and estimated cost. +
+
+ +
+
+

Edit chains · plan drift

+

Connect thinking to code.

+

Each file change linked back to the thinking block that produced it — including edits the agent later abandoned. Point it at your spec and it diffs intended vs. actual.

+
+
+ cLens session detail — edit chains binding reasoning blocks to the file edits they produced. +
+
+ +
+
+

Multi-agent · cross-session

+

Map the team, across every run.

+

Communication graph and per-agent metrics when a session spawns sub-agents, plus cross-session analytics over your whole history. Cost shown is labeled estimated. (Multi-agent collaboration is a Team-tier feature.)

+
+
+ cLens insights — cross-session analytics including plan-drift scatter. +
+
+
+
+
+ + +
+
+
+

Your code never leaves your machine.

+
+
+

+ cLens has no server, no cloud, no account, no telemetry. A compiled hook binary appends events to flat + JSONL in your repo at ~2ms per event. Full tool-call payloads are written locally, so you own the data — + and nothing is ever transmitted. MIT licensed: read the capture path yourself. +

+ +
+
+ + +
+
+
+

Get started in two commands

+
+
+
+
+

01

+ npm i -g clens +

Install the CLI.

+
+
+

02

+ clens init +

Register the Claude Code hooks — one time, per repo.

+
+
+

03

+ clens +

Open the local dashboard, or run clens distill / clens what --last in the terminal.

+
+
+
+
$ npm i -g clens
+$ clens init
+# use Claude Code normally, then…
+$ clens distill --last && clens what --last
+
+ +

clens is the public package name — npm publish is pending; verify it installs before relying on it.

+
+
+ + +
+
+
+

Pricing

+
+
+
+
+

Free

+

$0

+

forever · MIT

+

Try it; capture sessions and read the raw timeline.

+
    +
  • Capture (hooks, JSONL, ~2ms)
  • +
  • Local raw viewer / clens what
  • +
  • Full distill (20+ extractors)
  • +
  • Web dashboard + analytics
  • +
  • Multi-agent + cloud sync
  • +
+ Get the binary +
+ + + +
+

Team

+

$24 / seat / mo

+

$240 / seat / yr · 3-seat min

+

Teams running multi-agent workflows together.

+
    +
  • Everything in Individual
  • +
  • Multi-agent comm-graph + team metrics
  • +
  • Cloud sync + shared sessions
  • +
  • Collaboration (future)
  • +
+ Notify me +
+
+

+ Open-core: capture + raw viewing are free and MIT forever. Individual is a license key unlocking the full + local suite (zero backend). Team adds the cloud-sync/collab backend — that's what the ~2× seat premium funds. + Annual ≈ 17% off. Pricing is per seat, never per trace. +

+
+
+ + +
+
+
+

FAQ

+
+
+
+
+ Does cLens send my code anywhere? +

No. Zero network; everything stays in a local .clens/ folder in your repo.

+
+
+ How is this different from ccusage? +

ccusage meters cost and tokens from the same local JSONL. cLens adds the decision analysis — backtracks, edit-chains, plan-drift — what it did, not just what it cost.

+
+
+ How is this different from Langfuse / Phoenix / LangSmith? +

Those instrument production LLM apps and ship traces to a server. cLens is zero-setup, local, and Claude-Code-specific.

+
+
+ Are the cost numbers exact? +

No — they're estimated (cache reads dominate token accounting). We label them as such throughout the UI.

+
+
+ Is it really free / open source? +

Yes, MIT. Capture and raw viewing are free forever; paid tiers unlock the full analysis suite and team/cloud features.

+
+
+ Do you support OpenAI / other agents? +

Today: Claude Code. OpenTelemetry GenAI export lets you interoperate with other tooling.

+
+
+
+
+ + +
+
+

See how your agent actually reasoned.

+

Local-first, open source, two commands to start.

+
+ $ npm i -g clens && clens init + +
+ +
+
+
+ + + + + + + diff --git a/package.json b/package.json index 5d01232..72bb88b 100644 --- a/package.json +++ b/package.json @@ -1,74 +1,21 @@ { - "name": "@silou/clens", - "version": "0.2.1", - "type": "module", - "description": "cLens — Claude Lens. Capture and analyze AI agent sessions. Local-first, zero-dependency observability for Claude Code.", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/silouone/clens.git" - }, - "homepage": "https://github.com/silouone/clens", - "bugs": { - "url": "https://github.com/silouone/clens/issues" - }, - "keywords": [ - "claude", - "claude-code", - "claude-code-hooks", - "claude-session", - "ai-agent", - "ai-agent-observability", - "agent-tracing", - "observability", - "session-capture", - "session-analysis", - "session-replay", - "backtrack-detection", - "decision-analysis", - "tool-call-tracing", - "transcript-analysis", - "edit-chains", - "plan-drift", - "hooks", - "jsonl", - "local-first", - "developer-tools", - "debugging", - "cli", - "opentelemetry", - "otlp" - ], - "engines": { - "bun": ">=1.0.0" - }, - "author": "Silouane Galinou", - "main": "./dist/cli.js", - "files": [ - "dist/", - "agentic/" - ], - "bin": { - "clens": "dist/cli.js", - "clens-hook": "dist/hook.js" - }, + "name": "clens-monorepo", + "private": true, + "workspaces": ["packages/*"], "scripts": { - "build": "bun build src/cli.ts --outdir dist --target bun --entry-naming [name].js && bun build src/hook.ts --outdir dist --target bun --entry-naming [name].js", - "build:bin": "bun build --compile src/hook.ts --outfile bin/clens-hook && bun build --compile src/cli.ts --outfile bin/clens", - "typecheck": "tsc --noEmit", - "test": "bun test", - "bench": "bun run test/bench.ts", - "lint": "bunx biome check src/ test/", - "lint:fix": "bunx biome check --write src/ test/", - "format": "bunx biome format --write src/ test/", - "prepublishOnly": "bun run typecheck && bun test && bun run build", - "plugin:dev": "echo 'claude --plugin-dir ./agentic'" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.3", - "@types/bun": "latest", - "bun-types": "^1.3.9", - "typescript": "^5.7.0", - "undici-types": "^7.18.2" + "build:cli": "bun run --filter clens build", + "build:web": "bun run --filter @clens/web build", + "build": "bun run build:cli && bun run build:web", + "test:cli": "bun run --filter clens test", + "test:web": "bun run --filter @clens/web test:api", + "test": "bun run test:cli && bun run test:web", + "lint:cli": "bun run --filter clens lint", + "lint:web": "bun run --filter @clens/web lint", + "lint": "bun run lint:cli && bun run lint:web", + "typecheck": "bun run --filter clens typecheck && bun run --filter @clens/web typecheck", + "dev:api": "bun run --filter @clens/web dev:api", + "dev:api:global": "bun run --filter @clens/web dev:api:global", + "dev:web": "bun run --filter @clens/web dev:web", + "dev": "bun run dev:api:global & bun run dev:web" } } diff --git a/agentic/.claude-plugin/plugin.json b/packages/cli/agentic/.claude-plugin/plugin.json similarity index 92% rename from agentic/.claude-plugin/plugin.json rename to packages/cli/agentic/.claude-plugin/plugin.json index 662d483..29ed5ac 100644 --- a/agentic/.claude-plugin/plugin.json +++ b/packages/cli/agentic/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "clens", - "version": "0.2.1", + "version": "0.3.0", "description": "Session analysis tools for cLens — analyze distilled agent sessions, compare performance, and deep-dive into backtracking patterns", "author": { "name": "Silouane Galinou" diff --git a/agentic/agents/session-analyst.md b/packages/cli/agentic/agents/session-analyst.md similarity index 100% rename from agentic/agents/session-analyst.md rename to packages/cli/agentic/agents/session-analyst.md diff --git a/agentic/commands/backtrack-analysis.md b/packages/cli/agentic/commands/backtrack-analysis.md similarity index 100% rename from agentic/commands/backtrack-analysis.md rename to packages/cli/agentic/commands/backtrack-analysis.md diff --git a/agentic/commands/session-compare.md b/packages/cli/agentic/commands/session-compare.md similarity index 100% rename from agentic/commands/session-compare.md rename to packages/cli/agentic/commands/session-compare.md diff --git a/agentic/commands/session-report.md b/packages/cli/agentic/commands/session-report.md similarity index 100% rename from agentic/commands/session-report.md rename to packages/cli/agentic/commands/session-report.md diff --git a/agentic/hooks/hooks.json b/packages/cli/agentic/hooks/hooks.json similarity index 93% rename from agentic/hooks/hooks.json rename to packages/cli/agentic/hooks/hooks.json index 5e0d9c6..3137fcd 100644 --- a/agentic/hooks/hooks.json +++ b/packages/cli/agentic/hooks/hooks.json @@ -71,6 +71,13 @@ { "hooks": [{ "type": "command", "command": "clens-hook WorktreeRemove" }] } + ], + "InstructionsLoaded": [ + { + "hooks": [ + { "type": "command", "command": "clens-hook InstructionsLoaded" } + ] + } ] } } diff --git a/agentic/skills/backtrack-analysis/SKILL.md b/packages/cli/agentic/skills/backtrack-analysis/SKILL.md similarity index 100% rename from agentic/skills/backtrack-analysis/SKILL.md rename to packages/cli/agentic/skills/backtrack-analysis/SKILL.md diff --git a/agentic/skills/journey-report/SKILL.md b/packages/cli/agentic/skills/journey-report/SKILL.md similarity index 100% rename from agentic/skills/journey-report/SKILL.md rename to packages/cli/agentic/skills/journey-report/SKILL.md diff --git a/agentic/skills/session-analysis/SKILL.md b/packages/cli/agentic/skills/session-analysis/SKILL.md similarity index 100% rename from agentic/skills/session-analysis/SKILL.md rename to packages/cli/agentic/skills/session-analysis/SKILL.md diff --git a/agentic/skills/session-analysis/distill-schema.md b/packages/cli/agentic/skills/session-analysis/distill-schema.md similarity index 100% rename from agentic/skills/session-analysis/distill-schema.md rename to packages/cli/agentic/skills/session-analysis/distill-schema.md diff --git a/agentic/skills/session-analysis/interpretation-guide.md b/packages/cli/agentic/skills/session-analysis/interpretation-guide.md similarity index 100% rename from agentic/skills/session-analysis/interpretation-guide.md rename to packages/cli/agentic/skills/session-analysis/interpretation-guide.md diff --git a/agentic/skills/session-compare/SKILL.md b/packages/cli/agentic/skills/session-compare/SKILL.md similarity index 100% rename from agentic/skills/session-compare/SKILL.md rename to packages/cli/agentic/skills/session-compare/SKILL.md diff --git a/agentic/skills/session-report/SKILL.md b/packages/cli/agentic/skills/session-report/SKILL.md similarity index 100% rename from agentic/skills/session-report/SKILL.md rename to packages/cli/agentic/skills/session-report/SKILL.md diff --git a/packages/cli/build.ts b/packages/cli/build.ts new file mode 100644 index 0000000..e2594cb --- /dev/null +++ b/packages/cli/build.ts @@ -0,0 +1,63 @@ +/** + * Build the published `clens` CLI artifact. + * + * Produces a fully self-contained npm package that needs no workspace packages + * at install time: + * 1. Bundles `cli.ts` and `hook.ts` with bun. The web server (Hono app) is + * pulled in inline via the `@clens/web/server` dynamic import in + * commands/web.ts, so it ships inside dist/cli.js — it is NOT a runtime + * dependency. + * 2. Builds the SolidJS web client (vite) and copies the static bundle into + * dist/web/ so `clens web` can serve the dashboard in production mode. + * + * Run via `bun run build` (packages/cli) or `bun run build:cli` (repo root). + */ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cliDir = dirname(fileURLToPath(import.meta.url)); +const webDir = resolve(cliDir, "../web"); +const distDir = resolve(cliDir, "dist"); +const webDist = resolve(distDir, "web"); + +// ── 1. Bundle CLI + hook (web server bundled inline via web.ts import) ── +// Spawn the exact same `bun build` invocations the package historically used so +// the inlining behaviour stays identical to what was verified. +const bundle = (entry: string): void => { + const proc = Bun.spawnSync( + ["bun", "build", entry, "--outdir", "dist", "--target", "bun", "--entry-naming", "[name].js"], + { cwd: cliDir, stdout: "inherit", stderr: "inherit" }, + ); + if (proc.exitCode !== 0) { + throw new Error(`bun build failed for ${entry} (exit ${proc.exitCode})`); + } +}; + +bundle("src/cli.ts"); +bundle("src/hook.ts"); + +// ── 2. Build the web client (vite) ── +const web = Bun.spawnSync(["bun", "run", "build"], { + cwd: webDir, + stdout: "inherit", + stderr: "inherit", +}); +if (web.exitCode !== 0) { + throw new Error(`web client build failed (exit ${web.exitCode})`); +} + +// ── 3. Copy the static client bundle into the CLI artifact ── +const srcWebDist = resolve(webDir, "dist"); +if (!existsSync(resolve(srcWebDist, "index.html"))) { + throw new Error(`web client build produced no index.html at ${srcWebDist}`); +} +rmSync(webDist, { recursive: true, force: true }); +mkdirSync(webDist, { recursive: true }); +cpSync(srcWebDist, webDist, { + recursive: true, + // Drop tsc's build cache — it is large and not part of the served bundle. + filter: (src) => !src.endsWith(".tsbuildinfo"), +}); + +console.log(`Built CLI artifact -> ${distDir} (client -> ${webDist})`); diff --git a/packages/cli/bunfig.toml b/packages/cli/bunfig.toml new file mode 100644 index 0000000..1a80b3b --- /dev/null +++ b/packages/cli/bunfig.toml @@ -0,0 +1,4 @@ +[test] +coverage = true +coverageReporter = ["text", "lcov"] +coverageThreshold = { line = 75, function = 82 } diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..be1e35e --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,78 @@ +{ + "name": "clens", + "version": "0.3.0", + "type": "module", + "description": "cLens — Claude Lens. Capture and analyze AI agent sessions. Local-first, zero-dependency observability for Claude Code.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/silouone/clens.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/silouone/clens", + "bugs": { + "url": "https://github.com/silouone/clens/issues" + }, + "keywords": [ + "claude", + "claude-code", + "claude-code-hooks", + "claude-session", + "ai-agent", + "ai-agent-observability", + "agent-tracing", + "observability", + "session-capture", + "session-analysis", + "session-replay", + "backtrack-detection", + "decision-analysis", + "tool-call-tracing", + "transcript-analysis", + "edit-chains", + "plan-drift", + "hooks", + "jsonl", + "local-first", + "developer-tools", + "debugging", + "cli", + "opentelemetry", + "otlp" + ], + "engines": { + "bun": ">=1.0.0" + }, + "author": "Silouane Galinou", + "main": "./dist/cli.js", + "types": "./src/types/index.ts", + "files": [ + "dist/*.js", + "dist/web/", + "src/types/", + "agentic/" + ], + "bin": { + "clens": "dist/cli.js", + "clens-hook": "dist/hook.js" + }, + "scripts": { + "build": "bun run build.ts", + "build:bin": "bun build --compile src/hook.ts --outfile bin/clens-hook && bun build --compile src/cli.ts --outfile bin/clens", + "typecheck": "tsc --noEmit", + "test": "bun test", + "bench": "bun run test/bench.ts", + "lint": "bunx biome check src/ test/", + "lint:fix": "bunx biome check --write src/ test/", + "format": "bunx biome format --write src/ test/", + "pack:check": "npm pack --dry-run 2>&1 | grep -q 'src/types/index.ts' && echo 'OK: type declarations present in tarball' || (echo 'FAIL: type declarations missing from tarball' && exit 1)", + "smoke:publish": "bun run build:bin && CLENS_PUBLISH_SMOKE=1 bun test test/e2e/publish-smoke.test.ts", + "prepublishOnly": "bun run typecheck && bun test && bun run build && bun run pack:check && bun run smoke:publish" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.3", + "bun-types": "^1.3.9", + "typescript": "^5.7.0", + "undici-types": "^7.18.2" + } +} diff --git a/src/capture/context.ts b/packages/cli/src/capture/context.ts similarity index 80% rename from src/capture/context.ts rename to packages/cli/src/capture/context.ts index 6eba7d0..7954f78 100644 --- a/src/capture/context.ts +++ b/packages/cli/src/capture/context.ts @@ -1,4 +1,5 @@ -import type { SessionStartContext } from "../types"; +import type { SessionStartContext, SettingsSnapshot } from "../types"; +import { resolveSettingsSnapshot } from "./settings"; export const enrichSessionStart = (input: Record): SessionStartContext => { const cwd = (input.cwd as string) || process.cwd(); @@ -9,6 +10,8 @@ export const enrichSessionStart = (input: Record): SessionStart const validSources = new Set(["startup", "resume", "clear", "compact"]); const validTriggers = new Set(["manual", "auto"]); + const settings_snapshot = getSettingsSnapshot(projectDir); + return { project_dir: projectDir, cwd, @@ -27,9 +30,23 @@ export const enrichSessionStart = (input: Record): SessionStart trigger: trigger && validTriggers.has(trigger) ? (trigger as "manual" | "auto") : undefined, + ...(settings_snapshot ? { settings_snapshot } : {}), }; }; +/** + * Tier-B settings snapshot (CFG-3), captured once at SessionStart only. Wrapped so + * a resolver bug can never break capture (defense-in-depth — the resolver already + * returns undefined on read failure). NEVER call this on a hot-path event. + */ +const getSettingsSnapshot = (projectDir: string): SettingsSnapshot | undefined => { + try { + return resolveSettingsSnapshot(projectDir, "session_start"); + } catch { + return undefined; + } +}; + const getGitBranch = (cwd: string): string | null => { return runGitSync(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]); }; diff --git a/src/capture/links.ts b/packages/cli/src/capture/links.ts similarity index 90% rename from src/capture/links.ts rename to packages/cli/src/capture/links.ts index 18a7b85..a7721d9 100644 --- a/src/capture/links.ts +++ b/packages/cli/src/capture/links.ts @@ -71,7 +71,7 @@ export const extractLinkEvent = (event: string, input: Record): return { t, type: "teammate_idle", - teammate: (input.agent_name as string) || (input.agent_id as string) || "", + teammate: (input.teammate_name as string) || (input.agent_name as string) || (input.agent_id as string) || "", session_id: sid, team: input.team_name as string | undefined, } satisfies TeammateIdleLink; @@ -83,7 +83,7 @@ export const extractLinkEvent = (event: string, input: Record): task_id: (input.task_id as string) || "", agent: (input.agent_name as string) || sid, session_id: sid, - subject: input.subject as string | undefined, + subject: (input.task_subject as string) || (input.subject as string) || undefined, } satisfies TaskCompleteLink; } case "ConfigChange": { @@ -139,11 +139,16 @@ export const extractLinkEvent = (event: string, input: Record): session_id: sid, agent: sid, subject: toolInput.subject as string | undefined, + description: toolInput.description as string | undefined, + active_form: toolInput.activeForm as string | undefined, } satisfies TaskLink; } if (toolName === "TaskUpdate") { const action = toolInput.status ? ("status_change" as const) : ("assign" as const); + const blockedBy = Array.isArray(toolInput.addBlockedBy) + ? (toolInput.addBlockedBy as readonly string[]) + : undefined; return { t, type: "task", @@ -153,6 +158,7 @@ export const extractLinkEvent = (event: string, input: Record): agent: sid, owner: toolInput.owner as string | undefined, status: toolInput.status as string | undefined, + ...(blockedBy && blockedBy.length > 0 ? { blocked_by: blockedBy } : {}), } satisfies TaskLink; } diff --git a/src/capture/proxy.ts b/packages/cli/src/capture/proxy.ts similarity index 100% rename from src/capture/proxy.ts rename to packages/cli/src/capture/proxy.ts diff --git a/packages/cli/src/capture/settings.ts b/packages/cli/src/capture/settings.ts new file mode 100644 index 0000000..32bea32 --- /dev/null +++ b/packages/cli/src/capture/settings.ts @@ -0,0 +1,174 @@ +import { existsSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import type { ClaudeMdInEffect, SettingsScope, SettingsSnapshot } from "../types"; + +/** + * Tier-B / tier-C settings resolver (CFG-3). Snapshots the resolved `settings.json` + * config (output style, statusline, plugins, default permission mode, configured + * hook names) across scopes. Designed to run ONCE inside `enrichSessionStart` + * (SessionStart / InstructionsLoaded+session_start) — NEVER on the hot path. + * + * Honesty + safety contract: + * - Every read is wrapped so a missing/malformed file resolves to `undefined`, + * never throwing (the capture layer must never crash — `hook.ts` budget/contract). + * - Values are merged with higher scope winning per key; provenance scope is + * recorded for `output_style`. + * - Only known-safe, low-noise keys are lifted (no command bodies, no PII paths — + * statusline keeps a basename only). + */ + +const homeDir = (): string => process.env.HOME || process.env.USERPROFILE || ""; + +/** Scopes in lowest→highest precedence order, so a later read overrides earlier keys. */ +const settingsFilesLowToHigh = (projectDir: string): ReadonlyArray<{ + readonly scope: SettingsScope; + readonly path: string; +}> => { + const home = homeDir(); + const managed = process.platform === "darwin" + ? "/Library/Application Support/ClaudeCode/managed-settings.json" + : "/etc/claude-code/managed-settings.json"; + return [ + { scope: "user", path: home ? join(home, ".claude", "settings.json") : "" }, + { scope: "project", path: join(projectDir, ".claude", "settings.json") }, + { scope: "local", path: join(projectDir, ".claude", "settings.local.json") }, + { scope: "managed", path: managed }, + ]; +}; + +/** Read + parse a JSON settings file; any failure (missing/malformed) ⇒ undefined. */ +const readSettingsFile = (path: string): Record | undefined => { + if (!path) return undefined; + try { + if (!existsSync(path)) return undefined; + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; + } catch { + return undefined; + } +}; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +/** Plugin keys whose value is exactly `true` (e.g. `superwhisper@superwhisper`). */ +const enabledPluginKeys = (raw: unknown): readonly string[] | undefined => { + if (!raw || typeof raw !== "object") return undefined; + const keys = Object.entries(raw as Record) + .filter(([, v]) => v === true) + .map(([k]) => k) + .sort(); + return keys.length > 0 ? keys : undefined; +}; + +/** Configured hook event names (names only — never the command bodies; privacy + size). */ +const hookEventNames = (raw: unknown): readonly string[] | undefined => { + if (!raw || typeof raw !== "object") return undefined; + const names = Object.keys(raw as Record).sort(); + return names.length > 0 ? names : undefined; +}; + +const statusLineOf = ( + raw: unknown, +): { readonly type: string; readonly command_name?: string } | undefined => { + if (!raw || typeof raw !== "object") return undefined; + const obj = raw as Record; + const type = asString(obj.type); + if (!type) return undefined; + const command = asString(obj.command); + return command ? { type, command_name: basename(command) } : { type }; +}; + +const permissionsDefaultMode = (raw: unknown): string | undefined => { + if (!raw || typeof raw !== "object") return undefined; + return asString((raw as Record).defaultMode); +}; + +/** + * Resolve a settings snapshot for a project. Returns `undefined` only if no scope + * yielded a single usable field (so the snapshot is never an empty husk). Never throws. + * + * @param source labels provenance: `"session_start"` (tier B, point-in-time) vs + * `"current"` (tier C distill-time fallback, may have drifted). + */ +export const resolveSettingsSnapshot = ( + projectDir: string, + source: SettingsSnapshot["settings_source"] = "session_start", +): SettingsSnapshot | undefined => { + // Scalars: higher scope wins (last write). Collections (hooks, plugins): union + // across scopes per the spec's "array settings concatenate + dedupe" rule. + let output_style: string | undefined; + let output_style_scope: SettingsScope | undefined; + let status_line: SettingsSnapshot["status_line"]; + let permission_default_mode: string | undefined; + const pluginsAcc = new Set(); + const hooksAcc = new Set(); + + for (const { scope, path } of settingsFilesLowToHigh(projectDir)) { + const settings = readSettingsFile(path); + if (!settings) continue; + + const style = asString(settings.outputStyle); + if (style) { + output_style = style; + output_style_scope = scope; + } + const sl = statusLineOf(settings.statusLine); + if (sl) status_line = sl; + for (const p of enabledPluginKeys(settings.enabledPlugins) ?? []) pluginsAcc.add(p); + const defMode = permissionsDefaultMode(settings.permissions); + if (defMode) permission_default_mode = defMode; + for (const h of hookEventNames(settings.hooks) ?? []) hooksAcc.add(h); + } + + const plugins_enabled = pluginsAcc.size > 0 ? [...pluginsAcc].sort() : undefined; + const hooks_configured = hooksAcc.size > 0 ? [...hooksAcc].sort() : undefined; + + const hasAny = + output_style !== undefined || + status_line !== undefined || + plugins_enabled !== undefined || + permission_default_mode !== undefined || + hooks_configured !== undefined; + if (!hasAny) return undefined; + + return { + settings_source: source, + captured_at: Date.now(), + ...(output_style !== undefined ? { output_style } : {}), + ...(output_style_scope !== undefined ? { output_style_scope } : {}), + ...(status_line !== undefined ? { status_line } : {}), + ...(plugins_enabled !== undefined ? { plugins_enabled } : {}), + ...(permission_default_mode !== undefined ? { permission_default_mode } : {}), + ...(hooks_configured !== undefined ? { hooks_configured } : {}), + }; +}; + +/** + * Inferred CLAUDE.md fallback (CFG-5). When no `InstructionsLoaded` events were + * captured (the installed binary may predate the event — BLOCKED-VERIFY: zero such + * events exist today), record fact-of-existence of the project and user CLAUDE.md + * files. Content is intentionally NOT read (out of scope v1). Every entry is + * labeled `memory_type: "inferred"` so surfaces can disclose the lower confidence. + * Never throws; returns an empty array when neither file exists. + */ +export const inferClaudeMd = (projectDir: string): readonly ClaudeMdInEffect[] => { + const home = homeDir(); + const candidates: ReadonlyArray = [ + join(projectDir, "CLAUDE.md"), + join(projectDir, ".claude", "CLAUDE.md"), + home ? join(home, ".claude", "CLAUDE.md") : "", + ]; + const seen = new Set(); + const found: ClaudeMdInEffect[] = []; + for (const path of candidates) { + if (!path || seen.has(path)) continue; + seen.add(path); + try { + if (existsSync(path)) found.push({ file_path: path, memory_type: "inferred" }); + } catch { + // fact-of-existence probe must never break capture/distill + } + } + return found; +}; diff --git a/src/cli.ts b/packages/cli/src/cli.ts similarity index 51% rename from src/cli.ts rename to packages/cli/src/cli.ts index 38b3dc8..0f2e5b3 100755 --- a/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,7 +14,41 @@ type CommandDef = { readonly handler: (ctx: CommandContext) => Promise; }; -// --- 9 commands --- +// --- report argument parsing --- + +const REPORT_SUBCOMMANDS: ReadonlySet = new Set(["backtracks", "drift", "reasoning"]); +// Subcommands that consume a trailing positional argument (e.g. `drift `). +const REPORT_SUBCOMMANDS_WITH_ARG: ReadonlySet = new Set(["drift"]); + +type ReportArgs = { + readonly subcommand?: string; + readonly subcommandArg?: string; + readonly sessionInput?: string; +}; + +/** + * Parse the positionals that follow the `report` command word into a subcommand, + * an optional subcommand argument (e.g. the drift spec path), and the session-id + * input. Order-independent: the subcommand may appear before OR after the session + * id, and `--last` makes the session id implicit (so every remaining operand is an + * argument candidate). Pure function — easy to unit-test in isolation. + */ +const parseReportArgs = (afterReport: readonly string[], last: boolean): ReportArgs => { + const subIdx = afterReport.findIndex((a) => REPORT_SUBCOMMANDS.has(a)); + const subcommand = subIdx >= 0 ? afterReport[subIdx] : undefined; + + // Non-subcommand positionals, preserving their original order. + const operands = afterReport.filter((a) => !REPORT_SUBCOMMANDS.has(a)); + // Without --last the first operand is the session id; with --last it is implicit. + const sessionInput = last ? undefined : operands[0]; + const argOperands = last ? operands : operands.slice(1); + const subcommandArg = + subcommand && REPORT_SUBCOMMANDS_WITH_ARG.has(subcommand) ? argOperands[0] : undefined; + + return { subcommand, subcommandArg, sessionInput }; +}; + +// --- 10 commands --- const commands: Readonly> = { init: { @@ -28,53 +62,45 @@ const commands: Readonly> = { description: "List captured sessions", handler: async (ctx) => { const { listCommand } = await import("./commands/list"); - await listCommand({ projectDir: ctx.projectDir, json: ctx.flags.json }); + await listCommand({ projectDir: ctx.projectDir, json: ctx.flags.json, global: ctx.flags.global }); }, }, distill: { description: "Extract insights from session data", handler: async (ctx) => { - const { distillCommand } = await import("./commands/distill"); + const pricingTier = ctx.flags.pricing as import("./types").PricingTier | undefined; + + // --global: distill every session across all registered projects. + if (ctx.flags.global) { + const { distillAllGlobal } = await import("./commands/distill"); + await distillAllGlobal({ + deep: ctx.flags.deep, + pricingTier, + force: ctx.flags.force, + }); + return; + } + // --all: distill every session in the current project dir. if (ctx.flags.all) { - const { listSessions } = await import("./session/read"); - const sessions = listSessions(ctx.projectDir); - if (sessions.length === 0) { - console.log("No sessions found."); - return; - } - console.log(`Distilling ${sessions.length} session(s)...`); - const results = await sessions.reduce>( - async (accP, session, idx) => { - const acc = await accP; - const progress = `[${idx + 1}/${sessions.length}]`; - console.log(`${progress} ${session.session_id.slice(0, 8)}...`); - try { - await distillCommand({ - sessionId: session.session_id, - projectDir: ctx.projectDir, - deep: ctx.flags.deep, - json: false, - }); - return [...acc, session.session_id]; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(` Error: ${msg}`); - return acc; - } - }, - Promise.resolve([]), - ); - console.log(`\nDistilled ${results.length}/${sessions.length} session(s).`); + const { distillAllInDir } = await import("./commands/distill"); + await distillAllInDir({ + projectDir: ctx.projectDir, + deep: ctx.flags.deep, + pricingTier, + force: ctx.flags.force, + }); return; } + const { distillCommand } = await import("./commands/distill"); const { resolveSessionId } = await import("./commands/shared"); await distillCommand({ sessionId: resolveSessionId(ctx.positional[1], ctx.flags.last, ctx.projectDir), projectDir: ctx.projectDir, deep: ctx.flags.deep, json: ctx.flags.json, + pricingTier, }); }, }, @@ -84,19 +110,11 @@ const commands: Readonly> = { const { resolveSessionId } = await import("./commands/shared"); const { reportCommand } = await import("./commands/report"); - // Detect subcommand: first positional after "report" that isn't a session ID - const subcommands = new Set(["backtracks", "drift", "reasoning"]); - const afterReport = ctx.positional.slice(1); - const subIdx = afterReport.findIndex((a) => subcommands.has(a)); - const subcommand = subIdx >= 0 ? afterReport[subIdx] : undefined; - - // Session ID: positional that isn't the subcommand - const sessionPositional = afterReport.filter((a) => !subcommands.has(a))[0]; - - // Subcommand arg (e.g. spec path for drift): positional after the subcommand - const subcommandArg = subcommand && subIdx + 1 < afterReport.length - ? afterReport.filter((a) => !subcommands.has(a) && a !== sessionPositional)[0] - : undefined; + // Subcommand + session id + subcommand arg, order-independent (see parseReportArgs). + const { subcommand, subcommandArg, sessionInput } = parseReportArgs( + ctx.positional.slice(1), + ctx.flags.last, + ); // Extract --intent value const intentIdx = ctx.rawArgs.indexOf("--intent"); @@ -105,7 +123,7 @@ const commands: Readonly> = { : undefined; await reportCommand({ - sessionId: resolveSessionId(sessionPositional, ctx.flags.last, ctx.projectDir), + sessionId: resolveSessionId(sessionInput, ctx.flags.last, ctx.projectDir), projectDir: ctx.projectDir, json: ctx.flags.json, subcommand, @@ -121,13 +139,17 @@ const commands: Readonly> = { handler: async (ctx) => { const { resolveSessionId } = await import("./commands/shared"); const { agentsCommand } = await import("./commands/agents"); - const agentId = ctx.flags.last ? ctx.positional[1] : ctx.positional[2]; + + // Positionals after the `agents` command word. Without --last the first is + // the session id and the second is the agent id; with --last the session is + // implicit, so the first positional is the agent id. (--last is order- + // independent because it is a flag, not a positional.) + const afterAgents = ctx.positional.slice(1); + const sessionInput = ctx.flags.last ? undefined : afterAgents[0]; + const agentId = ctx.flags.last ? afterAgents[0] : afterAgents[1]; + await agentsCommand({ - sessionId: resolveSessionId( - ctx.flags.last ? undefined : ctx.positional[1], - ctx.flags.last, - ctx.projectDir, - ), + sessionId: resolveSessionId(sessionInput, ctx.flags.last, ctx.projectDir), projectDir: ctx.projectDir, json: ctx.flags.json, agentId, @@ -135,8 +157,11 @@ const commands: Readonly> = { }); }, }, + // D6 (resolved KEEP per DECISIONS.md #6 — NOT frozen): the TUI is kept as a + // documented, low-maintenance differentiator. The web dashboard is the canonical + // rich surface, so killed-command hints below route to BOTH `explore` and `web`. explore: { - description: "Interactive TUI session explorer", + description: "Interactive TUI session explorer (or 'clens web' for the dashboard)", handler: async (ctx) => { const { startTui } = await import("./commands/tui"); startTui(ctx.projectDir); @@ -161,19 +186,86 @@ const commands: Readonly> = { await exportCommand({ sessionId: resolveSessionId(ctx.positional[1], ctx.flags.last, ctx.projectDir), projectDir: ctx.projectDir, - otel: ctx.flags.otel, }); }, }, what: { description: "Quick session summary (request, outcome, cost, issues)", handler: async (ctx) => { - const { resolveSessionId } = await import("./commands/shared"); const { whatCommand } = await import("./commands/what"); + + if (ctx.flags.global && ctx.flags.last) { + const { listGlobalSessions, resolveProjectForSession } = await import("./session/global-read"); + const sessions = listGlobalSessions(); + if (sessions.length === 0) throw new Error("No sessions found across registered projects."); + const sessionId = sessions[0].session_id; + const project = resolveProjectForSession(sessionId); + const projectDir = project ? project.path : ctx.projectDir; + await whatCommand({ + sessionId, + projectDir, + json: ctx.flags.json, + pricingTier: ctx.flags.pricing as import("./types").PricingTier | undefined, + }); + return; + } + + const { resolveSessionId } = await import("./commands/shared"); await whatCommand({ sessionId: resolveSessionId(ctx.positional[1], ctx.flags.last, ctx.projectDir), projectDir: ctx.projectDir, json: ctx.flags.json, + pricingTier: ctx.flags.pricing as import("./types").PricingTier | undefined, + }); + }, + }, + name: { + description: "Set/clear a session's label and color (no args prints current)", + handler: async (ctx) => { + const { nameCommand } = await import("./commands/name"); + const colorIdx = ctx.rawArgs.indexOf("--color"); + const color = colorIdx >= 0 && colorIdx + 1 < ctx.rawArgs.length + ? ctx.rawArgs[colorIdx + 1] + : undefined; + nameCommand({ + sessionArg: ctx.positional[1], + projectDir: ctx.projectDir, + label: ctx.positional[2], + color, + clear: ctx.rawArgs.includes("--clear"), + json: ctx.flags.json, + }); + }, + }, + config: { + description: "View or update clens configuration", + handler: async (ctx) => { + const { configCommand } = await import("./commands/config"); + const gmIdx = ctx.rawArgs.indexOf("--global-mode"); + const globalMode = gmIdx >= 0 && gmIdx + 1 < ctx.rawArgs.length + ? ctx.rawArgs[gmIdx + 1] + : undefined; + configCommand({ + projectDir: ctx.projectDir, + pricing: ctx.flags.pricing, + globalMode, + json: ctx.flags.json, + }); + }, + }, + web: { + description: "Launch web dashboard", + handler: async (ctx) => { + const { webCommand } = await import("./commands/web"); + const portIdx = ctx.rawArgs.indexOf("--port"); + const port = portIdx >= 0 && portIdx + 1 < ctx.rawArgs.length + ? parseInt(ctx.rawArgs[portIdx + 1], 10) + : 3700; + await webCommand({ + projectDir: ctx.projectDir, + port, + open: !ctx.rawArgs.includes("--no-open"), + global: ctx.flags.global, }); }, }, @@ -187,19 +279,19 @@ const KILLED_COMMANDS: Readonly> = { stats: "Did you mean 'clens report'?", backtracks: "Did you mean 'clens report backtracks'?", backtrack: "Did you mean 'clens report backtracks'?", - decisions: "decisions is available in: clens explore", - decision: "decisions is available in: clens explore", + decisions: "decisions is available in: clens explore | clens web", + decision: "decisions is available in: clens explore | clens web", reasoning: "Did you mean 'clens report reasoning'?", - edits: "edits is available in: clens explore", - edit: "edits is available in: clens explore", + edits: "edits is available in: clens explore | clens web", + edit: "edits is available in: clens explore | clens web", drift: "Did you mean 'clens report drift'?", - timeline: "timeline is available in: clens explore", + timeline: "timeline is available in: clens explore | clens web", tree: "Did you mean 'clens agents'?", agent: "Did you mean 'clens agents '?", messages: "Did you mean 'clens agents --comms'?", message: "Did you mean 'clens agents --comms'?", - graph: "graph is available in: clens explore", - journey: "journey is available in: clens explore", + graph: "graph is available in: clens explore | clens web", + journey: "journey is available in: clens explore | clens web", }; // --- Flag validation --- @@ -208,14 +300,17 @@ const GLOBAL_FLAGS = new Set(["--help", "-h", "--version", "-v"]); const VALID_FLAGS_BY_COMMAND: Readonly>> = { init: new Set(["--remove", "--status", "--dev", "--global", "--legacy"]), - list: new Set(["--json"]), - distill: new Set(["--last", "--all", "--deep", "--json"]), + list: new Set(["--json", "--global"]), + distill: new Set(["--last", "--all", "--global", "--force", "--deep", "--json", "--pricing"]), report: new Set(["--last", "--json", "--detail", "--full", "--intent"]), agents: new Set(["--last", "--json", "--comms"]), + name: new Set(["--color", "--clear", "--json"]), + config: new Set(["--pricing", "--json", "--global-mode"]), explore: new Set([]), - clean: new Set(["--last", "--all", "--force"]), - export: new Set(["--last", "--otel"]), - what: new Set(["--last", "--json"]), + clean: new Set(["--last", "--all", "--force", "--yes"]), + export: new Set(["--last"]), + what: new Set(["--last", "--json", "--pricing", "--global"]), + web: new Set(["--port", "--no-open", "--global"]), }; /** Find which command a flag belongs to, for suggestion messages. */ @@ -230,9 +325,10 @@ const validateFlags = (cmd: string, rawArgs: readonly string[]): string | undefi if (!validSet) return undefined; const flagArgs = rawArgs.filter((a) => a.startsWith("--") || (a.startsWith("-") && a.length === 2)); - // Skip the value after --intent (it's not a flag) + // Skip values after --intent and --port (they're not flags) + const VALUE_FLAGS = new Set(["--intent", "--port", "--pricing", "--global-mode", "--color"]); const actualFlags = flagArgs.reduce((acc, arg, i) => { - if (i > 0 && rawArgs[rawArgs.indexOf(arg) - 1] === "--intent") return acc; + if (i > 0 && VALUE_FLAGS.has(rawArgs[rawArgs.indexOf(arg) - 1])) return acc; return [...acc, arg]; }, []); @@ -267,9 +363,15 @@ ${bold("Setup:")} ${bold("Sessions:")} ${cyan("list")} List captured sessions + ${cyan("list --global")} List sessions across all registered projects + ${cyan("name")} Set/clear a session's label & color flag ${cyan("distill")} Extract insights from session data - ${cyan("clean")} Remove session data + ${cyan("distill --global")} Distill every session across all registered projects + ${cyan("clean ")} Remove one session's data (or --last) + ${cyan("clean --all")} Remove every session in this project (prompts; --yes to skip) ${cyan("export")} Export session as archive + ${cyan("config")} View or update configuration + ${cyan("config --global-mode ")} Set global mode: repository or project ${bold("Analysis:")} ${cyan("what")} Quick summary: request, outcome, cost, issues @@ -281,20 +383,27 @@ ${bold("Analysis:")} ${cyan("agents ")} Drill into specific agent ${cyan("agents --comms")} Communication timeline ${cyan("explore")} Interactive TUI session explorer + ${cyan("web")} Launch web dashboard ${bold("Options:")} ${dim("--last")} Use most recent session ${dim("--force")} Force operation (skip safety checks) - ${dim("--deep")} Deep distill: enrich agents with transcript data + ${dim("--yes, -y")} Skip confirmation prompt (required for 'clean --all' when non-interactive) + ${dim("--deep")} Deep distill: add git enrichment (commit history, unified diffs); spawns git ${dim("--json")} Output structured JSON - ${dim("--otel")} Export in OTLP format ${dim("--detail")} Show detailed backtrack breakdown ${dim("--full")} Show full reasoning text ${dim("--intent")} Filter by reasoning intent type ${dim("--all")} Distill all sessions ${dim("--comms")} Show communication timeline - ${dim("--global")} Install hooks globally (~/.claude/settings.json) + ${dim("--global")} Global mode: operate across all registered projects + ${dim("--global-mode ")} Set discovery mode: repository (git root) or project (each .clens/) ${dim("--legacy")} Include legacy hooks in --remove + ${dim("--port ")} Web dashboard port (default 3700) + ${dim("--no-open")} Don't open browser automatically + ${dim("--pricing ")} Pricing tier: api, max, or auto + ${dim("--color ")} Session color flag: none, red, amber, green, blue, violet, gray + ${dim("--clear")} Clear a session's label and color ${dim("--version")} Show version ${dim("--help")} Show help @@ -302,22 +411,33 @@ ${bold("Examples:")} clens init # Set up hooks (local) clens init --global # Set up hooks (global) clens list # See all sessions + clens name a288 "Auth refactor" --color amber # Label + flag a session + clens name a288 --clear # Revert to computed name, unflag clens distill --last # Distill latest session + clens distill --global # distill all sessions in every repo clens report --last # Summary of latest session clens report --last backtracks # Backtrack analysis clens report --last drift specs/p.md # Drift against spec clens agents --last # Agent tree - clens explore # Interactive explorer`); + clens explore # Interactive explorer + clens web # Launch web dashboard + clens web --port 8080 --no-open # Custom port, no browser + clens config # View current config + clens config --pricing max # Set pricing tier to max + clens distill --last --pricing max # Distill with max tier pricing`); }; // --- Main --- const args = Bun.argv.slice(2); +const pricingIdx = args.indexOf("--pricing"); +const pricingValue = pricingIdx >= 0 && pricingIdx + 1 < args.length ? args[pricingIdx + 1] : undefined; + const flags: Flags = { last: args.includes("--last"), force: args.includes("--force"), - otel: args.includes("--otel"), + yes: args.includes("--yes") || args.includes("-y"), deep: args.includes("--deep"), json: args.includes("--json"), help: args.includes("--help") || args.includes("-h"), @@ -331,9 +451,16 @@ const flags: Flags = { comms: args.includes("--comms"), global: args.includes("--global"), legacy: args.includes("--legacy"), + ...(pricingValue ? { pricing: pricingValue } : {}), }; -const positional = args.filter((a) => !a.startsWith("--") && !a.startsWith("-")); +// Exclude values that follow value-bearing flags (--intent, --port, --pricing) +const VALUE_FLAG_SET = new Set(["--intent", "--port", "--pricing", "--color"]); +const positional = args.filter((a, i) => { + if (a.startsWith("--") || a.startsWith("-")) return false; + if (i > 0 && VALUE_FLAG_SET.has(args[i - 1])) return false; + return true; +}); const command = positional[0]; const main = async () => { diff --git a/src/commands/agent.ts b/packages/cli/src/commands/agent.ts similarity index 99% rename from src/commands/agent.ts rename to packages/cli/src/commands/agent.ts index 4cd7624..6447b06 100644 --- a/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -175,7 +175,7 @@ export const renderAgentReport = ( const renderToolUsage = (agent: AgentNode): readonly string[] => { const toolsByName = agent.stats?.tools_by_name; if (!toolsByName || Object.keys(toolsByName).length === 0) { - return ["Tool Usage: (no stats available - run distill --deep)"]; + return ["Tool Usage: (no stats available - run distill)"]; } const totalCalls = agent.stats?.tool_call_count ?? 0; diff --git a/src/commands/agents.ts b/packages/cli/src/commands/agents.ts similarity index 100% rename from src/commands/agents.ts rename to packages/cli/src/commands/agents.ts diff --git a/src/commands/backtracks.ts b/packages/cli/src/commands/backtracks.ts similarity index 100% rename from src/commands/backtracks.ts rename to packages/cli/src/commands/backtracks.ts diff --git a/packages/cli/src/commands/clean.ts b/packages/cli/src/commands/clean.ts new file mode 100644 index 0000000..e21e3a0 --- /dev/null +++ b/packages/cli/src/commands/clean.ts @@ -0,0 +1,74 @@ +import { createInterface } from "node:readline"; +import { formatBytes } from "../utils"; +import { type Flags, resolveSessionId } from "./shared"; + +/** Interactive [y/N] confirmation. Resolves true only on an explicit yes. */ +const confirm = (question: string): Promise => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${question} [y/N] `, (answer) => { + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +}; + +export const cleanCommand = async (args: { + sessionArg: string | undefined; + flags: Flags; + projectDir: string; +}): Promise => { + const { cleanSession, cleanAll } = await import("../session/clean"); + + // Targeted delete: an explicit session id, or --last (single most-recent file). + const sid = args.sessionArg + ? resolveSessionId(args.sessionArg, args.flags.last, args.projectDir) + : args.flags.last + ? resolveSessionId(undefined, true, args.projectDir) + : null; + + if (sid) { + const result = cleanSession(sid, args.projectDir, { force: args.flags.force }); + console.log( + `Cleaned session ${result.session_id.slice(0, 8)}. Freed ${formatBytes(result.freed_bytes)}.`, + ); + return; + } + + // No target specified. Bare `clens clean` must delete nothing and error — + // blanket deletion only happens behind the explicit --all lever. + if (!args.flags.all) { + throw new Error( + "Nothing to clean. Specify a session id, --last for the most recent, or --all to remove every session in this project.", + ); + } + + // --all: blanket delete of all sessions WITHIN the resolved project dir. + // Gate it: confirm interactively, or require --yes when non-interactive. + const confirmed = + args.flags.yes || + (Boolean(process.stdin.isTTY) && + (await confirm( + `Remove ALL session data in ${args.projectDir}/.clens/sessions? This cannot be undone.`, + ))); + + if (!confirmed) { + if (!process.stdin.isTTY) { + throw new Error( + "Refusing to 'clean --all' without confirmation in a non-interactive context. Re-run with --yes to proceed.", + ); + } + console.log("Aborted. Nothing was deleted."); + return; + } + + const result = cleanAll(args.projectDir, { force: args.flags.force }); + if (result.skipped.length > 0) { + console.log( + result.skipped + .map((id) => `Skipping ${id.slice(0, 8)} (not distilled). Use --force to override.`) + .join("\n"), + ); + } + console.log(`Cleaned ${result.cleaned} session(s). Freed ${formatBytes(result.freed_bytes)}.`); +}; diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 0000000..6f752ae --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,90 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import type { ClensConfig, PricingTier } from "../types"; +import { readGlobalConfig, writeGlobalConfig, isValidGlobalMode } from "../session/registry"; +import { bold, cyan, dim } from "./shared"; + +const VALID_PRICING_TIERS: readonly PricingTier[] = ["api", "max", "auto"] as const; + +const isValidPricingTier = (value: string): value is PricingTier => + (VALID_PRICING_TIERS as readonly string[]).includes(value); + +const readConfig = (projectDir: string): ClensConfig => { + const configPath = `${projectDir}/.clens/config.json`; + if (!existsSync(configPath)) return { capture: true }; + try { + const raw: unknown = JSON.parse(readFileSync(configPath, "utf-8")); + if (typeof raw !== "object" || raw === null) return { capture: true }; + const obj = raw as Record; + const capture = typeof obj.capture === "boolean" ? obj.capture : true; + const pricing = typeof obj.pricing === "string" && isValidPricingTier(obj.pricing) + ? obj.pricing + : undefined; + return { capture, ...(pricing ? { pricing } : {}) }; + } catch { + return { capture: true }; + } +}; + +const writeConfig = (projectDir: string, config: ClensConfig): void => { + const configPath = `${projectDir}/.clens/config.json`; + writeFileSync(configPath, JSON.stringify(config, null, 2)); +}; + +export const configCommand = (args: { + readonly projectDir: string; + readonly pricing?: string; + readonly globalMode?: string; + readonly json: boolean; +}): void => { + // Handle --global-mode: read/write global config (not per-project) + if (args.globalMode !== undefined) { + if (!isValidGlobalMode(args.globalMode)) { + throw new Error( + `Invalid global mode "${args.globalMode}". Valid values: repository, project\n` + + ` ${dim("repository")} — group sessions by git repo root (default)\n` + + ` ${dim("project")} — every .clens/ directory is its own source`, + ); + } + const globalConfig = readGlobalConfig(); + const updated = { ...globalConfig, global_mode: args.globalMode }; + writeGlobalConfig(updated); + console.log(`Global mode set to ${bold(cyan(args.globalMode))}.`); + return; + } + + const config = readConfig(args.projectDir); + + // If --pricing is provided, update the pricing tier + if (args.pricing !== undefined) { + if (!isValidPricingTier(args.pricing)) { + throw new Error( + `Invalid pricing tier "${args.pricing}". Valid values: ${VALID_PRICING_TIERS.join(", ")}`, + ); + } + const updated: ClensConfig = { ...config, pricing: args.pricing }; + writeConfig(args.projectDir, updated); + console.log(`Pricing tier set to ${bold(cyan(args.pricing))}.`); + return; + } + + // Show current config (local + global) + const globalConfig = readGlobalConfig(); + + if (args.json) { + console.log(JSON.stringify({ local: config, global: globalConfig }, null, 2)); + return; + } + + const lines = [ + bold("clens config"), + "", + ` ${dim("Local (per-project):")}`, + ` ${dim("capture:")} ${config.capture}`, + ` ${dim("pricing:")} ${config.pricing ?? "api (default)"}`, + ...(config.events ? [` ${dim("events:")} ${config.events.join(", ")}`] : []), + "", + ` ${dim("Global (~/.clens/):")}`, + ` ${dim("global_mode:")} ${globalConfig.global_mode}`, + ]; + console.log(lines.join("\n")); +}; diff --git a/src/commands/decisions.ts b/packages/cli/src/commands/decisions.ts similarity index 100% rename from src/commands/decisions.ts rename to packages/cli/src/commands/decisions.ts diff --git a/packages/cli/src/commands/distill.ts b/packages/cli/src/commands/distill.ts new file mode 100644 index 0000000..076e51a --- /dev/null +++ b/packages/cli/src/commands/distill.ts @@ -0,0 +1,423 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import type { BacktrackResult, CostEstimate, DistilledSession, GlobalSessionSummary } from "../types/distill"; +import type { PricingTier } from "../types"; +import { fmtDuration } from "./format-helpers"; +import { bold, cyan, dim, green, red, yellow } from "./shared"; + +/** + * Distilled-output schema version. Stamped onto every written artifact and compared by + * `isDistilledFresh`. Bump this whenever the distill output shape changes so existing + * cached artifacts are treated as stale and re-distilled (independent of file mtime). + */ +export const DISTILL_SCHEMA_VERSION = 1; + +const BACKTRACK_LABELS: Readonly> = { + debugging_loop: "debugging loop", + failure_retry: "failure retry", + iteration_struggle: "iteration struggle", +} as const; + +/** Group backtracks by type and produce "N type_label" fragments. */ +const backtrackBreakdown = (backtracks: readonly BacktrackResult[]): string => { + const counts = backtracks.reduce>>( + (acc, b) => ({ ...acc, [b.type]: (acc[b.type] ?? 0) + 1 }), + {}, + ); + return Object.entries(counts) + .map(([type, count]) => `${count} ${BACKTRACK_LABELS[type as BacktrackResult["type"]] ?? type}${count !== 1 ? "s" : ""}`) + .join(", "); +}; + +/** Format cost line: "$0.43 (claude-sonnet-4-6, api tier)" or "~$0.43 (rough estimate)" */ +const formatCost = (ce: CostEstimate): string => { + const prefix = ce.is_estimated ? "~" : ""; + const tierSuffix = ce.pricing_tier ? `, ${ce.pricing_tier} tier` : ""; + const suffix = ce.is_estimated ? " (rough estimate)" : ` (${ce.model}${tierSuffix})`; + return `${prefix}$${ce.estimated_cost_usd.toFixed(2)}${suffix}`; +}; + +/** Pad a label to a fixed width for two-column alignment. */ +const metricLine = (label: string, value: string, width: number = 13): string => + ` ${label.padEnd(width)}${value}`; + +/** Build structured narrative lines from distilled data. */ +const buildNarrative = (result: DistilledSession): readonly string[] => { + const { stats, backtracks, summary } = result; + + // Line 1: duration, active time, model, tool calls + const activeDurMs = summary?.key_metrics?.active_duration_ms; + const model = stats.model ?? stats.cost_estimate?.model; + const durationPart = activeDurMs + ? `${fmtDuration(stats.duration_ms)} session (${fmtDuration(activeDurMs)} active)` + : `${fmtDuration(stats.duration_ms)} session`; + const modelPart = model ? ` using ${model}` : ""; + const line1 = `A ${durationPart}${modelPart} with ${stats.tool_call_count} tool calls.`; + + // Line 2: phases + const phaseNames = summary?.phases?.map((p) => p.name) ?? []; + const line2 = phaseNames.length > 0 + ? `${phaseNames.length} phase${phaseNames.length === 1 ? "" : "s"}: ${phaseNames.join(", ")}.` + : undefined; + + // Line 3: primary tools + files modified + const topTools = Object.entries(stats.tools_by_name) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([name]) => name); + const filesModified = summary?.key_metrics?.files_modified ?? stats.unique_files.length; + const toolsPart = topTools.length > 0 ? `Primary tools: ${topTools.join(", ")}.` : ""; + const filesPart = `${filesModified} file${filesModified === 1 ? "" : "s"} modified.`; + const line3 = toolsPart ? `${toolsPart} ${filesPart}` : filesPart; + + // Line 4: backtracks + failure rate + const failRate = (stats.failure_rate * 100).toFixed(1); + const line4 = backtracks.length > 0 + ? `${backtracks.length} backtrack${backtracks.length === 1 ? "" : "s"} (${backtrackBreakdown(backtracks)}). Failure rate: ${failRate}%.` + : `No backtracks. Failure rate: ${failRate}%.`; + + return [` ${line1}`, ...(line2 ? [` ${line2}`] : []), ` ${line3}`, ` ${line4}`]; +}; + +/** Build the two-column metrics block. */ +const buildMetrics = (result: DistilledSession): readonly string[] => { + const ce = result.cost_estimate ?? result.stats.cost_estimate; + const timelineCount = result.timeline?.length; + + const leftCol = [ + metricLine("Backtracks:", String(result.backtracks.length)), + metricLine("Files:", String(result.file_map.files.length)), + metricLine("User msgs:", String(result.user_messages.length)), + metricLine("Cost:", ce ? formatCost(ce) : "n/a"), + metricLine("Context:", result.context_consumption + ? `${Math.round(result.context_consumption.peak_context_pct)}% peak, ${result.context_consumption.compaction_count} compaction${result.context_consumption.compaction_count === 1 ? "" : "s"}` + : "n/a"), + ]; + + const rightCol = [ + `Decisions: ${result.decisions.length}`, + `Reasoning: ${result.reasoning.length} blocks`, + `Timeline: ${timelineCount ?? "n/a"} entries`, + ]; + + // Merge columns side by side + const colWidth = 28; + return leftCol.map((left, i) => { + const right = rightCol[i]; + const stripped = left.replace(/\x1b\[[0-9;]*m/g, ""); + const padding = Math.max(0, colWidth - stripped.length); + return right ? `${left}${" ".repeat(padding)}${right}` : left; + }); +}; + +/** Build optional team line. */ +const buildTeamLine = (result: DistilledSession): readonly string[] => { + if (!result.team_metrics) return []; + const tm = result.team_metrics; + return [metricLine("Team:", `${tm.agent_count} agents, ${tm.task_completed_count} tasks completed`)]; +}; + +/** Build optional drift line. */ +const buildDriftLine = (result: DistilledSession): readonly string[] => { + if (!result.plan_drift) return []; + const pd = result.plan_drift; + const score = pd.drift_score; + const colorFn = score < 0.3 ? green : score < 0.7 ? yellow : red; + return [colorFn(metricLine("Drift:", `${score.toFixed(2)} (${pd.spec_path}: ${pd.expected_files.length} expected, ${pd.actual_files.length} actual)`))]; +}; + +export const distillCommand = async (args: { + readonly sessionId: string; + readonly projectDir: string; + readonly deep: boolean; + readonly json: boolean; + readonly pricingTier?: import("../types").PricingTier; + /** + * Skip the per-session analytics-summary write. Batch drivers set this and + * flush the whole run once at the end (one O(N) pass instead of O(N²)). + */ + readonly deferAnalyticsSummary?: boolean; +}): Promise => { + const { distill } = await import("../distill/index"); + const distilled = await distill(args.sessionId, args.projectDir, { + deep: args.deep, + pricingTier: args.pricingTier, + }); + + // Stamp the schema version so a freshness check can detect shape drift independent of mtime. + const result: DistilledSession = { ...distilled, schema_version: DISTILL_SCHEMA_VERSION }; + + // Save distilled result to disk + const distilledDir = `${args.projectDir}/.clens/distilled`; + mkdirSync(distilledDir, { recursive: true }); + writeFileSync(`${distilledDir}/${args.sessionId}.json`, JSON.stringify(result, null, 2)); + + // Write analytics summary row (best-effort). Skipped in batch mode — the + // driver accumulates rows and flushes once via writeAnalyticsSummaryBatch. + if (!args.deferAnalyticsSummary) { + try { + const { writeAnalyticsSummary } = await import("../distill/analytics-summary"); + writeAnalyticsSummary(result, args.projectDir); + } catch { /* best-effort — summary is a derived artifact */ } + } + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + return result; + } + + const sessionPrefix = args.sessionId.slice(0, 8); + const header = bold(`Distilled session ${cyan(sessionPrefix)}`); + const narrative = buildNarrative(result); + const metrics = buildMetrics(result); + const teamLine = buildTeamLine(result); + const driftLine = buildDriftLine(result); + const savedLine = dim(` Saved to: .clens/distilled/${sessionPrefix}.json`); + + const output = [ + header, + "", + ...narrative, + "", + ...metrics.map(dim), + ...teamLine.map(dim), + ...driftLine, + "", + savedLine, + ].join("\n"); + + console.log(output); + return result; +}; + +// ── Batch drivers ──────────────────────────────────────── + +/** Tally of a batch distill run. */ +export type BatchDistillCounts = { + readonly distilled: number; + readonly skipped: number; + readonly failed: number; +}; + +/** Cross-repo batch tally (adds project span). */ +export type GlobalDistillCounts = BatchDistillCounts & { + readonly projectCount: number; +}; + +/** + * Current explicit pricing tier for a capture dir: an explicit `--pricing api|max` + * override wins, otherwise the explicit tier from `.clens/config.json`. Returns + * `undefined` for `auto`/absent config — auto resolves per-session at distill time, so + * a cheap up-front comparison is only meaningful for explicit `api`/`max` (mirrors the + * web staleness check). A `undefined` expected tier disables the tier-staleness gate. + */ +export const resolveExpectedTier = ( + captureDir: string, + override?: PricingTier, +): "api" | "max" | undefined => { + if (override === "api" || override === "max") return override; + try { + const raw: unknown = JSON.parse(readFileSync(`${captureDir}/.clens/config.json`, "utf-8")); + const pricing = raw && typeof raw === "object" ? (raw as { pricing?: unknown }).pricing : undefined; + return pricing === "api" || pricing === "max" ? pricing : undefined; + } catch { + return undefined; + } +}; + +/** + * True iff the cached distilled artifact is up to date and can be skipped on an + * incremental run. A session is stale (NOT fresh) when ANY of: + * - the distilled or session file is missing; + * - the distilled artifact is older than its source `.jsonl` (mtime) — raw is append-only; + * - the stamped `schema_version` differs from the current `DISTILL_SCHEMA_VERSION` + * (artifacts predating the stamp have no version and are treated as stale); + * - an explicit `expectedTier` is given and the distilled `pricing_tier` differs from it. + */ +export const isDistilledFresh = ( + sessionFile: string, + distilledFile: string, + expectedTier?: "api" | "max", +): boolean => { + if (!existsSync(distilledFile) || !existsSync(sessionFile)) return false; + if (statSync(distilledFile).mtimeMs < statSync(sessionFile).mtimeMs) return false; + try { + const parsed = JSON.parse(readFileSync(distilledFile, "utf-8")) as { + readonly schema_version?: number; + readonly pricing_tier?: string; + }; + if (parsed.schema_version !== DISTILL_SCHEMA_VERSION) return false; + if (expectedTier !== undefined && parsed.pricing_tier !== undefined && parsed.pricing_tier !== expectedTier) { + return false; + } + return true; + } catch { + // Unparseable cached artifact — re-distill. + return false; + } +}; + +const sessionFilePath = (captureDir: string, sessionId: string): string => + `${captureDir}/.clens/sessions/${sessionId}.jsonl`; + +const distilledFilePath = (captureDir: string, sessionId: string): string => + `${captureDir}/.clens/distilled/${sessionId}.json`; + +/** + * Distill every session captured in a single project dir (cwd batch). + * Skips sessions whose distilled artifact is already fresh unless `force`. + * One bad session is counted, never fatal. Returns the run tally. + */ +export const distillAllInDir = async (args: { + readonly projectDir: string; + readonly deep: boolean; + readonly pricingTier?: PricingTier; + readonly force: boolean; +}): Promise => { + const { listSessions } = await import("../session/read"); + const sessions = listSessions(args.projectDir); + if (sessions.length === 0) { + console.log("No sessions found."); + return { distilled: 0, skipped: 0, failed: 0 }; + } + + console.log(`Distilling ${sessions.length} session(s)...`); + // Accumulate freshly-distilled results and flush the analytics summary once at + // batch end (O(N) total), instead of rewriting the whole file per session. + const distilledResults: DistilledSession[] = []; + const expectedTier = resolveExpectedTier(args.projectDir, args.pricingTier); + const counts = await sessions.reduce>( + async (accP, session, idx) => { + const acc = await accP; + const progress = `[${idx + 1}/${sessions.length}]`; + const prefix = session.session_id.slice(0, 8); + const fresh = + !args.force && + isDistilledFresh( + sessionFilePath(args.projectDir, session.session_id), + distilledFilePath(args.projectDir, session.session_id), + expectedTier, + ); + if (fresh) { + console.log(`${progress} ${prefix}… (up to date, skipped)`); + return { ...acc, skipped: acc.skipped + 1 }; + } + console.log(`${progress} ${prefix}...`); + try { + const result = await distillCommand({ + sessionId: session.session_id, + projectDir: args.projectDir, + deep: args.deep, + json: false, + pricingTier: args.pricingTier, + deferAnalyticsSummary: true, + }); + distilledResults.push(result); + return { ...acc, distilled: acc.distilled + 1 }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` Error: ${msg}`); + return { ...acc, failed: acc.failed + 1 }; + } + }, + Promise.resolve({ distilled: 0, skipped: 0, failed: 0 }), + ); + + try { + const { writeAnalyticsSummaryBatch } = await import("../distill/analytics-summary"); + writeAnalyticsSummaryBatch(distilledResults, args.projectDir); + } catch { /* best-effort — summary is a derived artifact */ } + + console.log( + `\nDistilled ${counts.distilled}, skipped ${counts.skipped}, failed ${counts.failed} of ${sessions.length} session(s).`, + ); + return counts; +}; + +/** + * Distill every session across every registered project, writing each result + * into its own (possibly nested) `.clens/distilled/`. Sessions are grouped by + * project for readable output. Incremental by default; `force` re-distills all. + * + * `sessions` is injectable for testing; it defaults to `listGlobalSessions()`. + */ +export const distillAllGlobal = async (args: { + readonly deep: boolean; + readonly pricingTier?: PricingTier; + readonly force: boolean; + readonly sessions?: readonly GlobalSessionSummary[]; +}): Promise => { + const sessions = + args.sessions ?? (await import("../session/global-read")).listGlobalSessions(); + if (sessions.length === 0) { + throw new Error( + "No sessions found across registered projects. Run 'clens init --global' or 'clens list --global' first.", + ); + } + + console.log(`Distilling ${sessions.length} session(s) across registered projects...`); + + // Group freshly-distilled results by their capture dir and flush each project's + // analytics summary once at the end (O(N) total), not once per session. + const resultsByDir = new Map(); + type Acc = BatchDistillCounts & { readonly lastProject?: string }; + const final = await sessions.reduce>( + async (accP, s, idx) => { + const acc = await accP; + const progress = `[${idx + 1}/${sessions.length}]`; + const prefix = s.session_id.slice(0, 8); + const header = acc.lastProject !== s.project_name ? [`\n── ${s.project_name} ──`] : []; + header.forEach((h) => console.log(h)); + const base: Acc = { ...acc, lastProject: s.project_name }; + + const fresh = + !args.force && + isDistilledFresh( + sessionFilePath(s.capture_dir, s.session_id), + distilledFilePath(s.capture_dir, s.session_id), + resolveExpectedTier(s.capture_dir, args.pricingTier), + ); + if (fresh) { + console.log(`${progress} ${prefix}… (up to date, skipped)`); + return { ...base, skipped: base.skipped + 1 }; + } + console.log(`${progress} ${prefix}...`); + try { + const result = await distillCommand({ + sessionId: s.session_id, + projectDir: s.capture_dir, + deep: args.deep, + json: false, + pricingTier: args.pricingTier, + deferAnalyticsSummary: true, + }); + const bucket = resultsByDir.get(s.capture_dir) ?? []; + bucket.push(result); + resultsByDir.set(s.capture_dir, bucket); + return { ...base, distilled: base.distilled + 1 }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` Error (${prefix}): ${msg}`); + return { ...base, failed: base.failed + 1 }; + } + }, + Promise.resolve({ distilled: 0, skipped: 0, failed: 0 }), + ); + + try { + const { writeAnalyticsSummaryBatch } = await import("../distill/analytics-summary"); + for (const [captureDir, results] of resultsByDir) { + writeAnalyticsSummaryBatch(results, captureDir); + } + } catch { /* best-effort — summary is a derived artifact */ } + + const projectCount = new Set(sessions.map((s) => s.project_name)).size; + console.log( + `\nDistilled ${final.distilled}, skipped ${final.skipped}, failed ${final.failed} across ${projectCount} projects.`, + ); + return { + distilled: final.distilled, + skipped: final.skipped, + failed: final.failed, + projectCount, + }; +}; diff --git a/src/commands/drift.ts b/packages/cli/src/commands/drift.ts similarity index 100% rename from src/commands/drift.ts rename to packages/cli/src/commands/drift.ts diff --git a/src/commands/edits.ts b/packages/cli/src/commands/edits.ts similarity index 100% rename from src/commands/edits.ts rename to packages/cli/src/commands/edits.ts diff --git a/src/commands/export.ts b/packages/cli/src/commands/export.ts similarity index 88% rename from src/commands/export.ts rename to packages/cli/src/commands/export.ts index 8a1a6bc..da812d3 100644 --- a/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -3,9 +3,8 @@ import { green } from "./shared"; export const exportCommand = async (args: { sessionId: string; projectDir: string; - otel: boolean; }): Promise => { const { exportSession } = await import("../session/export"); - const outPath = await exportSession(args.sessionId, args.projectDir, { otel: args.otel }); + const outPath = await exportSession(args.sessionId, args.projectDir); console.log(green(`\u2713 Exported to ${outPath}`)); }; diff --git a/src/commands/format-helpers.ts b/packages/cli/src/commands/format-helpers.ts similarity index 100% rename from src/commands/format-helpers.ts rename to packages/cli/src/commands/format-helpers.ts diff --git a/src/commands/init.ts b/packages/cli/src/commands/init.ts similarity index 98% rename from src/commands/init.ts rename to packages/cli/src/commands/init.ts index 06c7705..181bc17 100644 --- a/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -8,6 +8,7 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { type ClensConfig, type DelegatedHooks, HOOK_EVENTS } from "../types"; +import { registerProject } from "../session/registry"; import type { Flags } from "./shared"; import { dim, green, red, yellow } from "./shared"; @@ -219,6 +220,9 @@ export const init = (projectDir: string, target: InitTarget = "local"): InitResu writeFileSync(configPath, JSON.stringify(config, null, 2)); } + // Auto-register project in global registry + registerProject(projectDir); + // Detect legacy hooks when installing to local tier const legacyWarning = target === "local" && detectLegacyInstall(projectDir) @@ -236,7 +240,7 @@ export const init = (projectDir: string, target: InitTarget = "local"): InitResu backed_up: true, delegated_hooks_count: delegatedCount, warning: warnings.join("\n "), - tip: "Install analysis tools with: clens plugin install", + tip: "Install analysis tools with: clens init plugin", }; }; @@ -499,8 +503,3 @@ export const initCommand = (args: { const result = init(args.projectDir, target); renderInitResult(result); }; - -/** @deprecated Use initCommand with flags instead. Kept for backward compat during migration. */ -export const uninitCommand = (projectDir: string): void => { - renderUninit(projectDir, false); -}; diff --git a/src/commands/list.ts b/packages/cli/src/commands/list.ts similarity index 58% rename from src/commands/list.ts rename to packages/cli/src/commands/list.ts index 4524e8f..4c71e06 100644 --- a/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,3 +1,4 @@ +import type { GlobalSessionSummary, SessionSummary } from "../types"; import { formatBytes, formatDuration, formatSessionDate } from "../utils"; import { bold, dim, green, yellow } from "./shared"; @@ -5,10 +6,21 @@ import { bold, dim, green, yellow } from "./shared"; const truncate = (str: string, maxLen: number): string => str.length > maxLen ? `${str.slice(0, maxLen - 1)}\u2026` : str; -export const listCommand = async (args: { projectDir: string; json: boolean }): Promise => { - const { listSessions, enrichSessionSummaries } = await import("../session/read"); - const rawSessions = listSessions(args.projectDir); - const sessions = enrichSessionSummaries(rawSessions, args.projectDir); +const isGlobalSession = (s: SessionSummary): s is GlobalSessionSummary => + "project_name" in s; + +export const listCommand = async (args: { projectDir: string; json: boolean; global: boolean }): Promise => { + const sessions: readonly SessionSummary[] = args.global + ? await (async () => { + const { listGlobalSessions } = await import("../session/global-read"); + return listGlobalSessions(); + })() + : await (async () => { + const { listSessions, enrichSessionSummaries } = await import("../session/read"); + const raw = listSessions(args.projectDir); + return enrichSessionSummaries(raw, args.projectDir); + })(); + if (args.json) { console.log(JSON.stringify(sessions, null, 2)); return; @@ -17,26 +29,33 @@ export const listCommand = async (args: { projectDir: string; json: boolean }): console.log("No sessions found."); return; } + + const projectHeader = args.global ? "Project".padEnd(17) : ""; console.log( bold( "ID".padEnd(12) + + projectHeader + "Name".padEnd(27) + "Started".padEnd(15) + "Branch".padEnd(16) + "Team".padEnd(15) + "Type".padEnd(10) + "D".padEnd(3) + - "Duration".padEnd(12) + + "Span".padEnd(12) + "Events".padEnd(10) + "Status", ), ); - console.log(dim("\u2500".repeat(130))); + const separatorWidth = args.global ? 147 : 130; + console.log(dim("\u2500".repeat(separatorWidth))); console.log( sessions .map((s) => { const id = s.session_id.slice(0, 8); - const name = truncate(s.session_name ?? "-", 25); + const projectCol = args.global + ? (isGlobalSession(s) ? (s.project_name ?? "").slice(0, 15) : "").padEnd(17) + : ""; + const name = truncate(s.display_name ?? s.session_name ?? "-", 25); const started = formatSessionDate(s.start_time); const branch = (s.git_branch || "-").slice(0, 14); const team = (s.team_name || "-").slice(0, 13); @@ -46,7 +65,7 @@ export const listCommand = async (args: { projectDir: string; json: boolean }): const dur = formatDuration(s.duration_ms); const events = String(s.event_count); const status = s.status === "complete" ? green("complete") : yellow("[incomplete]"); - return `${id.padEnd(12)}${name.padEnd(27)}${started.padEnd(15)}${branch.padEnd(16)}${team.padEnd(15)}${type.padEnd(10)}${distillMark.padEnd(3)}${dur.padEnd(12)}${events.padEnd(10)}${status}`; + return `${id.padEnd(12)}${projectCol}${name.padEnd(27)}${started.padEnd(15)}${branch.padEnd(16)}${team.padEnd(15)}${type.padEnd(10)}${distillMark.padEnd(3)}${dur.padEnd(12)}${events.padEnd(10)}${status}`; }) .join("\n"), ); diff --git a/src/commands/messages.ts b/packages/cli/src/commands/messages.ts similarity index 100% rename from src/commands/messages.ts rename to packages/cli/src/commands/messages.ts diff --git a/packages/cli/src/commands/name.ts b/packages/cli/src/commands/name.ts new file mode 100644 index 0000000..15c9bbf --- /dev/null +++ b/packages/cli/src/commands/name.ts @@ -0,0 +1,99 @@ +import type { ColorName } from "../types"; +import { isColorName } from "../types"; +import { readSessionMeta, setSessionMeta } from "../session/session-meta"; +import { enrichSessionSummaries, listSessions } from "../session/read"; +import { resolveSessionId } from "./shared"; +import { bold, cyan, dim, green } from "./shared"; + +export interface NameCommandArgs { + readonly sessionArg: string | undefined; + readonly projectDir: string; + /** Positional label (may be undefined). */ + readonly label: string | undefined; + /** --color , if provided. */ + readonly color: string | undefined; + /** --clear flag: remove both label and color. */ + readonly clear: boolean; + readonly json: boolean; +} + +/** Resolve the current display name + meta for a session and print it. */ +const printCurrent = (sessionId: string, projectDir: string, json: boolean): void => { + const meta = readSessionMeta(projectDir)[sessionId]; + const summary = enrichSessionSummaries( + listSessions(projectDir).filter((s) => s.session_id === sessionId), + projectDir, + )[0]; + + if (json) { + console.log( + JSON.stringify( + { + session_id: sessionId, + display_name: summary?.display_name ?? sessionId.slice(0, 8), + name_source: summary?.name_source ?? "id", + label: meta?.label ?? null, + color: meta?.color ?? "none", + }, + null, + 2, + ), + ); + return; + } + + console.log(`${bold("Session")} ${cyan(sessionId.slice(0, 8))}`); + console.log(` ${dim("name")} ${summary?.display_name ?? sessionId.slice(0, 8)} ${dim(`(${summary?.name_source ?? "id"})`)}`); + console.log(` ${dim("label")} ${meta?.label ?? dim("—")}`); + console.log(` ${dim("color")} ${meta?.color ?? dim("none")}`); +}; + +/** + * `clens name [label] [--color c] [--clear]` + * - no label/color/clear → prints current meta. + * - label → sets label (whitespace clears it). + * - --color → sets/clears color (none clears); invalid → error. + * - --clear → clears both label and color. + */ +export const nameCommand = (args: NameCommandArgs): void => { + const sessionId = resolveSessionId(args.sessionArg, false, args.projectDir); + + // --clear wins: remove both fields. + if (args.clear) { + setSessionMeta(args.projectDir, sessionId, { label: null, color: null }); + if (!args.json) console.log(green(`Cleared label and color for ${sessionId.slice(0, 8)}.`)); + else printCurrent(sessionId, args.projectDir, true); + return; + } + + const hasLabel = args.label !== undefined; + const hasColor = args.color !== undefined; + + // No mutation requested → print current meta. + if (!hasLabel && !hasColor) { + printCurrent(sessionId, args.projectDir, args.json); + return; + } + + if (hasColor && !isColorName(args.color)) { + throw new Error( + `Invalid color "${String(args.color)}". Valid: none, red, amber, green, blue, violet, gray.`, + ); + } + + setSessionMeta(args.projectDir, sessionId, { + ...(hasLabel ? { label: args.label } : {}), + ...(hasColor ? { color: args.color as ColorName } : {}), + }); + + if (args.json) { + printCurrent(sessionId, args.projectDir, true); + return; + } + + const parts = [ + hasLabel ? (args.label?.trim() ? `label "${args.label}"` : "cleared label") : null, + hasColor ? (args.color === "none" ? "cleared color" : `color ${args.color}`) : null, + ].filter((p): p is string => p !== null); + console.log(green(`Updated ${sessionId.slice(0, 8)}: ${parts.join(", ")}.`)); +}; diff --git a/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts similarity index 100% rename from src/commands/plugin.ts rename to packages/cli/src/commands/plugin.ts diff --git a/src/commands/reasoning.ts b/packages/cli/src/commands/reasoning.ts similarity index 100% rename from src/commands/reasoning.ts rename to packages/cli/src/commands/reasoning.ts diff --git a/src/commands/report.ts b/packages/cli/src/commands/report.ts similarity index 86% rename from src/commands/report.ts rename to packages/cli/src/commands/report.ts index ab8ccbe..154bbd8 100644 --- a/src/commands/report.ts +++ b/packages/cli/src/commands/report.ts @@ -1,7 +1,41 @@ -import type { BacktrackResult, DistilledSession, FileMapEntry } from "../types"; +import type { BacktrackResult, DistilledSession, FileMapEntry, SessionConfig } from "../types"; import { flattenAgents, formatSessionDateFull } from "../utils"; import { classifySeverity, fmtDuration, truncate } from "./format-helpers"; -import { bold, cyan, dim, green } from "./shared"; +import { bold, cyan, dim, green, yellow } from "./shared"; + +// --- Config / Environment section (pure) --- + +/** Permission modes that relax guardrails -- rendered with an amber LED. */ +const RELAXED_PERMISSION_MODES = new Set(["bypassPermissions", "dontAsk"]); + +/** + * Render the CONFIG / ENVIRONMENT block for a session. Returns [] when no config + * data is known (old distills) so the caller can omit the section entirely. + * The permission-mode bullet maps to the locked LED palette: amber when guardrails + * are relaxed (bypassPermissions/dontAsk), signal-green otherwise. + */ +export const renderConfigSection = (config: SessionConfig | undefined): readonly string[] => { + if (!config) return []; + + const rows: string[] = []; + + if (config.permission_mode) { + const relaxed = RELAXED_PERMISSION_MODES.has(config.permission_mode); + const led = relaxed ? yellow("■") : green("■"); + rows.push(` ${led} Permission: ${config.permission_mode}`); + } + if (config.effort) { + rows.push(` Effort: ${config.effort}`); + } + if (config.mcp_servers.length > 0) { + const servers = config.mcp_servers.map((s) => `${s.name} (${s.count})`).join(", "); + rows.push(` MCP servers: ${servers}`); + } + + if (rows.length === 0) return []; + + return ["", bold(" Config / Environment:"), ...rows]; +}; const typeLabel = (type: BacktrackResult["type"]): string => type === "failure_retry" @@ -175,6 +209,7 @@ export const renderReportDefault = (distilled: DistilledSession): string => { ...btSection, ...highRiskSection, ...topToolsSection, + ...renderConfigSection(distilled.session_config), ...agentSection, "", tip, @@ -296,7 +331,7 @@ export const reportCommand = async (args: { return; } if (distilled.reasoning.length === 0) { - console.log("No reasoning data found. Run 'clens distill --deep' to extract reasoning from transcripts."); + console.log("No reasoning data found. Run 'clens distill' to extract reasoning from transcripts."); return; } console.log(args.full ? renderReasoningFull(distilled, args.intent) : renderReasoningSummary(distilled)); diff --git a/src/commands/shared.ts b/packages/cli/src/commands/shared.ts similarity index 97% rename from src/commands/shared.ts rename to packages/cli/src/commands/shared.ts index dea0490..e621d28 100644 --- a/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -3,7 +3,7 @@ import { readdirSync, statSync } from "node:fs"; export type Flags = { readonly last: boolean; readonly force: boolean; - readonly otel: boolean; + readonly yes: boolean; readonly deep: boolean; readonly json: boolean; readonly help: boolean; @@ -17,6 +17,7 @@ export type Flags = { readonly comms: boolean; readonly global: boolean; readonly legacy: boolean; + readonly pricing?: string; }; // ANSI color helpers diff --git a/src/commands/tui-formatters.ts b/packages/cli/src/commands/tui-formatters.ts similarity index 100% rename from src/commands/tui-formatters.ts rename to packages/cli/src/commands/tui-formatters.ts diff --git a/src/commands/tui-renderers.ts b/packages/cli/src/commands/tui-renderers.ts similarity index 97% rename from src/commands/tui-renderers.ts rename to packages/cli/src/commands/tui-renderers.ts index d04d929..0607902 100644 --- a/src/commands/tui-renderers.ts +++ b/packages/cli/src/commands/tui-renderers.ts @@ -78,7 +78,9 @@ export const formatAgentRow = ( ? agent.file_map.files.filter((f) => f.edits > 0 || f.writes > 0).length : 0; const files = String(filesCount).padEnd(8); - const cost = agent.cost_estimate ? `$${agent.cost_estimate.estimated_cost_usd.toFixed(2)}` : "-"; + const cost = agent.cost_estimate + ? `${agent.cost_estimate.is_estimated ? "~" : ""}$${agent.cost_estimate.estimated_cost_usd.toFixed(2)}` + : "-"; const row = `${truncLabel.padEnd(maxLabelLen + 2)}${dur}${tools}${files}${cost}`; const trimmed = row.slice(0, width); return selected ? ansi.inverse(trimmed.padEnd(width)) : trimmed; @@ -210,7 +212,11 @@ export const formatAgentDetail = (agent: AgentNode): readonly string[] => { : []; const costSection = agent.cost_estimate - ? [ansi.bold("Cost:"), ` Total: $${agent.cost_estimate.estimated_cost_usd.toFixed(2)}`] + ? (() => { + const prefix = agent.cost_estimate.is_estimated ? "~" : ""; + const label = agent.cost_estimate.is_estimated ? " (rough estimate)" : ""; + return [ansi.bold(`Cost:${label}`), ` Total: ${prefix}$${agent.cost_estimate.estimated_cost_usd.toFixed(2)}`]; + })() : []; return [ diff --git a/src/commands/tui-state.ts b/packages/cli/src/commands/tui-state.ts similarity index 100% rename from src/commands/tui-state.ts rename to packages/cli/src/commands/tui-state.ts diff --git a/src/commands/tui-tabs.ts b/packages/cli/src/commands/tui-tabs.ts similarity index 99% rename from src/commands/tui-tabs.ts rename to packages/cli/src/commands/tui-tabs.ts index 495aa12..70d34ff 100644 --- a/src/commands/tui-tabs.ts +++ b/packages/cli/src/commands/tui-tabs.ts @@ -310,7 +310,7 @@ export const formatCommsTab = ( return [ ansi.dim("No communication data available."), "", - ansi.dim("Re-run: clens distill --deep "), + ansi.dim("Re-run: clens distill "), ]; } @@ -498,7 +498,7 @@ export const formatReasoningTab = (distilled: DistilledSession): readonly string return [ ansi.bold("Reasoning:"), "", - ansi.dim("No reasoning data. Run 'clens distill --deep' to extract."), + ansi.dim("No reasoning data. Run 'clens distill' to extract."), ]; const byIntent = reasoning.reduce>>((acc, r) => { diff --git a/src/commands/tui.ts b/packages/cli/src/commands/tui.ts similarity index 100% rename from src/commands/tui.ts rename to packages/cli/src/commands/tui.ts diff --git a/packages/cli/src/commands/web.ts b/packages/cli/src/commands/web.ts new file mode 100644 index 0000000..368e586 --- /dev/null +++ b/packages/cli/src/commands/web.ts @@ -0,0 +1,84 @@ +import { resolve } from "node:path"; +import { bold, cyan, dim } from "./shared"; + +interface WebCommandOptions { + readonly projectDir: string; + readonly port: number; + readonly open: boolean; + readonly global: boolean; +} + +/** Open a URL in the default browser (cross-platform: macOS / Linux / Windows). */ +const openBrowser = (url: string): void => { + const command = + process.platform === "darwin" + ? ["open", url] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + try { + Bun.spawn(command, { stdout: "ignore", stderr: "ignore" }); + } catch { + // Silently ignore — user can open manually + } +}; + +export const webCommand = async (options: WebCommandOptions): Promise => { + // Force production mode by default: the dashboard is served from the bundled + // static client (set NODE_ENV=development only when driving the vite dev server). + if (!process.env.NODE_ENV) { + process.env.NODE_ENV = "production"; + } + + // The web server is bundled into this CLI at build time (see build.ts); it is + // NOT a runtime dependency, so npm consumers need nothing from @clens/web. + const { startServer, findProjectDir } = await import("@clens/web/server"); + + // Built client bundle ships next to the compiled CLI at dist/web/. When this + // module is bundled into dist/cli.js, import.meta.dir resolves to dist/. + const distDir = resolve(import.meta.dir, "web"); + + const projectDir = findProjectDir(options.projectDir); + + // In global mode, discover + register projects, then pass to server + const projects = options.global + ? await (async () => { + const { discoverAndRegisterProjects } = await import("../session/registry"); + return discoverAndRegisterProjects(); + })() + : undefined; + + const handle = startServer({ + projectDir, + port: options.port, + distDir, + ...(projects && projects.length > 0 ? { projects } : {}), + }); + + const authUrl = `${handle.url}?token=${handle.token}`; + + console.log(`${bold("cLens Web")} started on ${cyan(handle.url)}`); + if (projects && projects.length > 0) { + console.log(`${dim("Mode:")} global (${projects.length} project${projects.length === 1 ? "" : "s"})`); + } + console.log(`${dim("Token:")} ${handle.token}`); + console.log(`${dim("Open:")} ${authUrl}`); + + if (options.open) { + openBrowser(authUrl); + } + + // Keep process alive until interrupted + process.on("SIGINT", () => { + handle.stop(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + handle.stop(); + process.exit(0); + }); + + // Block indefinitely + await new Promise(() => {}); +}; diff --git a/src/commands/what.ts b/packages/cli/src/commands/what.ts similarity index 64% rename from src/commands/what.ts rename to packages/cli/src/commands/what.ts index 26b8058..ef7e32f 100644 --- a/src/commands/what.ts +++ b/packages/cli/src/commands/what.ts @@ -1,7 +1,26 @@ -import type { DistilledSession } from "../types"; +import type { DistilledSession, SessionConfig } from "../types"; import { classifySeverity, truncate } from "./format-helpers"; import { bold, cyan, dim, green } from "./shared"; +// --- Config line (pure) --- + +/** + * Dense single-line summary of a session's effective config. Omits segments with + * no data; returns undefined when nothing is known (so callers can skip the line). + * Pure — no I/O, no ANSI — so it is unit-testable in isolation. + */ +export const formatConfigLine = (config: SessionConfig | undefined): string | undefined => { + if (!config) return undefined; + const segments = [ + config.permission_mode ? `perm:${config.permission_mode}` : undefined, + config.effort ? `effort:${config.effort}` : undefined, + config.mcp_servers.length > 0 + ? `mcp:${config.mcp_servers.map((s) => s.name).join(",")}` + : undefined, + ].filter((s): s is string => s !== undefined); + return segments.length > 0 ? segments.join(" · ") : undefined; +}; + // --- Section builders --- const getRequestSection = (distilled: DistilledSession): string => { @@ -21,12 +40,22 @@ const getOutcomeSection = (distilled: DistilledSession): string => { : `${wtcSummary}, ${statusStr}`; }; +const getContextSection = (distilled: DistilledSession): string => { + const cc = distilled.context_consumption; + if (!cc) return "(no context data)"; + const peak = Math.round(cc.peak_context_pct); + const compactions = cc.compaction_count; + const velocity = cc.context_velocity_per_min.toFixed(1); + return `${peak}% peak (${compactions} compaction${compactions === 1 ? "" : "s"}), velocity: ${velocity}%/min`; +}; + const getCostSection = (distilled: DistilledSession): string => { const cost = distilled.cost_estimate ?? distilled.stats.cost_estimate; if (!cost) return "(no cost data)"; const prefix = cost.is_estimated ? "~" : ""; const model = cost.model; - return `${prefix}$${cost.estimated_cost_usd.toFixed(2)} (${model})`; + const tierSuffix = cost.pricing_tier ? `, ${cost.pricing_tier} tier` : ""; + return `${prefix}$${cost.estimated_cost_usd.toFixed(2)} (${model}${tierSuffix})`; }; const getIssuesSection = (distilled: DistilledSession): readonly string[] => { @@ -83,7 +112,15 @@ interface WhatJson { readonly sample_message?: string; }[]; }; + readonly context: { + readonly peak_context_pct: number; + readonly compaction_count: number; + readonly context_velocity_per_min: number; + readonly model_context_window: number; + readonly turn_count: number; + } | null; readonly files_changed: readonly string[]; + readonly config: SessionConfig | null; } const buildWhatJson = (distilled: DistilledSession): WhatJson => { @@ -100,7 +137,7 @@ const buildWhatJson = (distilled: DistilledSession): WhatJson => { cost: cost ? { estimated_cost_usd: cost.estimated_cost_usd, - is_estimated: cost.is_estimated ?? false, + is_estimated: cost.is_estimated, model: cost.model, } : null, @@ -112,10 +149,20 @@ const buildWhatJson = (distilled: DistilledSession): WhatJson => { ...(e.sample_message ? { sample_message: e.sample_message } : {}), })), }, + context: distilled.context_consumption + ? { + peak_context_pct: distilled.context_consumption.peak_context_pct, + compaction_count: distilled.context_consumption.compaction_count, + context_velocity_per_min: distilled.context_consumption.context_velocity_per_min, + model_context_window: distilled.context_consumption.model_context_window, + turn_count: distilled.context_consumption.turn_count, + } + : null, files_changed: distilled.file_map.files .filter((f) => f.edits > 0 || f.writes > 0) .map((f) => f.file_path) .slice(0, 15), + config: distilled.session_config ?? null, }; }; @@ -124,11 +171,14 @@ const buildWhatJson = (distilled: DistilledSession): WhatJson => { const renderWhatDefault = (distilled: DistilledSession): string => { const issues = getIssuesSection(distilled); const files = getFilesChangedSection(distilled); + const configLine = formatConfigLine(distilled.session_config); return [ `${bold("Request:")} ${getRequestSection(distilled)}`, `${bold("Outcome:")} ${getOutcomeSection(distilled)}`, `${bold("Cost:")} ${getCostSection(distilled)}`, + `${bold("Context:")} ${getContextSection(distilled)}`, + ...(configLine ? [`${bold("Config:")} ${configLine}`] : []), "", bold("Issues:"), ...issues, @@ -144,10 +194,18 @@ export const whatCommand = async (args: { readonly sessionId: string; readonly projectDir: string; readonly json: boolean; + readonly pricingTier?: import("../types").PricingTier; }): Promise => { const { readDistilled } = await import("../session/read"); - const distilled = readDistilled(args.sessionId, args.projectDir); + // If --pricing is passed, re-distill to recalculate costs at the requested tier + const distilled = args.pricingTier + ? await (async () => { + const { distill } = await import("../distill/index"); + return distill(args.sessionId, args.projectDir, { pricingTier: args.pricingTier }); + })() + : readDistilled(args.sessionId, args.projectDir); + if (!distilled) { throw new Error( `No distilled data for session ${args.sessionId.slice(0, 8)}. Run: clens distill ${args.sessionId.slice(0, 8)}`, diff --git a/src/distill/active-duration.ts b/packages/cli/src/distill/active-duration.ts similarity index 100% rename from src/distill/active-duration.ts rename to packages/cli/src/distill/active-duration.ts diff --git a/src/distill/agent-distill.ts b/packages/cli/src/distill/agent-distill.ts similarity index 74% rename from src/distill/agent-distill.ts rename to packages/cli/src/distill/agent-distill.ts index c9d6a72..fd3a96f 100644 --- a/src/distill/agent-distill.ts +++ b/packages/cli/src/distill/agent-distill.ts @@ -1,17 +1,26 @@ import type { AgentDistillResult, AgentStats, + EditChainsResult, + PricingTier, StoredEvent, TokenUsage, TranscriptContentBlock, TranscriptEntry, } from "../types"; import { extractBacktracks } from "./backtracks"; +import { extractContextConsumption } from "./context-consumption"; +import { computeToolSourcedDiff } from "./diff-attribution"; import { extractEditChains } from "./edit-chains"; import { extractFileMap } from "./file-map"; import { extractReasoning } from "./reasoning"; import { estimateCostFromTokens, extractStats } from "./stats"; +export interface DiffContext { + readonly projectDir: string; + readonly parentEvents: readonly StoredEvent[]; +} + const isAssistantEntry = (entry: TranscriptEntry): boolean => entry.type === "assistant"; const isToolUseBlock = ( @@ -90,14 +99,25 @@ export const transcriptToEvents = (entries: readonly TranscriptEntry[]): readonl const safeNumber = (value: unknown): number => (typeof value === "number" ? value : 0); /** - * Sum per-turn token usage across all assistant entries. + * Sum per-turn token usage across all assistant entries, deduplicating by requestId. + * Claude Code writes 2-4 assistant messages per API call with identical usage data + * (streaming chunks). Group by requestId and take the last entry per group to avoid + * 2-3x overcounting. Falls back to uuid when requestId is absent. + * * Claude API semantics: `input_tokens` excludes cached tokens (only new/uncached input), * `cache_read_input_tokens` = tokens served from prompt cache, * `cache_creation_input_tokens` = tokens written to cache. * Total billable input = input_tokens + cache_read + cache_creation. */ -export const extractTokenUsage = (entries: readonly TranscriptEntry[]): TokenUsage => - entries.filter(isAssistantEntry).reduce( +export const extractTokenUsage = (entries: readonly TranscriptEntry[]): TokenUsage => { + const assistantEntries = entries.filter(isAssistantEntry); + + // Group by requestId, take last per group (streaming dedup — last write wins) + const byRequest = new Map( + assistantEntries.map((entry) => [entry.requestId ?? entry.uuid, entry] as const), + ); + + return [...byRequest.values()].reduce( (acc, entry) => { const usage = entry.message?.usage; if (!usage) return acc; @@ -111,6 +131,10 @@ export const extractTokenUsage = (entries: readonly TranscriptEntry[]): TokenUsa }, { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, ); +}; + +export const extractUserType = (entries: readonly TranscriptEntry[]): string | undefined => + entries.find((e) => e.userType)?.userType; export const extractTaskPrompt = (entries: readonly TranscriptEntry[]): string | undefined => { const first = entries.find( @@ -133,16 +157,16 @@ export const extractAgentModel = (entries: readonly TranscriptEntry[]): string | return firstAssistant?.message?.model; }; -export const distillAgent = (entries: readonly TranscriptEntry[]): AgentDistillResult | undefined => { +export const distillAgent = (entries: readonly TranscriptEntry[], diffContext?: DiffContext, tier: PricingTier = "api"): AgentDistillResult | undefined => { if (entries.length === 0) return undefined; const events = transcriptToEvents(entries); - const statsResult = extractStats(events); + const statsResult = extractStats(events, [], undefined, tier); const file_map = extractFileMap(events); const token_usage = extractTokenUsage(entries); const model = extractAgentModel(entries); const realCost = model && token_usage.input_tokens > 0 - ? estimateCostFromTokens(model, token_usage.input_tokens, token_usage.output_tokens, token_usage.cache_read_tokens, token_usage.cache_creation_tokens) + ? estimateCostFromTokens(model, token_usage.input_tokens, token_usage.output_tokens, token_usage.cache_read_tokens, token_usage.cache_creation_tokens, tier) : statsResult.cost_estimate; const task_prompt = extractTaskPrompt(entries); @@ -155,6 +179,18 @@ export const distillAgent = (entries: readonly TranscriptEntry[]): AgentDistillR // Extract edit chains binding reasoning + backtracks to file edits const edit_chains = extractEditChains(events, reasoning, backtracks); + // Compute diff attribution from tool events (git-independent, works for sub-agents) + const diff_attribution = edit_chains.chains.length > 0 + ? computeToolSourcedDiff(events, edit_chains, diffContext?.projectDir ?? "") + : undefined; + + const fullEditChains: EditChainsResult | undefined = edit_chains.chains.length > 0 + ? { + ...edit_chains, + ...(diff_attribution && diff_attribution.length > 0 ? { diff_attribution } : {}), + } + : undefined; + const stats: AgentStats = { tool_call_count: statsResult.tool_call_count, failure_count: statsResult.failure_count, @@ -163,6 +199,9 @@ export const distillAgent = (entries: readonly TranscriptEntry[]): AgentDistillR token_usage, }; + // Extract per-agent context consumption from transcript + const context_consumption = extractContextConsumption(entries, model); + return { stats, file_map, @@ -172,6 +211,7 @@ export const distillAgent = (entries: readonly TranscriptEntry[]): AgentDistillR ...(task_prompt !== undefined ? { task_prompt } : {}), ...(reasoning.length > 0 ? { reasoning } : {}), ...(backtracks.length > 0 ? { backtracks } : {}), - ...(edit_chains.chains.length > 0 ? { edit_chains } : {}), + ...(fullEditChains ? { edit_chains: fullEditChains } : {}), + ...(context_consumption ? { context_consumption } : {}), }; }; diff --git a/src/distill/agent-enrich.ts b/packages/cli/src/distill/agent-enrich.ts similarity index 100% rename from src/distill/agent-enrich.ts rename to packages/cli/src/distill/agent-enrich.ts diff --git a/src/distill/agent-tree.ts b/packages/cli/src/distill/agent-tree.ts similarity index 75% rename from src/distill/agent-tree.ts rename to packages/cli/src/distill/agent-tree.ts index 5d793f3..7729456 100644 --- a/src/distill/agent-tree.ts +++ b/packages/cli/src/distill/agent-tree.ts @@ -1,12 +1,46 @@ -import type { AgentNode, LinkEvent, MessageLink, SpawnLink, StopLink, StoredEvent, TaskLink, TranscriptEntry } from "../types"; +import type { AgentNode, LinkEvent, MessageLink, PricingTier, SpawnLink, StopLink, StoredEvent, TaskLink, TranscriptEntry } from "../types"; import { computeEffectiveDuration, deduplicateSpawns, IDLE_THRESHOLD_MS } from "../utils"; -import { distillAgent } from "./agent-distill"; +import { type DiffContext, distillAgent } from "./agent-distill"; import { extractFileMap } from "./file-map"; import { extractStats } from "./stats"; const isSpawnLink = (link: LinkEvent): link is SpawnLink => link.type === "spawn"; const isStopLink = (link: LinkEvent): link is StopLink => link.type === "stop"; +/** Minimal event shape needed for tool-call attribution. */ +type AttributableEvent = { readonly t: number; readonly event: string; readonly data: Record }; + +/** Read a string `data.agent_id` tag from an event, if present (untrusted JSON — narrow, never cast). */ +const eventAgentId = (event: AttributableEvent): string | undefined => { + const raw = event.data.agent_id; + return typeof raw === "string" && raw.length > 0 ? raw : undefined; +}; + +/** + * Count PreToolUse calls attributable to an agent. + * + * Preferred: raw hook events carry `data.agent_id` tagging which agent issued each + * tool call. When the agent has any tagged events, that count is authoritative — pure + * time-window attribution mis-assigns calls across overlapping/nested agent intervals + * and zeroes agents whose tagged events fall outside the naive spawn→stop window (ghost + * zeroing). Fall back to the spawn→stop time window only for fully-untagged sessions. + */ +const countAgentToolCalls = ( + agentId: string, + spawnT: number, + stopT: number | undefined, + events: readonly AttributableEvent[], +): { readonly count: number; readonly tagged: boolean } => { + const preEvents = events.filter((e) => e.event === "PreToolUse"); + const tagged = preEvents.filter((e) => eventAgentId(e) === agentId); + if (tagged.length > 0) return { count: tagged.length, tagged: true }; + + const windowCount = preEvents.filter( + (e) => eventAgentId(e) === undefined && e.t >= spawnT && (stopT !== undefined ? e.t <= stopT : true), + ).length; + return { count: windowCount, tagged: false }; +}; + /** Agent interval for event attribution. */ interface AgentInterval { readonly agentId: string; @@ -96,9 +130,11 @@ export const enrichNodeWithTranscript = ( node: AgentNode, transcriptPath: string, readTranscriptFn: ReadTranscriptFn, + diffContext?: DiffContext, + tier: PricingTier = "api", ): AgentNode => { const entries = readTranscriptFn(transcriptPath); - const result = distillAgent(entries); + const result = distillAgent(entries, diffContext, tier); if (!result) { // Fallback: record that enrichment was attempted return { ...node, transcript_path: transcriptPath }; @@ -116,6 +152,7 @@ export const enrichNodeWithTranscript = ( ...(result.edit_chains ? { edit_chains: result.edit_chains } : {}), ...(result.backtracks ? { backtracks: result.backtracks } : {}), ...(result.reasoning ? { reasoning: result.reasoning } : {}), + ...(result.context_consumption ? { context_consumption: result.context_consumption } : {}), }; }; @@ -123,13 +160,14 @@ export const enrichNodeWithTranscript = ( * Enrich an AgentNode from its own session events (hook JSONL file). * Fallback for when transcript enrichment is unavailable. */ -const enrichNodeFromSessionEvents = ( +export const enrichNodeFromSessionEvents = ( node: AgentNode, agentEvents: readonly StoredEvent[], + tier: PricingTier = "api", ): AgentNode => { if (agentEvents.length === 0) return node; - const statsResult = extractStats(agentEvents); + const statsResult = extractStats(agentEvents, [], undefined, tier); const file_map = extractFileMap(agentEvents); if (statsResult.tool_call_count === 0 && file_map.files.length === 0) return node; @@ -160,6 +198,8 @@ export const buildAgentTree = ( events: readonly { t: number; event: string; data: Record }[], readTranscriptFn: ReadTranscriptFn, readAgentEventsFn?: ReadAgentEventsFn, + diffContext?: DiffContext, + tier: PricingTier = "api", ): AgentNode[] => { const spawns = deduplicateSpawns(links.filter(isSpawnLink)); const stops = links.filter(isStopLink); @@ -189,11 +229,10 @@ export const buildAgentTree = ( const childSpawns = spawns.filter((s) => s.parent_session === spawn.agent_id); const children = childSpawns.map(buildNode); - // Estimate tool calls from the spawn's events (rough: count PreToolUse in events matching time range) - const toolCallCount = events.filter( - (e) => - e.event === "PreToolUse" && e.t >= spawn.t && (matchingStop ? e.t <= matchingStop.t : true), - ).length; + // Attribute tool calls by data.agent_id when raw events carry it (authoritative); + // fall back to the spawn→stop time window only for untagged sessions. + const toolCalls = countAgentToolCalls(spawn.agent_id, spawn.t, matchingStop?.t, events); + const toolCallCount = toolCalls.count; const baseNode: AgentNode = { session_id: spawn.agent_id, @@ -207,7 +246,7 @@ export const buildAgentTree = ( // Auto-enrich when transcript path exists const transcriptPath = matchingStop?.transcript_path; if (transcriptPath) { - const enriched = enrichNodeWithTranscript(baseNode, transcriptPath, readTranscriptFn); + const enriched = enrichNodeWithTranscript(baseNode, transcriptPath, readTranscriptFn, diffContext, tier); // When hook-based toolCallCount is 0 but transcript enrichment produced stats, prefer transcript stats const finalEnriched = enriched.tool_call_count === 0 && enriched.stats && enriched.stats.tool_call_count > 0 ? { ...enriched, tool_call_count: enriched.stats.tool_call_count } @@ -220,11 +259,15 @@ export const buildAgentTree = ( // Fallback: read agent's own session events when transcript enrichment didn't produce stats if (readAgentEventsFn && baseNode.tool_call_count === 0) { const agentSessionEvents = readAgentEventsFn(spawn.agent_id); - const fromEvents = enrichNodeFromSessionEvents(baseNode, agentSessionEvents); + const fromEvents = enrichNodeFromSessionEvents(baseNode, agentSessionEvents, tier); if (fromEvents.tool_call_count > 0) return fromEvents; } - // Ghost agent: enrichment failed and tool_call_count was estimated from event attribution — reset to 0 + // When the count came from per-event data.agent_id tags it is authoritative — keep it, + // even without transcript/session-event stats (these are real tool calls, not a ghost estimate). + if (toolCalls.tagged && baseNode.tool_call_count > 0) return baseNode; + + // Ghost agent: enrichment failed and tool_call_count was estimated from the time window — reset to 0 return baseNode.tool_call_count > 0 && !baseNode.stats ? { ...baseNode, tool_call_count: 0 } : baseNode; @@ -240,17 +283,21 @@ const isTaskLinkGuard = (link: LinkEvent): link is TaskLink => link.type === "ta * Infer agent nodes from communication links when no spawn links exist. * Extracts agent names from msg_send recipients, maps names to UUIDs via task links, * and computes duration from first-to-last activity timestamps. + * + * When `teamMemberSessions` is provided, agent names found in the map get their + * real session_id (enabling event/transcript reading for enrichment). */ export const inferAgentsFromComms = ( sessionId: string, links: readonly LinkEvent[], + teamMemberSessions?: ReadonlyMap, ): AgentNode[] => { // Collect unique agent names from msg_send where the session is the sender const msgRecipients = links .filter(isMessageLink) .filter((msg) => msg.from === sessionId || msg.session_id === sessionId) .map((msg) => msg.to); - const uniqueNames = [...new Set(msgRecipients)]; + const uniqueNames = [...new Set(msgRecipients)].filter((name) => name.length > 0); if (uniqueNames.length === 0) return []; @@ -273,14 +320,17 @@ export const inferAgentsFromComms = ( return []; }); + // Resolution priority: teamMemberSessions (real session_id) > task-link UUID > agent name + const resolveSessionId = (agentName: string): string => + teamMemberSessions?.get(agentName) ?? nameToUuid.get(agentName) ?? agentName; + return uniqueNames.map((agentName): AgentNode => { - const uuid = nameToUuid.get(agentName); const timestamps = activityTimestamps(agentName); const firstT = timestamps.length > 0 ? Math.min(...timestamps) : 0; const lastT = timestamps.length > 0 ? Math.max(...timestamps) : 0; return { - session_id: uuid ?? agentName, + session_id: resolveSessionId(agentName), agent_type: "builder", agent_name: agentName, duration_ms: lastT - firstT, diff --git a/src/distill/aggregate.ts b/packages/cli/src/distill/aggregate.ts similarity index 71% rename from src/distill/aggregate.ts rename to packages/cli/src/distill/aggregate.ts index a01f23a..ef68836 100644 --- a/src/distill/aggregate.ts +++ b/packages/cli/src/distill/aggregate.ts @@ -9,6 +9,7 @@ import type { FileMapEntry, FileMapResult, StatsResult, + TokenUsage, TranscriptReasoning, } from "../types"; import { flattenAgents, sanitizeAgentName } from "../utils"; @@ -56,45 +57,60 @@ export const mergeFileMaps = (maps: readonly FileMapResult[]): FileMapResult => // --- mergeStats --- -/** Sum tool_call_count, failure_count, union unique_files, merge tools_by_name. */ +/** + * Merge agent-only dimensions onto the parent stats. + * + * The parent JSONL stream records EVERY tool call in the session — subagent tool + * calls land in the same parent stream as PreToolUse/PostToolUseFailure events, so + * `parentStats` already counts them. Per-agent stats are re-derived from each + * subagent transcript, i.e. the SAME calls a second time. Re-adding agent + * tool/failure/per-tool counts therefore double-counts (bug B4: 664 vs raw 336, + * failures 25 vs 11). Parent counts computed over the full stream are authoritative + * for the session, so tool_call_count, failure_count, failure_rate and + * tools_by_name are taken from the parent unchanged. + * + * Agent-only dimensions are still aggregated: + * - unique_files: unioned (a harmless superset of the parent set; parent tool + * events already carry file paths, so this rarely adds anything but never + * double-counts since it is a set union, not a sum). + * - token_usage: summed. Parent hook events carry no token usage; per-turn token + * counts only exist in the per-agent transcripts, so this is the one dimension + * that genuinely requires agent contributions. + */ export const mergeStats = ( parentStats: StatsResult, agentStats: readonly AgentStats[], ): StatsResult => { - const totalToolCallCount = agentStats.reduce( - (acc, s) => acc + s.tool_call_count, - parentStats.tool_call_count, - ); - - const totalFailureCount = agentStats.reduce( - (acc, s) => acc + s.failure_count, - parentStats.failure_count, - ); - const uniqueFilesSet = new Set([ ...parentStats.unique_files, ...agentStats.flatMap((s) => s.unique_files), ]); - const mergedToolsByName = agentStats.reduce>( - (acc, s) => - Object.entries(s.tools_by_name).reduce>( - (inner, [name, count]) => ({ - ...inner, - [name]: (inner[name] ?? 0) + count, - }), - acc, - ), - { ...parentStats.tools_by_name }, - ); + // Sum token_usage from parent + agents (parent hook events usually lack usage) + const allTokenUsages = [ + parentStats.token_usage, + ...agentStats.map((s) => s.token_usage), + ].filter((u): u is TokenUsage => u !== undefined); + + const mergedTokenUsage: TokenUsage | undefined = + allTokenUsages.length > 0 + ? allTokenUsages.reduce( + (acc, u) => ({ + input_tokens: acc.input_tokens + u.input_tokens, + output_tokens: acc.output_tokens + u.output_tokens, + cache_read_tokens: acc.cache_read_tokens + u.cache_read_tokens, + cache_creation_tokens: acc.cache_creation_tokens + u.cache_creation_tokens, + }), + { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, + ) + : undefined; return { ...parentStats, - tool_call_count: totalToolCallCount, - failure_count: totalFailureCount, - failure_rate: totalToolCallCount > 0 ? totalFailureCount / totalToolCallCount : 0, + // tool_call_count, failure_count, failure_rate, tools_by_name: authoritative + // from the parent stream — NOT re-added from per-agent stats (would double-count). unique_files: Array.from(uniqueFilesSet), - tools_by_name: mergedToolsByName, + ...(mergedTokenUsage ? { token_usage: mergedTokenUsage } : {}), }; }; @@ -178,7 +194,16 @@ export const mergeBacktracks = ( // --- mergeCostEstimates --- -/** Sum estimated_input_tokens, estimated_output_tokens, estimated_cost_usd, and cache tokens. Use parent's model or first available. */ +/** + * Sum estimated tokens, cost, and cache tokens across the parent and its agents. + * + * model: a merged cost spans every contributor. Labeling it with one arbitrary model + * (e.g. the first agent's) is a mislabel when agents ran different models. Only assert a + * single model when every contributing cost agrees on it; otherwise report "mixed". + * + * pricing_tier: preserved from the parent (or first available) so the merged estimate + * keeps reporting the tier it was priced at instead of silently dropping it. + */ export const mergeCostEstimates = ( parentCost: CostEstimate | undefined, agentCosts: readonly (CostEstimate | undefined)[], @@ -187,13 +212,17 @@ export const mergeCostEstimates = ( if (allCosts.length === 0) return undefined; - const model = parentCost?.model ?? allCosts[0].model; + const distinctModels = [...new Set(allCosts.map((c) => c.model))]; + const model = distinctModels.length === 1 ? distinctModels[0] : "mixed"; + + const pricingTier = parentCost?.pricing_tier ?? allCosts.find((c) => c.pricing_tier !== undefined)?.pricing_tier; const totalInputTokens = allCosts.reduce((acc, c) => acc + c.estimated_input_tokens, 0); const totalOutputTokens = allCosts.reduce((acc, c) => acc + c.estimated_output_tokens, 0); const totalCostUsd = allCosts.reduce((acc, c) => acc + c.estimated_cost_usd, 0); const totalCacheRead = allCosts.reduce((acc, c) => acc + (c.cache_read_tokens ?? 0), 0); const totalCacheCreation = allCosts.reduce((acc, c) => acc + (c.cache_creation_tokens ?? 0), 0); + const isEstimated = allCosts.some((c) => c.is_estimated); return { model, @@ -202,6 +231,8 @@ export const mergeCostEstimates = ( estimated_cost_usd: Math.round(totalCostUsd * 10000) / 10000, ...(totalCacheRead > 0 ? { cache_read_tokens: totalCacheRead } : {}), ...(totalCacheCreation > 0 ? { cache_creation_tokens: totalCacheCreation } : {}), + is_estimated: isEstimated, + ...(pricingTier !== undefined ? { pricing_tier: pricingTier } : {}), }; }; diff --git a/packages/cli/src/distill/analytics-summary.ts b/packages/cli/src/distill/analytics-summary.ts new file mode 100644 index 0000000..d193fc5 --- /dev/null +++ b/packages/cli/src/distill/analytics-summary.ts @@ -0,0 +1,352 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; +import type { CostBasis, CostEstimate, DistilledSession, AnalyticsSummaryRow } from "../types"; + +/** + * Atomically write content to a file: write to a unique temp sibling, then + * rename it over the target. `rename(2)` is atomic on the same filesystem, so a + * concurrent reader observes either the old file or the new one in full — never + * a half-written file. The temp name is keyed by pid + timestamp to avoid + * clobbering between concurrent writers. + */ +const atomicWriteFile = (filePath: string, content: string): void => { + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmpPath, content); + renameSync(tmpPath, filePath); +}; + +/** Parse a JSONL line's `session_id`, or undefined if the line is malformed. */ +const lineSessionId = (line: string): string | undefined => { + try { + const parsed: unknown = JSON.parse(line); + if (parsed && typeof parsed === "object" && "session_id" in parsed) { + const id = (parsed as { session_id?: unknown }).session_id; + return typeof id === "string" ? id : undefined; + } + } catch { + // malformed line + } + return undefined; +}; + +/** + * Derive the cost provenance for a summary row from a distilled session's cost estimate. + * + * Prefers the explicit `cost_basis` stamped at distill time. For rows distilled before the + * cost-truth work (no `cost_basis` tag), `is_estimated === false` historically meant + * token-grounded — i.e. "estimated", NEVER measured `total_cost_usd` (the measured tier did + * not exist yet). Mapping it to "measured" would inflate measured_fraction and under-report + * the "X% estimated" badge, so legacy token-grounded rows are "estimated" and the rest + * "heuristic". Only an explicit tag can claim "measured". + */ +const deriveCostBasis = (ce: CostEstimate | undefined): CostBasis => { + if (!ce) return "heuristic"; + if (ce.cost_basis) return ce.cost_basis; + return ce.is_estimated === false ? "estimated" : "heuristic"; +}; + +/** + * Format a timestamp as a LOCAL calendar day "YYYY-MM-DD". + * + * Day bucketing previously used `toISOString().slice(0,10)` which buckets by UTC + * day — a session at 23:30 local on the 14th (in a negative-offset zone) landed + * on the 15th, and vice versa. Analytics must bucket by the user's local day so + * sessions appear under the date they actually worked (see B18). + */ +export const localDayKey = (ms: number): string => { + const d = new Date(ms); + const year = d.getFullYear(); + const month = `${d.getMonth() + 1}`.padStart(2, "0"); + const day = `${d.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +/** Extract an analytics summary row from a distilled session. */ +const toSummaryRow = (d: DistilledSession): AnalyticsSummaryRow => { + const stats = d.stats; + const ce = stats.cost_estimate; + const tu = stats.token_usage; + + // Token fallback: token_usage > cost_estimate fields > zeros + const inputTokens = tu?.input_tokens ?? ce?.estimated_input_tokens ?? 0; + const outputTokens = tu?.output_tokens ?? ce?.estimated_output_tokens ?? 0; + const cacheRead = tu?.cache_read_tokens ?? ce?.cache_read_tokens ?? 0; + const cacheCreate = tu?.cache_creation_tokens ?? ce?.cache_creation_tokens ?? 0; + + // Agent type breakdown + const agentTypes = (d.agents ?? []).map((a) => ({ + type: a.agent_type, + count: 1, + cost: a.cost_estimate?.estimated_cost_usd ?? 0, + duration_ms: a.duration_ms, + tool_calls: a.tool_call_count, + failure_count: a.stats?.failure_count ?? 0, + })); + + // Reasoning by intent + const reasoningByIntent = d.reasoning.reduce>( + (acc, r) => ({ + ...acc, + [r.intent_hint ?? "unclassified"]: (acc[r.intent_hint ?? "unclassified"] ?? 0) + 1, + }), + {}, + ); + + // Decision types + const decisionTypes = d.decisions.reduce>( + (acc, dec) => ({ ...acc, [dec.type]: (acc[dec.type] ?? 0) + 1 }), + {}, + ); + + // Edit chain stats. edit_chain_links is the total number of edits across all + // chains; dividing by edit_chain_count yields the real mean chain length (B19). + const chains = d.edit_chains?.chains ?? []; + const abandonedEdits = chains.reduce((sum, c) => sum + c.abandoned_edit_ids.length, 0); + const survivingEdits = chains.reduce((sum, c) => sum + c.surviving_edit_ids.length, 0); + const editChainLinks = chains.reduce((sum, c) => sum + c.total_edits, 0); + + // Top errors from summary + const topErrors = (d.summary?.top_errors ?? []).map((e) => ({ + tool: e.tool_name, + message: e.sample_message ?? "", + count: e.count, + })); + + // Backtrack files + const backtrackFiles = [...new Set(d.backtracks.flatMap((b) => (b.file_path ? [b.file_path] : [])))]; + + // Backtracks by type + const backtracksByType = d.backtracks.reduce>( + (acc, b) => ({ ...acc, [b.type]: (acc[b.type] ?? 0) + 1 }), + {}, + ); + + // Per-tool call counts (denominator for tool failure rates; see B11) and failures + const toolsByName = stats.tools_by_name ?? {}; + const failuresByTool = stats.failures_by_tool ?? {}; + + // Date: use start_time, fallback to now — bucketed by LOCAL calendar day (B18) + const dateStr = localDayKey(d.start_time ?? Date.now()); + + // Cost-truth provenance. cost_usd is the API-equivalent value (full list price); + // measured_cost_usd is the portion of it backed by a real measured cost (else 0). + const costUsd = ce?.estimated_cost_usd ?? 0; + const costBasis = deriveCostBasis(ce); + const measuredCostUsd = costBasis === "measured" ? costUsd : 0; + + return { + session_id: d.session_id, + date: dateStr, + duration_ms: stats.duration_ms, + model: stats.model ?? ce?.model, + cost_usd: costUsd, + cost_basis: costBasis, + measured_cost_usd: measuredCostUsd, + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_tokens: cacheRead, + cache_creation_tokens: cacheCreate, + is_estimated: ce?.is_estimated ?? true, + tool_call_count: stats.tool_call_count, + failure_count: stats.failure_count, + tools_by_name: toolsByName, + failures_by_tool: failuresByTool, + agent_count: d.agents?.length ?? 0, + agent_types: agentTypes, + backtrack_count: d.backtracks.length, + backtracks_by_type: backtracksByType, + backtrack_files: backtrackFiles, + reasoning_by_intent: reasoningByIntent, + edit_chain_count: chains.length, + edit_chain_links: editChainLinks, + abandoned_edits: abandonedEdits, + surviving_edits: survivingEdits, + drift_score: d.plan_drift?.drift_score, + unexpected_files: d.plan_drift?.unexpected_files.length, + decision_types: decisionTypes, + top_errors: topErrors, + }; +}; + +const summaryPath = (projectDir: string): string => + `${projectDir}/.clens/analytics-summary.jsonl`; + +/** Parse JSONL summary content into rows, skipping malformed/non-row lines. */ +const parseSummaryRows = (content: string): AnalyticsSummaryRow[] => + content + .split("\n") + .filter(Boolean) + .flatMap((line): AnalyticsSummaryRow[] => { + try { + const parsed: unknown = JSON.parse(line); + if (parsed && typeof parsed === "object" && "session_id" in parsed) { + return [parsed as AnalyticsSummaryRow]; + } + return []; + } catch { + return []; + } + }); + +/** + * Load the existing summary file as a map of session_id → raw JSONL line, + * preserving file order (a `Map` keeps insertion order; updating an existing key + * keeps its original slot). Keyed on the parsed JSON id (not a substring scan) so + * an id appearing inside another row's free-text field — e.g. an error message or + * file path — cannot trigger a false match. + */ +const loadSummaryLineMap = (filePath: string): Map => { + const map = new Map(); + if (!existsSync(filePath)) return map; + for (const line of readFileSync(filePath, "utf-8").split("\n").filter(Boolean)) { + const id = lineSessionId(line); + if (id !== undefined) map.set(id, line); + } + return map; +}; + +/** + * Write or replace analytics summary rows for a batch of sessions in a SINGLE + * pass: load the existing file once, merge every row in memory (last write wins + * per session_id, existing rows preserved), then flush once via atomic + * temp+rename. A batch distill of N sessions thus performs O(N) total summary + * work, not the O(N²) of re-reading and re-writing the whole file per session. + */ +export const writeAnalyticsSummaryBatch = ( + distilled: readonly DistilledSession[], + projectDir: string, +): void => { + if (distilled.length === 0) return; + const filePath = summaryPath(projectDir); + const byId = loadSummaryLineMap(filePath); + for (const d of distilled) { + const row = toSummaryRow(d); + byId.set(row.session_id, JSON.stringify(row)); + } + + const dir = `${projectDir}/.clens`; + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + atomicWriteFile(filePath, `${[...byId.values()].join("\n")}\n`); +}; + +/** + * Write or replace a single session's analytics summary row. Thin wrapper over + * the batch writer (one read + one atomic write). + */ +export const writeAnalyticsSummary = ( + distilled: DistilledSession, + projectDir: string, +): void => writeAnalyticsSummaryBatch([distilled], projectDir); + +/** True iff the distilled file is strictly newer than the summary file. */ +const distilledNewerThanSummary = (distilledFilePath: string, summaryMtimeMs: number): boolean => { + try { + return statSync(distilledFilePath).mtimeMs > summaryMtimeMs; + } catch { + return false; + } +}; + +/** Build a summary row from a distilled file on disk, or undefined if unreadable. */ +const buildRowFromDistilled = (distilledFilePath: string): AnalyticsSummaryRow | undefined => { + try { + const distilled = JSON.parse(readFileSync(distilledFilePath, "utf-8")) as DistilledSession; + if (distilled.session_id && distilled.stats) return toSummaryRow(distilled); + } catch { + // malformed distilled file + } + return undefined; +}; + +/** + * Read all analytics summary rows, reconciled against `distilled/` on disk. This + * is a PURE read: it never mutates disk. + * + * The cached `analytics-summary.jsonl` can silently diverge from `distilled/` — + * rows lost to a write race or mid-batch crash, or rows left stale by a + * re-distill (the cache once reported 317 rows against 320 distilled sessions). + * So `distilled/` is the source of truth for BOTH which sessions count as + * analyzed AND whether each cached row is current: + * • a cached row is reused only when its distilled file is no newer than the + * summary file (mtime check) — otherwise it is rebuilt from distilled; + * • a distilled file with no cached row is built fresh (recovers lost rows); + * • a cached row with no distilled file is dropped, so the returned count + * equals the distilled-on-disk count (coverage no longer under-reports). + * + * Reconciliation stays in memory; `rebuildAnalyticsSummary` re-materializes the + * file explicitly so a read can never race on (or corrupt) the summary file. + */ +export const readAnalyticsSummary = (projectDir: string): readonly AnalyticsSummaryRow[] => { + const filePath = summaryPath(projectDir); + const distilledDir = `${projectDir}/.clens/distilled`; + + // No distilled/ dir → nothing to reconcile against; return cached rows verbatim. + if (!existsSync(distilledDir)) { + return existsSync(filePath) ? parseSummaryRows(readFileSync(filePath, "utf-8")) : []; + } + + const files = readdirSync(distilledDir).filter((f) => f.endsWith(".json")); + if (files.length === 0) return []; + + const cachedById = new Map(); + let summaryMtimeMs = 0; + if (existsSync(filePath)) { + summaryMtimeMs = statSync(filePath).mtimeMs; + for (const row of parseSummaryRows(readFileSync(filePath, "utf-8"))) { + cachedById.set(row.session_id, row); + } + } + + const rows: AnalyticsSummaryRow[] = []; + for (const file of files) { + const sessionId = file.slice(0, -".json".length); + const distilledFilePath = `${distilledDir}/${file}`; + const cached = cachedById.get(sessionId); + if (cached && !distilledNewerThanSummary(distilledFilePath, summaryMtimeMs)) { + rows.push(cached); + continue; + } + // Missing or stale → rebuild from distilled, falling back to the stale + // cached row only if the distilled file is unreadable. + const next = buildRowFromDistilled(distilledFilePath) ?? cached; + if (next) rows.push(next); + } + + return rows; +}; + +/** + * Force-rebuild analytics-summary.jsonl from all distilled/*.json files. + * Returns the number of sessions rebuilt. + */ +export const rebuildAnalyticsSummary = (projectDir: string): number => { + const distilledDir = `${projectDir}/.clens/distilled`; + if (!existsSync(distilledDir)) return 0; + + const files = readdirSync(distilledDir).filter((f) => f.endsWith(".json")); + if (files.length === 0) return 0; + + const rows: AnalyticsSummaryRow[] = []; + for (const file of files) { + try { + const content = readFileSync(`${distilledDir}/${file}`, "utf-8"); + const distilled = JSON.parse(content) as DistilledSession; + if (distilled.session_id && distilled.stats) { + rows.push(toSummaryRow(distilled)); + } + } catch { + // Skip malformed files + } + } + + if (rows.length > 0) { + const dir = `${projectDir}/.clens`; + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const filePath = summaryPath(projectDir); + const lines = rows.map((r) => JSON.stringify(r)).join("\n"); + atomicWriteFile(filePath, `${lines}\n`); + } + + return rows.length; +}; + +export { toSummaryRow }; diff --git a/src/distill/backtracks.ts b/packages/cli/src/distill/backtracks.ts similarity index 70% rename from src/distill/backtracks.ts rename to packages/cli/src/distill/backtracks.ts index 631dacf..84fd5e3 100644 --- a/src/distill/backtracks.ts +++ b/packages/cli/src/distill/backtracks.ts @@ -1,6 +1,21 @@ import type { BacktrackResult, StoredEvent } from "../types"; -export const extractBacktracks = (events: readonly StoredEvent[]): BacktrackResult[] => { +/** Normalized agent key for an event. Parent-session events use "" (no agent_id). */ +const agentKeyOf = (event: StoredEvent): string => + typeof event.data.agent_id === "string" ? event.data.agent_id : ""; + +/** Partition events by their agent_id so backtrack sequences never cross agent boundaries. */ +const partitionByAgent = (events: readonly StoredEvent[]): readonly (readonly StoredEvent[])[] => { + const grouped = events.reduce>((acc, event) => { + const key = agentKeyOf(event); + const existing = acc.get(key) ?? []; + return new Map(acc).set(key, [...existing, event]); + }, new Map()); + return [...grouped.values()]; +}; + +/** Detect all three backtrack patterns within a single agent's event stream. */ +const extractAgentBacktracks = (events: readonly StoredEvent[]): BacktrackResult[] => { // Pattern 1: failure_retry — PostToolUseFailure followed by PreToolUse with same tool_name const failureRetries = events.flatMap((failEvent, i): BacktrackResult[] => { if (failEvent.event !== "PostToolUseFailure") return []; @@ -97,11 +112,19 @@ export const extractBacktracks = (events: readonly StoredEvent[]): BacktrackResu const MAX_CHAIN = 50; const MAX_GAP_MS = 5 * 60 * 1000; const subsequent = bashEvents.slice(i + 1); - const consecutiveBash = subsequent.reduce<{ + const walk = subsequent.reduce<{ readonly items: typeof subsequent; readonly stopped: boolean; readonly lastT: number; readonly lastIndex: number; + // Timestamp of the most recent in-chain bash event (PreToolUse OR PostToolUseFailure), + // so a loop that ends in a failure reports its true extent rather than truncating + // at the last successful retry. (Real loops keep failing — see "requires no subsequent failures".) + readonly endT: number; + // Whether any in-chain bash event AFTER the initial failure was itself a + // PostToolUseFailure. A debugging loop is repeated *failure*; a single failure + // followed only by succeeding (non-failing) bash commands is recovery, not a loop. + readonly sawSubsequentFailure: boolean; }>( (acc, entry) => { if (acc.stopped) return acc; @@ -113,19 +136,28 @@ export const extractBacktracks = (events: readonly StoredEvent[]): BacktrackResu .some((e) => e.event === "PreToolUse" && e.data.tool_name !== "Bash"); if (hasNonBashInterleave) return { ...acc, stopped: true }; - // Include PreToolUse in chain, skip PostToolUseFailure (update tracking either way) + // Each retry is one attempt, counted from its PreToolUse. Subsequent + // PostToolUseFailure events still belong to the loop (they advance endT and + // tracking) but are not double-counted as separate attempts. return entry.event.event === "PreToolUse" - ? { items: [...acc.items, entry], stopped: false, lastT: entry.event.t, lastIndex: entry.index } - : { ...acc, lastT: entry.event.t, lastIndex: entry.index }; + ? { ...acc, items: [...acc.items, entry], lastT: entry.event.t, lastIndex: entry.index, endT: entry.event.t } + : { ...acc, lastT: entry.event.t, lastIndex: entry.index, endT: entry.event.t, sawSubsequentFailure: true }; }, - { items: [], stopped: false, lastT: bashEntry.event.t, lastIndex: bashEntry.index }, - ).items; + { items: [], stopped: false, lastT: bashEntry.event.t, lastIndex: bashEntry.index, endT: bashEntry.event.t, sawSubsequentFailure: false }, + ); + const consecutiveBash = walk.items; const debugAttempts = [ typeof bashEntry.event.data.tool_use_id === "string" ? bashEntry.event.data.tool_use_id : "", ...consecutiveBash.map((entry) => typeof entry.event.data.tool_use_id === "string" ? entry.event.data.tool_use_id : ""), ]; + // A debugging loop must be a *loop*: the initial failure plus at least one + // subsequent bash failure. Without a second failure we only have one failure + // followed by bash commands that did not fail (e.g. `git status`, `git add`) — + // that is recovery, not looping, and firing here is a false positive. + if (!walk.sawSubsequentFailure) return []; + if (debugAttempts.length < 3) return []; const initialFailEvent = bashEntry.event; @@ -135,7 +167,7 @@ export const extractBacktracks = (events: readonly StoredEvent[]): BacktrackResu tool_name: "Bash", attempts: debugAttempts.length, start_t: initialFailEvent.t, - end_t: consecutiveBash[consecutiveBash.length - 1].event.t, + end_t: walk.endT, tool_use_ids: debugAttempts, error_message: extractErrorMessage(initialFailEvent), command: extractCommand(initialFailEvent), @@ -169,6 +201,16 @@ export const extractBacktracks = (events: readonly StoredEvent[]): BacktrackResu return [...dedupedRetries, ...dedupedStruggles, ...dedupedDebugLoops]; }; +/** + * Detect backtrack patterns across a session. Events are partitioned by agent_id + * first so a failure in one agent is never matched against a retry in another + * (cross-agent false retries). Results are sorted by start time. + */ +export const extractBacktracks = (events: readonly StoredEvent[]): BacktrackResult[] => + partitionByAgent(events) + .flatMap(extractAgentBacktracks) + .sort((a, b) => a.start_t - b.start_t); + const extractFilePath = (event: StoredEvent): string | undefined => { const toolInput = event.data.tool_input; if (typeof toolInput !== "object" || toolInput === null) return undefined; diff --git a/src/distill/comm-graph.ts b/packages/cli/src/distill/comm-graph.ts similarity index 100% rename from src/distill/comm-graph.ts rename to packages/cli/src/distill/comm-graph.ts diff --git a/src/distill/comm-sequence.ts b/packages/cli/src/distill/comm-sequence.ts similarity index 100% rename from src/distill/comm-sequence.ts rename to packages/cli/src/distill/comm-sequence.ts diff --git a/packages/cli/src/distill/context-consumption.ts b/packages/cli/src/distill/context-consumption.ts new file mode 100644 index 0000000..2450c55 --- /dev/null +++ b/packages/cli/src/distill/context-consumption.ts @@ -0,0 +1,112 @@ +import type { ContextConsumption, ContextConsumptionPoint } from "../types/distill"; +import type { TranscriptEntry } from "../types/transcript"; +import { getModelContextWindow } from "./stats"; + +/** Compaction = context dropped more than 30% from previous turn. */ +const COMPACTION_DROP_THRESHOLD = 0.7; + +const safeNumber = (value: unknown): number => (typeof value === "number" ? value : 0); + +const roundTo2 = (n: number): number => Math.round(n * 100) / 100; + +const isAssistantEntry = (entry: TranscriptEntry): boolean => entry.type === "assistant"; + +/** + * Deduplicate assistant entries by requestId (streaming chunks share the same requestId). + * Takes the last entry per group, matching the pattern in agent-distill.ts. + */ +const deduplicateByRequest = (entries: readonly TranscriptEntry[]): readonly TranscriptEntry[] => { + const byRequest = new Map( + entries + .filter(isAssistantEntry) + .filter((e) => e.message?.usage !== undefined) + .map((entry) => [entry.requestId ?? entry.uuid, entry] as const), + ); + return [...byRequest.values()]; +}; + +const buildPoint = ( + entry: TranscriptEntry, + turnIndex: number, + modelContextWindow: number, + prevTotalTokens: number | undefined, +): ContextConsumptionPoint => { + const usage = entry.message?.usage; + const inputTokens = safeNumber(usage?.input_tokens); + const outputTokens = safeNumber(usage?.output_tokens); + const cacheReadTokens = safeNumber(usage?.cache_read_input_tokens); + const cacheCreationTokens = safeNumber(usage?.cache_creation_input_tokens); + const totalContextTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + const contextPct = (totalContextTokens / modelContextWindow) * 100; + const isCompaction = prevTotalTokens !== undefined && totalContextTokens < prevTotalTokens * COMPACTION_DROP_THRESHOLD; + + return { + t: new Date(entry.timestamp).getTime(), + turn_index: turnIndex, + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_tokens: cacheReadTokens, + cache_creation_tokens: cacheCreationTokens, + total_context_tokens: totalContextTokens, + context_pct: roundTo2(contextPct), + is_compaction: isCompaction, + }; +}; + +const buildPoints = ( + dedupedEntries: readonly TranscriptEntry[], + modelContextWindow: number, +): readonly ContextConsumptionPoint[] => + dedupedEntries.reduce((acc, entry, idx) => { + const prevTotal = acc.length > 0 ? acc[acc.length - 1].total_context_tokens : undefined; + const point = buildPoint(entry, idx, modelContextWindow, prevTotal); + return [...acc, point]; + }, []); + +const computeVelocity = (points: readonly ContextConsumptionPoint[]): number => { + const nonCompactionPoints = points.filter((p) => !p.is_compaction); + if (nonCompactionPoints.length < 2) return 0; + + const first = nonCompactionPoints[0]; + const last = nonCompactionPoints[nonCompactionPoints.length - 1]; + const timeSpanMs = last.t - first.t; + const timeSpanMin = timeSpanMs / 60_000; + + return timeSpanMin > 0 + ? roundTo2((last.context_pct - first.context_pct) / timeSpanMin) + : 0; +}; + +/** + * Extract context consumption data from transcript entries. + * Returns undefined if no usage data found or model context window is unknown. + */ +export const extractContextConsumption = ( + entries: readonly TranscriptEntry[], + model: string | undefined, +): ContextConsumption | undefined => { + if (!model) return undefined; + + const modelContextWindow = getModelContextWindow(model); + if (modelContextWindow === undefined) return undefined; + + const dedupedEntries = deduplicateByRequest(entries); + if (dedupedEntries.length === 0) return undefined; + + const points = buildPoints(dedupedEntries, modelContextWindow); + const peakPoint = points.reduce((max, p) => (p.total_context_tokens > max.total_context_tokens ? p : max), points[0]); + const finalPoint = points[points.length - 1]; + const compactionCount = points.filter((p) => p.is_compaction).length; + const velocity = computeVelocity(points); + + return { + points, + peak_context_pct: peakPoint.context_pct, + peak_context_tokens: peakPoint.total_context_tokens, + final_context_pct: finalPoint.context_pct, + compaction_count: compactionCount, + context_velocity_per_min: velocity, + model_context_window: modelContextWindow, + turn_count: points.length, + }; +}; diff --git a/src/distill/decisions-team.ts b/packages/cli/src/distill/decisions-team.ts similarity index 100% rename from src/distill/decisions-team.ts rename to packages/cli/src/distill/decisions-team.ts diff --git a/src/distill/decisions.ts b/packages/cli/src/distill/decisions.ts similarity index 97% rename from src/distill/decisions.ts rename to packages/cli/src/distill/decisions.ts index d5586f2..b42014d 100644 --- a/src/distill/decisions.ts +++ b/packages/cli/src/distill/decisions.ts @@ -264,7 +264,9 @@ export const extractDecisions = ( ): readonly DecisionPoint[] => { const timingGaps = extractTimingGaps(events); const toolPivots = extractToolPivots(events); - const phases = extractPhases(events); + // Pass links so phase boundaries use the same team-aware detection as the + // standalone phase extractor (consistent boundaries; was gap-based fallback only). + const phases = extractPhases(events, links); const phaseBoundaries = phaseBoundaryDecisions(phases); const agentDecisions = links && links.length > 0 ? extractAgentDecisions(links) : []; diff --git a/src/distill/diff-attribution.ts b/packages/cli/src/distill/diff-attribution.ts similarity index 56% rename from src/distill/diff-attribution.ts rename to packages/cli/src/distill/diff-attribution.ts index b1065f7..d99dadb 100644 --- a/src/distill/diff-attribution.ts +++ b/packages/cli/src/distill/diff-attribution.ts @@ -1,4 +1,4 @@ -import type { DiffLine, EditChainsResult, FileDiffAttribution, StoredEvent } from "../types"; +import type { DiffLine, EditChainsResult, FileDiffAttribution, StoredEvent, WorkingTreeChange } from "../types"; // --- Helper types --- @@ -14,10 +14,12 @@ export interface AgentEditEntry { /** * Find the git commit hash from the first SessionStart event. + * Falls back to InstructionsLoaded with load_reason "session_start" (sub-agents). */ export const getStartCommit = (events: readonly StoredEvent[]): string | undefined => - events.find((e) => e.event === "SessionStart" && e.context?.git_commit)?.context?.git_commit ?? - undefined; + events.find((e) => e.event === "SessionStart" && e.context?.git_commit)?.context?.git_commit + ?? events.find((e) => e.event === "InstructionsLoaded" && e.context?.git_commit)?.context?.git_commit + ?? undefined; /** * Convert an absolute file path to a relative path by stripping the projectDir prefix. @@ -294,8 +296,13 @@ export const attributeDiffLines = ( /** * Orchestrator: extract diff attribution for all files in edit chains. * Combines git diff capture, unified diff parsing, and agent attribution. + * + * NOTE: This queries git at distill-time, so diffs may be stale if the user + * committed or changed branches after the session. It is used as optional + * enrichment for display; `computeToolSourcedDiff` provides the + * git-independent source of truth for stats and attribution. */ -export const extractDiffAttribution = ( +export const extractGitDiffAttribution = ( projectDir: string, events: readonly StoredEvent[], editChains: EditChainsResult, @@ -346,3 +353,193 @@ export const extractDiffAttribution = ( }, ); }; + +/** + * Capture raw unified diffs for working tree / staged changes not already in diff_attribution. + * Returns additional FileDiffAttribution entries (without agent attribution). + */ +export const captureMissingDiffs = ( + projectDir: string, + events: readonly StoredEvent[], + existingAttrs: readonly FileDiffAttribution[], + workingTreeChanges: readonly WorkingTreeChange[], +): readonly FileDiffAttribution[] => { + const startCommit = getStartCommit(events); + if (startCommit === undefined) return []; + + if (workingTreeChanges.length === 0) return []; + + const coveredPaths = new Set(existingAttrs.map((a) => a.file_path)); + + // Working tree changes already use relative paths + const missingPaths = workingTreeChanges + .map((c) => c.file_path) + .filter((p) => !coveredPaths.has(p)); + + if (missingPaths.length === 0) return []; + + // captureUnifiedDiff expects absolute paths, but also handles relative ones + // Working tree changes use relative paths — pass them directly + const diffMap = captureUnifiedDiff(projectDir, startCommit, missingPaths); + + return Array.from(diffMap.entries()).flatMap( + ([relativePath, rawDiff]): readonly FileDiffAttribution[] => { + const parsedLines = parseUnifiedDiff(rawDiff); + if (parsedLines.length === 0) return []; + + const totalAdditions = parsedLines.filter((l) => l.type === "add").length; + const totalDeletions = parsedLines.filter((l) => l.type === "remove").length; + + return [ + { + file_path: relativePath, + lines: parsedLines, + total_additions: totalAdditions, + total_deletions: totalDeletions, + }, + ]; + }, + ); +}; + +// --- Tool-sourced diff attribution (git-independent) --- + +/** Multiset from string array — preserves duplicate counts. */ +export const toBag = (lines: readonly string[]): ReadonlyMap => + lines.reduce>((acc, line) => { + const next = new Map(acc); + next.set(line, (acc.get(line) ?? 0) + 1); + return next; + }, new Map()); + +/** Multiset difference: lines in `a` that exceed their count in `b`. */ +export const bagDiff = (a: ReadonlyMap, b: ReadonlyMap): readonly string[] => + Array.from(a.entries()).flatMap(([line, count]) => { + const bCount = b.get(line) ?? 0; + const excess = count - bCount; + return excess > 0 ? Array.from({ length: excess }, () => line) : []; + }); + +/** + * For an Edit call: compute diff lines from old_string -> new_string. + * Lines unique to old_string are deletions. Lines unique to new_string are additions. + * Lines present in both are unchanged (not emitted -- they're context). + */ +export const computeEditDiffLines = ( + toolInput: Readonly>, + agentName: string, +): readonly DiffLine[] => { + const oldString = typeof toolInput.old_string === "string" ? toolInput.old_string : ""; + const newString = typeof toolInput.new_string === "string" ? toolInput.new_string : ""; + + if (oldString === "" && newString === "") return []; + + const oldLines = oldString.split("\n"); + const newLines = newString.split("\n"); + + // Build multiset (bag) counts to handle duplicate lines correctly + const oldBag = toBag(oldLines); + const newBag = toBag(newLines); + + const deletions: readonly DiffLine[] = bagDiff(oldBag, newBag) + .filter((l) => l.trim().length > 0) + .map((content) => ({ type: "remove" as const, content, agent_name: agentName })); + + const additions: readonly DiffLine[] = bagDiff(newBag, oldBag) + .filter((l) => l.trim().length > 0) + .map((content) => ({ type: "add" as const, content, agent_name: agentName })); + + return [...deletions, ...additions]; +}; + +/** + * For a Write call: all non-empty content lines are additions. + * We don't have the previous file content, so deletions = 0. + * + * This is correct for new files. For overwrites of existing files, + * it over-counts additions (shows total written lines, not delta). + * This is a known trade-off: accuracy for new files, over-estimate + * for overwrites -- but always git-independent and always attributed. + */ +export const computeWriteDiffLines = ( + toolInput: Readonly>, + agentName: string, +): readonly DiffLine[] => { + const content = typeof toolInput.content === "string" ? toolInput.content : ""; + if (content === "") return []; + + return content.split("\n") + .filter((l) => l.trim().length > 0) + .map((line) => ({ type: "add" as const, content: line, agent_name: agentName })); +}; + +/** + * Compute diff attribution from Edit/Write tool_input payloads. + * Git-independent: works regardless of user commits, works for sub-agents. + * + * For Edit: old_string -> new_string is the exact change. + * - Lines in old_string but not new_string -> deletions + * - Lines in new_string but not old_string -> additions + * - Lines in both -> unchanged (not counted) + * + * For Write: content is the written output. + * - All content lines counted as additions + * - Deletions unknown without prior file state (counted as 0) + * + * Every line is attributed to the agent + tool_use_id that produced it. + */ +export const computeToolSourcedDiff = ( + events: readonly StoredEvent[], + editChains: EditChainsResult, + projectDir: string, +): readonly FileDiffAttribution[] => { + // Build failure set to exclude failed tool calls + const failureIds = new Set( + events + .filter((e) => e.event === "PostToolUseFailure") + .map((e) => typeof e.data.tool_use_id === "string" ? e.data.tool_use_id : undefined) + .filter((id): id is string => id !== undefined), + ); + + // Build lookup: tool_use_id -> PreToolUse event (for full tool_input) + const preToolUseMap = new Map( + events + .filter((e) => e.event === "PreToolUse") + .flatMap((e): readonly [string, StoredEvent][] => { + const id = typeof e.data.tool_use_id === "string" ? e.data.tool_use_id : undefined; + return id !== undefined ? [[id, e]] : []; + }), + ); + + return editChains.chains.flatMap((chain): readonly FileDiffAttribution[] => { + const relativePath = toRelativePath(chain.file_path, projectDir); + const agentName = chain.agent_name ?? "session"; + + // Process each successful Edit/Write step + const lines: readonly DiffLine[] = chain.steps + .filter((step) => step.tool_name === "Edit" || step.tool_name === "Write") + .filter((step) => !failureIds.has(step.tool_use_id)) + .flatMap((step): readonly DiffLine[] => { + const event = preToolUseMap.get(step.tool_use_id); + if (event === undefined) return []; + + const toolInput = event.data.tool_input as Readonly> | undefined; + if (toolInput === undefined) return []; + + if (step.tool_name === "Edit") { + return computeEditDiffLines(toolInput, agentName); + } + // Write: content lines are additions + return computeWriteDiffLines(toolInput, agentName); + }); + + if (lines.length === 0) return []; + + return [{ + file_path: relativePath, + lines, + total_additions: lines.filter((l) => l.type === "add").length, + total_deletions: lines.filter((l) => l.type === "remove").length, + }]; + }); +}; diff --git a/src/distill/edit-chains.ts b/packages/cli/src/distill/edit-chains.ts similarity index 100% rename from src/distill/edit-chains.ts rename to packages/cli/src/distill/edit-chains.ts diff --git a/packages/cli/src/distill/feature-usage.ts b/packages/cli/src/distill/feature-usage.ts new file mode 100644 index 0000000..71b1e04 --- /dev/null +++ b/packages/cli/src/distill/feature-usage.ts @@ -0,0 +1,225 @@ +import type { FeatureFlag, FeatureUsage, LoopWakeup, StoredEvent, WorkflowRun } from "../types"; + +// ── Detection signatures ──────────────────────────────────────────── +// +// Loop (/loop): +// - ScheduleWakeup tool calls (dynamic pacing; prompt may be <>) +// - Skill tool with skill === "loop" +// - UserPromptSubmit prompts starting with /loop +// - CronCreate / ScheduleWakeup carrying the < | undefined => + value && typeof value === "object" ? (value as Record) : undefined; + +const isPreToolUse = (e: StoredEvent): boolean => e.event === "PreToolUse"; + +const toolName = (e: StoredEvent): string | undefined => { + const name = (e.data as ToolEventData).tool_name; + return typeof name === "string" ? name : undefined; +}; + +const toolInput = (e: StoredEvent): Record | undefined => + asRecord((e.data as ToolEventData).tool_input); + +const promptOf = (e: StoredEvent): string | undefined => { + const prompt = (e.data as ToolEventData).prompt; + return typeof prompt === "string" ? prompt : undefined; +}; + +// ── Loop extraction ───────────────────────────────────────────────── + +const toLoopWakeup = (e: StoredEvent): LoopWakeup | undefined => { + const input = toolInput(e); + if (!input) return undefined; + const delay = typeof input.delaySeconds === "number" ? input.delaySeconds : 0; + const reason = typeof input.reason === "string" ? input.reason : undefined; + return { t: e.t, delay_seconds: delay, ...(reason ? { reason } : {}) }; +}; + +const inputContainsSentinel = (e: StoredEvent): boolean => { + const input = toolInput(e); + if (!input) return false; + return Object.values(input).some( + (v) => typeof v === "string" && v.includes(AUTONOMOUS_SENTINEL), + ); +}; + +const extractLoop = (events: readonly StoredEvent[]): FeatureUsage["loop"] => { + const preTool = events.filter(isPreToolUse); + const wakeupEvents = preTool.filter((e) => toolName(e) === "ScheduleWakeup"); + const wakeups = wakeupEvents.flatMap((e) => { + const w = toLoopWakeup(e); + return w ? [w] : []; + }); + + const skillInvocations = preTool.filter((e) => { + if (toolName(e) !== "Skill") return false; + return toolInput(e)?.skill === "loop"; + }).length; + + const loopPrompts = events.filter( + (e) => e.event === "UserPromptSubmit" && LOOP_PROMPT.test(promptOf(e) ?? ""), + ).length; + + const autonomous = preTool.some( + (e) => (toolName(e) === "ScheduleWakeup" || toolName(e) === "CronCreate") && inputContainsSentinel(e), + ); + + if (wakeups.length === 0 && skillInvocations === 0 && loopPrompts === 0 && !autonomous) { + return undefined; + } + + return { + wakeup_count: wakeups.length, + total_scheduled_wait_s: wakeups.reduce((sum, w) => sum + w.delay_seconds, 0), + autonomous, + skill_invocations: skillInvocations + loopPrompts, + wakeups, + }; +}; + +// ── Goal extraction ───────────────────────────────────────────────── + +const extractGoalText = (prompt: string): string => { + const idx = prompt.search(GOAL_TOKEN); + const fromGoal = prompt.slice(prompt.indexOf("/goal", idx)).trim(); + return fromGoal.length > GOAL_EXCERPT_LEN ? `${fromGoal.slice(0, GOAL_EXCERPT_LEN)}…` : fromGoal; +}; + +const extractGoal = (events: readonly StoredEvent[]): FeatureUsage["goal"] => { + const goals = events.flatMap((e) => { + if (e.event !== "UserPromptSubmit") return []; + const prompt = promptOf(e); + return prompt && GOAL_TOKEN.test(prompt) ? [extractGoalText(prompt)] : []; + }); + return goals.length > 0 ? { goals } : undefined; +}; + +// ── Workflow extraction ───────────────────────────────────────────── + +/** Pull name/description out of a workflow script's `export const meta = {...}` literal. */ +const parseScriptMeta = (script: string): { name?: string; description?: string } => { + const nameMatch = script.match(/name:\s*['"`]([^'"`]+)['"`]/); + const descMatch = script.match(/description:\s*['"`]([^'"`]+)['"`]/); + return { + ...(nameMatch ? { name: nameMatch[1] } : {}), + ...(descMatch ? { description: descMatch[1] } : {}), + }; +}; + +const toWorkflowRun = (e: StoredEvent): WorkflowRun => { + const input = toolInput(e) ?? {}; + if (typeof input.name === "string") return { t: e.t, name: input.name }; + if (typeof input.script === "string") { + const meta = parseScriptMeta(input.script); + return { t: e.t, ...meta }; + } + if (typeof input.scriptPath === "string") { + const file = input.scriptPath.split("/").pop() ?? input.scriptPath; + return { t: e.t, name: file.replace(/\.[^.]+$/, "") }; + } + return { t: e.t }; +}; + +const extractWorkflow = (events: readonly StoredEvent[]): FeatureUsage["workflow"] => { + const runs = events + .filter((e) => isPreToolUse(e) && toolName(e) === "Workflow") + .map(toWorkflowRun); + return runs.length > 0 ? { invocation_count: runs.length, runs } : undefined; +}; + +// ── Public API ────────────────────────────────────────────────────── + +/** Extract loop/goal/workflow usage from session events. Undefined when none used. */ +export const extractFeatureUsage = (events: readonly StoredEvent[]): FeatureUsage | undefined => { + const loop = extractLoop(events); + const goal = extractGoal(events); + const workflow = extractWorkflow(events); + + const flags: readonly FeatureFlag[] = [ + ...(loop ? (["loop"] as const) : []), + ...(goal ? (["goal"] as const) : []), + ...(workflow ? (["workflow"] as const) : []), + ]; + + if (flags.length === 0) return undefined; + + return { + flags, + ...(loop ? { loop } : {}), + ...(goal ? { goal } : {}), + ...(workflow ? { workflow } : {}), + }; +}; + +// ── Raw-content fast path (for session listing) ───────────────────── + +// Structural markers whose mere presence on a line is a reliable loop signal: +// they only ever occur as the tool_name / skill of an actual loop tool call, +// not as free-floating text inside file content being read. +const RAW_LOOP_MARKERS = [ + '"tool_name":"ScheduleWakeup"', + '"skill":"loop"', +] as const; + +const RAW_WORKFLOW_MARKER = '"tool_name":"Workflow"'; + +/** + * The < + line.includes(AUTONOMOUS_SENTINEL) && + (line.includes('"tool_name":"ScheduleWakeup"') || line.includes('"tool_name":"CronCreate"')); + +/** A line is a goal hit only when it's a UserPromptSubmit whose prompt has the /goal token. */ +const lineHasGoalPrompt = (line: string): boolean => { + if (!line.includes('"event":"UserPromptSubmit"')) return false; + try { + const parsed: unknown = JSON.parse(line); + const data = asRecord(asRecord(parsed)?.data); + const prompt = data?.prompt; + return typeof prompt === "string" && GOAL_TOKEN.test(prompt); + } catch { + return false; + } +}; + +/** + * Cheap substring-based detection over raw JSONL content. + * Used by the session listing where full event parsing is too expensive. + */ +export const detectFeatureFlags = (rawContent: string): readonly FeatureFlag[] => { + const loop = RAW_LOOP_MARKERS.some((m) => rawContent.includes(m)) + || /"prompt":"\s*\/loop[ \\"]/.test(rawContent) + || (rawContent.includes(AUTONOMOUS_SENTINEL) + && rawContent.split("\n").some(lineHasAutonomousSentinel)); + const workflow = rawContent.includes(RAW_WORKFLOW_MARKER); + const goal = rawContent.includes("/goal") + && rawContent.split("\n").some(lineHasGoalPrompt); + + return [ + ...(loop ? (["loop"] as const) : []), + ...(goal ? (["goal"] as const) : []), + ...(workflow ? (["workflow"] as const) : []), + ]; +}; diff --git a/packages/cli/src/distill/file-map.ts b/packages/cli/src/distill/file-map.ts new file mode 100644 index 0000000..907e3b7 --- /dev/null +++ b/packages/cli/src/distill/file-map.ts @@ -0,0 +1,237 @@ +import type { FileMapEntry, FileMapResult, StoredEvent } from "../types"; + +const FILE_TOOLS = new Set(["Edit", "Read", "Write", "Glob", "Grep"]); + +/** + * Heuristics for spotting a file argument inside a Bash command. + * + * Each pattern is anchored to the *start of a command segment* (`(?:^|[;&|]\s*)`) + * so that operators appearing mid-command -- arrow functions (`=>`), comparisons + * (`>=`), or `>`/`mkdir`/`touch` tokens embedded inside a quoted string or a + * `node -e "..."` script -- do not get mistaken for a real redirect/command. + * Without the anchor, `node -e "rows.map(r => r.id)"` matched the `>` redirect + * rule and produced the garbage token `r.id)"` (see B: file-map-bash-regex-garbage-paths). + */ +const BASH_FILE_PATTERNS = [ + /(?:^|[;&|]\s*)mkdir\s+(?:-p\s+)?([^\s&|;<>()]+)/, + /(?:^|[;&|]\s*)(?:cp|mv|rm)\s+(?:-\S+\s+)*\S+\s+([^\s&|;<>()]+)/, + /(?:^|[;&|]\s*)(?:cp|mv|rm)\s+(?:-\S+\s+)*([^\s&|;<>()]+)/, + /(?:^|\s)>>?\s*([^\s&|;<>()]+)/, + /(?:^|[;&|]\s*)touch\s+([^\s&|;<>()]+)/, +] as const; + +/** + * Reject capture groups that cannot be a real file path: tokens carrying shell + * syntax (quotes, parens, `=`, redirection/expansion sigils) are command + * fragments, not files. This is the second line of defence after anchoring -- + * e.g. `if [ $x >= 5 ]` yields `=` from the redirect rule, which this filters out. + */ +const isPlausibleFilePath = (token: string): boolean => + token.length > 0 && !/["'`(){}=$<>;&|]/.test(token); + +const extractBashFilePaths = (command: string): readonly string[] => { + const tokens = BASH_FILE_PATTERNS.flatMap((pattern) => { + const match = command.match(pattern); + const token = match?.[1]; + return token && isPlausibleFilePath(token) ? [token] : []; + }); + return [...new Set(tokens)]; +}; + +/** + * Resolve the session's repo root from captured events so that absolute tool + * paths fold onto the same repo-relative key as git-diff and bash paths. + * + * Prefers the SessionStart context `project_dir` -- the hook resolves this to + * the repo root via `resolveProjectRoot`, so normalizing against it produces + * repo-root-relative paths that line up with git diff output (which git emits + * relative to the repo root). Only when no `project_dir` was captured do we fall + * back to `cwd` (which may be a subdirectory and therefore yield subdir-relative + * keys that will NOT match git-diff paths). Returns undefined when nothing was + * captured, in which case paths are left untouched. + */ +const resolveRepoRoot = (events: readonly StoredEvent[]): string | undefined => { + const fromProjectDir = events + .map((e) => e.context?.project_dir) + .find((dir): dir is string => typeof dir === "string" && dir.length > 0); + if (fromProjectDir) return fromProjectDir; + + const fromContextCwd = events + .map((e) => e.context?.cwd) + .find((dir): dir is string => typeof dir === "string" && dir.length > 0); + if (fromContextCwd) return fromContextCwd; + + const fromData = events + .map((e) => e.data.cwd) + .find((dir): dir is string => typeof dir === "string" && dir.length > 0); + return fromData; +}; + +/** + * Normalize a file path to repo-relative form by stripping the repo-root prefix. + * Absolute paths outside the root and already-relative paths are returned + * unchanged. This collapses the abs/rel duplication where the same file is + * captured as `/repo/package.json` by a tool event and `package.json` by a bash + * heuristic or git diff. + */ +const normalizePath = (filePath: string, root: string | undefined): string => { + if (!root) return filePath; + const prefix = root.endsWith("/") ? root : `${root}/`; + return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath; +}; + +const mergeToolUseId = (existing: readonly string[], toolUseId: string | undefined): readonly string[] => + toolUseId && !existing.includes(toolUseId) ? [...existing, toolUseId] : existing; + +/** + * Fold a single tool op (identified by its PreToolUse, or an orphan failure) + * into the file map. `failed` reflects whether the op ultimately failed, which + * is determined by the presence of a matching PostToolUseFailure -- NOT by the + * event kind being processed. A failing Edit therefore records an error and no + * edit, instead of double-counting as both (see B: file-map-failed-ops-counted- + * as-success-and-dup-ids). + */ +const processToolEvent = + (root: string | undefined, failed: boolean) => + ( + fileMap: ReadonlyMap, + event: StoredEvent, + ): ReadonlyMap => { + const toolName = typeof event.data.tool_name === "string" ? event.data.tool_name : ""; + if (!FILE_TOOLS.has(toolName)) return fileMap; + + const toolInput = event.data.tool_input as Record | undefined; + if (!toolInput) return fileMap; + + const rawPath = toolInput.file_path ?? toolInput.path; + const filePath = typeof rawPath === "string" ? normalizePath(rawPath, root) : undefined; + if (!filePath) return fileMap; + + const existing = fileMap.get(filePath) ?? { + file_path: filePath, + reads: 0, + edits: 0, + writes: 0, + errors: 0, + tool_use_ids: [], + source: "tool" as const, + }; + + const toolUseId = typeof event.data.tool_use_id === "string" ? event.data.tool_use_id : undefined; + const updatedIds = mergeToolUseId(existing.tool_use_ids, toolUseId); + + const updated: FileMapEntry = { + ...existing, + tool_use_ids: updatedIds, + source: "tool", + errors: existing.errors + (failed ? 1 : 0), + reads: existing.reads + (!failed && toolName === "Read" ? 1 : 0), + edits: existing.edits + (!failed && toolName === "Edit" ? 1 : 0), + writes: existing.writes + (!failed && toolName === "Write" ? 1 : 0), + }; + + const next = new Map(fileMap); + next.set(filePath, updated); + return next; + }; + +const processBashEvent = + (root: string | undefined) => + ( + fileMap: ReadonlyMap, + event: StoredEvent, + ): ReadonlyMap => { + if (event.event !== "PreToolUse") return fileMap; + + const toolName = typeof event.data.tool_name === "string" ? event.data.tool_name : undefined; + if (toolName !== "Bash") return fileMap; + + const toolInput = event.data.tool_input as Record | undefined; + const rawCommand = toolInput?.command; + const command = typeof rawCommand === "string" ? rawCommand : undefined; + if (!command) return fileMap; + + const bashPaths = extractBashFilePaths(command).map((p) => normalizePath(p, root)); + + return bashPaths.reduce>((acc, filePath) => { + if (acc.has(filePath)) return acc; + + const entry: FileMapEntry = { + file_path: filePath, + reads: 0, + edits: 0, + writes: 0, + errors: 0, + tool_use_ids: [], + source: "bash", + }; + + const next = new Map(acc); + next.set(filePath, entry); + return next; + }, fileMap); + }; + +/** + * Extract a per-file activity map from captured events. + * + * A tool op is represented across up to two events: a `PreToolUse` (always + * present) and either a `PostToolUse` (success) or a `PostToolUseFailure` + * (failure). We count each op exactly once -- driven by its `PreToolUse` -- and + * mark it failed when a `PostToolUseFailure` shares its `tool_use_id`. Failures + * with no matching `PreToolUse` (truncated capture) are still recorded so error + * counts are never lost. + */ +export const extractFileMap = (events: readonly StoredEvent[]): FileMapResult => { + const root = resolveRepoRoot(events); + + // tool_use_ids that ultimately failed (have a PostToolUseFailure). + const failedIds = new Set( + events + .filter((e) => e.event === "PostToolUseFailure") + .map((e) => (typeof e.data.tool_use_id === "string" ? e.data.tool_use_id : undefined)) + .filter((id): id is string => typeof id === "string"), + ); + + // tool_use_ids that have a PreToolUse -- used to detect orphan failures. + const preIds = new Set( + events + .filter((e) => e.event === "PreToolUse") + .map((e) => (typeof e.data.tool_use_id === "string" ? e.data.tool_use_id : undefined)) + .filter((id): id is string => typeof id === "string"), + ); + + // Pass 1a: count each op once via its PreToolUse; mark failed by tool_use_id. + const afterPrePass = events + .filter((e) => e.event === "PreToolUse") + .reduce>((acc, event) => { + const id = typeof event.data.tool_use_id === "string" ? event.data.tool_use_id : undefined; + const failed = id !== undefined && failedIds.has(id); + return processToolEvent(root, failed)(acc, event); + }, new Map()); + + // Pass 1b: record orphan failures (PostToolUseFailure with no matching PreToolUse). + const afterToolPass = events + .filter((e) => { + if (e.event !== "PostToolUseFailure") return false; + const id = typeof e.data.tool_use_id === "string" ? e.data.tool_use_id : undefined; + return id === undefined || !preIds.has(id); + }) + .reduce>( + (acc, event) => processToolEvent(root, true)(acc, event), + afterPrePass, + ); + + // Pass 2: scan Bash PreToolUse events for file paths (same normalization, so + // `> dist/out.js` folds onto the absolute tool path for the same file). + const afterBashPass = events.reduce>( + (acc, event) => processBashEvent(root)(acc, event), + afterToolPass, + ); + + const files = Array.from(afterBashPass.values()).sort( + (a, b) => b.edits + b.writes + b.errors - (a.edits + a.writes + a.errors), + ); + + return { files }; +}; diff --git a/src/distill/git-diff.ts b/packages/cli/src/distill/git-diff.ts similarity index 100% rename from src/distill/git-diff.ts rename to packages/cli/src/distill/git-diff.ts diff --git a/packages/cli/src/distill/index.ts b/packages/cli/src/distill/index.ts new file mode 100644 index 0000000..9ad7ae7 --- /dev/null +++ b/packages/cli/src/distill/index.ts @@ -0,0 +1,528 @@ +// TODO: existsSync + readFileSync remain for plan-drift spec reading (lines ~84-90). +// This is the last I/O leak in the distill layer. Ideally the spec content should +// be passed in from the CLI caller so this module stays pure. +import { existsSync, readFileSync } from "node:fs"; +import { readLinks, readSessionEvents } from "../session/read"; +import { readTranscript, readTranscriptWithMeta, resolveTranscriptPath } from "../session/transcript"; +import type { + AgentNode, + ClensConfig, + DistilledSession, + EditChainsResult, + LinkEvent, + PricingTier, + SpawnLink, + StoredEvent, + TokenUsage, + TranscriptReasoning, + TranscriptUserMessage, + WorkingTreeChange, +} from "../types"; +import { buildNameMap, buildTeamMemberSessionMap, filterLinksForSession, isUuidLike } from "../utils"; +import { computeActiveDuration } from "./active-duration"; +import { type DiffContext, extractAgentModel, extractTokenUsage, extractUserType } from "./agent-distill"; +import { enrichNodeWithLinks } from "./agent-enrich"; +import { buildAgentTree, enrichNodeFromSessionEvents, enrichNodeWithTranscript, inferAgentsFromComms } from "./agent-tree"; +import { aggregateTeamData } from "./aggregate"; +import { extractBacktracks } from "./backtracks"; +import { buildCommGraph } from "./comm-graph"; +import { extractAgentLifetimes, extractCommSequence } from "./comm-sequence"; +import { extractDecisions, extractPhases, extractRawTimingGaps } from "./decisions"; +import { captureMissingDiffs, computeToolSourcedDiff, extractGitDiffAttribution } from "./diff-attribution"; +import { extractEditChains } from "./edit-chains"; +import { extractFeatureUsage } from "./feature-usage"; +import { extractFileMap } from "./file-map"; +import { extractGitDiff, extractNetChanges } from "./git-diff"; +import { computePlanDrift, detectSpecRef } from "./plan-drift"; +import { extractContextConsumption } from "./context-consumption"; +import { extractReasoning } from "./reasoning"; +import { extractSessionConfig } from "./session-config"; +import { estimateCostFromTokens, extractStats } from "./stats"; +import { extractSummary } from "./summary"; +import { extractTeamMetrics } from "./team"; +import { extractTaskList } from "./task-list"; +import { extractTimeline } from "./timeline"; +import { scanSessionFiles } from "../session/synthetic-scan"; +import { synthesizeSpawnLinks } from "./synthetic-links"; +import { extractUserMessages } from "./user-messages"; + +const isSpawnLink = (link: LinkEvent): link is SpawnLink => link.type === "spawn"; + +const VALID_TIERS: readonly string[] = ["api", "max", "auto"]; +const isValidPricingTier = (value: string): value is PricingTier => VALID_TIERS.includes(value); + +/** Read .clens/config.json from project dir. Returns undefined if file missing or malformed. */ +const readClensConfig = (projectDir: string): ClensConfig | undefined => { + const configPath = `${projectDir}/.clens/config.json`; + if (!existsSync(configPath)) return undefined; + try { + const raw: unknown = JSON.parse(readFileSync(configPath, "utf-8")); + if (typeof raw !== "object" || raw === null) return undefined; + const obj = raw as Record; + if (typeof obj.capture !== "boolean") return undefined; + const pricing = typeof obj.pricing === "string" && isValidPricingTier(obj.pricing) + ? obj.pricing + : undefined; + return { capture: obj.capture, ...(pricing ? { pricing } : {}) }; + } catch { + return undefined; + } +}; + +/** Resolve pricing tier: explicit "api"/"max" pass through; "auto" uses userType heuristic. */ +const resolvePricingTier = (configTier: PricingTier, userType?: string): "api" | "max" => { + if (configTier === "api" || configTier === "max") return configTier; + // "auto": external = subscription = max rates + return userType === "external" ? "max" : "api"; +}; + +/** Collect all names and session_ids from an agent tree (recursive). */ +const collectTreeIdentifiers = (nodes: readonly AgentNode[]): readonly string[] => + nodes.flatMap((n) => [ + ...(n.agent_name ? [n.agent_name] : []), + ...(n.session_id ? [n.session_id] : []), + ...collectTreeIdentifiers(n.children), + ]); + +interface MergeAgentsParams { + readonly sessionId: string; + readonly rawAgents: readonly AgentNode[] | undefined; + readonly sessionLinks: readonly LinkEvent[]; + readonly nameMap: ReadonlyMap | undefined; + readonly readAgentEvents: (agentId: string) => readonly StoredEvent[]; + readonly readTranscriptFn: (path: string) => readonly import("../types").TranscriptEntry[]; + readonly diffContext: DiffContext; + readonly tier: "api" | "max"; +} + +/** + * Merge spawn-based agent tree with comm-inferred agents. + * Enriches inferred agents from session events + transcripts, then attaches + * them as children of the spawned agent with the most communication partners. + */ +const mergeSpawnAndInferredAgents = ({ + sessionId, + rawAgents, + sessionLinks, + nameMap, + readAgentEvents, + readTranscriptFn, + diffContext, + tier, +}: MergeAgentsParams): readonly AgentNode[] | undefined => { + const fromTree = rawAgents?.map((node) => enrichNodeWithLinks(node, sessionLinks, nameMap)); + + const teamMemberSessions = sessionLinks.length > 0 ? buildTeamMemberSessionMap(sessionLinks) : undefined; + const inferred = sessionLinks.length > 0 + ? inferAgentsFromComms(sessionId, sessionLinks, teamMemberSessions) + : []; + + const enrichInferredAgent = (node: AgentNode): AgentNode => { + if (!isUuidLike(node.session_id)) return node; + + const agentEvents = readAgentEvents(node.session_id); + const fromEvents = agentEvents.length > 0 + ? enrichNodeFromSessionEvents(node, agentEvents, tier) + : node; + + const transcriptPath = resolveTranscriptPath(agentEvents); + if (transcriptPath) { + return enrichNodeWithTranscript(fromEvents, transcriptPath, readTranscriptFn, diffContext, tier); + } + return fromEvents; + }; + + const enrichAndLink = (agents: readonly AgentNode[]): readonly AgentNode[] => { + const enriched = agents.map(enrichInferredAgent); + const inferredNameMap = new Map(enriched.map((a) => [a.session_id, a.agent_name ?? a.agent_type])); + const mergedNameMap = nameMap ? new Map([...nameMap, ...inferredNameMap]) : inferredNameMap; + return enriched.map((node) => enrichNodeWithLinks(node, sessionLinks, mergedNameMap)); + }; + + if (fromTree && fromTree.length > 0) { + if (inferred.length === 0) return fromTree; + + const existingNames = new Set(collectTreeIdentifiers(fromTree)); + const newAgents = inferred.filter( + (a) => !existingNames.has(a.agent_name ?? "") && !existingNames.has(a.session_id), + ); + if (newAgents.length === 0) return fromTree; + + const enrichedNew = enrichAndLink(newAgents); + + const withMostComms = fromTree.reduce((best, node) => + (node.communication_partners?.length ?? 0) > (best.communication_partners?.length ?? 0) ? node : best, + ); + return fromTree.map((node) => + node.session_id === withMostComms.session_id + ? { ...node, children: [...node.children, ...enrichedNew] } + : node, + ); + } + + if (inferred.length === 0) return undefined; + return enrichAndLink(inferred); +}; + +export interface DistillOptions { + readonly deep?: boolean; + readonly pricingTier?: PricingTier; +} + +export const distill = async ( + sessionId: string, + projectDir: string, + options?: DistillOptions, +): Promise => { + const events = readSessionEvents(sessionId, projectDir); + + // `--deep` gates git enrichment. The default (shallow) distill is git-free: stats, + // attribution and net changes all come from captured tool_input (git-independent). + // Only when `deep` is set do we spawn git for commit history and unified-diff context. + const deep = options?.deep ?? false; + + // Layer 2: Transcript enrichment (graceful fallback) -- extracted early so reasoning can be passed to stats + const transcriptData: { + reasoning: TranscriptReasoning[]; + user_messages: readonly TranscriptUserMessage[]; + transcript_path: string | undefined; + token_usage: TokenUsage | undefined; + transcript_model: string | undefined; + session_name: string | undefined; + user_type: string | undefined; + context_consumption: import("../types").ContextConsumption | undefined; + } = (() => { + const tPath = resolveTranscriptPath(events); + if (!tPath) + return { + reasoning: [], + user_messages: [], + transcript_path: undefined, + token_usage: undefined, + transcript_model: undefined, + session_name: undefined, + user_type: undefined, + context_consumption: undefined, + }; + + // Single read of the parent transcript yields both entries and the custom title, + // avoiding a second full file read + parse per session on the batch distill path. + const { entries, customTitle } = readTranscriptWithMeta(tPath); + const sessionName = customTitle ?? undefined; + if (entries.length === 0) + return { + reasoning: [], + user_messages: [], + transcript_path: tPath, + token_usage: undefined, + transcript_model: undefined, + session_name: sessionName, + user_type: undefined, + context_consumption: undefined, + }; + + const usage = extractTokenUsage(entries); + const model = extractAgentModel(entries); + return { + reasoning: extractReasoning(entries), + user_messages: extractUserMessages(entries), + transcript_path: tPath, + token_usage: usage.input_tokens > 0 ? usage : undefined, + transcript_model: model, + session_name: sessionName, + user_type: extractUserType(entries), + context_consumption: extractContextConsumption(entries, model), + }; + })(); + + const { reasoning, user_messages, transcript_path, token_usage, transcript_model, context_consumption } = + transcriptData; + + // Resolve pricing tier: CLI override > config file > default "api" + const configTier = options?.pricingTier ?? readClensConfig(projectDir)?.pricing ?? "api"; + const resolvedTier = resolvePricingTier(configTier, transcriptData.user_type); + + // Stored per-session cost is ALWAYS the API-equivalent value at full list price + // (analytics-truth invariant): a subscription is applied as a flat monthly fee in the + // web paid/ROI layer, never as a per-token multiplier. resolvedTier is kept only for the + // pricing_tier provenance stamp / staleness check — never for cost computation. + const costTier: PricingTier = "api"; + + // Layer 0: Link reading (needed early for decisions enrichment) + const links = readLinks(projectDir); + const sessionLinks = filterLinksForSession(sessionId, links); + + // Synthesize spawn/stop links for background sub-agents that lack SubagentStart/SubagentStop + const syntheticResult = synthesizeSpawnLinks(events, sessionLinks, projectDir, sessionId, scanSessionFiles); + const effectiveSessionLinks = syntheticResult.spawns.length > 0 + ? [...sessionLinks, ...syntheticResult.spawns, ...syntheticResult.stops] as readonly LinkEvent[] + : sessionLinks; + + const nameMap = effectiveSessionLinks.length > 0 ? buildNameMap(effectiveSessionLinks) : undefined; + + // Layer 1: Hook-based extractors (stats receives reasoning + transcript token usage for cost) + const stats = extractStats(events, reasoning, token_usage, costTier); + const feature_usage = extractFeatureUsage(events); + const session_config = extractSessionConfig(events); + const backtracks = extractBacktracks(events); + const decisions = extractDecisions(events, effectiveSessionLinks.length > 0 ? effectiveSessionLinks : undefined); + const file_map = extractFileMap(events); + const git_diff = deep ? await extractGitDiff(sessionId, projectDir, events) : { commits: [], hunks: [] }; + + // Plan drift detection + const allPrompts: readonly string[] = [ + ...user_messages + .filter((m) => m.message_type === "prompt" || m.message_type === "command") + .map((m) => m.content), + ...events + .filter((e) => e.event === "UserPromptSubmit" && typeof e.data.prompt === "string") + .map((e) => e.data.prompt as string), + ]; + + const specRef = detectSpecRef(allPrompts); + const plan_drift = (() => { + if (!specRef) return undefined; + if (stats.tool_call_count === 0) return undefined; + const specPath = `${projectDir}/${specRef}`; + if (!existsSync(specPath)) return undefined; + const specContent = readFileSync(specPath, "utf-8"); + return computePlanDrift(specRef, specContent, [file_map], projectDir); + })(); + + // Edit chains: thinking-to-code binding + const edit_chains_raw = extractEditChains(events, reasoning, backtracks); + + // PRIMARY: event-sourced diff attribution (git-independent, always accurate) + const tool_sourced_diffs = computeToolSourcedDiff(events, edit_chains_raw, projectDir); + + // OPTIONAL ENRICHMENT (--deep only): git-based unified diffs (for display, may be stale). + // Provides line numbers and context lines that tool-sourced diffs don't have. Skipped on + // a shallow distill so the default path performs zero git spawns. + const git_diffs = deep + ? (() => { + try { + return extractGitDiffAttribution(projectDir, events, edit_chains_raw); + } catch { + return []; + } + })() + : []; + + // Also capture diffs for working tree / staged changes not covered by edit chains (--deep only) + const git_net_changes = deep ? extractNetChanges(projectDir, events) : []; + const allChangedFiles = [...(git_diff.working_tree_changes ?? []), ...(git_diff.staged_changes ?? []), ...git_net_changes]; + const extraDiffs = deep ? captureMissingDiffs(projectDir, events, git_diffs, allChangedFiles) : []; + const all_git_diffs = [...git_diffs, ...extraDiffs]; + + // Tool-sourced is the source of truth for stats and attribution. + // Git diffs are attached separately for display purposes. + const diff_attribution = tool_sourced_diffs; + + // Net changes: compute from tool-sourced diffs instead of git + const net_changes: readonly WorkingTreeChange[] = tool_sourced_diffs.map((attr) => ({ + file_path: attr.file_path, + status: attr.total_deletions > 0 && attr.total_additions > 0 ? "modified" as const + : attr.total_additions > 0 ? "added" as const + : "deleted" as const, + additions: attr.total_additions, + deletions: attr.total_deletions, + })); + + const edit_chains: EditChainsResult = { + ...edit_chains_raw, + ...(net_changes.length > 0 ? { net_changes } : {}), + ...(diff_attribution.length > 0 ? { diff_attribution } : {}), + // NEW: attach git enrichment separately if available + ...(all_git_diffs.length > 0 ? { git_enriched_diffs: all_git_diffs } : {}), + }; + + // Layer 4: Sub-agent hierarchy (graceful -- no error if links file missing) + const readAgentEvents = (agentId: string): readonly StoredEvent[] => { + try { + return readSessionEvents(agentId, projectDir); + } catch { + return []; + } + }; + const diffContext: DiffContext = { projectDir, parentEvents: events }; + const rawAgents = + effectiveSessionLinks.length > 0 + ? buildAgentTree(sessionId, effectiveSessionLinks, events, readTranscript, readAgentEvents, diffContext, costTier) + : undefined; + + // Merge spawn-based tree with comm-inferred agents (team teammates not captured by spawn links) + const agents = mergeSpawnAndInferredAgents({ + sessionId, + rawAgents, + sessionLinks: effectiveSessionLinks, + nameMap, + readAgentEvents, + readTranscriptFn: readTranscript, + diffContext, + tier: costTier, + }); + + // Build effective nameMap that includes inferred agent names for downstream extractors + const effectiveNameMap = (() => { + if (agents && !nameMap) { + return new Map(agents.map((a) => [a.session_id, a.agent_name ?? a.agent_type])); + } + if (agents && nameMap) { + const inferredEntries = agents + .filter((a) => !nameMap.has(a.session_id)) + .map((a) => [a.session_id, a.agent_name ?? a.agent_type] as const); + return inferredEntries.length > 0 ? new Map([...nameMap, ...inferredEntries]) : nameMap; + } + return nameMap; + })(); + + // Ensure parent/orchestrator session is in the nameMap so comm graph/sequence show "leader" not raw UUIDs + const finalNameMap = (() => { + const base = effectiveNameMap ?? new Map(); + if (base.has(sessionId)) return base; + if (effectiveSessionLinks.length === 0) return base; + return new Map([...base, [sessionId, "leader"]]); + })(); + + const allAgentIds = + effectiveSessionLinks.length > 0 + ? new Set(effectiveSessionLinks.filter(isSpawnLink).map((s) => s.agent_id)) + : undefined; + const team_metrics = + effectiveSessionLinks.length > 0 ? extractTeamMetrics(effectiveSessionLinks, allAgentIds, sessionId) : undefined; + const communication_graph = + effectiveSessionLinks.length > 0 ? buildCommGraph(effectiveSessionLinks, finalNameMap) : undefined; + const comm_sequence = + effectiveSessionLinks.length > 0 ? extractCommSequence(effectiveSessionLinks, finalNameMap) : undefined; + const agent_lifetimes = + effectiveSessionLinks.length > 0 ? extractAgentLifetimes(effectiveSessionLinks, finalNameMap) : undefined; + const task_list = + effectiveSessionLinks.length > 0 ? extractTaskList(effectiveSessionLinks) : undefined; + + // Model inference: stats.model → transcript model → first agent model + const inferredModel = stats.model ?? transcript_model ?? agents?.find((a) => a.model)?.model; + + // If model was inferred from transcript/agents but not from events, enrich stats + const finalStats = + inferredModel && !stats.model + ? (() => { + // Recompute cost with inferred model if stats had no model + const enrichedCost = token_usage + ? estimateCostFromTokens( + inferredModel, + token_usage.input_tokens, + token_usage.output_tokens, + token_usage.cache_read_tokens, + token_usage.cache_creation_tokens, + costTier, + ) + : stats.cost_estimate; + return { + ...stats, + model: inferredModel, + cost_estimate: enrichedCost, + }; + })() + : stats; + + // Aggregate agent data into parent-level stats when agents are present + const aggregated = + agents && agents.length > 0 + ? aggregateTeamData({ + parentStats: finalStats, + parentFileMap: file_map, + parentEditChains: edit_chains, + parentBacktracks: backtracks, + parentReasoning: reasoning, + parentCost: finalStats.cost_estimate, + agents, + }) + : undefined; + + const effectiveStats = aggregated + ? { + ...aggregated.stats, + cost_estimate: aggregated.cost_estimate ?? aggregated.stats.cost_estimate, + } + : finalStats; + const effectiveFileMap = aggregated?.file_map ?? file_map; + const effectiveEditChains = aggregated?.edit_chains ?? edit_chains; + const effectiveBacktracks = aggregated?.backtracks ?? backtracks; + const effectiveReasoning = aggregated?.reasoning ?? reasoning; + + // Active duration computation (uses raw unfiltered timing gaps, not noise-filtered decisions). + // Gaps must be subtracted from the WALL span: stats.duration_ms is already + // idle-trimmed, so subtracting the same gaps from it zeroed Active for any + // session containing a >5min pause (bug B3, double subtraction). + const wallDurationMs = + effectiveStats.wall_duration_ms ?? + (events.length > 1 ? events[events.length - 1].t - events[0].t : 0); + const rawTimingGaps = extractRawTimingGaps(events); + const rawActiveDuration = computeActiveDuration(rawTimingGaps, wallDurationMs); + + // For multi-agent sessions, parent timing gaps alone may show 0 active time. + // Fallback: use the idle-trimmed duration when agents exist but raw computation yields 0. + const activeDuration = + rawActiveDuration.active_ms === 0 && agents && agents.length > 0 + ? { ...rawActiveDuration, active_ms: effectiveStats.duration_ms } + : rawActiveDuration; + + // Layer 3: Synthesis + const phases = extractPhases(events, effectiveSessionLinks.length > 0 ? effectiveSessionLinks : undefined); + const summary = extractSummary({ + stats: effectiveStats, + backtracks: effectiveBacktracks, + phases, + file_map: effectiveFileMap.files, + reasoning: effectiveReasoning, + team_metrics, + activeDuration, + agents, + events, + editChains: effectiveEditChains, + }); + const timeline = extractTimeline( + events, + effectiveReasoning, + user_messages, + effectiveBacktracks, + phases, + effectiveSessionLinks.length > 0 ? effectiveSessionLinks : undefined, + finalNameMap, + ); + + const result: DistilledSession = { + session_id: sessionId, + ...(transcriptData.session_name ? { session_name: transcriptData.session_name } : {}), + start_time: events[0]?.t, + stats: effectiveStats, + // Top-level mirror of stats.cost_estimate — the web UI reads it here (bug B9) + ...(effectiveStats.cost_estimate ? { cost_estimate: effectiveStats.cost_estimate } : {}), + backtracks: [...effectiveBacktracks], + decisions, + file_map: effectiveFileMap, + git_diff, + edit_chains: effectiveEditChains, + reasoning: [...effectiveReasoning], + user_messages, + transcript_path, + summary, + timeline, + ...(agents && agents.length > 0 ? { agents } : {}), + ...(team_metrics && team_metrics.agent_count > 0 ? { team_metrics } : {}), + ...(communication_graph && communication_graph.length > 0 ? { communication_graph } : {}), + ...(comm_sequence && comm_sequence.length > 0 ? { comm_sequence } : {}), + ...(agent_lifetimes && agent_lifetimes.length > 0 ? { agent_lifetimes } : {}), + ...(plan_drift ? { plan_drift } : {}), + ...(task_list && task_list.tasks.length > 0 ? { task_list } : {}), + ...(context_consumption ? { context_consumption } : {}), + ...(feature_usage ? { feature_usage } : {}), + session_config, + // Record the tier this distill was priced at so a staleness check can detect when + // the configured tier changed since (tier-change-never-recomputes-stale-tier-mixing). + pricing_tier: resolvedTier, + complete: true, + }; + + return result; +}; diff --git a/src/distill/journey.ts b/packages/cli/src/distill/journey.ts similarity index 100% rename from src/distill/journey.ts rename to packages/cli/src/distill/journey.ts diff --git a/src/distill/plan-drift.ts b/packages/cli/src/distill/plan-drift.ts similarity index 91% rename from src/distill/plan-drift.ts rename to packages/cli/src/distill/plan-drift.ts index ef79145..9d1e84b 100644 --- a/src/distill/plan-drift.ts +++ b/packages/cli/src/distill/plan-drift.ts @@ -150,22 +150,24 @@ export const parseSpecExpectedFiles = (specContent: string): readonly string[] = return { ...acc, paths: [...acc.paths, ...extractCodeBlockPaths(line)] }; } - // Check for prefix paths anywhere in the doc + // Explicit declarations (Create:/Modify:/File:) are unambiguous deliverables + // and are honored anywhere in the doc. const prefixPath = extractPrefixPath(line); const prefixPaths = prefixPath ? [normalizePath(prefixPath)] : []; - // Extract inline backtick paths from anywhere (non-bullet lines) - const inlinePaths = extractInlineBacktickPaths(line); - - // Extract table row paths - const tablePaths = extractTablePaths(line); + // Inline backtick and table paths are extracted ONLY inside a files section. + // Outside one they are prose code-references (e.g. "modify `src/foo.ts`", + // placeholder paths like `path/to/file.ts`) that inflate the expected set and + // distort drift. Gating mirrors the bullet-path handling below. + const inlinePaths = acc.inFilesSection ? extractInlineBacktickPaths(line) : []; + const tablePaths = acc.inFilesSection ? extractTablePaths(line) : []; // Track section context if (isFilesSectionHeading(line)) { return { inFilesSection: true, inCodeBlock: false, - paths: [...acc.paths, ...prefixPaths, ...inlinePaths, ...tablePaths], + paths: [...acc.paths, ...prefixPaths], }; } @@ -173,7 +175,7 @@ export const parseSpecExpectedFiles = (specContent: string): readonly string[] = return { inFilesSection: false, inCodeBlock: false, - paths: [...acc.paths, ...prefixPaths, ...inlinePaths, ...tablePaths], + paths: [...acc.paths, ...prefixPaths], }; } diff --git a/src/distill/reasoning.ts b/packages/cli/src/distill/reasoning.ts similarity index 100% rename from src/distill/reasoning.ts rename to packages/cli/src/distill/reasoning.ts diff --git a/packages/cli/src/distill/risk-score.ts b/packages/cli/src/distill/risk-score.ts new file mode 100644 index 0000000..f0b95dd --- /dev/null +++ b/packages/cli/src/distill/risk-score.ts @@ -0,0 +1,81 @@ +import type { DistilledSession } from "../types/distill"; +import type { FileRiskScore, RiskLevel } from "../types/risk"; + +/** + * Compute risk level from individual risk factors. + * - high: 3+ backtracks OR >50% abandoned edits OR failure rate >30% + * - medium: 1-2 backtracks OR some abandoned edits + * - low: clean — no backtracks, no abandoned edits, low failure rate + */ +const computeRiskLevel = ( + backtrackCount: number, + abandonedEditCount: number, + totalEditCount: number, + failureRate: number, +): RiskLevel => { + const abandonedRatio = totalEditCount > 0 ? abandonedEditCount / totalEditCount : 0; + + if (backtrackCount >= 3 || abandonedRatio > 0.5 || failureRate > 0.3) return "high"; + if (backtrackCount >= 1 || abandonedEditCount > 0) return "medium"; + return "low"; +}; + +/** + * Compute per-file risk scores from a distilled session. + * Pure function — no I/O. + */ +export const computeFileRiskScores = ( + distilled: DistilledSession, +): readonly FileRiskScore[] => { + const files = distilled.file_map.files; + if (files.length === 0) return []; + + const chains = distilled.edit_chains?.chains ?? []; + const backtracks = distilled.backtracks; + + return files.map((file): FileRiskScore => { + const filePath = file.file_path; + + // Count backtracks targeting this file + const backtrackCount = backtracks.filter( + (b) => b.file_path === filePath, + ).length; + + // Find edit chains for this file + const fileChains = chains.filter((c) => c.file_path === filePath); + const abandonedEditCount = fileChains.reduce( + (sum, c) => sum + c.abandoned_edit_ids.length, + 0, + ); + const totalEditCount = fileChains.reduce( + (sum, c) => sum + c.total_edits, + 0, + ); + const totalFailures = fileChains.reduce( + (sum, c) => sum + c.total_failures, + 0, + ); + const editChainLength = fileChains.length; + + // Failure rate scoped to this file's edit chains + const failureRate = + totalEditCount > 0 ? totalFailures / totalEditCount : 0; + + const riskLevel = computeRiskLevel( + backtrackCount, + abandonedEditCount, + totalEditCount, + failureRate, + ); + + return { + file_path: filePath, + risk_level: riskLevel, + backtrack_count: backtrackCount, + abandoned_edit_count: abandonedEditCount, + total_edit_count: totalEditCount, + failure_rate: failureRate, + edit_chain_length: editChainLength, + }; + }); +}; diff --git a/packages/cli/src/distill/session-config.ts b/packages/cli/src/distill/session-config.ts new file mode 100644 index 0000000..78cab02 --- /dev/null +++ b/packages/cli/src/distill/session-config.ts @@ -0,0 +1,123 @@ +import { + type ClaudeMdInEffect, + type EffortLevel, + isEffortLevel, + isPermissionMode, + type McpServerUsage, + type PermissionMode, + type SessionConfig, + type StoredEvent, +} from "../types"; + +/** + * MCP tool naming convention: `mcp____`. Server names may contain + * underscores (e.g. `mcp__claude_ai_Atlassian__search`, `mcp__ide__executeCode`), + * so the server segment is captured non-greedily up to the first `__` delimiter. + */ +const MCP_TOOL_PATTERN = /^mcp__(.+?)__/; + +/** Extract the MCP server name from a tool name, or undefined if it is not an MCP tool. */ +const mcpServerOf = (toolName: string): string | undefined => { + const match = MCP_TOOL_PATTERN.exec(toolName); + return match ? match[1] : undefined; +}; + +/** Most-recent value of a data field across the stream that satisfies `accept`. */ +const latestField = ( + events: readonly StoredEvent[], + field: string, + accept: (value: unknown) => value is T, +): T | undefined => + events.reduceRight((found, e) => { + if (found !== undefined) return found; + const value = e.data[field]; + return accept(value) ? value : undefined; + }, undefined); + +const VALID_MEMORY_TYPES = new Set(["User", "Project", "Local", "Managed"]); + +/** + * Realize CLAUDE.md-in-effect entries from captured `InstructionsLoaded` events. + * Returns undefined when none were captured (the installed binary may predate the + * event — see CFG-5 BLOCKED-VERIFY), letting the caller fall back to an inferred + * list. Deduped by `file_path`, preserving first-seen order. Pure (zero I/O). + */ +const realizeClaudeMd = (events: readonly StoredEvent[]): readonly ClaudeMdInEffect[] | undefined => { + const seen = new Set(); + const realized: ClaudeMdInEffect[] = []; + for (const e of events) { + if (e.event !== "InstructionsLoaded") continue; + const filePath = e.data.file_path; + if (typeof filePath !== "string" || filePath.length === 0 || seen.has(filePath)) continue; + const rawType = e.data.memory_type; + const memory_type = typeof rawType === "string" && VALID_MEMORY_TYPES.has(rawType) + ? (rawType as ClaudeMdInEffect["memory_type"]) + : "inferred"; + const loadReason = e.data.load_reason; + seen.add(filePath); + realized.push({ + file_path: filePath, + memory_type, + ...(typeof loadReason === "string" && loadReason.length > 0 ? { load_reason: loadReason } : {}), + }); + } + return realized.length > 0 ? realized : undefined; +}; + +export interface SessionConfigOptions { + /** + * Inferred CLAUDE.md fallback (CFG-5), used only when no `InstructionsLoaded` + * events were captured. Produced by `capture/settings.ts:inferClaudeMd` at the + * (impure) call site so this extractor stays I/O-free. + */ + readonly claudeMdFallback?: readonly ClaudeMdInEffect[]; +} + +/** + * Pure extraction of a session's effective configuration from its event stream. + * + * - `permission_mode` and `effort` are lifted as the most-recent *recognized* + * values observed on event payloads (unknown raw values are dropped, never + * thrown on); later events override earlier ones. + * - `mcp_servers` aggregates the distinct MCP servers whose tools were invoked, + * counted from `PreToolUse` events (matching `tool_call_count` in stats; Post/ + * Failure events are NOT counted to avoid double-counting a single call). + * The result is deduped by server name and sorted by count desc, then name asc. + * - `claude_md_in_effect` is realized from `InstructionsLoaded` events when the + * live binary emits them, otherwise the inferred fallback (if provided). + * + * Performs zero I/O. + */ +export const extractSessionConfig = ( + events: readonly StoredEvent[], + options: SessionConfigOptions = {}, +): SessionConfig => { + const permission_mode: PermissionMode | undefined = latestField( + events, + "permission_mode", + isPermissionMode, + ); + const effort: EffortLevel | undefined = latestField(events, "effort", isEffortLevel); + + const counts = events.reduce>((acc, e) => { + if (e.event !== "PreToolUse") return acc; + const toolName = e.data.tool_name; + if (typeof toolName !== "string") return acc; + const server = mcpServerOf(toolName); + if (server === undefined) return acc; + return acc.set(server, (acc.get(server) ?? 0) + 1); + }, new Map()); + + const mcp_servers: readonly McpServerUsage[] = [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + + const claude_md_in_effect = realizeClaudeMd(events) ?? options.claudeMdFallback; + + return { + ...(permission_mode !== undefined ? { permission_mode } : {}), + ...(effort !== undefined ? { effort } : {}), + mcp_servers, + ...(claude_md_in_effect && claude_md_in_effect.length > 0 ? { claude_md_in_effect } : {}), + }; +}; diff --git a/packages/cli/src/distill/stats.ts b/packages/cli/src/distill/stats.ts new file mode 100644 index 0000000..1b51051 --- /dev/null +++ b/packages/cli/src/distill/stats.ts @@ -0,0 +1,456 @@ +import type { CostBasis, CostEstimate, PricingTier, StatsResult, StoredEvent, TokenUsage, TranscriptReasoning } from "../types"; +import { computeEffectiveDuration, findLastMeaningfulEvent } from "../utils"; + +// Per-MTok API rates (platform.claude.com pricing, verified 2026-06-11). +// Longest matching prefix wins, so version-specific entries override the +// family fallbacks (e.g. Opus 4.5+ at $5/$25 vs Opus 4.0/4.1 at $15/$75). +// Cache rates: read = 0.1x input, write = 1.25x input (5-minute TTL). +const API_PRICING = { + "claude-fable-5": { input: 10, output: 50, cache_read: 1.0, cache_write: 12.5 }, + "claude-opus-4-8": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, + "claude-opus-4-7": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, + "claude-opus-4-6": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, + "claude-opus-4-5": { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, + "claude-opus-4": { input: 15, output: 75, cache_read: 1.5, cache_write: 18.75 }, + "claude-sonnet-4-6": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + "claude-sonnet-4": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + "claude-haiku-4-5": { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 }, + "claude-haiku-4": { input: 0.8, output: 4, cache_read: 0.08, cache_write: 1.0 }, +} as const; + +export const MODEL_CONTEXT_WINDOWS: Readonly> = { + "claude-fable-5": 1_000_000, + "claude-opus-4-8": 1_000_000, + "claude-opus-4-7": 1_000_000, + "claude-opus-4-6": 1_000_000, + "claude-opus-4": 200_000, + "claude-sonnet-4-6": 1_000_000, + "claude-sonnet-4": 200_000, + "claude-haiku-4-5": 200_000, + "claude-haiku-4": 200_000, +}; + +/** Longest prefix first so version-specific entries beat family fallbacks. */ +const byLengthDesc = (a: string, b: string): number => b.length - a.length; + +/** + * Bare model aliases Claude Code may emit (e.g. `opus`, `sonnet`, `haiku`) + * instead of a fully-qualified id. Each maps to its CURRENT canonical id — the + * same tier the alias resolves to today — so the longest-prefix match below + * never silently falls through to an unpriced $0. Keyed on the exact lowercased + * string, so fully-qualified ids (`claude-opus-4-8`) are untouched. + */ +const MODEL_ALIASES: Readonly> = { + opus: "claude-opus-4-8", + sonnet: "claude-sonnet-4-6", + haiku: "claude-haiku-4-5", + fable: "claude-fable-5", +}; + +/** Resolve a bare alias to its canonical model id; pass anything else through. */ +const normalizeModelId = (model: string): string => MODEL_ALIASES[model.toLowerCase()] ?? model; + +const CONTEXT_WINDOW_PREFIXES = Object.keys(MODEL_CONTEXT_WINDOWS).sort(byLengthDesc); + +export const getModelContextWindow = (model: string): number | undefined => { + const normalized = normalizeModelId(model); + const matchedPrefix = CONTEXT_WINDOW_PREFIXES.find((prefix) => normalized.startsWith(prefix)); + return matchedPrefix ? MODEL_CONTEXT_WINDOWS[matchedPrefix] : undefined; +}; + +/** Max subscription effective rate is ~1/3 of API pricing. */ +const SUBSCRIPTION_MULTIPLIER = 1 / 3; + +type ModelPrefix = keyof typeof API_PRICING; + +const MODEL_PREFIXES = (Object.keys(API_PRICING) as ModelPrefix[]).sort(byLengthDesc); + +interface ModelRates { + readonly input: number; + readonly output: number; + readonly cache_read: number; + readonly cache_write: number; +} + +/** Get pricing rates for a model+tier combination. "auto" treated same as "api". */ +export const getPricing = (model: string, tier: PricingTier = "api"): ModelRates | undefined => { + const normalized = normalizeModelId(model); + const matchedPrefix = MODEL_PREFIXES.find((prefix) => normalized.startsWith(prefix)); + if (!matchedPrefix) return undefined; + const base = API_PRICING[matchedPrefix]; + const multiplier = tier === "max" ? SUBSCRIPTION_MULTIPLIER : 1; + return { + input: base.input * multiplier, + output: base.output * multiplier, + cache_read: base.cache_read * multiplier, + cache_write: base.cache_write * multiplier, + }; +}; + + +/** Safely extract a model string from an unknown config value (type guard, no unsafe cast). */ +const extractModelFromConfig = (cfg: unknown): string | undefined => { + if (typeof cfg !== "object" || cfg === null) return undefined; + if (!("model" in cfg)) return undefined; + const model = (cfg as { model: unknown }).model; // narrowed by object + "model" in cfg + return typeof model === "string" ? model : undefined; +}; + +/** Extract model identifier from events with multi-step fallback chain. */ +const extractModel = (events: readonly StoredEvent[]): string | undefined => { + // 1. Primary: SessionStart context.model + const sessionStartModel = events.find( + (e) => e.event === "SessionStart" && e.context?.model, + )?.context?.model; + if (sessionStartModel) return sessionStartModel; + + // 2. Fallback: any event with a data.model string field + const eventWithModel = events.find( + (e) => typeof e.data.model === "string" && String(e.data.model).length > 0, + ); + if (eventWithModel) return String(eventWithModel.data.model); + + // 3. Fallback: ConfigChange events with model in nested config object + const configModel = events + .filter((e) => e.event === "ConfigChange") + .reduce((found, e) => { + if (found) return found; + const cfg = e.data.config; + return extractModelFromConfig(cfg); + }, undefined); + return configModel; +}; + +/** + * Version stamp for the active API pricing table (`API_PRICING`). Bump this + * whenever a rate in that table changes so a re-priced cost can be distinguished + * from a frozen distill-time value, and stale caches can be detected. + */ +export const PRICING_VERSION = "2026-06-11"; + +/** + * Re-price a frozen CostEstimate at the CURRENT pricing table. + * + * Distilled `cost_estimate` values are frozen at distill-time rates. When the API + * price table later changes (e.g. Opus 4.5+ dropping from the legacy $15/$75 to + * $5/$25 — exactly 3x on every component) the stored `estimated_cost_usd` becomes + * stale (~3x too high). This recomputes the cost from the estimate's OWN token + * counts × current-table rates — via the same longest-prefix `getPricing` match, + * so version-specific entries still beat family fallbacks — and stamps + * `pricing_version`. The frozen number itself is never transformed, only the + * tokens are re-multiplied. + * + * Measured costs (Tier 0) are verbatim captured values, not table-derived, so + * they are returned unchanged — repricing them would corrupt a real figure. + * Callers keep the on-disk estimate as the frozen record and use the return value + * for display only. + */ +export const repriceCostEstimate = ( + estimate: CostEstimate, + tier: PricingTier = "api", +): CostEstimate => { + // Tier 0 measured: verbatim captured value — never re-priced (cost-basis.test). + if (estimate.cost_basis === "measured") return estimate; + + const pricing = getPricing(estimate.model, tier); + // Unknown model (no prefix match) ⇒ cannot re-price; keep the frozen value but + // still stamp the version so downstream readers know it was evaluated. + if (!pricing) return { ...estimate, pricing_version: PRICING_VERSION }; + + const repricedUsd = + (estimate.estimated_input_tokens / 1_000_000) * pricing.input + + (estimate.estimated_output_tokens / 1_000_000) * pricing.output + + ((estimate.cache_read_tokens ?? 0) / 1_000_000) * pricing.cache_read + + ((estimate.cache_creation_tokens ?? 0) / 1_000_000) * pricing.cache_write; + + return { + ...estimate, + estimated_cost_usd: Math.round(repricedUsd * 10000) / 10000, + pricing_tier: tier, + pricing_version: PRICING_VERSION, + }; +}; + +/** Recompute cost estimate from known token counts and a model identifier. */ +export const estimateCostFromTokens = ( + model: string, + inputTokens: number, + outputTokens: number, + cacheReadTokens?: number, + cacheCreationTokens?: number, + tier: PricingTier = "api", +): CostEstimate | undefined => { + const pricing = getPricing(model, tier); + if (!pricing) return undefined; + + const estimatedCostUsd = + (inputTokens / 1_000_000) * pricing.input + + (outputTokens / 1_000_000) * pricing.output + + ((cacheReadTokens ?? 0) / 1_000_000) * pricing.cache_read + + ((cacheCreationTokens ?? 0) / 1_000_000) * pricing.cache_write; + + // is_estimated is false ONLY when grounded in real token usage (B26). If no + // real tokens backed this call, the result is not measured usage and must not + // claim to be — fall back to is_estimated: true (heuristic-equivalent). + const hasRealUsage = + inputTokens > 0 || outputTokens > 0 || (cacheReadTokens ?? 0) > 0 || (cacheCreationTokens ?? 0) > 0; + + return { + model, + estimated_input_tokens: inputTokens, + estimated_output_tokens: outputTokens, + estimated_cost_usd: Math.round(estimatedCostUsd * 10000) / 10000, + ...(cacheReadTokens ? { cache_read_tokens: cacheReadTokens } : {}), + ...(cacheCreationTokens ? { cache_creation_tokens: cacheCreationTokens } : {}), + is_estimated: !hasRealUsage, + // Real tokens ⇒ "estimated"; no real tokens ⇒ this is a heuristic-equivalent + // (e.g. an all-zero usage object) so it must not claim token-grounded provenance. + cost_basis: hasRealUsage ? "estimated" : "heuristic", + pricing_tier: tier, + }; +}; + +/** + * Extract a measured per-session cost (Tier-0) from captured event data. + * + * Claude Code's local transcripts do NOT carry an SDK result `total_cost_usd` + * today (coverage measured at ~0% on disk, 2026-06-21 — only token `usage` is + * present), so this is forward-looking: it activates only when a capture path + * supplies `total_cost_usd`/`costUSD` (> 0) on a hook event's data. Picks the + * largest such value (a cumulative session total rather than a per-turn delta). + */ +const extractMeasuredCostUsd = (events: readonly StoredEvent[]): number | undefined => { + const readCost = (raw: unknown): number | undefined => + typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : undefined; + + const costs = events.flatMap((e) => { + const direct = readCost(e.data.total_cost_usd) ?? readCost(e.data.costUSD); + return direct !== undefined ? [direct] : []; + }); + + return costs.length > 0 ? Math.max(...costs) : undefined; +}; + +/** Build a Tier-0 "measured" cost estimate from a verbatim measured cost. */ +const measuredCostEstimate = ( + model: string, + measuredCostUsd: number, + tokenUsage: TokenUsage | undefined, + tier: PricingTier, +): CostEstimate => ({ + model, + estimated_input_tokens: tokenUsage?.input_tokens ?? 0, + estimated_output_tokens: tokenUsage?.output_tokens ?? 0, + // Verbatim measured value — never rounded away, never multiplied by a subscription factor. + estimated_cost_usd: measuredCostUsd, + ...(tokenUsage?.cache_read_tokens ? { cache_read_tokens: tokenUsage.cache_read_tokens } : {}), + ...(tokenUsage?.cache_creation_tokens ? { cache_creation_tokens: tokenUsage.cache_creation_tokens } : {}), + is_estimated: false, + cost_basis: "measured", + pricing_tier: tier, +}); + +/** Extract accumulated token usage from events containing usage/token_usage data. */ +const extractTokenUsage = (events: readonly StoredEvent[]): TokenUsage | undefined => { + const usageEntries = events + .map((e) => { + const usage = e.data.usage ?? e.data.token_usage; + if (typeof usage !== "object" || usage === null) return undefined; + const u = usage as Readonly>; + const input = typeof u.input_tokens === "number" ? u.input_tokens : 0; + const output = typeof u.output_tokens === "number" ? u.output_tokens : 0; + const cacheRead = typeof u.cache_read_tokens === "number" ? u.cache_read_tokens : 0; + const cacheCreation = typeof u.cache_creation_tokens === "number" ? u.cache_creation_tokens : 0; + return input > 0 || output > 0 + ? { input, output, cacheRead, cacheCreation } + : undefined; + }) + .filter((u): u is { input: number; output: number; cacheRead: number; cacheCreation: number } => + u !== undefined, + ); + + if (usageEntries.length === 0) return undefined; + + return usageEntries.reduce( + (acc, u) => ({ + input_tokens: acc.input_tokens + u.input, + output_tokens: acc.output_tokens + u.output, + cache_read_tokens: acc.cache_read_tokens + u.cacheRead, + cache_creation_tokens: acc.cache_creation_tokens + u.cacheCreation, + }), + { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, + ); +}; + +const estimateCost = ( + model: string | undefined, + totalEvents: number, + toolCallCount: number, + reasoning: readonly TranscriptReasoning[], + tier: PricingTier = "api", +): CostEstimate | undefined => { + if (!model) return undefined; + + const pricing = getPricing(model, tier); + if (!pricing) return undefined; + + const reasoningCharCount = reasoning.reduce((acc, r) => acc + r.thinking.length, 0); + + const estimatedInputTokens = totalEvents * 500 + Math.ceil(reasoningCharCount / 4); + const estimatedOutputTokens = toolCallCount * 200 + Math.ceil(reasoningCharCount / 4); + + const estimatedCostUsd = + (estimatedInputTokens / 1_000_000) * pricing.input + + (estimatedOutputTokens / 1_000_000) * pricing.output; + + return { + model, + estimated_input_tokens: estimatedInputTokens, + estimated_output_tokens: estimatedOutputTokens, + estimated_cost_usd: Math.round(estimatedCostUsd * 10000) / 10000, + is_estimated: true, + cost_basis: "heuristic", + pricing_tier: tier, + }; +}; + +export const extractStats = ( + events: readonly StoredEvent[], + reasoning: readonly TranscriptReasoning[] = [], + transcriptTokenUsage?: TokenUsage, + tier: PricingTier = "api", +): StatsResult => { + if (events.length === 0) { + return { + total_events: 0, + duration_ms: 0, + events_by_type: {}, + tools_by_name: {}, + tool_call_count: 0, + failure_count: 0, + failure_rate: 0, + unique_files: [], + }; + } + + const eventsByType = events.reduce( + (acc, event) => ({ + ...acc, + [event.event]: (acc[event.event] ?? 0) + 1, + }), + {} as Record, + ); + + const model = extractModel(events); + + const toolEvents = events.filter( + (e) => + e.event === "PreToolUse" || e.event === "PostToolUse" || e.event === "PostToolUseFailure", + ); + + const toolsByName = toolEvents + .filter((e) => e.event === "PreToolUse" && typeof e.data.tool_name === "string") + .reduce( + (acc, e) => { + const name = typeof e.data.tool_name === "string" ? e.data.tool_name : ""; + return { ...acc, [name]: (acc[name] ?? 0) + 1 }; + }, + {} as Record, + ); + + const toolCallCount = toolEvents.filter( + (e) => e.event === "PreToolUse" && e.data.tool_name, + ).length; + + const failureCount = toolEvents.filter( + (e) => e.event === "PostToolUseFailure" && e.data.tool_name && !e.data.is_interrupt, + ).length; + + const filesSet = new Set( + toolEvents + .map((e) => { + const toolInput = e.data.tool_input as Record | undefined; + const raw = toolInput?.file_path ?? toolInput?.path; + return typeof raw === "string" ? raw : undefined; + }) + .filter((f): f is string => f !== undefined), + ); + + const failuresByTool = events + .filter((e) => e.event === "PostToolUseFailure" && typeof e.data.tool_name === "string" && !e.data.is_interrupt) + .reduce( + (acc, e) => { + const name = typeof e.data.tool_name === "string" ? e.data.tool_name : ""; + return { ...acc, [name]: (acc[name] ?? 0) + 1 }; + }, + {} as Record, + ); + + const startTime = events[0].t; + const lastMeaningful = findLastMeaningfulEvent(events); + const endTime = lastMeaningful?.t ?? events[events.length - 1].t; + + // 4-tier cost resolution: measured > transcript tokens > hook event tokens > heuristic. + // The stored cost is ALWAYS API-equivalent value at FULL LIST PRICE — token/heuristic + // tiers price at "api" (never the subscription-multiplied "max" rate). The resolved + // `tier` is still recorded on `pricing_tier` so a staleness layer can detect tier drift. + const hookTokenUsage = extractTokenUsage(events); + const resolvedTokenUsage = transcriptTokenUsage ?? hookTokenUsage; + const measuredCostUsd = extractMeasuredCostUsd(events); + + // Stamp the resolved tier onto a full-list-priced estimate for staleness detection. + const withResolvedTier = (estimate: CostEstimate | undefined): CostEstimate | undefined => + estimate ? { ...estimate, pricing_tier: tier } : undefined; + + const cost_estimate = (() => { + // Tier 0: Measured cost — taken verbatim, marked cost_basis: "measured". + if (measuredCostUsd !== undefined && model) { + return measuredCostEstimate(model, measuredCostUsd, resolvedTokenUsage, tier); + } + // Tier 1: Transcript token usage (most accurate token-based estimate) + if (transcriptTokenUsage && model) { + return withResolvedTier( + estimateCostFromTokens( + model, + transcriptTokenUsage.input_tokens, + transcriptTokenUsage.output_tokens, + transcriptTokenUsage.cache_read_tokens, + transcriptTokenUsage.cache_creation_tokens, + ), + ); + } + // Tier 2: Hook event token usage + if (hookTokenUsage && model) { + return withResolvedTier( + estimateCostFromTokens( + model, + hookTokenUsage.input_tokens, + hookTokenUsage.output_tokens, + hookTokenUsage.cache_read_tokens, + hookTokenUsage.cache_creation_tokens, + ), + ); + } + // Tier 3: Heuristic (magic numbers) + return withResolvedTier(estimateCost(model, events.length, toolCallCount, reasoning)); + })(); + + const timestamps = events.map((e) => e.t); + const effectiveDuration = computeEffectiveDuration(timestamps); + + return { + total_events: events.length, + duration_ms: effectiveDuration.effective_duration_ms, + wall_duration_ms: effectiveDuration.wall_duration_ms, + events_by_type: eventsByType, + tools_by_name: toolsByName, + tool_call_count: toolCallCount, + failure_count: failureCount, + failure_rate: toolCallCount > 0 ? failureCount / toolCallCount : 0, + unique_files: Array.from(filesSet), + model, + cost_estimate, + ...(resolvedTokenUsage ? { token_usage: resolvedTokenUsage } : {}), + failures_by_tool: Object.keys(failuresByTool).length > 0 ? failuresByTool : undefined, + }; +}; diff --git a/src/distill/summary-team.ts b/packages/cli/src/distill/summary-team.ts similarity index 100% rename from src/distill/summary-team.ts rename to packages/cli/src/distill/summary-team.ts diff --git a/src/distill/summary.ts b/packages/cli/src/distill/summary.ts similarity index 100% rename from src/distill/summary.ts rename to packages/cli/src/distill/summary.ts diff --git a/packages/cli/src/distill/synthetic-links.ts b/packages/cli/src/distill/synthetic-links.ts new file mode 100644 index 0000000..c240a65 --- /dev/null +++ b/packages/cli/src/distill/synthetic-links.ts @@ -0,0 +1,171 @@ +// Synthesize SpawnLink/StopLink events for background sub-agents +// that don't fire SubagentStart/SubagentStop hook events. +// Matches by timestamp correlation between Agent tool calls and session files. +// +// Pure module: I/O (file scanning) is injected via the `scanFn` parameter. +// The default scanSessionFiles implementation lives in session/synthetic-scan.ts. + +import type { LinkEvent, SpawnLink, StopLink, StoredEvent } from "../types"; +import type { SessionFileInfo } from "../session/synthetic-scan"; + +/** An Agent tool call extracted from parent session events. */ +interface AgentCall { + readonly t: number; + readonly name: string; + readonly agentType: string; + readonly description: string; +} + +/** Match between an Agent call and a session file. */ +interface AgentSessionMatch { + readonly call: AgentCall; + readonly session: SessionFileInfo; + readonly deltaMs: number; +} + +/** Signature for the session file scanner (injected I/O). */ +export type ScanSessionFilesFn = ( + projectDir: string, + parentSessionId: string, + linkedSessionIds: ReadonlySet, + timeRange: { readonly minT: number; readonly maxT: number }, +) => readonly SessionFileInfo[]; + +const MATCH_WINDOW_MS = 15_000; // 15 seconds + +const isSpawnLink = (link: LinkEvent): link is SpawnLink => link.type === "spawn"; + +/** + * Extract Agent tool PreToolUse events that have no matching SubagentStart. + * A SubagentStart "matches" if it occurs within 2s of the PreToolUse and shares the agent_type. + */ +export const extractUnlinkedAgentCalls = ( + events: readonly StoredEvent[], + links: readonly LinkEvent[], +): readonly AgentCall[] => { + const spawns = links.filter(isSpawnLink); + + const agentPreToolUses = events.filter( + (e) => e.event === "PreToolUse" && e.data?.tool_name === "Agent", + ); + + return agentPreToolUses.flatMap((e): readonly AgentCall[] => { + const rawInput: unknown = e.data?.tool_input ?? {}; + const toolInput = typeof rawInput === "object" && rawInput !== null ? rawInput as Readonly> : {}; + const name = typeof toolInput.name === "string" ? toolInput.name : ""; + const agentType = typeof toolInput.subagent_type === "string" ? toolInput.subagent_type : ""; + const description = typeof toolInput.description === "string" ? toolInput.description : ""; + + if (!name && !agentType) return []; + + // Check if a SubagentStart exists within 2s with matching agent_type + const hasMatchingSpawn = spawns.some( + (s) => Math.abs(s.t - e.t) < 2000 && s.agent_type === agentType, + ); + + return hasMatchingSpawn ? [] : [{ t: e.t, name, agentType, description }]; + }); +}; + +/** + * Match unlinked Agent calls to candidate session files by timestamp proximity. + * For each call, finds the session whose startT is closest and within MATCH_WINDOW_MS. + * Handles duplicate agent names by matching sequentially (second call -> second candidate). + */ +export const matchAgentCallsToSessions = ( + calls: readonly AgentCall[], + candidates: readonly SessionFileInfo[], +): readonly AgentSessionMatch[] => { + // Sort calls by timestamp for sequential matching + const sortedCalls = [...calls].sort((a, b) => a.t - b.t); + + // Use reduce to accumulate matches without mutation + const { matches } = sortedCalls.reduce<{ + readonly matches: readonly AgentSessionMatch[]; + readonly claimed: ReadonlySet; + }>( + (acc, call) => { + // Find candidates whose startT is within window AFTER the call + const eligible = candidates + .filter((c) => !acc.claimed.has(c.sessionId)) + .filter((c) => c.startT >= call.t && c.startT - call.t <= MATCH_WINDOW_MS) + .sort((a, b) => (a.startT - call.t) - (b.startT - call.t)); + + const best = eligible[0]; + if (!best) return acc; + + return { + matches: [...acc.matches, { call, session: best, deltaMs: best.startT - call.t }], + claimed: new Set([...acc.claimed, best.sessionId]), + }; + }, + { matches: [], claimed: new Set() }, + ); + + return matches; +}; + +/** + * Generate synthetic SpawnLink and StopLink events from matched Agent calls. + */ +export const buildSyntheticLinks = ( + matches: readonly AgentSessionMatch[], + parentSessionId: string, +): { readonly spawns: readonly SpawnLink[]; readonly stops: readonly StopLink[] } => { + const spawns: readonly SpawnLink[] = matches.map((m) => ({ + t: m.call.t, + type: "spawn" as const, + parent_session: parentSessionId, + agent_id: m.session.sessionId, + agent_type: m.call.agentType, + agent_name: m.call.name !== "" ? m.call.name : undefined, + synthetic: true, + })); + + const stops: readonly StopLink[] = matches.flatMap((m): readonly StopLink[] => { + if (m.session.endT === undefined) return []; + return [{ + t: m.session.endT, + type: "stop" as const, + parent_session: parentSessionId, + agent_id: m.session.sessionId, + synthetic: true, + }]; + }); + + return { spawns, stops }; +}; + +/** + * Main entry point: synthesize spawn/stop links for background sub-agents. + * Pure orchestration — I/O is performed by the injected scanFn. + */ +export const synthesizeSpawnLinks = ( + events: readonly StoredEvent[], + existingLinks: readonly LinkEvent[], + projectDir: string, + sessionId: string, + scanFn: ScanSessionFilesFn, +): { readonly spawns: readonly SpawnLink[]; readonly stops: readonly StopLink[] } => { + const unlinkedCalls = extractUnlinkedAgentCalls(events, existingLinks); + if (unlinkedCalls.length === 0) return { spawns: [], stops: [] }; + + // Build set of already-linked session IDs + const linkedIds = new Set( + existingLinks.filter(isSpawnLink).map((s) => s.agent_id), + ); + + // Determine parent session time range from events + const timestamps = events.map((e) => e.t); + const minT = timestamps.length > 0 ? timestamps.reduce((a, b) => (a < b ? a : b)) : 0; + const maxT = timestamps.length > 0 ? timestamps.reduce((a, b) => (a > b ? a : b)) : 0; + if (minT === 0 && maxT === 0) return { spawns: [], stops: [] }; + + const candidates = scanFn(projectDir, sessionId, linkedIds, { minT, maxT }); + if (candidates.length === 0) return { spawns: [], stops: [] }; + + const matches = matchAgentCallsToSessions(unlinkedCalls, candidates); + if (matches.length === 0) return { spawns: [], stops: [] }; + + return buildSyntheticLinks(matches, sessionId); +}; diff --git a/packages/cli/src/distill/task-list.ts b/packages/cli/src/distill/task-list.ts new file mode 100644 index 0000000..bd48549 --- /dev/null +++ b/packages/cli/src/distill/task-list.ts @@ -0,0 +1,116 @@ +import type { LinkEvent, TaskCompleteLink, TaskLink, TaskListResult, TaskRecord } from "../types"; + +const isTaskLink = (link: LinkEvent): link is TaskLink => link.type === "task"; +const isTaskCompleteLink = (link: LinkEvent): link is TaskCompleteLink => link.type === "task_complete"; + +const buildTaskFromCreate = (link: TaskLink, ordinal: number): TaskRecord => ({ + task_id: link.task_id || `task-${ordinal}`, + subject: link.subject ?? `Task ${ordinal}`, + description: link.description, + active_form: link.active_form, + status: "pending", + created_at: link.t, + created_by: link.session_id, +}); + +const applyUpdate = (existing: TaskRecord, link: TaskLink): TaskRecord => ({ + ...existing, + ...(link.owner ? { owner: link.owner } : {}), + ...(link.status === "in_progress" ? { status: "in_progress" as const } : {}), + ...(link.status === "completed" + ? { status: "completed" as const, completed_at: link.t } + : {}), + ...(link.blocked_by && link.blocked_by.length > 0 ? { blocked_by: link.blocked_by } : {}), +}); + +/** A status_change carrying status "deleted" removes the task entirely. */ +const isDeletion = (link: TaskLink): boolean => link.status === "deleted"; + +const applyCompletion = (existing: TaskRecord, link: TaskCompleteLink): TaskRecord => ({ + ...existing, + status: "completed", + completed_at: link.t, + // Preserve existing subject; only override if empty and link has one + ...((!existing.subject || existing.subject === existing.task_id) && link.subject + ? { subject: link.subject } + : {}), +}); + +const buildOrphanComplete = (link: TaskCompleteLink): TaskRecord => ({ + task_id: link.task_id, + subject: link.subject ?? link.task_id, + status: "completed", + created_at: link.t, + completed_at: link.t, + created_by: link.session_id ?? "unknown", + owner: link.agent, +}); + +/** + * Try to find a task record by ID, with fallback to synthetic `task-{id}` key. + * When matched via fallback, re-keys the record to use the real ID so subsequent + * lookups work without fallback. + */ +const resolveTask = ( + map: ReadonlyMap, + realId: string, +): { readonly record: TaskRecord | undefined; readonly map: ReadonlyMap } => { + const direct = map.get(realId); + if (direct) return { record: direct, map }; + + // Try synthetic fallback: "1" → "task-1" + const syntheticKey = `task-${realId}`; + const synthetic = map.get(syntheticKey); + if (!synthetic) return { record: undefined, map }; + + // Re-key: remove synthetic entry, add with real ID + const newMap = new Map([...map]); + newMap.delete(syntheticKey); + const rekeyed: TaskRecord = { ...synthetic, task_id: realId }; + newMap.set(realId, rekeyed); + return { record: rekeyed, map: newMap }; +}; + +export const extractTaskList = (links: readonly LinkEvent[]): TaskListResult => { + const taskLinks = links.filter(isTaskLink); + const taskCompleteLinks = links.filter(isTaskCompleteLink); + + // Build initial task records from create events + const createLinks = taskLinks.filter((l) => l.action === "create"); + const seedMap = createLinks.reduce>((acc, link, i) => { + const ordinal = i + 1; + const record = buildTaskFromCreate(link, ordinal); + return new Map([...acc, [record.task_id, record]]); + }, new Map()); + + // Apply updates (assign, status_change) — with ID reconciliation + const updateLinks = taskLinks.filter((l) => l.action === "assign" || l.action === "status_change"); + const updatedMap = updateLinks.reduce>((acc, link) => { + const { record: existing, map: reconciled } = resolveTask(acc, link.task_id); + if (!existing) return acc; + // A status_change to "deleted" removes the task from the list. + if (isDeletion(link)) { + const withoutDeleted = new Map([...reconciled]); + withoutDeleted.delete(existing.task_id); + return withoutDeleted; + } + return new Map([...reconciled, [existing.task_id, applyUpdate(existing, link)]]); + }, seedMap); + + // Apply completions — with ID reconciliation + const finalMap = taskCompleteLinks.reduce>((acc, link) => { + const { record: existing, map: reconciled } = resolveTask(acc, link.task_id); + const record = existing ? applyCompletion(existing, link) : buildOrphanComplete(link); + return new Map([...reconciled, [record.task_id, record]]); + }, updatedMap); + + const tasks = [...finalMap.values()].sort((a, b) => a.created_at - b.created_at); + const completedCount = tasks.filter((t) => t.status === "completed").length; + + return { + tasks, + total_count: tasks.length, + completed_count: completedCount, + completion_rate: tasks.length > 0 ? completedCount / tasks.length : 0, + }; +}; diff --git a/src/distill/team.ts b/packages/cli/src/distill/team.ts similarity index 64% rename from src/distill/team.ts rename to packages/cli/src/distill/team.ts index 108c2cf..44cc73b 100644 --- a/src/distill/team.ts +++ b/packages/cli/src/distill/team.ts @@ -94,12 +94,62 @@ export const extractTeamMetrics = ( const idleTeammateNames = idles.map((idle) => idle.teammate); const teammateNames = uniqueStrings([...spawnNames, ...inferredNames, ...taskAgentNames, ...idleTeammateNames]); - const tasks = taskCompletes.map((tc) => ({ - task_id: tc.task_id, - agent: tc.agent, - subject: tc.subject, - t: tc.t, - })); + // Build comprehensive task list by merging task create/assign/complete links + const taskLinks = links.filter(isTaskLink); + + // TaskCreate fires at PreToolUse — task_id is empty there, but `subject` is present. + // The stable correlation key between a create link and its later update/complete + // links is therefore the SUBJECT, not a session-local ordinal: with persistent task + // lists (CLAUDE_CODE_TASK_LIST_ID) the real ids are arbitrary (e.g. "42") and never + // line up with 1..N creation order. We key creates by subject (earliest wins) and + // also by real id when one is present. + const creates = taskLinks + .filter((tl) => tl.action === "create" && tl.subject) + .sort((a, b) => a.t - b.t); + + const createBySubject = creates.reduce>((acc, tl) => { + const subject = tl.subject ?? ""; + return acc.has(subject) ? acc : new Map(acc).set(subject, tl); + }, new Map()); + + const createById = new Map( + creates.filter((tl) => tl.task_id.length > 0).map((tl) => [tl.task_id, tl] as const), + ); + + // subject-by-id: only real (non-empty) create ids carry a usable subject correlation. + const subjectById = new Map( + creates.filter((tl) => tl.task_id.length > 0).map((tl) => [tl.task_id, tl.subject ?? ""] as const), + ); + + const ownerMap = new Map( + taskLinks + .filter((tl) => tl.owner) + .map((tl) => [tl.task_id, tl.owner ?? ""] as const), + ); + const completedSet = new Set(taskCompletes.map((tc) => tc.task_id)); + + // Collect all unique task_ids from update/complete links (skip empty create IDs) + const allTaskIds = [...new Set([ + ...taskLinks.filter((tl) => tl.task_id.length > 0).map((tl) => tl.task_id), + ...taskCompletes.map((tc) => tc.task_id), + ])].filter((id) => id.length > 0); + + const tasks = allTaskIds.map((id) => { + const complete = taskCompletes.find((tc) => tc.task_id === id); + // Resolve subject: a real create id wins, else fall back to the complete link's subject. + const subject = subjectById.get(id) ?? complete?.subject; + // Correlate creation time by id when the create carried one, else by subject — + // never by ordinal position, which breaks for persistent (non-1..N) ids. + const create = + createById.get(id) ?? (subject ? createBySubject.get(subject) : undefined); + return { + task_id: id, + agent: ownerMap.get(id) ?? complete?.agent ?? "", + subject, + status: completedSet.has(id) ? "completed" as const : undefined, + t: create?.t ?? complete?.t ?? 0, + }; + }); const idleTransitions = idles.map((idle) => ({ teammate: idle.teammate, diff --git a/src/distill/timeline.ts b/packages/cli/src/distill/timeline.ts similarity index 98% rename from src/distill/timeline.ts rename to packages/cli/src/distill/timeline.ts index 48e1ca0..b146f88 100644 --- a/src/distill/timeline.ts +++ b/packages/cli/src/distill/timeline.ts @@ -63,7 +63,7 @@ const eventsToEntries = (events: readonly StoredEvent[]): readonly TimelineEntry } if (event.event === "TaskCompleted") { const taskId = typeof event.data.task_id === "string" ? event.data.task_id : undefined; - const taskSubject = typeof event.data.subject === "string" ? event.data.subject : undefined; + const taskSubject = typeof event.data.task_subject === "string" ? event.data.task_subject : (typeof event.data.subject === "string" ? event.data.subject : undefined); return [ { t: event.t, diff --git a/src/distill/user-messages.ts b/packages/cli/src/distill/user-messages.ts similarity index 90% rename from src/distill/user-messages.ts rename to packages/cli/src/distill/user-messages.ts index 570c6d2..6d30f6f 100644 --- a/src/distill/user-messages.ts +++ b/packages/cli/src/distill/user-messages.ts @@ -3,7 +3,9 @@ import type { TranscriptEntry, TranscriptUserMessage } from "../types"; const classifyMessageType = (content: string): TranscriptUserMessage["message_type"] => { if (content.includes("") || content.includes("")) return "command"; if (content.includes(" = new Set([ + "hook_event_name", "session_id", "tool_name", "tool_use_id", + "agent_id", "agent_type", "agent_name", "permission_mode", + "memory_type", "load_reason", "source", "trigger", "model", + "file_path", "cwd", "transcript_path", "status", "end_reason", "effort", +]); + +const maskSecretString = (s: string): string => + SECRET_VALUE_PATTERNS.some((re) => re.test(s)) + ? REDACTED + : s.replace(ENV_SECRET_ASSIGN, `$1${REDACTED}`); + +const redactValue = (value: unknown, mode: Exclude): unknown => { + if (typeof value === "string") return mode === "metadata" ? REDACTED : maskSecretString(value); + if (Array.isArray(value)) return value.map((v) => redactValue(v, mode)); + if (value !== null && typeof value === "object") return redactObject(value as Record, mode); + return value; // number | boolean | null | undefined — structural, no cleartext +}; + +const redactObject = ( + obj: Record, + mode: Exclude, +): Record => { + const entries = Object.entries(obj).flatMap(([key, value]): readonly (readonly [string, unknown])[] => { + if (mode === "metadata") { + if (value !== null && typeof value === "object") return [[key, redactValue(value, mode)]]; + if (typeof value === "string") return METADATA_STRING_KEYS.has(key) ? [[key, value]] : []; + return [[key, value]]; // numbers / booleans / null kept as structural metadata + } + // redacted: mask values under sensitive key names, otherwise recurse. + if (SENSITIVE_KEY.test(key)) { + if (value !== null && typeof value === "object") return [[key, redactValue(value, mode)]]; + return [[key, value === null || value === undefined ? value : REDACTED]]; + } + return [[key, redactValue(value, mode)]]; + }); + return Object.fromEntries(entries); +}; + +/** Pure redaction entry point. `full` returns the input unchanged. */ +const redact = (value: unknown, mode: CaptureMode): unknown => + mode === "full" ? value : redactValue(value, mode); + +/** Read the capture/redaction mode from `.clens/config.json`. Defaults to `full`. */ +const readCaptureMode = (projectDir: string): CaptureMode => { + try { + const configPath = `${projectDir}/.clens/config.json`; + if (!existsSync(configPath)) return "full"; + const parsed: unknown = JSON.parse(readFileSync(configPath, "utf-8")); + if (parsed !== null && typeof parsed === "object") { + const mode = (parsed as Record).mode; + if (isCaptureMode(mode)) return mode; + } + } catch { + // Malformed/unreadable config must never break capture — fall back to full. + } + return "full"; +}; + +// Read stdin synchronously for performance +const raw = await Bun.stdin.text(); +if (!raw.trim()) process.exit(0); + +// Guard: reject non-JSON input silently (e.g. plain text, binary) +const firstChar = raw.trimStart()[0]; +if (firstChar !== "{" && firstChar !== "[") process.exit(0); + +const event = (Bun.argv[2] || "unknown") as HookEventType; + +try { + const input = JSON.parse(raw); + const sid: string = input.session_id || "unknown"; + // Resolve the project root by walking up from cwd to the nearest `.clens/` + // (or `.git/`) — prevents a subagent running in a subdirectory from + // fragmenting session capture into a nested `.clens/`. + const projectDir: string = resolveProjectRoot(input.cwd || process.cwd()); + + // Guard: refuse to capture into a root that is itself nested under a `.clens/` + // directory. Such a root produces a recursive `.clens/sessions/.clens/sessions` + // capture dir — and because `resolveProjectRoot` prefers the nearest `.clens/` + // marker, that nesting is self-perpetuating once it exists. Exact-segment match + // (not substring) so a legit path like `.clens-backup/` is unaffected. + if (projectDir.split(sep).includes(".clens")) process.exit(0); + + const sessionsDir = `${projectDir}/.clens/sessions`; + mkdirSync(sessionsDir, { recursive: true }); + + // Build stored event (with optional context enrichment on SessionStart or InstructionsLoaded with session_start) + const shouldEnrichContext = event === "SessionStart" + || (event === "InstructionsLoaded" && input.load_reason === "session_start"); + + const context = shouldEnrichContext + ? await (async () => { + try { + const { enrichSessionStart } = await import("./capture/context"); + return enrichSessionStart(input); + } catch (err) { + logError(projectDir, `hook:enrichSessionStart:${event}`, err); + return undefined; + } + })() + : undefined; + + // Apply opt-in redaction to the persisted payload only (link detection below + // still uses the original `input` so cross-agent structure stays intact). + const captureMode = readCaptureMode(projectDir); + const data: Record = + captureMode === "full" ? input : (redact(input, captureMode) as Record); + + const stored: StoredEvent = { + t: Date.now(), + event, + sid, + ...(context ? { context } : {}), + data, + }; + + // Append to session JSONL (hot path — appendFileSync is atomic for small writes) + // biome-ignore lint/style/useTemplate: performance-critical hot path + appendFileSync(`${sessionsDir}/${sid}.jsonl`, JSON.stringify(stored) + "\n"); + + // Cross-agent link detection + try { + const { isLinkEvent, extractLinkEvent, appendLink } = await import("./capture/links"); + if (isLinkEvent(event, input)) { + const linkEvent = extractLinkEvent(event, input); + appendLink(projectDir, linkEvent); + } + } catch (err) { + logError(projectDir, `hook:linkDetection:${event}`, err); + } + + // Hook proxy delegation + try { + const delegatedPath = `${projectDir}/.clens/delegated-hooks.json`; + if (existsSync(delegatedPath)) { + const { delegateToUserHooks } = await import("./capture/proxy"); + const result = await delegateToUserHooks(event, raw, projectDir); + if (result) { + // Output merged result for Claude Code to consume + process.stdout.write(JSON.stringify(result)); + } + } + } catch (err) { + logError(projectDir, `hook:proxyDelegation:${event}`, err); + } +} catch (err) { + // Silent fail — NEVER break Claude Code workflow + // Errors go to .clens/errors.log, not stdout/stderr + try { + const projectDir = process.cwd(); + const errorLog = `${projectDir}/.clens/errors.log`; + mkdirSync(`${projectDir}/.clens`, { recursive: true }); + const errMessage = err instanceof Error ? err.message.slice(0, 500) : String(err).slice(0, 500); + const stackLines = + err instanceof Error && err.stack + ? err.stack.split("\n").slice(0, 3).join("\n ") + : "no stack"; + const truncatedInput = raw.slice(0, 200); + appendFileSync( + errorLog, + `${new Date().toISOString()} [${event}] ${errMessage}\n stack: ${stackLines}\n input: ${truncatedInput}\n`, + ); + } catch { + // Even error logging failed — truly silent + } + process.exit(0); +} diff --git a/src/session/clean.ts b/packages/cli/src/session/clean.ts similarity index 100% rename from src/session/clean.ts rename to packages/cli/src/session/clean.ts diff --git a/packages/cli/src/session/conversation.ts b/packages/cli/src/session/conversation.ts new file mode 100644 index 0000000..db62edf --- /dev/null +++ b/packages/cli/src/session/conversation.ts @@ -0,0 +1,289 @@ +import type { ConversationEntry } from "../types/conversation"; +import type { AgentNode, DistilledSession, StoredEvent, TranscriptContentBlock, TranscriptEntry } from "../types"; + +/** Truncate a value to a JSON preview of ~maxLen chars. */ +const truncatePreview = (value: unknown, maxLen: number = 100): string => { + const raw = typeof value === "string" ? value : JSON.stringify(value ?? {}); + return raw.length > maxLen ? `${raw.slice(0, maxLen)}...` : raw; +}; + +/** Check if a record has a string field. */ +const hasStringField = ( + data: Readonly>, + key: string, +): data is Readonly> & Record => + typeof data[key] === "string"; + +/** Extract file_path from tool_input data (PreToolUse). */ +const extractFilePath = (data: Readonly>): string | undefined => { + const toolInput = data["tool_input"]; + if (toolInput && typeof toolInput === "object" && toolInput !== null) { + const input = toolInput as Readonly>; + if (typeof input["file_path"] === "string") return input["file_path"]; + if (typeof input["path"] === "string") return input["path"]; + } + return undefined; +}; + +/** Build args_preview from tool_input data. */ +const buildArgsPreview = (data: Readonly>): string => + truncatePreview(data["tool_input"]); + +/** Map user_messages from distilled session to UserPromptEntry[]. */ +const mapUserPrompts = (distilled: DistilledSession): readonly ConversationEntry[] => + distilled.user_messages + .filter((m) => !m.is_tool_result && m.message_type !== "system") + .map((m, i) => ({ + type: "user_prompt" as const, + t: m.t, + text: m.content, + index: i, + })); + +/** Map reasoning entries to ThinkingEntry[]. */ +const mapThinking = (distilled: DistilledSession): readonly ConversationEntry[] => + distilled.reasoning.map((r) => ({ + type: "thinking" as const, + t: r.t, + text: r.thinking, + intent: r.intent_hint ?? "general", + })); + +/** Map PreToolUse events to ToolCallEntry[]. */ +const mapToolCalls = (events: readonly StoredEvent[]): readonly ConversationEntry[] => + events + .filter((e) => e.event === "PreToolUse") + .filter((e) => hasStringField(e.data, "tool_use_id") && hasStringField(e.data, "tool_name")) + .map((e) => ({ + type: "tool_call" as const, + t: e.t, + tool_name: e.data["tool_name"] as string, + tool_use_id: e.data["tool_use_id"] as string, + file_path: extractFilePath(e.data), + args_preview: buildArgsPreview(e.data), + })); + +/** Map PostToolUse / PostToolUseFailure events to ToolResultEntry[]. */ +const mapToolResults = (events: readonly StoredEvent[]): readonly ConversationEntry[] => + events + .filter((e) => e.event === "PostToolUse" || e.event === "PostToolUseFailure") + .filter((e) => hasStringField(e.data, "tool_use_id") && hasStringField(e.data, "tool_name")) + .map((e) => ({ + type: "tool_result" as const, + t: e.t, + tool_use_id: e.data["tool_use_id"] as string, + tool_name: e.data["tool_name"] as string, + outcome: (e.event === "PostToolUseFailure" ? "failure" : "success") as "success" | "failure", + ...(e.event === "PostToolUseFailure" && hasStringField(e.data, "error") + ? { error: e.data["error"] as string } + : {}), + })); + +/** Map backtracks to BacktrackEntry[]. */ +const mapBacktracks = (distilled: DistilledSession): readonly ConversationEntry[] => + distilled.backtracks.map((b) => ({ + type: "backtrack" as const, + t: b.start_t, + backtrack_type: b.type, + attempt: b.attempts, + reverted_tool_ids: b.tool_use_ids, + })); + +/** Map summary phases to PhaseBoundaryEntry[]. */ +const mapPhaseBoundaries = (distilled: DistilledSession): readonly ConversationEntry[] => + (distilled.summary?.phases ?? []).map((p, i) => ({ + type: "phase_boundary" as const, + t: p.start_t, + phase_name: p.name, + phase_index: i, + })); + +/** + * Build a sorted conversation timeline from distilled data and raw events. + * Pure function — no I/O, no mutation. + */ +export const buildConversation = ( + distilled: DistilledSession, + events: readonly StoredEvent[], +): readonly ConversationEntry[] => { + const all: readonly ConversationEntry[] = [ + ...mapUserPrompts(distilled), + ...mapThinking(distilled), + ...mapToolCalls(events), + ...mapToolResults(events), + ...mapBacktracks(distilled), + ...mapPhaseBoundaries(distilled), + ]; + + return [...all].sort((a, b) => a.t - b.t); +}; + +// ── Transcript-based conversation builder (for agents without hook events) ── + +/** Type guard for content block arrays. */ +const isContentBlockArray = ( + content: string | readonly TranscriptContentBlock[], +): content is readonly TranscriptContentBlock[] => Array.isArray(content); + +/** Extract user prompt entries from transcript entries. */ +const mapTranscriptUserPrompts = (entries: readonly TranscriptEntry[]): readonly ConversationEntry[] => + entries + .filter((e) => e.type === "user" && e.message?.role === "user") + .flatMap((e, i): readonly ConversationEntry[] => { + const content = e.message?.content; + if (!content) return []; + const text = typeof content === "string" + ? content + : content + .filter((b): b is Extract => b.type === "text") + .map((b) => b.text) + .join("\n"); + return text ? [{ + type: "user_prompt" as const, + t: new Date(e.timestamp).getTime(), + text, + index: i, + }] : []; + }); + +/** Extract tool call entries from assistant transcript content blocks. */ +const mapTranscriptToolCalls = (entries: readonly TranscriptEntry[]): readonly ConversationEntry[] => + entries + .filter((e) => e.type === "assistant" && e.message?.content) + .flatMap((e): readonly ConversationEntry[] => { + const content = e.message?.content; + if (!content || !isContentBlockArray(content)) return []; + const t = new Date(e.timestamp).getTime(); + return content + .filter((b): b is Extract => b.type === "tool_use") + .map((b) => { + // b.input is Record — bracket access is required + const filePath = typeof b.input["file_path"] === "string" + ? b.input["file_path"] + : typeof b.input["path"] === "string" + ? b.input["path"] + : undefined; + return { + type: "tool_call" as const, + t, + tool_name: b.name, + tool_use_id: b.id, + file_path: filePath, + args_preview: truncatePreview(b.input), + }; + }); + }); + +/** Extract tool result entries from user transcript content blocks (tool results come back as user messages). */ +const mapTranscriptToolResults = (entries: readonly TranscriptEntry[]): readonly ConversationEntry[] => + entries + .filter((e) => e.type === "user" && e.message?.content) + .flatMap((e): readonly ConversationEntry[] => { + const content = e.message?.content; + if (!content || !isContentBlockArray(content)) return []; + const t = new Date(e.timestamp).getTime(); + return content + .filter((b): b is Extract => b.type === "tool_result") + .map((b) => ({ + type: "tool_result" as const, + t, + tool_use_id: b.tool_use_id, + tool_name: "unknown", + outcome: (b.is_error ? "failure" : "success") as "success" | "failure", + ...(b.is_error && typeof b.content === "string" ? { error: b.content } : {}), + })); + }); + +/** Extract thinking entries from assistant transcript content blocks. */ +const mapTranscriptThinking = (entries: readonly TranscriptEntry[]): readonly ConversationEntry[] => + entries + .filter((e) => e.type === "assistant" && e.message?.content) + .flatMap((e): readonly ConversationEntry[] => { + const content = e.message?.content; + if (!content || !isContentBlockArray(content)) return []; + const t = new Date(e.timestamp).getTime(); + return content + .filter((b): b is Extract => b.type === "thinking") + .map((b) => ({ + type: "thinking" as const, + t, + text: b.thinking, + intent: "general", + })); + }); + +/** Map agent messages (from link enrichment) to AgentMessageEntry[]. */ +const mapAgentMessages = (agent?: AgentNode): readonly ConversationEntry[] => + (agent?.messages ?? []).map((m) => ({ + type: "agent_message" as const, + t: m.t, + direction: m.direction, + partner: m.partner, + msg_type: m.msg_type, + ...(m.summary ? { summary: m.summary } : {}), + })); + +/** Derive earliest timestamp from agent data (messages, task_events, reasoning). */ +const getAgentStartTime = (agent?: AgentNode): number | undefined => { + const timestamps = [ + ...(agent?.messages ?? []).map((m) => m.t), + ...(agent?.task_events ?? []).map((te) => te.t), + ...(agent?.reasoning ?? []).map((r) => r.t), + ]; + return timestamps.length > 0 ? Math.min(...timestamps) : undefined; +}; + +/** + * Build conversation from transcript entries and optional agent node data. + * Used as fallback when hook events are unavailable (sub-agents). + * Pure function — no I/O, no mutation. + */ +export const buildConversationFromTranscript = ( + transcript: readonly TranscriptEntry[], + agent?: AgentNode, +): readonly ConversationEntry[] => { + const agentReasoning: readonly ConversationEntry[] = (agent?.reasoning ?? []).map((r) => ({ + type: "thinking" as const, + t: r.t, + text: r.thinking, + intent: r.intent_hint ?? "general", + })); + + const agentBacktracks: readonly ConversationEntry[] = (agent?.backtracks ?? []).map((b) => ({ + type: "backtrack" as const, + t: b.start_t, + backtrack_type: b.type, + attempt: b.attempts, + reverted_tool_ids: b.tool_use_ids, + })); + + // Use agent-level reasoning if available (richer with intent_hint), else extract from transcript + const thinking = agentReasoning.length > 0 ? agentReasoning : mapTranscriptThinking(transcript); + + // Task prompt as first user message (when transcript is empty and agent has one) + const taskPrompt: readonly ConversationEntry[] = (() => { + if (!agent?.task_prompt) return []; + const startT = getAgentStartTime(agent) ?? 0; + return [{ + type: "user_prompt" as const, + t: startT - 1, // before any other entries + text: agent.task_prompt, + index: 0, + }]; + })(); + + // Agent messages from link enrichment (inter-agent communication) + const messages = mapAgentMessages(agent); + + const all: readonly ConversationEntry[] = [ + ...taskPrompt, + ...mapTranscriptUserPrompts(transcript), + ...thinking, + ...mapTranscriptToolCalls(transcript), + ...mapTranscriptToolResults(transcript), + ...agentBacktracks, + ...messages, + ]; + + return [...all].sort((a, b) => a.t - b.t); +}; diff --git a/src/session/export.ts b/packages/cli/src/session/export.ts similarity index 99% rename from src/session/export.ts rename to packages/cli/src/session/export.ts index 937b07f..b7a8aee 100644 --- a/src/session/export.ts +++ b/packages/cli/src/session/export.ts @@ -11,7 +11,6 @@ const extractTimestamp = (obj: Record): number | undefined => export const exportSession = async ( sessionId: string, projectDir: string, - _options?: { otel?: boolean }, ): Promise => { const sessionsDir = `${projectDir}/.clens/sessions`; const sessionPath = `${sessionsDir}/${sessionId}.jsonl`; diff --git a/packages/cli/src/session/feature-index.ts b/packages/cli/src/session/feature-index.ts new file mode 100644 index 0000000..c4e6c8b --- /dev/null +++ b/packages/cli/src/session/feature-index.ts @@ -0,0 +1,105 @@ +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { detectFeatureFlags } from "../distill/feature-usage"; +import type { FeatureFlag } from "../types"; + +// ── Feature index ─────────────────────────────────────────────────── +// +// Detecting loop/goal/workflow usage requires scanning full session JSONL +// files (signatures live in tool events anywhere in the file, not the +// first/last lines the lightweight listing reads). Session files are +// append-only, so a (mtime, size)-keyed cache makes the scan a one-time +// cost per file; subsequent listings only stat. + +const INDEX_VERSION = 1; + +interface FeatureIndexEntry { + readonly flags: readonly FeatureFlag[]; + readonly mtime_ms: number; + readonly size: number; +} + +interface FeatureIndexFile { + readonly version: number; + readonly entries: Readonly>; +} + +const indexPath = (projectDir: string): string => `${projectDir}/.clens/feature-index.json`; + +const VALID_FLAGS: readonly string[] = ["loop", "goal", "workflow"]; + +const sanitizeEntry = (value: unknown): FeatureIndexEntry | undefined => { + if (!value || typeof value !== "object") return undefined; + const obj = value as Record; + if (typeof obj.mtime_ms !== "number" || typeof obj.size !== "number") return undefined; + if (!Array.isArray(obj.flags) || !obj.flags.every((f) => VALID_FLAGS.includes(f as string))) return undefined; + return { flags: obj.flags as readonly FeatureFlag[], mtime_ms: obj.mtime_ms, size: obj.size }; +}; + +const readIndexFile = (projectDir: string): Readonly> => { + const path = indexPath(projectDir); + if (!existsSync(path)) return {}; + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + const file = parsed as FeatureIndexFile; + if (file.version !== INDEX_VERSION || !file.entries || typeof file.entries !== "object") return {}; + return Object.fromEntries( + Object.entries(file.entries).flatMap(([sid, entry]) => { + const clean = sanitizeEntry(entry); + return clean ? [[sid, clean] as const] : []; + }), + ); + } catch { + return {}; + } +}; + +const writeIndexFile = (projectDir: string, entries: Readonly>): void => { + try { + const file: FeatureIndexFile = { version: INDEX_VERSION, entries }; + writeFileSync(indexPath(projectDir), JSON.stringify(file)); + } catch { + // Cache write failure is non-fatal — next call re-scans + } +}; + +/** + * Feature flags for every session in a project, keyed by session ID. + * Cache hits cost one stat per file; misses scan the raw JSONL once. + */ +export const readFeatureIndex = (projectDir: string): ReadonlyMap => { + const sessionsDir = `${projectDir}/.clens/sessions`; + + const files = (() => { + try { + return readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl") && f !== "_links.jsonl"); + } catch { + return []; + } + })(); + + if (files.length === 0) return new Map(); + + const cached = readIndexFile(projectDir); + let dirty = false; + + const entries = files.flatMap((file): readonly (readonly [string, FeatureIndexEntry])[] => { + const sessionId = file.replace(".jsonl", ""); + const filePath = `${sessionsDir}/${file}`; + try { + const stat = statSync(filePath); + const hit = cached[sessionId]; + if (hit && hit.mtime_ms === stat.mtimeMs && hit.size === stat.size) { + return [[sessionId, hit]]; + } + const flags = detectFeatureFlags(readFileSync(filePath, "utf-8")); + dirty = true; + return [[sessionId, { flags, mtime_ms: stat.mtimeMs, size: stat.size }]]; + } catch { + return []; + } + }); + + if (dirty) writeIndexFile(projectDir, Object.fromEntries(entries)); + + return new Map(entries.map(([sid, entry]) => [sid, entry.flags])); +}; diff --git a/packages/cli/src/session/global-read.ts b/packages/cli/src/session/global-read.ts new file mode 100644 index 0000000..d009df6 --- /dev/null +++ b/packages/cli/src/session/global-read.ts @@ -0,0 +1,119 @@ +import { existsSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import type { GlobalSessionSummary, ProjectEntry, SessionSummary } from "../types"; +import { readGlobalConfig, resolveProjectEntries } from "./registry"; +import { listSessions, enrichSessionSummaries } from "./read"; + +/** + * In repository mode, a single project (git root) may contain multiple + * `.clens/sessions/` directories (root + nested packages). This finds them all. + */ +const findAllClensDirs = (projectDir: string, maxDepth = 3): readonly string[] => { + const dirs: string[] = []; + + const scan = (dir: string, depth: number): void => { + if (depth > maxDepth) return; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === "node_modules" || entry.name === ".git") continue; + + const fullPath = resolve(dir, entry.name); + if (entry.name === ".clens") { + if (existsSync(resolve(fullPath, "sessions"))) { + dirs.push(dir); + } + continue; + } + if (entry.name.startsWith(".")) continue; + scan(fullPath, depth + 1); + } + } catch { + // Permission error — skip + } + }; + + scan(projectDir, 0); + return dirs; +}; + +/** + * List sessions for a project. In repository mode, scans all nested `.clens/` + * dirs within the project. In project mode, just reads the single dir. + */ +const listSessionsForProject = ( + project: ProjectEntry, + isRepoMode: boolean, +): readonly (SessionSummary & { readonly capture_dir: string })[] => { + if (!isRepoMode) { + return enrichSessionSummaries(listSessions(project.path), project.path).map( + (session) => ({ ...session, capture_dir: project.path }), + ); + } + + // Repository mode: merge sessions from all .clens/ dirs in the repo. Each + // owning `dir` is the capture dir for the sessions it yields. + const clensDirs = findAllClensDirs(project.path); + return clensDirs.flatMap((dir) => + enrichSessionSummaries(listSessions(dir), dir).map((session) => ({ + ...session, + capture_dir: dir, + })), + ); +}; + +/** + * List sessions across all registered projects. + * Reads registry, collects sessions per project (respecting global_mode), + * tags with project info, merges, and sorts by start_time descending. + */ +export const listGlobalSessions = (): readonly GlobalSessionSummary[] => { + const projects = resolveProjectEntries(); + const config = readGlobalConfig(); + const isRepoMode = config.global_mode === "repository"; + + const allSessions = projects.flatMap((project): readonly GlobalSessionSummary[] => + listSessionsForProject(project, isRepoMode).map((session): GlobalSessionSummary => ({ + ...session, + project_id: project.id, + project_name: project.name, + })), + ); + + // Deduplicate by session_id — the same session can be captured into multiple + // nested .clens dirs (root + package broadcasts), so it appears once per dir. + // Keep the most-complete copy (max event_count) as the canonical owner so every + // displayed field (capture_dir, status, size, counts) comes from one consistent + // source and totals match the web global list (bug NUM-1). + const byId = new Map(); + for (const session of allSessions) { + const existing = byId.get(session.session_id); + if (!existing || session.event_count > existing.event_count) { + byId.set(session.session_id, session); + } + } + + return [...byId.values()].sort((a, b) => b.start_time - a.start_time); +}; + +/** + * Scan registry projects to find which one owns a given session ID. + */ +export const resolveProjectForSession = (sessionId: string): ProjectEntry | undefined => { + const projects = resolveProjectEntries(); + const config = readGlobalConfig(); + const isRepoMode = config.global_mode === "repository"; + + for (const project of projects) { + if (isRepoMode) { + const dirs = findAllClensDirs(project.path); + if (dirs.some((dir) => existsSync(`${dir}/.clens/sessions/${sessionId}.jsonl`))) { + return project; + } + } else if (existsSync(`${project.path}/.clens/sessions/${sessionId}.jsonl`)) { + return project; + } + } + return undefined; +}; diff --git a/packages/cli/src/session/index.ts b/packages/cli/src/session/index.ts new file mode 100644 index 0000000..2ab7567 --- /dev/null +++ b/packages/cli/src/session/index.ts @@ -0,0 +1,14 @@ +export { cleanAll, cleanSession } from "./clean"; +export { buildConversation, buildConversationFromTranscript } from "./conversation"; +export { exportSession } from "./export"; +export { readFeatureIndex } from "./feature-index"; +export { listJourneys, resolveJourneyId } from "./journey"; +export { enrichSessionSummaries, listSessions, readDistilled, readLinks, readSessionEvents } from "./read"; +export { computeSessionName, resolveDisplayName } from "./session-name"; +export type { DisplayNameInputs, ResolvedDisplayName } from "./session-name"; +export { readSessionMeta, sessionMetaPath, setSessionMeta, writeSessionMeta } from "./session-meta"; +export type { SessionMetaMap, SessionMetaPatch } from "./session-meta"; +export { readSessionName, readTranscript, readTranscriptWithMeta, resolveTranscriptPath } from "./transcript"; +export type { TranscriptWithMeta } from "./transcript"; +export { registerProject, unregisterProject, readRegistry, writeRegistry, registryPath, resolveProjectEntries, discoverAndRegisterProjects, readGlobalConfig, writeGlobalConfig, isValidGlobalMode, globalConfigPath } from "./registry"; +export { listGlobalSessions, resolveProjectForSession } from "./global-read"; diff --git a/src/session/journey.ts b/packages/cli/src/session/journey.ts similarity index 100% rename from src/session/journey.ts rename to packages/cli/src/session/journey.ts diff --git a/src/session/parsers.ts b/packages/cli/src/session/parsers.ts similarity index 100% rename from src/session/parsers.ts rename to packages/cli/src/session/parsers.ts diff --git a/src/session/read.ts b/packages/cli/src/session/read.ts similarity index 55% rename from src/session/read.ts rename to packages/cli/src/session/read.ts index 44875c5..cf4dfb8 100644 --- a/src/session/read.ts +++ b/packages/cli/src/session/read.ts @@ -1,8 +1,11 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import type { DistilledSession, LinkEvent, SessionSummary, SpawnLink, StoredEvent } from "../types"; -import { BROADCAST_EVENTS } from "../types"; -import { computeEffectiveDuration, isGhostSession, logError } from "../utils"; +import type { AgentNode, CostEstimate, DistilledSession, LinkEvent, SessionSummary, SpawnLink, StoredEvent } from "../types"; +import { BROADCAST_EVENTS, deriveSessionStatus } from "../types"; +import { repriceCostEstimate } from "../distill/stats"; +import { computeEffectiveDuration, deduplicateSpawns, isGhostSession, logError } from "../utils"; import { parseDistilledSession, parseLinkEvent } from "./parsers"; +import { computeSessionName, resolveDisplayName } from "./session-name"; +import { readSessionMeta } from "./session-meta"; import { readSessionName } from "./transcript"; /** Parse a JSON line into a StoredEvent, returning undefined for invalid data. */ @@ -64,7 +67,11 @@ export const listSessions = (projectDir: string): SessionSummary[] => { const endTime = meaningfulLast.t; const timestamps = parsedLines.filter((e): e is StoredEvent => e !== undefined).map((e) => e.t); const effectiveDuration = computeEffectiveDuration(timestamps); - const isComplete = meaningfulLast.event === "SessionEnd" || meaningfulLast.event === "Stop"; + // A clean SessionEnd marks completion. A trailing Stop no longer does + // (it fires after every turn — bug B6). Non-ended sessions are active + // only while their last event is recent, else idle. + const isSessionEnd = meaningfulLast.event === "SessionEnd"; + const status = deriveSessionStatus(isSessionEnd, endTime); const source = typeof firstEvent.data.source === "string" ? firstEvent.data.source : undefined; @@ -75,14 +82,16 @@ export const listSessions = (projectDir: string): SessionSummary[] => { { session_id: sessionId, start_time: startTime, - end_time: isComplete ? endTime : undefined, - duration_ms: effectiveDuration.effective_duration_ms, + end_time: isSessionEnd ? endTime : undefined, + // Wall-clock span — must agree with the web list (bug B2: same field + // carried wall in the API but idle-trimmed here, so list views disagreed) + duration_ms: effectiveDuration.wall_duration_ms, event_count: lines.length, git_branch: firstEvent.context?.git_branch || undefined, team_name: firstEvent.context?.team_name || undefined, source, end_reason: endReason, - status: isComplete ? "complete" : "incomplete", + status, file_size_bytes: stat.size, }, ]; @@ -137,9 +146,43 @@ export const readLinks = (projectDir: string): readonly LinkEvent[] => { } }; +/** + * Re-price an agent node (and its children) at the current pricing table. + * `children` is typed non-optional, but the parse guard (parsers.ts) never + * validates it, so a legacy/partial on-disk distill can omit it — guard with + * `?? []` so re-pricing never throws and silently drops the whole session. + */ +const repriceAgent = (a: AgentNode): AgentNode => ({ + ...a, + cost_estimate: a.cost_estimate ? repriceCostEstimate(a.cost_estimate) : a.cost_estimate, + children: (a.children ?? []).map(repriceAgent), +}); + +/** + * Re-price a distilled session's frozen cost estimates against the CURRENT pricing + * table, for DISPLAY only. Distilled `cost_estimate` values are frozen at + * distill-time rates; when the API price table changes (e.g. Opus 4.5+ dropping + * ~3x) those numbers become stale. The on-disk JSON stays the frozen record — this + * only transforms the in-memory object returned to callers. Covers the + * session-level estimate (top-level + `stats`) and every (nested) agent estimate. + * Verbatim measured costs are left unchanged by `repriceCostEstimate`. + */ +const repriceDistilled = (d: DistilledSession): DistilledSession => { + const reprice = (ce: CostEstimate | undefined): CostEstimate | undefined => + ce ? repriceCostEstimate(ce) : ce; + return { + ...d, + cost_estimate: reprice(d.cost_estimate), + stats: { ...d.stats, cost_estimate: reprice(d.stats.cost_estimate) }, + agents: d.agents?.map(repriceAgent), + }; +}; + /** * Read and parse a distilled session JSON file. * Returns undefined if the file does not exist or cannot be parsed. + * Cost estimates are re-priced against the current table for display; the + * on-disk file remains the frozen distill-time record. */ export const readDistilled = ( sessionId: string, @@ -149,7 +192,8 @@ export const readDistilled = ( if (!existsSync(distilledPath)) return undefined; try { const content = readFileSync(distilledPath, "utf-8"); - return parseDistilledSession(content); + const parsed = parseDistilledSession(content); + return parsed ? repriceDistilled(parsed) : parsed; } catch (err) { logError(projectDir, `readDistilled:${sessionId}`, err); return undefined; @@ -158,6 +202,26 @@ export const readDistilled = ( const isSpawnLink = (link: LinkEvent): link is SpawnLink => link.type === "spawn"; +/** + * Count agents in a distilled JSON file recursively (children at every depth). + * Returns 0 if the file is missing or cannot be parsed. Used as the authoritative + * agent count when a session has been distilled (bug B15 — one source of truth). + */ +const countDistilledAgents = (distilledPath: string): number => { + try { + const parsed: unknown = JSON.parse(readFileSync(distilledPath, "utf-8")); + const agents = parsed && typeof parsed === "object" ? (parsed as { agents?: unknown }).agents : undefined; + const countRecursive = (nodes: readonly { children?: readonly unknown[] }[]): number => + nodes.reduce( + (sum, n) => sum + 1 + countRecursive((n.children ?? []) as readonly { children?: readonly unknown[] }[]), + 0, + ); + return Array.isArray(agents) ? countRecursive(agents as readonly { children?: readonly unknown[] }[]) : 0; + } catch { + return 0; + } +}; + /** * Extract transcript_path from the first event of a session JSONL file that has one. * Reads the file content and scans events until it finds one with `data.transcript_path`. @@ -182,6 +246,31 @@ const resolveTranscriptPathFromSession = (sessionId: string, projectDir: string) } }; +/** + * Extract the first substantive user prompt from a session JSONL file. + * Scans for the first `UserPromptSubmit` event with a string `data.prompt`. + * Returns null if the file is missing/empty or no such event exists. The raw + * prompt is returned verbatim; cleaning/truncation is done by computeSessionName. + */ +const readFirstPrompt = (sessionId: string, projectDir: string): string | null => { + const filePath = `${projectDir}/.clens/sessions/${sessionId}.jsonl`; + try { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, "utf-8").trim(); + if (!content) return null; + const lines = content.split("\n").filter(Boolean); + return lines.reduce((acc, line) => { + if (acc !== null) return acc; + const event = parseEvent(line); + if (event?.event !== "UserPromptSubmit") return null; + const prompt = event.data?.prompt; + return typeof prompt === "string" ? prompt : null; + }, null); + } catch { + return null; + } +}; + /** * Enrich session summaries with agent count, distill status, spec presence, and session name. * Reads links once and scans the distilled directory to annotate each session. @@ -191,9 +280,14 @@ export const enrichSessionSummaries = ( projectDir: string, ): readonly SessionSummary[] => { const links = readLinks(projectDir); - const spawns = links.filter(isSpawnLink); + // Load the cLens sidecar ONCE per listing, not per session (R16). + const sessionMeta = readSessionMeta(projectDir); + // Deduplicate by agent_id so resumed agents (which emit multiple spawn events) + // count once. This must match the web list route exactly (bug B15 — CLI counted + // raw spawn links while the API counted distinct agents, so they disagreed). + const spawns = deduplicateSpawns(links.filter(isSpawnLink)); - // Count spawn links per parent_session + // Count deduplicated spawn links per parent_session const spawnCountByParent: ReadonlyMap = spawns.reduce>( (acc, spawn) => { const current = acc.get(spawn.parent_session) ?? 0; @@ -217,13 +311,19 @@ export const enrichSessionSummaries = ( })(); return sessions.map((session): SessionSummary => { - const spawnCount = spawnCountByParent.get(session.session_id) ?? 0; - const agentCount = spawnCount > 0 - ? spawnCount - : msgRecipientsBySession.get(session.session_id) ?? 0; const distilledPath = `${projectDir}/.clens/distilled/${session.session_id}.json`; const isDistilled = existsSync(distilledPath); + // Single source of truth (bug B15): distilled agent count when distilled, + // else deduplicated spawn links, else unique msg_send recipients. + const distilledCount = isDistilled ? countDistilledAgents(distilledPath) : 0; + const spawnCount = spawnCountByParent.get(session.session_id) ?? 0; + const agentCount = distilledCount > 0 + ? distilledCount + : spawnCount > 0 + ? spawnCount + : msgRecipientsBySession.get(session.session_id) ?? 0; + const hasSpec = isDistilled ? (() => { try { @@ -236,15 +336,30 @@ export const enrichSessionSummaries = ( })() : false; - // Resolve session name from transcript custom-title event - const session_name = (() => { + // Resolve session name from transcript custom-title event (legacy field kept). + const customTitle = (() => { const tPath = resolveTranscriptPathFromSession(session.session_id, projectDir); return tPath ? readSessionName(tPath) ?? undefined : undefined; })(); + // Resolve the display name by precedence: sidecar label > CC custom-title > + // computed first-prompt > short id (R1/R5). Sidecar carries label + color. + const meta = sessionMeta[session.session_id]; + const computed = computeSessionName(readFirstPrompt(session.session_id, projectDir)); + const { display_name, name_source } = resolveDisplayName({ + label: meta?.label ?? null, + customTitle: customTitle ?? null, + computed, + id: session.session_id, + }); + return { ...session, - ...(session_name ? { session_name } : {}), + ...(customTitle ? { session_name: customTitle } : {}), + display_name, + name_source, + ...(meta?.label ? { label: meta.label } : {}), + ...(meta?.color ? { color: meta.color } : {}), agent_count: agentCount, is_distilled: isDistilled, has_spec: hasSpec, diff --git a/packages/cli/src/session/registry.ts b/packages/cli/src/session/registry.ts new file mode 100644 index 0000000..413de13 --- /dev/null +++ b/packages/cli/src/session/registry.ts @@ -0,0 +1,281 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { homedir } from "node:os"; +import type { GlobalConfig, GlobalMode, ProjectEntry, ProjectRegistry } from "../types"; + +// ── Paths ──────────────────────────────────────────────────────── + +const globalDir = (): string => `${homedir()}/.clens`; + +/** Path to the global project registry file. */ +export const registryPath = (): string => `${globalDir()}/projects.json`; + +/** Path to the global config file. */ +export const globalConfigPath = (): string => `${globalDir()}/config.json`; + +// ── Global Config ──────────────────────────────────────────────── + +const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { global_mode: "repository" }; +const VALID_GLOBAL_MODES: readonly GlobalMode[] = ["repository", "project"]; + +export const isValidGlobalMode = (value: string): value is GlobalMode => + (VALID_GLOBAL_MODES as readonly string[]).includes(value); + +export const readGlobalConfig = (): GlobalConfig => { + const path = globalConfigPath(); + if (!existsSync(path)) return DEFAULT_GLOBAL_CONFIG; + try { + const raw: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (typeof raw !== "object" || raw === null) return DEFAULT_GLOBAL_CONFIG; + const obj = raw as Record; + const global_mode = + typeof obj.global_mode === "string" && isValidGlobalMode(obj.global_mode) + ? obj.global_mode + : DEFAULT_GLOBAL_CONFIG.global_mode; + return { global_mode }; + } catch { + return DEFAULT_GLOBAL_CONFIG; + } +}; + +export const writeGlobalConfig = (config: GlobalConfig): void => { + const dir = globalDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(globalConfigPath(), JSON.stringify(config, null, 2)); +}; + +// ── Registry CRUD ──────────────────────────────────────────────── + +/** Read the project registry. Returns empty registry if file is missing or invalid. */ +export const readRegistry = (): ProjectRegistry => { + const path = registryPath(); + if (!existsSync(path)) return { version: 1, projects: [] }; + try { + const content = readFileSync(path, "utf-8"); + const parsed: unknown = JSON.parse(content); + if ( + parsed && + typeof parsed === "object" && + "version" in parsed && + "projects" in parsed && + Array.isArray((parsed as Record).projects) + ) { + return parsed as ProjectRegistry; + } + return { version: 1, projects: [] }; + } catch { + return { version: 1, projects: [] }; + } +}; + +/** Write the project registry atomically. */ +export const writeRegistry = (registry: ProjectRegistry): void => { + const dir = globalDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(registryPath(), JSON.stringify(registry, null, 2)); +}; + +/** Derive a kebab-case project ID from a directory path. */ +const deriveProjectId = (projectDir: string): string => + basename(projectDir) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +/** Register a project in the global registry. Idempotent — returns existing entry if already registered. */ +export const registerProject = (projectDir: string): ProjectEntry => { + const registry = readRegistry(); + const existing = registry.projects.find((p) => p.path === projectDir); + if (existing) return existing; + + const entry: ProjectEntry = { + id: deriveProjectId(projectDir), + path: projectDir, + name: basename(projectDir), + added_at: Date.now(), + }; + + writeRegistry({ + ...registry, + projects: [...registry.projects, entry], + }); + + return entry; +}; + +/** Unregister a project from the global registry. Returns true if the project was found and removed. */ +export const unregisterProject = (projectDir: string): boolean => { + const registry = readRegistry(); + const filtered = registry.projects.filter((p) => p.path !== projectDir); + if (filtered.length === registry.projects.length) return false; + writeRegistry({ ...registry, projects: filtered }); + return true; +}; + +/** + * Whether a registered project still has a `.clens/` directory reachable from its path. + * + * The original check was `existsSync(${path}/.clens)`. In repository mode a project's + * `path` is the git root, but its `.clens/` may live in a nested package (e.g. + * `gitRoot/packages/web/.clens/sessions`), so that check dropped every repo whose + * capture dir was nested (bug repo-mode-nested-clens-projects-dropped). We keep the + * cheap depth-0 check for project-mode entries and otherwise mirror global-read's + * findAllClensDirs: accept the project if a nested `.clens/sessions/` exists within a + * bounded depth below the path. + */ +const hasReachableClensDir = (projectDir: string, maxDepth = 3): boolean => { + // Fast path — `.clens` directly at the registered path (project mode / root capture). + if (existsSync(`${projectDir}/.clens`)) return true; + + // Repository mode — `.clens/sessions/` may be nested below the git root. + const scan = (dir: string, depth: number): boolean => { + if (depth > maxDepth) return false; + const entries = (() => { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + })(); + return entries.some((entry) => { + if (!entry.isDirectory()) return false; + if (entry.name === "node_modules" || entry.name === ".git") return false; + const fullPath = resolve(dir, entry.name); + if (entry.name === ".clens") return existsSync(resolve(fullPath, "sessions")); + if (entry.name.startsWith(".")) return false; + return scan(fullPath, depth + 1); + }); + }; + return scan(projectDir, 0); +}; + +/** Read registry and filter to entries whose `.clens/` directory is still reachable. */ +export const resolveProjectEntries = (): readonly ProjectEntry[] => { + const registry = readRegistry(); + return registry.projects.filter((p) => hasReachableClensDir(p.path)); +}; + +// ── Discovery ──────────────────────────────────────────────────── + +/** Scan home dir for directories containing `.clens/sessions/`. */ +const scanForClensDirs = (maxDepth = 3): readonly string[] => { + const home = homedir(); + const discovered: string[] = []; + + const scan = (dir: string, depth: number): void => { + if (depth > maxDepth) return; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".") && entry.name !== ".clens") continue; + if (entry.name === "node_modules" || entry.name === ".Trash") continue; + + const fullPath = resolve(dir, entry.name); + if (entry.name === ".clens") { + if (existsSync(resolve(fullPath, "sessions"))) { + discovered.push(dir); + } + continue; + } + scan(fullPath, depth + 1); + } + } catch { + // Permission denied or other FS error — skip + } + }; + + scan(home, 0); + return discovered; +}; + +/** Find the git root for a directory, or undefined if not in a git repo. */ +const findGitRoot = (dir: string): string | undefined => { + try { + const root = execSync("git rev-parse --show-toplevel", { + cwd: dir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return root || undefined; + } catch { + return undefined; + } +}; + +/** + * "repository" mode: group all `.clens/sessions/` dirs by their git root. + * Each git repo becomes one ProjectEntry. Orphans (no git root) become their own entry. + * The `session_dirs` on ProjectEntry are not stored — callers read sessions from all + * `.clens/sessions/` dirs within the repo. + */ +const resolveRepositoryMode = (clensDirs: readonly string[]): readonly ProjectEntry[] => { + // Group by git root + const gitRootMap = new Map(); + const orphans: string[] = []; + + for (const dir of clensDirs) { + const gitRoot = findGitRoot(dir); + if (gitRoot) { + gitRootMap.set(gitRoot, [...(gitRootMap.get(gitRoot) ?? []), dir]); + } else { + orphans.push(dir); + } + } + + const entries: ProjectEntry[] = []; + + // Each git root becomes one project + for (const [gitRoot] of gitRootMap) { + entries.push({ + id: deriveProjectId(gitRoot), + path: gitRoot, + name: basename(gitRoot), + added_at: Date.now(), + }); + } + + // Orphans are their own projects + for (const dir of orphans) { + entries.push({ + id: deriveProjectId(dir), + path: dir, + name: basename(dir), + added_at: Date.now(), + }); + } + + return entries; +}; + +/** + * "project" mode: every `.clens/sessions/` directory is its own source. + * No grouping, no dedup. Every capture location is a separate project. + */ +const resolveProjectMode = (clensDirs: readonly string[]): readonly ProjectEntry[] => + clensDirs.map((dir) => ({ + id: deriveProjectId(dir), + path: dir, + name: basename(dir), + added_at: Date.now(), + })); + +// ── Public API ─────────────────────────────────────────────────── + +/** + * Discover projects with `.clens/sessions/` under ~, resolve using + * the configured global_mode, persist to registry, and return. + */ +export const discoverAndRegisterProjects = (maxDepth = 3): readonly ProjectEntry[] => { + const config = readGlobalConfig(); + const clensDirs = scanForClensDirs(maxDepth); + + const entries = config.global_mode === "project" + ? resolveProjectMode(clensDirs) + : resolveRepositoryMode(clensDirs); + + // Persist — full replace with fresh discovery + writeRegistry({ version: 1, projects: entries }); + return entries; +}; diff --git a/packages/cli/src/session/session-meta.ts b/packages/cli/src/session/session-meta.ts new file mode 100644 index 0000000..bdc293b --- /dev/null +++ b/packages/cli/src/session/session-meta.ts @@ -0,0 +1,115 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import type { ColorName, SessionMeta } from "../types"; +import { isColorName } from "../types"; + +/** Sidecar map: session id → cLens-owned metadata. */ +export type SessionMetaMap = Readonly>; + +/** Path to the cLens session-metadata sidecar for a project. */ +export const sessionMetaPath = (dir: string): string => `${dir}/.clens/session-meta.json`; + +/** Coerce one raw sidecar entry into a clean SessionMeta, or null if unusable. */ +const parseEntry = (raw: unknown): SessionMeta | null => { + if (typeof raw !== "object" || raw === null) return null; + const obj = raw as Record; + const label = typeof obj.label === "string" && obj.label.trim().length > 0 ? obj.label : undefined; + // Drop invalid colors silently (graceful degradation); "none" is normalized away. + const color = + isColorName(obj.color) && obj.color !== "none" ? (obj.color as ColorName) : undefined; + const updated_at = typeof obj.updated_at === "number" ? obj.updated_at : 0; + return { + ...(label ? { label } : {}), + ...(color ? { color } : {}), + updated_at, + }; +}; + +/** + * Read the session-metadata sidecar. A missing or malformed file degrades + * gracefully to `{}` and never throws (R15). Individual malformed entries are + * dropped rather than failing the whole read. + */ +export const readSessionMeta = (dir: string): SessionMetaMap => { + const path = sessionMetaPath(dir); + if (!existsSync(path)) return {}; + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return Object.entries(parsed as Record).reduce>( + (acc, [id, rawEntry]) => { + const entry = parseEntry(rawEntry); + return entry ? { ...acc, [id]: entry } : acc; + }, + {}, + ); + } catch { + return {}; + } +}; + +/** + * Write the sidecar atomically via temp-file + rename (R15) so a concurrent + * reader never observes a torn file. Creates the `.clens` directory if needed. + */ +export const writeSessionMeta = (dir: string, map: SessionMetaMap): void => { + const clensDir = `${dir}/.clens`; + if (!existsSync(clensDir)) mkdirSync(clensDir, { recursive: true }); + const path = sessionMetaPath(dir); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, JSON.stringify(map, null, 2)); + renameSync(tmp, path); +}; + +/** Patch applied to a single session's sidecar entry. */ +export interface SessionMetaPatch { + readonly label?: string | null; + readonly color?: ColorName | null; +} + +/** A label is "clear" when null, empty, or whitespace-only (R7/R8). */ +const isClearLabel = (label: string | null | undefined): boolean => + label === null || (typeof label === "string" && label.trim().length === 0); + +/** + * Set or clear a single session's label and/or color, persisting atomically. + * - `label`: null/empty/whitespace clears it (R7/R8); otherwise stored verbatim. + * - `color`: null or `"none"` clears the flag (R13); any other palette value sets it. + * - An invalid color value is rejected and existing metadata is left unchanged (R14). + * When the resulting entry holds no label and no color it is removed entirely. + */ +export const setSessionMeta = (dir: string, id: string, patch: SessionMetaPatch): SessionMetaMap => { + // Validate color first so an invalid value never mutates state (R14). + const colorTouched = "color" in patch; + if (colorTouched && patch.color !== null && !isColorName(patch.color)) { + throw new Error(`Invalid color "${String(patch.color)}". Valid: none, red, amber, green, blue, violet, gray.`); + } + + const current = readSessionMeta(dir); + const existing = current[id]; + + const labelTouched = "label" in patch; + const nextLabel = labelTouched + ? isClearLabel(patch.label) ? undefined : (patch.label as string) + : existing?.label; + + const nextColor = colorTouched + ? patch.color === null || patch.color === "none" ? undefined : patch.color + : existing?.color; + + const { [id]: _omit, ...rest } = current; + + // Drop the entry entirely when nothing remains. + if (nextLabel === undefined && nextColor === undefined) { + writeSessionMeta(dir, rest); + return rest; + } + + const entry: SessionMeta = { + ...(nextLabel !== undefined ? { label: nextLabel } : {}), + ...(nextColor !== undefined ? { color: nextColor } : {}), + updated_at: Date.now(), + }; + const next: SessionMetaMap = { ...rest, [id]: entry }; + writeSessionMeta(dir, next); + return next; +}; diff --git a/packages/cli/src/session/session-name.ts b/packages/cli/src/session/session-name.ts new file mode 100644 index 0000000..5493a24 --- /dev/null +++ b/packages/cli/src/session/session-name.ts @@ -0,0 +1,82 @@ +import type { NameSource } from "../types"; + +/** Max length of a computed display name before truncation (R2). */ +const MAX_NAME_LENGTH = 60; + +/** + * Strip Claude Code harness noise from a raw user prompt: + * - `` blocks (multiline) + * - ``, ``, `` wrapper TAGS only + * (the inner text — e.g. the slash command itself — is KEPT, mirroring CC). + * Collapses all remaining whitespace runs to single spaces and trims. + */ +const stripHarnessNoise = (raw: string): string => + raw + // Remove entire system-reminder blocks (non-greedy, dotall via [\s\S]). + .replace(/[\s\S]*?<\/system-reminder>/g, " ") + // Remove command-* wrapper tags but keep their inner content. + .replace(/<\/?command-(?:name|message|args)>/g, " ") + .replace(/\s+/g, " ") + .trim(); + +/** + * Truncate to MAX_NAME_LENGTH characters, appending an ellipsis when the input + * is longer. Counts by code points so the result never exceeds the limit. + */ +const truncateName = (name: string): string => { + const chars = [...name]; + if (chars.length <= MAX_NAME_LENGTH) return name; + return `${chars.slice(0, MAX_NAME_LENGTH - 1).join("")}…`; +}; + +/** + * Compute a deterministic display name from a session's first user prompt. + * Strips harness noise, collapses whitespace, keeps slash-command text, and + * truncates to ≤ 60 characters with an ellipsis. Returns `null` when the prompt + * is missing or reduces to nothing after cleaning (R2/R3/R4). Pure, zero-network. + */ +export const computeSessionName = (firstPrompt: string | null | undefined): string | null => { + if (typeof firstPrompt !== "string") return null; + const cleaned = stripHarnessNoise(firstPrompt); + if (cleaned.length === 0) return null; + return truncateName(cleaned); +}; + +/** A trimmed non-empty string, or null. */ +const nonEmpty = (value: string | null | undefined): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +export interface DisplayNameInputs { + readonly label?: string | null; + readonly customTitle?: string | null; + readonly computed?: string | null; + readonly id: string; +} + +export interface ResolvedDisplayName { + readonly display_name: string; + readonly name_source: NameSource; +} + +/** + * Resolve a session's display name by precedence (R1/R5): + * user label > Claude Code custom-title > computed first-prompt > short id. + * Whitespace-only candidates are treated as absent (R8). The short id is the + * first 8 characters of the session id and is always a valid final fallback (R4). + * Pure function — unit-tested against the precedence table. + */ +export const resolveDisplayName = (inputs: DisplayNameInputs): ResolvedDisplayName => { + const label = nonEmpty(inputs.label); + if (label) return { display_name: label, name_source: "label" }; + + const customTitle = nonEmpty(inputs.customTitle); + if (customTitle) return { display_name: customTitle, name_source: "custom_title" }; + + const computed = nonEmpty(inputs.computed); + if (computed) return { display_name: computed, name_source: "computed" }; + + return { display_name: inputs.id.slice(0, 8), name_source: "id" }; +}; diff --git a/packages/cli/src/session/synthetic-scan.ts b/packages/cli/src/session/synthetic-scan.ts new file mode 100644 index 0000000..8487b95 --- /dev/null +++ b/packages/cli/src/session/synthetic-scan.ts @@ -0,0 +1,85 @@ +// I/O layer for synthetic link scanning — reads session files to extract timestamp metadata. +// Used by distill/synthetic-links.ts via injection to maintain layer separation. + +import { readdirSync, readFileSync } from "node:fs"; + +/** Metadata from scanning a session file (first + last line only). */ +export interface SessionFileInfo { + readonly sessionId: string; + readonly startT: number; + readonly endT: number | undefined; +} + +/** + * Parse first and last line of a session file to extract timestamp metadata. + * Returns undefined if the file cannot be parsed or doesn't start with SessionStart. + */ +const parseSessionFile = ( + filePath: string, + timeRange: { readonly minT: number; readonly maxT: number }, +): { readonly startT: number; readonly endT: number | undefined } | undefined => { + try { + const content = readFileSync(filePath, "utf-8"); + const firstNewline = content.indexOf("\n"); + const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline); + + const firstEvent: unknown = JSON.parse(firstLine); + if (!firstEvent || typeof firstEvent !== "object" || !("event" in firstEvent) || !("t" in firstEvent)) return undefined; + const { event, t: rawT } = firstEvent as { event: unknown; t: unknown }; + if (event !== "SessionStart" || typeof rawT !== "number") return undefined; + + const startT = rawT; + + // Filter: session must have started within parent's time range + if (startT < timeRange.minT || startT > timeRange.maxT) return undefined; + + // Read last line for endT + const trimmed = content.trimEnd(); + const lastNewline = trimmed.lastIndexOf("\n"); + const lastLine = lastNewline === -1 ? trimmed : trimmed.slice(lastNewline + 1); + const lastEvent: unknown = JSON.parse(lastLine); + const endT = lastEvent && typeof lastEvent === "object" && "t" in lastEvent && typeof (lastEvent as { t: unknown }).t === "number" + ? (lastEvent as { t: number }).t + : undefined; + + return { startT, endT }; + } catch { + return undefined; + } +}; + +/** + * Scan session files in .clens/sessions/ for timestamp metadata. + * Reads only the first and last lines of each file for performance. + * Filters to sessions that started within the parent session's time range. + */ +export const scanSessionFiles = ( + projectDir: string, + parentSessionId: string, + linkedSessionIds: ReadonlySet, + timeRange: { readonly minT: number; readonly maxT: number }, +): readonly SessionFileInfo[] => { + const sessionsDir = `${projectDir}/.clens/sessions`; + + const files = (() => { + try { + return readdirSync(sessionsDir).filter( + (f) => f.endsWith(".jsonl") && f !== "_links.jsonl", + ); + } catch { + return []; + } + })(); + + return files.flatMap((file): readonly SessionFileInfo[] => { + const sessionId = file.replace(".jsonl", ""); + + // Skip parent session and already-linked sessions + if (sessionId === parentSessionId || linkedSessionIds.has(sessionId)) return []; + + const parsed = parseSessionFile(`${sessionsDir}/${file}`, timeRange); + if (!parsed) return []; + + return [{ sessionId, startT: parsed.startT, endT: parsed.endT }]; + }); +}; diff --git a/src/session/transcript.ts b/packages/cli/src/session/transcript.ts similarity index 64% rename from src/session/transcript.ts rename to packages/cli/src/session/transcript.ts index 86d5e78..b6811be 100644 --- a/src/session/transcript.ts +++ b/packages/cli/src/session/transcript.ts @@ -36,7 +36,7 @@ export const readTranscript = (transcriptPath: string): TranscriptEntry[] => { } }; -export const resolveTranscriptPath = (events: StoredEvent[]): string | null => { +export const resolveTranscriptPath = (events: readonly StoredEvent[]): string | null => { const match = events.find((e) => e.data?.transcript_path); return (match?.data?.transcript_path as string) ?? null; }; @@ -86,3 +86,48 @@ export const readSessionName = (transcriptPath: string): string | null => { return null; } }; + +/** Transcript content plus its custom title, parsed from a single file read. */ +export interface TranscriptWithMeta { + readonly entries: readonly TranscriptEntry[]; + readonly customTitle: string | null; +} + +/** + * Read a transcript JSONL ONCE and derive both the chronological entries and the + * latest custom title. Equivalent to `readTranscript` + `readSessionName` but with + * a single file read and a single JSON.parse per line — used on the hot distill path + * where the parent transcript would otherwise be read twice per session. + */ +export const readTranscriptWithMeta = (transcriptPath: string): TranscriptWithMeta => { + try { + if (!existsSync(transcriptPath)) return { entries: [], customTitle: null }; + const content = readFileSync(transcriptPath, "utf-8").trim(); + if (!content) return { entries: [], customTitle: null }; + + const parsedLines = content + .split("\n") + .filter(Boolean) + .flatMap((line): unknown[] => { + try { + return [JSON.parse(line)]; + } catch { + return []; + } + }); + + const entries = parsedLines + .filter(isTranscriptEntry) + .filter((e) => e.type === "user" || e.type === "assistant") + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + const customTitle = parsedLines.reduce( + (acc, parsed) => (isCustomTitleEntry(parsed) ? stripEscapedQuotes(parsed.customTitle) : acc), + null, + ); + + return { entries, customTitle }; + } catch { + return { entries: [], customTitle: null }; + } +}; diff --git a/packages/cli/src/types/conversation.ts b/packages/cli/src/types/conversation.ts new file mode 100644 index 0000000..0ad0236 --- /dev/null +++ b/packages/cli/src/types/conversation.ts @@ -0,0 +1,67 @@ +// ConversationEntry — discriminated union for web conversation view + +export interface UserPromptEntry { + readonly type: "user_prompt"; + readonly t: number; + readonly text: string; + readonly index: number; +} + +export interface ThinkingEntry { + readonly type: "thinking"; + readonly t: number; + readonly text: string; + readonly intent: string; + readonly duration_ms?: number; +} + +export interface ToolCallEntry { + readonly type: "tool_call"; + readonly t: number; + readonly tool_name: string; + readonly tool_use_id: string; + readonly file_path?: string; + readonly args_preview: string; +} + +export interface ToolResultEntry { + readonly type: "tool_result"; + readonly t: number; + readonly tool_use_id: string; + readonly tool_name: string; + readonly outcome: "success" | "failure"; + readonly error?: string; +} + +export interface BacktrackEntry { + readonly type: "backtrack"; + readonly t: number; + readonly backtrack_type: string; + readonly attempt: number; + readonly reverted_tool_ids: readonly string[]; +} + +export interface PhaseBoundaryEntry { + readonly type: "phase_boundary"; + readonly t: number; + readonly phase_name: string; + readonly phase_index: number; +} + +export interface AgentMessageEntry { + readonly type: "agent_message"; + readonly t: number; + readonly direction: "sent" | "received"; + readonly partner: string; + readonly msg_type: string; + readonly summary?: string; +} + +export type ConversationEntry = + | UserPromptEntry + | ThinkingEntry + | ToolCallEntry + | ToolResultEntry + | BacktrackEntry + | PhaseBoundaryEntry + | AgentMessageEntry; diff --git a/src/types/distill.ts b/packages/cli/src/types/distill.ts similarity index 64% rename from src/types/distill.ts rename to packages/cli/src/types/distill.ts index e471492..532daaf 100644 --- a/src/types/distill.ts +++ b/packages/cli/src/types/distill.ts @@ -1,9 +1,12 @@ +import type { SessionConfig, SessionSummary } from "./session"; import type { TranscriptReasoning, TranscriptUserMessage } from "./transcript"; // Distill types export interface StatsResult { readonly total_events: number; readonly duration_ms: number; + /** Wall-clock span (last event t - first event t); duration_ms is idle-trimmed. */ + readonly wall_duration_ms?: number; readonly events_by_type: Readonly>; readonly tools_by_name: Readonly>; readonly tool_call_count: number; @@ -12,6 +15,7 @@ export interface StatsResult { readonly unique_files: readonly string[]; readonly model?: string; readonly cost_estimate?: CostEstimate; + readonly token_usage?: TokenUsage; readonly failures_by_tool?: Readonly>; } @@ -191,6 +195,15 @@ export interface TimelineEntry { readonly msg_to?: string; } +/** + * How `estimated_cost_usd` was derived (cost-truth tiers; see analytics-truth spec): + * - "measured" — taken verbatim from a captured `total_cost_usd`/`costUSD` (>0). + * - "estimated" — computed from real token counts × API price table. + * - "heuristic" — computed from event/tool-call magic numbers (no real tokens). + */ +export const COST_BASES = ["measured", "estimated", "heuristic"] as const; +export type CostBasis = (typeof COST_BASES)[number]; + export interface CostEstimate { readonly model: string; readonly estimated_input_tokens: number; @@ -198,7 +211,22 @@ export interface CostEstimate { readonly estimated_cost_usd: number; readonly cache_read_tokens?: number; readonly cache_creation_tokens?: number; - readonly is_estimated?: boolean; + readonly is_estimated: boolean; + readonly pricing_tier?: string; + /** + * Provenance of `estimated_cost_usd`. Optional because CostEstimate values + * persisted before the cost-truth work lack it; readers derive a fallback + * (measured-absent ⇒ estimated/heuristic from `is_estimated`). + */ + readonly cost_basis?: CostBasis; + /** + * Version of the pricing table `estimated_cost_usd` was (re-)priced against + * (see `PRICING_VERSION` in distill/stats.ts). Distilled costs are frozen at + * distill-time rates; readers re-price token-grounded estimates against the + * current table for display and stamp this. Absent ⇒ value is at frozen + * distill-time pricing. Never present on verbatim measured costs. + */ + readonly pricing_version?: string; } export interface TokenUsage { @@ -252,6 +280,7 @@ export interface AgentNode { readonly edit_chains?: EditChainsResult; readonly backtracks?: readonly BacktrackResult[]; readonly reasoning?: readonly TranscriptReasoning[]; + readonly context_consumption?: ContextConsumption; } export interface AggregatedTeamData { @@ -272,6 +301,7 @@ export interface TeamMetrics { readonly task_id: string; readonly agent: string; readonly subject?: string; + readonly status?: "completed"; readonly t: number; }>; readonly idle_transitions: ReadonlyArray<{ @@ -328,6 +358,8 @@ export interface EditChainsResult { readonly chains: readonly EditChain[]; readonly net_changes?: readonly WorkingTreeChange[]; readonly diff_attribution?: readonly FileDiffAttribution[]; + /** Git-based unified diffs with line numbers/context. May be stale if repo changed since session. */ + readonly git_enriched_diffs?: readonly FileDiffAttribution[]; } // --- Journey Types --- @@ -472,6 +504,7 @@ export interface AgentDistillResult { readonly edit_chains?: EditChainsResult; readonly backtracks?: readonly BacktrackResult[]; readonly reasoning?: readonly TranscriptReasoning[]; + readonly context_consumption?: ContextConsumption; } export interface ActiveDurationResult { @@ -480,6 +513,49 @@ export interface ActiveDurationResult { readonly pause_ms: number; } +export interface TaskRecord { + readonly task_id: string; + readonly subject: string; + readonly description?: string; + readonly active_form?: string; + readonly status: "pending" | "in_progress" | "completed"; + readonly owner?: string; + readonly blocked_by?: readonly string[]; + readonly created_at: number; + readonly completed_at?: number; + readonly created_by: string; +} + +export interface TaskListResult { + readonly tasks: readonly TaskRecord[]; + readonly total_count: number; + readonly completed_count: number; + readonly completion_rate: number; +} + +export interface ContextConsumptionPoint { + readonly t: number; + readonly turn_index: number; + readonly input_tokens: number; + readonly output_tokens: number; + readonly cache_read_tokens: number; + readonly cache_creation_tokens: number; + readonly total_context_tokens: number; + readonly context_pct: number; + readonly is_compaction: boolean; +} + +export interface ContextConsumption { + readonly points: readonly ContextConsumptionPoint[]; + readonly peak_context_pct: number; + readonly peak_context_tokens: number; + readonly final_context_pct: number; + readonly compaction_count: number; + readonly context_velocity_per_min: number; + readonly model_context_window: number; + readonly turn_count: number; +} + export interface DistilledSession { readonly session_id: string; readonly session_name?: string; @@ -503,4 +579,145 @@ export interface DistilledSession { readonly comm_sequence?: readonly CommunicationSequenceEntry[]; readonly agent_lifetimes?: readonly AgentLifetime[]; readonly plan_drift?: PlanDriftReport; + readonly task_list?: TaskListResult; + readonly context_consumption?: ContextConsumption; + readonly feature_usage?: FeatureUsage; + /** + * Effective session configuration (permission mode, effort, MCP servers) lifted + * purely from the event stream. Optional only for back-compat with distills + * written before this field shipped; `extractSessionConfig` always returns an + * object for freshly distilled sessions. + */ + readonly session_config?: SessionConfig; + /** + * Resolved pricing tier this session was distilled (and priced) with. Surfaced so a + * cache/staleness layer can detect when the configured tier has changed since distill + * and the cached cost figures are stale (tier-change-never-recomputes). "auto" is + * resolved to a concrete "api"/"max" at distill time, so only those two appear here. + */ + readonly pricing_tier?: "api" | "max"; + /** + * Distill schema version stamped at write time. A freshness check treats a cached + * distilled artifact as stale when this differs from the current `DISTILL_SCHEMA_VERSION`, + * so bumping the version forces a re-distill even when the raw session mtime is unchanged. + */ + readonly schema_version?: number; +} + +// --- Global Mode Types --- + +export interface GlobalSessionSummary extends SessionSummary { + readonly project_id: string; + readonly project_name: string; + /** + * Directory containing the `.clens/sessions/.jsonl` that owns this session. + * Equals `project.path` in project mode; the nested capture dir in repository + * mode (where a session may live below the git root, e.g. `gitRoot/packages/web`). + * Used to route per-session distill to the correct (possibly nested) capture dir. + */ + readonly capture_dir: string; +} + +// --- Feature Usage Types (loop / goal / workflow) --- + +export type FeatureFlag = "loop" | "goal" | "workflow"; + +export interface LoopWakeup { + readonly t: number; + readonly delay_seconds: number; + readonly reason?: string; +} + +export interface LoopUsage { + readonly wakeup_count: number; + readonly total_scheduled_wait_s: number; + readonly autonomous: boolean; + readonly skill_invocations: number; + readonly wakeups: readonly LoopWakeup[]; +} + +export interface GoalUsage { + readonly goals: readonly string[]; +} + +export interface WorkflowRun { + readonly t: number; + readonly name?: string; + readonly description?: string; +} + +export interface WorkflowUsage { + readonly invocation_count: number; + readonly runs: readonly WorkflowRun[]; +} + +export interface FeatureUsage { + readonly flags: readonly FeatureFlag[]; + readonly loop?: LoopUsage; + readonly goal?: GoalUsage; + readonly workflow?: WorkflowUsage; +} + +// --- Analytics Summary Types --- + +export interface AnalyticsSummaryRow { + readonly session_id: string; + readonly date: string; // "2026-03-15" (LOCAL calendar day; see B18) + readonly duration_ms: number; + readonly model?: string; + /** API-equivalent value at full list price (never multiplied by subscription factor). */ + readonly cost_usd: number; + /** + * How `cost_usd` was derived. Optional because rows persisted before the cost-truth + * work lack it; readers default to deriving from `is_estimated`. + */ + readonly cost_basis?: CostBasis; + /** + * Portion of `cost_usd` that is genuinely measured (cost_basis === "measured" ⇒ + * equals cost_usd; otherwise 0). Optional for the same back-compat reason. + */ + readonly measured_cost_usd?: number; + readonly input_tokens: number; + readonly output_tokens: number; + readonly cache_read_tokens: number; + readonly cache_creation_tokens: number; + readonly is_estimated: boolean; + readonly tool_call_count: number; + readonly failure_count: number; + /** + * Per-tool invocation counts (denominator for tool failure rates; see B11). + * Optional because summary rows persisted before B11 lack it; readers default to {}. + */ + readonly tools_by_name?: Readonly>; + readonly failures_by_tool: Readonly>; + readonly agent_count: number; + readonly agent_types: readonly { + readonly type: string; + readonly count: number; + readonly cost: number; + readonly duration_ms: number; + readonly tool_calls: number; + readonly failure_count: number; + }[]; + readonly backtrack_count: number; + readonly backtracks_by_type: Readonly>; + readonly backtrack_files: readonly string[]; + readonly reasoning_by_intent: Readonly>; + readonly edit_chain_count: number; + /** + * Sum of edits across all chains; divided by edit_chain_count gives the real mean + * chain length (see B19). Optional because rows persisted before B19 lack it; + * readers fall back to edit_chain_count. + */ + readonly edit_chain_links?: number; + readonly abandoned_edits: number; + readonly surviving_edits: number; + readonly drift_score?: number; + readonly unexpected_files?: number; + readonly decision_types: Readonly>; + readonly top_errors: readonly { + readonly tool: string; + readonly message: string; + readonly count: number; + }[]; } diff --git a/packages/cli/src/types/events.ts b/packages/cli/src/types/events.ts new file mode 100644 index 0000000..c576bd3 --- /dev/null +++ b/packages/cli/src/types/events.ts @@ -0,0 +1,147 @@ +// All 18 hook event types +export const HOOK_EVENTS = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "Notification", + "SubagentStart", + "SubagentStop", + "Stop", + "TeammateIdle", + "TaskCompleted", + "PreCompact", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", +] as const; +export type HookEventType = (typeof HOOK_EVENTS)[number]; + +/** Event types that Claude Code broadcasts to ALL session files, not just the originating session. */ +export const BROADCAST_EVENTS: ReadonlySet = new Set([ + "ConfigChange", + "Notification", +] as const); + +/** + * Active permission posture reported on (almost) every hook payload except + * SessionStart (`events.ts:34`, availability audit). Closed set per + * `cc-hooks.md:446`; an unrecognized raw value is dropped at extraction time. + */ +export const PERMISSION_MODES = [ + "default", + "plan", + "acceptEdits", + "dontAsk", + "bypassPermissions", +] as const; +export type PermissionMode = (typeof PERMISSION_MODES)[number]; + +/** Type guard for a recognized permission mode. */ +export const isPermissionMode = (value: unknown): value is PermissionMode => + typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value); + +/** + * `CLAUDE_CODE_EFFORT_LEVEL` (low/medium/high), present on every tool/stop event + * (undocumented-but-real per the availability audit). Captured raw in `data` — + * lifted into typed config by `extractSessionConfig`. + */ +export const EFFORT_LEVELS = ["low", "medium", "high"] as const; +export type EffortLevel = (typeof EFFORT_LEVELS)[number]; + +/** Type guard for a recognized effort level. */ +export const isEffortLevel = (value: unknown): value is EffortLevel => + typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value); + +export interface BaseHookInput { + readonly session_id: string; + readonly transcript_path: string; + readonly cwd: string; + readonly permission_mode: string; + readonly hook_event_name: string; +} + +export interface ToolEvent extends BaseHookInput { + readonly tool_name: string; + readonly tool_input: Readonly>; + readonly tool_use_id: string; + /** `CLAUDE_CODE_EFFORT_LEVEL` carried on tool events (raw; may be absent). */ + readonly effort?: string; +} + +export interface PostToolEvent extends ToolEvent { + readonly tool_response: Readonly>; +} + +export interface FailureEvent extends ToolEvent { + readonly error: string; + readonly is_interrupt?: boolean; +} + +export interface AgentEvent extends BaseHookInput { + readonly agent_id: string; + readonly agent_type: string; + readonly agent_name?: string; + readonly agent_transcript_path?: string; + readonly last_assistant_message?: string; +} + +/** settings.json scope a captured value was resolved from (highest precedence first). */ +export type SettingsScope = "managed" | "local" | "project" | "user"; + +/** + * Point-in-time snapshot of the resolved `settings.json` config (CFG-3, tier B). + * Captured once at SessionStart inside `enrichSessionStart` — NEVER on the hot + * path. Every field is optional; an unset key is simply absent (never fabricated + * to "default"). `settings_source` distinguishes the SessionStart snapshot from a + * later distill-time "current" read that may have drifted. + */ +export interface SettingsSnapshot { + readonly settings_source: "session_start" | "current"; + readonly captured_at: number; + readonly output_style?: string; + readonly output_style_scope?: SettingsScope; + readonly status_line?: { readonly type: string; readonly command_name?: string }; + readonly plugins_enabled?: readonly string[]; + readonly permission_default_mode?: string; + readonly hooks_configured?: readonly string[]; +} + +export interface SessionStartContext { + readonly project_dir: string; + readonly cwd: string; + readonly git_branch: string | null; + readonly git_remote: string | null; + readonly git_commit: string | null; + readonly git_worktree: string | null; + readonly team_name: string | null; + readonly task_list_dir: string | null; + readonly claude_entrypoint: string | null; + readonly model: string | null; + readonly agent_type: string | null; + readonly source?: "startup" | "resume" | "clear" | "compact"; + readonly trigger?: "manual" | "auto"; + /** Resolved settings.json snapshot (CFG-3); absent if the read failed/empty. */ + readonly settings_snapshot?: SettingsSnapshot; +} + +export interface StoredEvent { + readonly t: number; // timestamp ms + readonly event: HookEventType; + readonly sid: string; // session_id + readonly context?: SessionStartContext; + readonly data: Readonly>; +} + +export interface InstructionsLoadedEvent extends BaseHookInput { + readonly file_path: string; + readonly memory_type: "User" | "Project" | "Local" | "Managed"; + readonly load_reason: "session_start" | "nested_traversal" | "path_glob_match" | "include"; + readonly globs?: readonly string[]; + readonly trigger_file_path?: string; + readonly parent_file_path?: string; +} diff --git a/src/types/index.ts b/packages/cli/src/types/index.ts similarity index 58% rename from src/types/index.ts rename to packages/cli/src/types/index.ts index 7a2f75a..7b22d9e 100644 --- a/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -10,11 +10,15 @@ export type { AgentStats, AgentTaskEvent, AggregatedTeamData, + AnalyticsSummaryRow, BacktrackResult, CommunicationEdge, + ContextConsumption, + ContextConsumptionPoint, CommunicationEdgeType, CommunicationSequenceEntry, ConversationGroup, + CostBasis, CostEstimate, CumulativeStats, DecisionPoint, @@ -24,14 +28,19 @@ export type { EditChain, EditChainsResult, EditStep, + FeatureFlag, + FeatureUsage, FileDiffAttribution, FileMapEntry, FileMapResult, GitDiffHunk, GitDiffResult, + GoalUsage, Journey, JourneyPhase, LifecycleType, + LoopUsage, + LoopWakeup, PhaseBoundaryDecision, PhaseInfo, PhaseTransition, @@ -40,23 +49,38 @@ export type { StatsResult, TaskCompletionDecision, TaskDelegationDecision, + TaskListResult, + TaskRecord, TeamMetrics, TimelineEntry, TimingGapDecision, TokenUsage, ToolPivotDecision, TransitionTrigger, + WorkflowRun, + WorkflowUsage, WorkingTreeChange, + GlobalSessionSummary, } from "./distill"; +export { COST_BASES } from "./distill"; export { type AgentEvent, type BaseHookInput, BROADCAST_EVENTS, + EFFORT_LEVELS, + type EffortLevel, type FailureEvent, HOOK_EVENTS, type HookEventType, + type InstructionsLoadedEvent, + isEffortLevel, + isPermissionMode, + PERMISSION_MODES, + type PermissionMode, type PostToolEvent, type SessionStartContext, + type SettingsScope, + type SettingsSnapshot, type StoredEvent, type ToolEvent, } from "./events"; @@ -77,11 +101,31 @@ export { type WorktreeCreateLink, type WorktreeRemoveLink, } from "./links"; -export type { - ClensConfig, - DelegatedHooks, - ExportManifest, - SessionSummary, +export { + ACTIVE_THRESHOLD_MS, + CAPTURE_MODES, + type CaptureMode, + type ClaudeMdInEffect, + type ClensConfig, + COLOR_NAMES, + type ColorName, + type DelegatedHooks, + deriveSessionStatus, + type ExportManifest, + type GlobalConfig, + type GlobalMode, + isCaptureMode, + isColorName, + type McpServerUsage, + type NameSource, + type PricingTier, + type ProjectEntry, + type ProjectRegistry, + SESSION_STATUSES, + type SessionConfig, + type SessionMeta, + type SessionStatus, + type SessionSummary, } from "./session"; export type { TranscriptContentBlock, @@ -89,3 +133,14 @@ export type { TranscriptReasoning, TranscriptUserMessage, } from "./transcript"; +export type { + AgentMessageEntry, + BacktrackEntry, + ConversationEntry, + PhaseBoundaryEntry, + ThinkingEntry, + ToolCallEntry, + ToolResultEntry, + UserPromptEntry, +} from "./conversation"; +export type { FileRiskScore, RiskLevel } from "./risk"; diff --git a/src/types/links.ts b/packages/cli/src/types/links.ts similarity index 89% rename from src/types/links.ts rename to packages/cli/src/types/links.ts index 8625317..00c743f 100644 --- a/src/types/links.ts +++ b/packages/cli/src/types/links.ts @@ -26,6 +26,8 @@ export interface SpawnLink extends BaseLinkEvent { readonly agent_id: string; readonly agent_type: string; readonly agent_name?: string; + /** Generated at distill time (from PreToolUse Agent events) rather than hook time */ + readonly synthetic?: boolean; } export interface StopLink extends BaseLinkEvent { @@ -33,6 +35,8 @@ export interface StopLink extends BaseLinkEvent { readonly parent_session: string; readonly agent_id: string; readonly transcript_path?: string; + /** Generated at distill time (from PreToolUse Agent events) rather than hook time */ + readonly synthetic?: boolean; } export interface MessageLink extends BaseLinkEvent { @@ -55,8 +59,11 @@ export interface TaskLink extends BaseLinkEvent { readonly session_id: string; readonly agent?: string; readonly subject?: string; + readonly description?: string; + readonly active_form?: string; readonly owner?: string; readonly status?: string; + readonly blocked_by?: readonly string[]; } export interface TeamLink extends BaseLinkEvent { diff --git a/packages/cli/src/types/risk.ts b/packages/cli/src/types/risk.ts new file mode 100644 index 0000000..a160ac4 --- /dev/null +++ b/packages/cli/src/types/risk.ts @@ -0,0 +1,11 @@ +export type RiskLevel = "low" | "medium" | "high"; + +export interface FileRiskScore { + readonly file_path: string; + readonly risk_level: RiskLevel; + readonly backtrack_count: number; + readonly abandoned_edit_count: number; + readonly total_edit_count: number; + readonly failure_rate: number; + readonly edit_chain_length: number; +} diff --git a/packages/cli/src/types/session.ts b/packages/cli/src/types/session.ts new file mode 100644 index 0000000..c66308a --- /dev/null +++ b/packages/cli/src/types/session.ts @@ -0,0 +1,204 @@ +import type { FeatureFlag } from "./distill"; +import type { EffortLevel, HookEventType, PermissionMode } from "./events"; + +// Export types +export interface ExportManifest { + readonly version: string; + readonly exported_at: string; + readonly session_id: string; + readonly session_name?: string; + readonly project_dir: string; + readonly agents: ReadonlyArray<{ + readonly session_id: string; + readonly agent_type: string; + readonly agent_name?: string; + readonly event_count: number; + readonly duration_ms: number; + }>; + readonly tasks?: ReadonlyArray<{ + readonly task_id: string; + readonly subject: string; + readonly status: string; + }>; + readonly messages_count: number; + readonly git_branch?: string; + readonly git_commit?: string; +} + +// Config types +export interface DelegatedHooks { + readonly [eventType: string]: readonly string[]; +} + +export type PricingTier = "api" | "max" | "auto"; + +/** + * Capture redaction mode (privacy gate, OSS-9). Controls how much of a raw hook + * payload is written to JSONL: + * - `full` — verbatim payload (DEFAULT; preserves historic behavior). + * - `redacted` — structure preserved, secret-looking values masked (denylist). + * - `metadata` — only known-safe structural fields kept, all free-text dropped (allowlist). + */ +export const CAPTURE_MODES = ["full", "redacted", "metadata"] as const; +export type CaptureMode = (typeof CAPTURE_MODES)[number]; + +/** Type guard for a valid capture/redaction mode. */ +export const isCaptureMode = (value: unknown): value is CaptureMode => + typeof value === "string" && (CAPTURE_MODES as readonly string[]).includes(value); + +export interface ClensConfig { + readonly capture: boolean; + readonly events?: readonly HookEventType[]; + readonly pricing?: PricingTier; + /** Redaction mode applied at capture time. Absent ⇒ `full`. */ + readonly mode?: CaptureMode; +} + +// --- Session config extraction (CFG-1) --- + +/** A single MCP server observed in a session, with how many times its tools were called. */ +export interface McpServerUsage { + readonly name: string; + readonly count: number; +} + +/** + * A CLAUDE.md memory file in effect for a session. `memory_type` is reported by + * the `InstructionsLoaded` hook event when the live binary emits it ("realized"); + * `"inferred"` marks the distill-time fallback that records fact-of-existence of + * the project/user CLAUDE.md paths when no such event was captured (CFG-5). + */ +export interface ClaudeMdInEffect { + readonly file_path: string; + readonly memory_type: "User" | "Project" | "Local" | "Managed" | "inferred"; + readonly load_reason?: string; +} + +/** + * Typed, derived view of a session's effective configuration, lifted purely from + * its event stream (zero I/O). `permission_mode` and `effort` are the most-recent + * recognized values seen (unknown raw values dropped); `mcp_servers` is the + * deduped set of MCP servers whose tools were invoked (matched on the + * `mcp____` name pattern). `claude_md_in_effect` is realized from + * `InstructionsLoaded` events when present, else an inferred fallback (CFG-5). + */ +export interface SessionConfig { + readonly permission_mode?: PermissionMode; + readonly effort?: EffortLevel; + readonly mcp_servers: readonly McpServerUsage[]; + readonly claude_md_in_effect?: readonly ClaudeMdInEffect[]; +} + +// --- Session naming / color flag types --- + +/** + * Closed palette of session color flags. `none` means unflagged; any other value + * marks the session as flagged (bookmarked) so it pops out in lists. + */ +export const COLOR_NAMES = ["none", "red", "amber", "green", "blue", "violet", "gray"] as const; +export type ColorName = (typeof COLOR_NAMES)[number]; + +/** Type guard for a valid color palette value. */ +export const isColorName = (value: unknown): value is ColorName => + typeof value === "string" && (COLOR_NAMES as readonly string[]).includes(value); + +/** Provenance of a session's resolved display name (highest precedence first). */ +export type NameSource = "label" | "custom_title" | "computed" | "id"; + +/** + * cLens-owned per-session metadata, stored in the `.clens/session-meta.json` + * sidecar keyed by session id. Independent of raw/distilled artifacts so it + * survives `clens clean` and re-distill. + */ +export interface SessionMeta { + readonly label?: string; + readonly color?: ColorName; + readonly updated_at: number; +} + +// A session is "complete" only when it ended cleanly (last meaningful event is +// SessionEnd). If it didn't end but its last event is recent it's "active"; +// otherwise it's gone quiet and is "idle". See bug B6 (Stop ⇒ complete was wrong). +export const SESSION_STATUSES = ["complete", "active", "idle"] as const; +export type SessionStatus = (typeof SESSION_STATUSES)[number]; + +/** A session whose last event is older than this is considered idle, not active. */ +export const ACTIVE_THRESHOLD_MS = 600_000; // 10 minutes + +/** + * Derive a session's live status from its last event. + * @param lastEventIsSessionEnd whether the last *meaningful* event is SessionEnd + * @param lastEventTime epoch ms of the last event + * @param now epoch ms reference point (injected for testability) + */ +export const deriveSessionStatus = ( + lastEventIsSessionEnd: boolean, + lastEventTime: number, + now: number = Date.now(), +): SessionStatus => + lastEventIsSessionEnd + ? "complete" + : now - lastEventTime <= ACTIVE_THRESHOLD_MS + ? "active" + : "idle"; + +// Session summary for list command +export interface SessionSummary { + readonly session_id: string; + readonly session_name?: string; + readonly start_time: number; + readonly end_time?: number; + readonly duration_ms: number; + readonly event_count: number; + readonly git_branch?: string; + readonly team_name?: string; + readonly source?: string; + readonly end_reason?: string; + // "complete" iff last meaningful event is SessionEnd. Otherwise "active" iff + // the last event is within ACTIVE_THRESHOLD_MS of now, else "idle". A Stop + // event no longer implies complete — it fires after every turn (bug B6). + readonly status: SessionStatus; + readonly file_size_bytes: number; + readonly agent_count?: number; // 0 = single-agent, >0 = multi-agent + readonly is_distilled?: boolean; // true if .clens/distilled/{sid}.json exists + // Idle-trimmed active span (distilled stats.duration_ms; locked semantics). Source + // for the "ACTIVE" header chip — distinct from wall-clock duration_ms. Present only + // for analyzed sessions; absent (undefined) when not distilled. + readonly active_duration_ms?: number; + readonly has_spec?: boolean; // true if distilled data has plan_drift + readonly is_subagent?: boolean; // true if spawned by another session + // Lone-SessionEnd / torn capture: the file's only event is a SessionEnd (the + // session start was never recorded), so it carries no real content. Such rows + // render as EMPTY rather than "DONE 0s" (NUM-4). Set by the web lightweight + // listing; the CLI list leaves it undefined. + readonly is_empty?: boolean; + readonly features?: readonly FeatureFlag[]; // harness features used (loop/goal/workflow) + // --- Naming / color flag (resolved at list time, never recomputed by surfaces) --- + readonly display_name?: string; // resolved name by precedence (label>custom_title>computed>id) + readonly name_source?: NameSource; // provenance of display_name + readonly label?: string; // user-entered custom label (sidecar), if any + readonly color?: ColorName; // user color flag (sidecar); non-"none" = flagged +} + +// --- Global Config Types --- + +/** How --global discovers and groups sessions across the filesystem. */ +export type GlobalMode = "repository" | "project"; + +export interface GlobalConfig { + readonly global_mode: GlobalMode; +} + +// --- Project Registry Types --- + +export interface ProjectEntry { + readonly id: string; // kebab-case slug, e.g. "agent-observability-project" + readonly path: string; // absolute path to project root + readonly name: string; // display name (basename of path by default) + readonly added_at: number; // timestamp +} + +export interface ProjectRegistry { + readonly version: 1; + readonly projects: readonly ProjectEntry[]; +} diff --git a/src/types/transcript.ts b/packages/cli/src/types/transcript.ts similarity index 96% rename from src/types/transcript.ts rename to packages/cli/src/types/transcript.ts index 60215ba..43de6cf 100644 --- a/src/types/transcript.ts +++ b/packages/cli/src/types/transcript.ts @@ -5,6 +5,8 @@ export interface TranscriptEntry { readonly sessionId: string; readonly type: "user" | "assistant" | "progress" | "file-history-snapshot"; readonly timestamp: string; + readonly requestId?: string; + readonly userType?: string; readonly message?: { readonly role: "user" | "assistant"; readonly content: string | readonly TranscriptContentBlock[]; diff --git a/src/utils.ts b/packages/cli/src/utils.ts similarity index 68% rename from src/utils.ts rename to packages/cli/src/utils.ts index 39a8dbd..a63ae1f 100644 --- a/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,9 +1,34 @@ -import { appendFileSync, mkdirSync } from "node:fs"; -import type { AgentNode, LinkEvent, SpawnLink, StoredEvent } from "./types"; +import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { AgentNode, DiffLine, LinkEvent, MessageLink, SpawnLink, StoredEvent, TeammateIdleLink } from "./types"; import { BROADCAST_EVENTS } from "./types"; export const IDLE_THRESHOLD_MS = 300_000; +/** + * Resolve the project root by walking up from `start` to the nearest directory + * containing `.clens/` (preferred — that is where session data already lives), + * then falling back to the nearest `.git/` parent, then `start` itself. + * + * Used at hook time so a subagent running with cwd inside a subdirectory does + * not fragment session capture into a nested `.clens/`. Mirrors `findProjectDir` + * in `packages/web/src/server/index.ts`, but prefers `.clens/` over `.git/`. + * + * Performance: a few `existsSync` checks per level, no spawning — stays within + * the ~2ms hook budget. + */ +export const resolveProjectRoot = (start: string): string => { + const walkUp = ( + dir: string, + marker: string, + ): string | undefined => { + if (existsSync(resolve(dir, marker))) return dir; + const parent = dirname(dir); + return parent === dir ? undefined : walkUp(parent, marker); + }; + return walkUp(start, ".clens") ?? walkUp(start, ".git") ?? start; +}; + export interface EffectiveDuration { readonly effective_duration_ms: number; readonly idle_gaps_ms: number; @@ -155,12 +180,32 @@ export const resolveParentSession = ( return { id: "leader", name: "leader" }; }; +const isTeammateIdleLink = (link: LinkEvent): link is TeammateIdleLink => link.type === "teammate_idle"; + +/** + * Build a map from team member name to session_id using teammate_idle links. + * Only includes entries where both teammate name and session_id are non-empty. + */ +export const buildTeamMemberSessionMap = ( + links: readonly LinkEvent[], +): ReadonlyMap => + new Map( + links + .filter(isTeammateIdleLink) + .filter((link): link is TeammateIdleLink & { readonly session_id: string } => + link.teammate !== "" && link.session_id !== undefined && link.session_id !== "", + ) + .map((link) => [link.teammate, link.session_id] as const), + ); + /** * Filter link events to only those belonging to a specific session and its descendants. * * Step 1: Build agent ID set by recursively walking SpawnLink.parent_session chains. * Step 2: Build agent name set from spawn links matching the agent ID set. - * Step 3: Filter each link type by matching against ID set or name set. + * Step 3: Expand agent IDs with team member session IDs (from teammate_idle links) + * where the teammate name appears in agentNames or has msg_send from this session. + * Step 4: Filter each link type by matching against ID set or name set. * * Known limitation: `task_complete` and `teammate_idle` use agent names not UUIDs, * so name collisions across sessions cannot be fully resolved. @@ -182,16 +227,38 @@ export const filterLinksForSession = ( return nextIds.size === ids.size ? ids : expandAgentIds(nextIds); }; - const agentIds = expandAgentIds(new Set([sessionId])); + const spawnAgentIds = expandAgentIds(new Set([sessionId])); // Step 2: Build agent name set from spawn links whose agent_id is in the ID set const agentNames: ReadonlySet = new Set( spawns - .filter((s): s is SpawnLink & { agent_name: string } => agentIds.has(s.agent_id) && s.agent_name !== undefined) + .filter((s): s is SpawnLink & { agent_name: string } => spawnAgentIds.has(s.agent_id) && s.agent_name !== undefined) .map((s) => s.agent_name), ); - // Step 3: Filter each link type + // Step 3: Expand with team member session IDs from teammate_idle links + // Only include team members whose name appears in agentNames (spawned by this session) + // or who received a msg_send from any agent in this session tree + const teamMemberMap = buildTeamMemberSessionMap(links); + + const isMessageLink = (l: LinkEvent): l is MessageLink => l.type === "msg_send"; + + const msgRecipientNames: ReadonlySet = new Set( + links + .filter(isMessageLink) + .filter((l) => spawnAgentIds.has(l.from) || spawnAgentIds.has(l.session_id)) + .map((l) => l.to), + ); + + const teamMemberSessionIds: ReadonlySet = new Set( + [...teamMemberMap.entries()] + .filter(([name]) => agentNames.has(name) || msgRecipientNames.has(name)) + .map(([, sid]) => sid), + ); + + const agentIds: ReadonlySet = new Set([...spawnAgentIds, ...teamMemberSessionIds]); + + // Step 4: Filter each link type const matchesLink = (link: LinkEvent): boolean => { switch (link.type) { case "spawn": @@ -246,3 +313,29 @@ export const logError = ( // Even error logging failed — truly silent } }; + +/** + * Convert DiffLine[] back into a unified diff string with standard headers. + * Groups consecutive lines into a single hunk per call. + */ +export const diffLinesToUnified = ( + filePath: string, + lines: readonly DiffLine[], +): string => { + if (lines.length === 0) return ""; + + const header = `--- a/${filePath}\n+++ b/${filePath}`; + + // Compute hunk header line numbers + const oldStart = 1; + const oldCount = lines.filter((l) => l.type === "remove" || l.type === "context").length; + const newCount = lines.filter((l) => l.type === "add" || l.type === "context").length; + const hunkHeader = `@@ -${oldStart},${oldCount} +${oldStart},${newCount} @@`; + + const body = lines.map((l) => { + const prefix = l.type === "add" ? "+" : l.type === "remove" ? "-" : " "; + return `${prefix}${l.content}`; + }).join("\n"); + + return `${header}\n${hunkHeader}\n${body}`; +}; diff --git a/test/agentic.test.ts b/packages/cli/test/agentic.test.ts similarity index 100% rename from test/agentic.test.ts rename to packages/cli/test/agentic.test.ts diff --git a/packages/cli/test/analytics-summary.test.ts b/packages/cli/test/analytics-summary.test.ts new file mode 100644 index 0000000..7a9cc8a --- /dev/null +++ b/packages/cli/test/analytics-summary.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, test, expect } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + toSummaryRow, + localDayKey, + readAnalyticsSummary, + writeAnalyticsSummary, + writeAnalyticsSummaryBatch, +} from "../src/distill/analytics-summary"; +import type { DistilledSession, EditChain } from "../src/types"; + +// Regression tests for the analytics summary extractor (specs/revive/bug-register.md): +// - B11: per-tool calls must be carried (tools_by_name) so the route can compute +// real tool failure rates instead of a permanent 0. +// - B18: day bucketing must use the LOCAL calendar day, not UTC. +// - B19: edit_chain_links must carry the total edits across chains so the route can +// compute the real mean chain length (links per chain), not chains-per-session. + +const makeDistilled = (overrides: Partial = {}): DistilledSession => ({ + session_id: "test-session", + start_time: Date.parse("2026-03-15T12:00:00Z"), + stats: { + total_events: 10, + duration_ms: 5000, + events_by_type: {}, + tools_by_name: {}, + tool_call_count: 5, + failure_count: 0, + failure_rate: 0, + unique_files: [], + }, + backtracks: [], + decisions: [], + file_map: { files: [] }, + git_diff: { commits: [], hunks: [] }, + reasoning: [], + user_messages: [], + complete: true, + ...overrides, +}); + +const makeChain = (totalEdits: number, overrides: Partial = {}): EditChain => ({ + file_path: "src/x.ts", + steps: [], + total_edits: totalEdits, + total_failures: 0, + total_reads: 0, + effort_ms: 0, + has_backtrack: false, + surviving_edit_ids: [], + abandoned_edit_ids: [], + ...overrides, +}); + +describe("localDayKey (B18 local-day bucketing)", () => { + test("buckets by local calendar day, not UTC day", () => { + // 2026-03-15T12:00:00Z is well inside the same day for every common offset. + const ms = Date.parse("2026-03-15T12:00:00Z"); + const d = new Date(ms); + const expected = `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, "0")}-${`${d.getDate()}`.padStart(2, "0")}`; + expect(localDayKey(ms)).toBe(expected); + }); + + test("uses the machine's local components (matches Date getters)", () => { + const ms = Date.parse("2026-12-31T23:30:00Z"); + const d = new Date(ms); + const expected = `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, "0")}-${`${d.getDate()}`.padStart(2, "0")}`; + expect(localDayKey(ms)).toBe(expected); + }); +}); + +describe("toSummaryRow", () => { + test("B18: row.date is the local day of start_time", () => { + const start = Date.parse("2026-03-15T08:00:00Z"); + const row = toSummaryRow(makeDistilled({ start_time: start })); + expect(row.date).toBe(localDayKey(start)); + }); + + test("B11: carries tools_by_name (per-tool call counts) from stats", () => { + const row = toSummaryRow( + makeDistilled({ + stats: { + total_events: 20, + duration_ms: 1000, + events_by_type: {}, + tools_by_name: { Bash: 10, Read: 7, Edit: 3 }, + tool_call_count: 20, + failure_count: 2, + failure_rate: 0.1, + unique_files: [], + failures_by_tool: { Bash: 2 }, + }, + }), + ); + expect(row.tools_by_name).toEqual({ Bash: 10, Read: 7, Edit: 3 }); + expect(row.failures_by_tool).toEqual({ Bash: 2 }); + }); + + test("B11: tools_by_name defaults to {} when stats omit it", () => { + const row = toSummaryRow(makeDistilled()); + expect(row.tools_by_name).toEqual({}); + }); + + test("B19: edit_chain_links sums total_edits across chains (not chain count)", () => { + const row = toSummaryRow( + makeDistilled({ + edit_chains: { chains: [makeChain(18), makeChain(5), makeChain(2), makeChain(1)] }, + }), + ); + expect(row.edit_chain_count).toBe(4); + expect(row.edit_chain_links).toBe(26); // 18 + 5 + 2 + 1 + // Real mean chain length = links / chains = 6.5, NOT chains-per-session. + expect(row.edit_chain_links / row.edit_chain_count).toBeCloseTo(6.5); + }); + + test("B19: edit_chain_links is 0 when there are no chains", () => { + const row = toSummaryRow(makeDistilled({ edit_chains: { chains: [] } })); + expect(row.edit_chain_count).toBe(0); + expect(row.edit_chain_links).toBe(0); + }); +}); + +describe("analytics-summary disk reconcile (NUM-7) + batch flush (DIST-2)", () => { + let projectDir: string; + + const distilledDir = () => join(projectDir, ".clens", "distilled"); + const summaryFile = () => join(projectDir, ".clens", "analytics-summary.jsonl"); + + const writeDistilledFile = (sessionId: string): DistilledSession => { + const d = makeDistilled({ session_id: sessionId }); + mkdirSync(distilledDir(), { recursive: true }); + writeFileSync(join(distilledDir(), `${sessionId}.json`), JSON.stringify(d, null, 2)); + return d; + }; + + beforeEach(() => { + projectDir = join( + tmpdir(), + `clens-test-analytics-summary-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(projectDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test("DIST-2: writeAnalyticsSummaryBatch flushes all rows in a single file", () => { + const ds = ["s1", "s2", "s3"].map(writeDistilledFile); + writeAnalyticsSummaryBatch(ds, projectDir); + + const lines = readFileSync(summaryFile(), "utf-8").split("\n").filter(Boolean); + expect(lines.length).toBe(3); + expect(readAnalyticsSummary(projectDir).map((r) => r.session_id).sort()).toEqual([ + "s1", + "s2", + "s3", + ]); + }); + + test("DIST-2: batch merges with existing rows (last write wins, no duplicates)", () => { + writeDistilledFile("s1"); + writeAnalyticsSummary(makeDistilled({ session_id: "s1", start_time: 1 }), projectDir); + // Re-flush a batch that updates s1 and adds s2 — must not duplicate s1. + writeDistilledFile("s2"); + writeAnalyticsSummaryBatch( + [makeDistilled({ session_id: "s1", start_time: 2 }), makeDistilled({ session_id: "s2" })], + projectDir, + ); + + const lines = readFileSync(summaryFile(), "utf-8").split("\n").filter(Boolean); + expect(lines.length).toBe(2); + }); + + test("NUM-7: read coverage equals distilled-on-disk count when rows are missing", () => { + // 3 distilled on disk, but the cached summary only ever captured 2. + writeDistilledFile("s1"); + writeDistilledFile("s2"); + writeDistilledFile("s3"); + writeAnalyticsSummaryBatch( + [makeDistilled({ session_id: "s1" }), makeDistilled({ session_id: "s2" })], + projectDir, + ); + // Cache on disk has 2 rows, distilled/ has 3. + expect(readFileSync(summaryFile(), "utf-8").split("\n").filter(Boolean).length).toBe(2); + + // Reconcile-on-read recovers the lost row → coverage matches distilled count. + const rows = readAnalyticsSummary(projectDir); + expect(rows.length).toBe(3); + expect(rows.map((r) => r.session_id).sort()).toEqual(["s1", "s2", "s3"]); + }); + + test("NUM-7: orphan cached rows (no distilled file) are dropped from coverage", () => { + writeDistilledFile("s1"); + // Cache holds a row whose distilled file no longer exists. + writeFileSync( + summaryFile(), + [ + JSON.stringify(toSummaryRow(makeDistilled({ session_id: "s1" }))), + JSON.stringify(toSummaryRow(makeDistilled({ session_id: "ghost" }))), + ].join("\n") + "\n", + ); + + const rows = readAnalyticsSummary(projectDir); + expect(rows.map((r) => r.session_id)).toEqual(["s1"]); + }); + + test("NUM-7: a distilled file newer than the summary is rebuilt from disk", () => { + const summaryTime = new Date("2026-01-01T00:00:00Z"); + const newerTime = new Date("2026-01-02T00:00:00Z"); + + writeDistilledFile("s1"); + // Stale cached row: marks duration as a sentinel we can detect. + writeFileSync( + summaryFile(), + JSON.stringify({ ...toSummaryRow(makeDistilled({ session_id: "s1" })), duration_ms: 999999 }) + "\n", + ); + // Summary is OLDER than the distilled file → cached row is stale and must be rebuilt. + utimesSync(summaryFile(), summaryTime, summaryTime); + utimesSync(join(distilledDir(), "s1.json"), newerTime, newerTime); + + const rows = readAnalyticsSummary(projectDir); + expect(rows.length).toBe(1); + // Rebuilt from distilled (duration_ms 5000), not the stale cache (999999). + expect(rows[0]?.duration_ms).toBe(5000); + }); +}); diff --git a/test/backtracks-command.test.ts b/packages/cli/test/backtracks-command.test.ts similarity index 100% rename from test/backtracks-command.test.ts rename to packages/cli/test/backtracks-command.test.ts diff --git a/test/bench.ts b/packages/cli/test/bench.ts similarity index 100% rename from test/bench.ts rename to packages/cli/test/bench.ts diff --git a/packages/cli/test/capture-settings.test.ts b/packages/cli/test/capture-settings.test.ts new file mode 100644 index 0000000..82e387d --- /dev/null +++ b/packages/cli/test/capture-settings.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inferClaudeMd, resolveSettingsSnapshot } from "../src/capture/settings"; + +let root: string; +let dir: string; // isolated project dir +let savedHome: string | undefined; +let savedUserProfile: string | undefined; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "clens-cfg-")); + // Isolate the user scope: point HOME at an empty home so the resolver never + // picks up the real ~/.claude/settings.json (env-independent assertions). + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + process.env.HOME = join(root, "home"); + process.env.USERPROFILE = join(root, "home"); + mkdirSync(join(root, "home", ".claude"), { recursive: true }); + dir = join(root, "project"); + mkdirSync(join(dir, ".claude"), { recursive: true }); +}); + +afterEach(() => { + process.env.HOME = savedHome; + process.env.USERPROFILE = savedUserProfile; + rmSync(root, { recursive: true, force: true }); +}); + +const writeProjectSettings = (obj: unknown, file = "settings.json") => + writeFileSync(join(dir, ".claude", file), JSON.stringify(obj)); + +describe("resolveSettingsSnapshot (CFG-3)", () => { + test("snapshots known settings keys with provenance + source label", () => { + writeProjectSettings({ + outputStyle: "Observable: Tools + Diffs + TTS", + statusLine: { type: "command", command: "/Users/x/scripts/status_line_main.py" }, + enabledPlugins: { "superwhisper@superwhisper": true, "off@off": false }, + permissions: { defaultMode: "acceptEdits" }, + hooks: { PreToolUse: [{}], Stop: [{}] }, + }); + const snap = resolveSettingsSnapshot(dir); + expect(snap?.settings_source).toBe("session_start"); + expect(typeof snap?.captured_at).toBe("number"); + expect(snap?.output_style).toBe("Observable: Tools + Diffs + TTS"); + expect(snap?.output_style_scope).toBe("project"); + // statusline keeps a basename only, never the full path + expect(snap?.status_line).toEqual({ type: "command", command_name: "status_line_main.py" }); + expect(snap?.plugins_enabled).toEqual(["superwhisper@superwhisper"]); + expect(snap?.permission_default_mode).toBe("acceptEdits"); + expect(snap?.hooks_configured).toEqual(["PreToolUse", "Stop"]); + }); + + test("local scope overrides project scope per key", () => { + writeProjectSettings({ outputStyle: "project-style" }, "settings.json"); + writeProjectSettings({ outputStyle: "local-style" }, "settings.local.json"); + const snap = resolveSettingsSnapshot(dir); + expect(snap?.output_style).toBe("local-style"); + expect(snap?.output_style_scope).toBe("local"); + }); + + test("hooks + plugins union across scopes (concat + dedupe, not override)", () => { + // user scope (HOME/.claude/settings.json) + writeFileSync( + join(process.env.HOME as string, ".claude", "settings.json"), + JSON.stringify({ hooks: { Stop: [{}] }, enabledPlugins: { "a@a": true } }), + ); + // project scope + writeProjectSettings({ hooks: { PreToolUse: [{}] }, enabledPlugins: { "b@b": true } }); + const snap = resolveSettingsSnapshot(dir); + expect(snap?.hooks_configured).toEqual(["PreToolUse", "Stop"]); + expect(snap?.plugins_enabled).toEqual(["a@a", "b@b"]); + }); + + test("can label a distill-time read as 'current' (tier C)", () => { + writeProjectSettings({ outputStyle: "x" }); + expect(resolveSettingsSnapshot(dir, "current")?.settings_source).toBe("current"); + }); + + test("returns undefined (never an empty husk) when no settings file exists", () => { + expect(resolveSettingsSnapshot(join(dir, "nope"))).toBeUndefined(); + }); + + test("malformed settings JSON yields undefined, never throws", () => { + writeFileSync(join(dir, ".claude", "settings.json"), "{ not json"); + expect(() => resolveSettingsSnapshot(dir)).not.toThrow(); + expect(resolveSettingsSnapshot(dir)).toBeUndefined(); + }); +}); + +describe("inferClaudeMd (CFG-5 fallback)", () => { + test("records fact-of-existence of project CLAUDE.md, labeled inferred", () => { + writeFileSync(join(dir, "CLAUDE.md"), "# project memory"); + const found = inferClaudeMd(dir); + expect(found.some((e) => e.file_path === join(dir, "CLAUDE.md"))).toBe(true); + expect(found.every((e) => e.memory_type === "inferred")).toBe(true); + }); + + test("returns empty array (never throws) when no CLAUDE.md exists in project", () => { + const empty = join(dir, "empty-project"); + mkdirSync(empty, { recursive: true }); + expect(() => inferClaudeMd(empty)).not.toThrow(); + }); +}); diff --git a/packages/cli/test/clean.test.ts b/packages/cli/test/clean.test.ts new file mode 100644 index 0000000..f5591cb --- /dev/null +++ b/packages/cli/test/clean.test.ts @@ -0,0 +1,223 @@ +/** + * Pins the LOCKED clean-safety semantics (revival-2026-06 / talk-prep footgun): + * + * - bare `clens clean` (no id, no --last, no --all) deletes NOTHING and errors, + * - blanket `--all` is gated behind a confirmation (interactive [y/N]) and, + * in a non-interactive context, requires an explicit `--yes`, + * - `--yes` performs the non-interactive blanket delete, + * - undistilled sessions are skipped unless `--force`, + * - an end-to-end happy path through the real CLI binary entrypoint. + * + * GUARDRAIL: every path runs against a throwaway temp fixture dir under os.tmpdir(). + * It NEVER touches a real `.clens/` (the project's or `~/.clens`). + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Flags } from "../src/commands/shared"; +import { runCli } from "./e2e/helpers"; + +// ── Interactive-confirm mock ──────────────────────────── +// commands/clean.ts builds its [y/N] prompt via node:readline's createInterface. +// We replace it with a deterministic stub whose answer is controlled per-test. +let mockAnswer = ""; +mock.module("node:readline", () => ({ + createInterface: () => ({ + question: (_question: string, cb: (answer: string) => void) => cb(mockAnswer), + close: () => {}, + }), +})); + +// ── Flag builder ──────────────────────────────────────── +const makeFlags = (overrides: Partial = {}): Flags => ({ + last: false, + force: false, + yes: false, + deep: false, + json: false, + help: false, + version: false, + detail: false, + full: false, + all: false, + remove: false, + status: false, + dev: false, + comms: false, + global: false, + legacy: false, + ...overrides, +}); + +const DISTILLED_ID = "11111111-1111-1111-1111-111111111111"; +const UNDISTILLED_ID = "22222222-2222-2222-2222-222222222222"; + +const sessionPath = (dir: string, id: string) => join(dir, ".clens", "sessions", `${id}.jsonl`); +const distilledPath = (dir: string, id: string) => join(dir, ".clens", "distilled", `${id}.json`); + +const writeSession = (dir: string, id: string, distilled: boolean): void => { + writeFileSync( + sessionPath(dir, id), + [ + JSON.stringify({ event: "SessionStart", t: 1000, sid: id, data: {} }), + JSON.stringify({ event: "SessionEnd", t: 2000, sid: id, data: {} }), + ].join("\n") + "\n", + ); + if (distilled) { + writeFileSync(distilledPath(dir, id), JSON.stringify({ session_id: id })); + } +}; + +describe("clean command — safety gating", () => { + let tempDir: string; + + // Capture console output so the suite stays quiet and so we can assert on it. + const logs: string[] = []; + const originalLog = console.log; + + beforeEach(() => { + mockAnswer = ""; + logs.length = 0; + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; + + tempDir = join(tmpdir(), `clens-test-clean-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tempDir, ".clens", "sessions"), { recursive: true }); + mkdirSync(join(tempDir, ".clens", "distilled"), { recursive: true }); + writeSession(tempDir, DISTILLED_ID, true); + writeSession(tempDir, UNDISTILLED_ID, false); + }); + + afterEach(() => { + console.log = originalLog; + rmSync(tempDir, { recursive: true, force: true }); + }); + + const runClean = async (args: { + sessionArg?: string; + flags?: Partial; + }): Promise => { + const { cleanCommand } = await import("../src/commands/clean"); + await cleanCommand({ + sessionArg: args.sessionArg, + flags: makeFlags(args.flags), + projectDir: tempDir, + }); + }; + + test("bare clean (no id, no --all) errors and deletes nothing", async () => { + await expect(runClean({})).rejects.toThrow(/Nothing to clean/); + + // Guardrail: both sessions are untouched. + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(true); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(true); + }); + + test("--all in a non-interactive context refuses without --yes (confirmation gating)", async () => { + // In `bun test` process.stdin.isTTY is undefined → non-interactive path. + await expect(runClean({ flags: { all: true } })).rejects.toThrow(/Refusing to .*clean --all/); + + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(true); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(true); + }); + + test("--all --yes deletes distilled sessions non-interactively, skips undistilled", async () => { + await runClean({ flags: { all: true, yes: true } }); + + // Distilled session removed; undistilled one skipped (kept). + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(false); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(true); + expect(logs.join("\n")).toMatch(/Cleaned 1 session/); + expect(logs.join("\n")).toMatch(/Skipping/); + }); + + test("--all --yes --force deletes every session including undistilled", async () => { + await runClean({ flags: { all: true, yes: true, force: true } }); + + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(false); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(false); + expect(logs.join("\n")).toMatch(/Cleaned 2 session/); + }); + + test("interactive confirm: 'y' proceeds with the blanket delete", async () => { + mockAnswer = "y"; + const stdin = process.stdin as { isTTY?: boolean }; + const prev = stdin.isTTY; + stdin.isTTY = true; + try { + await runClean({ flags: { all: true } }); + } finally { + stdin.isTTY = prev; + } + + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(false); + }); + + test("interactive confirm: 'n' aborts and deletes nothing", async () => { + mockAnswer = "n"; + const stdin = process.stdin as { isTTY?: boolean }; + const prev = stdin.isTTY; + stdin.isTTY = true; + try { + await runClean({ flags: { all: true } }); + } finally { + stdin.isTTY = prev; + } + + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(true); + expect(logs.join("\n")).toMatch(/Aborted/); + }); + + test("targeted clean of a distilled session removes only that session", async () => { + await runClean({ sessionArg: DISTILLED_ID }); + + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(false); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(true); + expect(logs.join("\n")).toMatch(/Cleaned session 11111111/); + }); + + test("targeted clean of an undistilled session errors without --force", async () => { + await expect(runClean({ sessionArg: UNDISTILLED_ID })).rejects.toThrow(/not been distilled/); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(true); + }); + + test("targeted clean of an undistilled session succeeds with --force", async () => { + await runClean({ sessionArg: UNDISTILLED_ID, flags: { force: true } }); + expect(existsSync(sessionPath(tempDir, UNDISTILLED_ID))).toBe(false); + }); +}); + +// ── End-to-end happy path through the real CLI entrypoint ── +describe("clean command — e2e", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `clens-test-clean-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tempDir, ".clens", "sessions"), { recursive: true }); + mkdirSync(join(tempDir, ".clens", "distilled"), { recursive: true }); + writeSession(tempDir, DISTILLED_ID, true); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("`clens clean ` exits 0, reports freed space, and removes the file", async () => { + const result = await runCli(["clean", DISTILLED_ID], tempDir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Cleaned session 11111111/); + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(false); + }); + + test("`clens clean` with no target exits non-zero and deletes nothing", async () => { + const result = await runCli(["clean"], tempDir); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/Nothing to clean/); + expect(existsSync(sessionPath(tempDir, DISTILLED_ID))).toBe(true); + }); +}); diff --git a/test/cli.test.ts b/packages/cli/test/cli.test.ts similarity index 99% rename from test/cli.test.ts rename to packages/cli/test/cli.test.ts index 11ce848..76c5bf0 100644 --- a/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -128,7 +128,7 @@ describe("cli list", () => { expect(stdout).toContain("ID"); expect(stdout).toContain("Branch"); expect(stdout).toContain("Team"); - expect(stdout).toContain("Duration"); + expect(stdout).toContain("Span"); expect(stdout).toContain("Events"); expect(stdout).toContain("Status"); expect(stdout).toContain("aaaabbbb"); diff --git a/test/concurrency.test.ts b/packages/cli/test/concurrency.test.ts similarity index 100% rename from test/concurrency.test.ts rename to packages/cli/test/concurrency.test.ts diff --git a/packages/cli/test/config-command.test.ts b/packages/cli/test/config-command.test.ts new file mode 100644 index 0000000..84fc0f0 --- /dev/null +++ b/packages/cli/test/config-command.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { renderConfigSection } from "../src/commands/report"; +import { formatConfigLine } from "../src/commands/what"; +import type { SessionConfig } from "../src/types"; + +// -- ANSI stripping helper -- + +const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ""); + +const makeConfig = (overrides: Partial = {}): SessionConfig => ({ + permission_mode: "acceptEdits", + effort: "high", + mcp_servers: [ + { name: "claude_ai_Atlassian", count: 5 }, + { name: "filesystem", count: 2 }, + ], + ...overrides, +}); + +describe("formatConfigLine (clens what)", () => { + test("renders all segments joined with a dot separator", () => { + const line = formatConfigLine(makeConfig()); + expect(line).toBe( + "perm:acceptEdits · effort:high · mcp:claude_ai_Atlassian,filesystem", + ); + }); + + test("omits segments that have no data", () => { + const line = formatConfigLine({ mcp_servers: [], effort: "low" }); + expect(line).toBe("effort:low"); + }); + + test("returns undefined for undefined config", () => { + expect(formatConfigLine(undefined)).toBeUndefined(); + }); + + test("returns undefined when nothing is known", () => { + expect(formatConfigLine({ mcp_servers: [] })).toBeUndefined(); + }); +}); + +describe("renderConfigSection (clens report)", () => { + test("returns [] for undefined config (old distills)", () => { + expect(renderConfigSection(undefined)).toEqual([]); + }); + + test("returns [] when no fields are populated", () => { + expect(renderConfigSection({ mcp_servers: [] })).toEqual([]); + }); + + test("renders a CONFIG / ENVIRONMENT heading with rows", () => { + const out = renderConfigSection(makeConfig()).map(stripAnsi); + expect(out.some((l) => l.includes("Config / Environment:"))).toBe(true); + expect(out.some((l) => l.includes("Permission: acceptEdits"))).toBe(true); + expect(out.some((l) => l.includes("Effort: high"))).toBe(true); + expect(out.some((l) => l.includes("MCP servers: claude_ai_Atlassian (5), filesystem (2)"))).toBe(true); + }); + + test("uses a green LED for safe permission modes", () => { + const raw = renderConfigSection(makeConfig({ permission_mode: "plan" })); + const permRow = raw.find((l) => stripAnsi(l).includes("Permission:")); + expect(permRow).toContain("\x1b[32m"); // green + expect(permRow).not.toContain("\x1b[33m"); // not amber + }); + + test("uses an amber LED for relaxed permission modes", () => { + for (const mode of ["bypassPermissions", "dontAsk"]) { + const raw = renderConfigSection(makeConfig({ permission_mode: mode })); + const permRow = raw.find((l) => stripAnsi(l).includes("Permission:")); + expect(permRow).toContain("\x1b[33m"); // amber/yellow + } + }); +}); diff --git a/packages/cli/test/context-consumption.test.ts b/packages/cli/test/context-consumption.test.ts new file mode 100644 index 0000000..7f1829d --- /dev/null +++ b/packages/cli/test/context-consumption.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, test } from "bun:test"; +import type { TranscriptEntry } from "../src/types/transcript"; +import { extractContextConsumption } from "../src/distill/context-consumption"; +import { getModelContextWindow, MODEL_CONTEXT_WINDOWS } from "../src/distill/stats"; + +const mockEntry = (overrides: Partial = {}): TranscriptEntry => ({ + type: "assistant", + timestamp: new Date().toISOString(), + uuid: crypto.randomUUID(), + parentUuid: null, + sessionId: "test-session", + message: { + role: "assistant", + model: "claude-sonnet-4-20250514", + content: [], + usage: { + input_tokens: 1000, + output_tokens: 200, + cache_read_input_tokens: 500, + cache_creation_input_tokens: 300, + }, + }, + ...overrides, +}); + +/** Guard that asserts a value is defined and returns it (avoids ! operator). */ +const defined = (value: T | undefined, msg = "expected defined"): T => { + if (value === undefined || value === null) throw new Error(msg); + return value; +}; + +describe("extractContextConsumption", () => { + test("returns undefined when model is undefined", () => { + const entries = [mockEntry()]; + const result = extractContextConsumption(entries, undefined); + expect(result).toBeUndefined(); + }); + + test("returns undefined for unknown model", () => { + const entries = [mockEntry()]; + const result = extractContextConsumption(entries, "gpt-4o"); + expect(result).toBeUndefined(); + }); + + test("returns undefined when no entries have usage data", () => { + const entries = [ + mockEntry({ message: { role: "assistant", content: [] } }), + ]; + const result = extractContextConsumption(entries, "claude-sonnet-4-20250514"); + expect(result).toBeUndefined(); + }); + + test("returns undefined for empty entries", () => { + const result = extractContextConsumption([], "claude-sonnet-4-20250514"); + expect(result).toBeUndefined(); + }); + + test("extracts basic consumption from a single turn", () => { + const entries = [mockEntry()]; + const r = defined(extractContextConsumption(entries, "claude-sonnet-4-20250514")); + + expect(r.turn_count).toBe(1); + expect(r.model_context_window).toBe(200_000); + expect(r.points).toHaveLength(1); + + const point = r.points[0]; + // total = 1000 + 500 + 300 = 1800 + expect(point.total_context_tokens).toBe(1800); + expect(point.input_tokens).toBe(1000); + expect(point.output_tokens).toBe(200); + expect(point.cache_read_tokens).toBe(500); + expect(point.cache_creation_tokens).toBe(300); + expect(point.context_pct).toBe(0.9); // 1800/200000 * 100 + expect(point.is_compaction).toBe(false); + expect(point.turn_index).toBe(0); + }); + + test("extracts multi-turn consumption with correct summary", () => { + const baseTime = new Date("2026-01-01T00:00:00Z").getTime(); + const entries = [ + mockEntry({ + timestamp: new Date(baseTime).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 2000, output_tokens: 400, cache_read_input_tokens: 1000, cache_creation_input_tokens: 500 }, + }, + }), + mockEntry({ + timestamp: new Date(baseTime + 60_000).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 5000, output_tokens: 600, cache_read_input_tokens: 3000, cache_creation_input_tokens: 1000 }, + }, + }), + mockEntry({ + timestamp: new Date(baseTime + 120_000).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 10000, output_tokens: 800, cache_read_input_tokens: 5000, cache_creation_input_tokens: 2000 }, + }, + }), + ]; + + const r = defined(extractContextConsumption(entries, "claude-sonnet-4-20250514")); + expect(r.turn_count).toBe(3); + expect(r.points).toHaveLength(3); + + // Peak should be the last turn: 10000 + 5000 + 2000 = 17000 + expect(r.peak_context_tokens).toBe(17000); + expect(r.peak_context_pct).toBe(8.5); // 17000/200000 * 100 + + // Final should also be the last turn + expect(r.final_context_pct).toBe(8.5); + + // No compaction + expect(r.compaction_count).toBe(0); + }); + + test("detects compaction when context drops >30%", () => { + const baseTime = new Date("2026-01-01T00:00:00Z").getTime(); + const entries = [ + mockEntry({ + timestamp: new Date(baseTime).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 100000, output_tokens: 1000, cache_read_input_tokens: 50000, cache_creation_input_tokens: 10000 }, + }, + }), + // Compaction: total drops from 160000 to 30000 (< 160000 * 0.7 = 112000) + mockEntry({ + timestamp: new Date(baseTime + 60_000).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 20000, output_tokens: 500, cache_read_input_tokens: 8000, cache_creation_input_tokens: 2000 }, + }, + }), + // Growth after compaction + mockEntry({ + timestamp: new Date(baseTime + 120_000).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 40000, output_tokens: 700, cache_read_input_tokens: 15000, cache_creation_input_tokens: 5000 }, + }, + }), + ]; + + const r = defined(extractContextConsumption(entries, "claude-sonnet-4-20250514")); + expect(r.compaction_count).toBe(1); + expect(r.points[1].is_compaction).toBe(true); + expect(r.points[0].is_compaction).toBe(false); + expect(r.points[2].is_compaction).toBe(false); + + // Peak should be first turn: 100000 + 50000 + 10000 = 160000 + expect(r.peak_context_tokens).toBe(160000); + }); + + test("deduplicates entries with same requestId", () => { + const sharedRequestId = "req-123"; + const entries = [ + mockEntry({ + requestId: sharedRequestId, + message: { + role: "assistant", + content: [], + usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 500, cache_creation_input_tokens: 200 }, + }, + }), + // Second streaming chunk with same requestId — should overwrite first + mockEntry({ + requestId: sharedRequestId, + message: { + role: "assistant", + content: [], + usage: { input_tokens: 1000, output_tokens: 200, cache_read_input_tokens: 500, cache_creation_input_tokens: 200 }, + }, + }), + // Different requestId — separate turn + mockEntry({ + requestId: "req-456", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 3000, output_tokens: 400, cache_read_input_tokens: 1500, cache_creation_input_tokens: 500 }, + }, + }), + ]; + + const r = defined(extractContextConsumption(entries, "claude-sonnet-4-20250514")); + // Should deduplicate to 2 turns (req-123 and req-456) + expect(r.turn_count).toBe(2); + expect(r.points).toHaveLength(2); + // First point should use the LAST entry for req-123 (output_tokens: 200) + expect(r.points[0].output_tokens).toBe(200); + }); + + test("computes velocity correctly excluding compaction points", () => { + const baseTime = new Date("2026-01-01T00:00:00Z").getTime(); + const entries = [ + mockEntry({ + timestamp: new Date(baseTime).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 10000, output_tokens: 500, cache_read_input_tokens: 5000, cache_creation_input_tokens: 1000 }, + }, + }), + // Compaction at 2 min + mockEntry({ + timestamp: new Date(baseTime + 120_000).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 1000, cache_creation_input_tokens: 200 }, + }, + }), + // Growth at 4 min + mockEntry({ + timestamp: new Date(baseTime + 240_000).toISOString(), + message: { + role: "assistant", + content: [], + usage: { input_tokens: 20000, output_tokens: 800, cache_read_input_tokens: 10000, cache_creation_input_tokens: 3000 }, + }, + }), + ]; + + const r = defined(extractContextConsumption(entries, "claude-sonnet-4-20250514")); + + // Non-compaction points: first (t=0, 16000 total, 8%) and third (t=240s, 33000 total, 16.5%) + // Velocity = (16.5 - 8) / 4 min = 2.125 per min → rounded to 2.13 + // First: (10000+5000+1000)/200000*100 = 8 + // Third: (20000+10000+3000)/200000*100 = 16.5 + // Time span: 240000ms = 4 min + // Velocity: (16.5 - 8) / 4 = 2.125 → 2.13 + expect(r.context_velocity_per_min).toBe(2.13); + }); + + test("filters out non-assistant entries", () => { + const entries = [ + mockEntry({ type: "user" }), + mockEntry({ type: "progress" }), + mockEntry(), // only this one is assistant + ]; + + const r = defined(extractContextConsumption(entries, "claude-sonnet-4-20250514")); + expect(r.turn_count).toBe(1); + }); + + test("works with claude-opus-4-6 model (1M context)", () => { + const entries = [ + mockEntry({ + message: { + role: "assistant", + content: [], + usage: { input_tokens: 100000, output_tokens: 5000, cache_read_input_tokens: 50000, cache_creation_input_tokens: 20000 }, + }, + }), + ]; + + const r = defined(extractContextConsumption(entries, "claude-opus-4-6")); + expect(r.model_context_window).toBe(1_000_000); + // total = 170000, pct = 170000/1000000 * 100 = 17 + expect(r.points[0].context_pct).toBe(17); + }); +}); + +describe("getModelContextWindow", () => { + // Regression for bug B8: every Opus variant resolved to 1M; only 4.6+ are 1M. + test("claude-opus-4-0 (legacy) resolves to 200K", () => { + expect(getModelContextWindow("claude-opus-4-20250514")).toBe(200_000); + }); + + test("claude-opus-4-6 and later resolve to 1M", () => { + expect(getModelContextWindow("claude-opus-4-6")).toBe(1_000_000); + expect(getModelContextWindow("claude-opus-4-7")).toBe(1_000_000); + expect(getModelContextWindow("claude-opus-4-8")).toBe(1_000_000); + }); + + test("claude-fable-5 resolves to 1M", () => { + expect(getModelContextWindow("claude-fable-5[1m]")).toBe(1_000_000); + }); + + test("claude-sonnet-4-6 resolves to 1M, older sonnet to 200K", () => { + expect(getModelContextWindow("claude-sonnet-4-6")).toBe(1_000_000); + expect(getModelContextWindow("claude-sonnet-4-20250514")).toBe(200_000); + }); + + test("resolves claude-haiku-4 prefix", () => { + expect(getModelContextWindow("claude-haiku-4-20250514")).toBe(200_000); + }); + + test("returns undefined for unknown model", () => { + expect(getModelContextWindow("gpt-4o")).toBeUndefined(); + }); + + test("matches exact prefix", () => { + expect(getModelContextWindow("claude-opus-4")).toBe(200_000); + }); +}); diff --git a/test/context.test.ts b/packages/cli/test/context.test.ts similarity index 100% rename from test/context.test.ts rename to packages/cli/test/context.test.ts diff --git a/packages/cli/test/conversation-transcript.test.ts b/packages/cli/test/conversation-transcript.test.ts new file mode 100644 index 0000000..eba6c13 --- /dev/null +++ b/packages/cli/test/conversation-transcript.test.ts @@ -0,0 +1,712 @@ +import { describe, expect, test } from "bun:test"; +import { buildConversationFromTranscript } from "../src/session/conversation"; +import type { AgentMessage, AgentNode, BacktrackResult, TranscriptEntry } from "../src/types"; + +// --- Factories --- + +const makeTranscriptEntry = ( + overrides: Partial & Pick, +): TranscriptEntry => ({ + uuid: "uuid-1", + parentUuid: null, + sessionId: "agent-1", + timestamp: "2024-01-01T00:00:01.000Z", + ...overrides, +}); + +const makeAgentNode = (overrides: Partial = {}): AgentNode => ({ + session_id: "agent-1", + agent_type: "builder", + duration_ms: 5000, + tool_call_count: 0, + children: [], + ...overrides, +}); + +const makeBacktrack = (overrides: Partial = {}): BacktrackResult => ({ + type: "failure_retry", + tool_name: "Edit", + attempts: 2, + start_t: 3000, + end_t: 4000, + tool_use_ids: ["tu-1", "tu-2"], + ...overrides, +}); + +const makeAgentMessage = (overrides: Partial = {}): AgentMessage => ({ + t: 2000, + direction: "received", + partner: "orchestrator", + msg_type: "task_assign", + ...overrides, +}); + +// --- Tests --- + +describe("buildConversationFromTranscript", () => { + // 1. Empty transcript + test("returns empty array for empty transcript with no agent", () => { + const result = buildConversationFromTranscript([]); + expect(result).toEqual([]); + }); + + test("returns empty array for empty transcript with empty agent node", () => { + const result = buildConversationFromTranscript([], makeAgentNode()); + expect(result).toEqual([]); + }); + + // 2. User prompts from transcript + test("extracts user prompt from transcript entry with string content", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:01.000Z", + message: { role: "user", content: "Fix the bug" }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "user_prompt", + text: "Fix the bug", + t: new Date("2024-01-01T00:00:01.000Z").getTime(), + }); + }); + + test("extracts user prompt from transcript entry with array content (text blocks)", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:02.000Z", + message: { + role: "user", + content: [ + { type: "text", text: "Hello " }, + { type: "text", text: "world" }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "user_prompt", + text: "Hello \nworld", + }); + }); + + test("skips user entries with no message content", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:01.000Z", + message: { role: "user", content: "" }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toEqual([]); + }); + + test("skips assistant entries when looking for user prompts", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:01.000Z", + message: { role: "assistant", content: "Sure, I can help." }, + }), + ]; + const result = buildConversationFromTranscript(entries); + // assistant entries with string content have no tool_use/thinking blocks — empty result + expect(result).toEqual([]); + }); + + // 3. Tool calls from assistant entries + test("extracts tool call entries from assistant transcript content blocks", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:05.000Z", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tu-123", + name: "Read", + input: { file_path: "/src/index.ts" }, + }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_call", + tool_name: "Read", + tool_use_id: "tu-123", + file_path: "/src/index.ts", + t: new Date("2024-01-01T00:00:05.000Z").getTime(), + }); + }); + + test("extracts file_path from tool_input.path fallback", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:06.000Z", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tu-456", + name: "Glob", + input: { path: "/src" }, + }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_call", + tool_name: "Glob", + file_path: "/src", + }); + }); + + test("handles tool_use with no file_path or path field", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:07.000Z", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tu-789", + name: "Bash", + input: { command: "ls -la" }, + }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "tool_call", tool_name: "Bash" }); + if (result[0].type === "tool_call") { + expect(result[0].file_path).toBeUndefined(); + } + }); + + test("extracts multiple tool calls from a single assistant entry", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:08.000Z", + message: { + role: "assistant", + content: [ + { type: "tool_use", id: "tu-a", name: "Read", input: { file_path: "/a.ts" } }, + { type: "tool_use", id: "tu-b", name: "Edit", input: { file_path: "/b.ts" } }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ type: "tool_call", tool_use_id: "tu-a" }); + expect(result[1]).toMatchObject({ type: "tool_call", tool_use_id: "tu-b" }); + }); + + // 4. Tool results from user entries + test("extracts tool_result success from user transcript content blocks", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:10.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tu-123", + content: "File contents here", + }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_result", + tool_use_id: "tu-123", + outcome: "success", + t: new Date("2024-01-01T00:00:10.000Z").getTime(), + }); + }); + + test("extracts tool_result failure with is_error and error message", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:11.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tu-456", + content: "old_string not found", + is_error: true, + }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_result", + tool_use_id: "tu-456", + outcome: "failure", + error: "old_string not found", + }); + }); + + test("tool_result has tool_name 'unknown' (not extractable from transcript)", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:12.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tu-789", + content: "ok", + }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "tool_result", tool_name: "unknown" }); + }); + + // 5. Thinking blocks from assistant entries + test("extracts thinking entries from assistant transcript content blocks", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:03.000Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me analyze the code first." }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "thinking", + text: "Let me analyze the code first.", + intent: "general", + t: new Date("2024-01-01T00:00:03.000Z").getTime(), + }); + }); + + test("extracts multiple thinking blocks from a single assistant entry", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:04.000Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "First thought." }, + { type: "thinking", thinking: "Second thought." }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ type: "thinking", text: "First thought." }); + expect(result[1]).toMatchObject({ type: "thinking", text: "Second thought." }); + }); + + // 6. Agent reasoning from AgentNode — prefers over transcript thinking + test("prefers agent reasoning over transcript thinking blocks", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:04.000Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Raw transcript thinking (less rich)." }, + ], + }, + }), + ]; + const agent = makeAgentNode({ + reasoning: [ + { + t: 5000, + thinking: "Agent reasoning with intent hint.", + intent_hint: "planning", + }, + ], + }); + const result = buildConversationFromTranscript(entries, agent); + const thinkingEntries = result.filter((e) => e.type === "thinking"); + expect(thinkingEntries).toHaveLength(1); + expect(thinkingEntries[0]).toMatchObject({ + type: "thinking", + text: "Agent reasoning with intent hint.", + intent: "planning", + t: 5000, + }); + }); + + test("falls back to transcript thinking when agent has no reasoning", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "assistant", + timestamp: "2024-01-01T00:00:04.000Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Transcript thinking used." }, + ], + }, + }), + ]; + const agent = makeAgentNode({ reasoning: [] }); + const result = buildConversationFromTranscript(entries, agent); + const thinkingEntries = result.filter((e) => e.type === "thinking"); + expect(thinkingEntries).toHaveLength(1); + expect(thinkingEntries[0]).toMatchObject({ + type: "thinking", + text: "Transcript thinking used.", + }); + }); + + test("agent reasoning uses intent_hint with fallback to 'general'", () => { + const agent = makeAgentNode({ + reasoning: [ + { t: 1000, thinking: "No hint here." }, + { t: 2000, thinking: "Debug thought.", intent_hint: "debugging" }, + ], + }); + const result = buildConversationFromTranscript([], agent); + const thinkingEntries = result.filter((e) => e.type === "thinking"); + expect(thinkingEntries).toHaveLength(2); + expect(thinkingEntries[0]).toMatchObject({ intent: "general" }); + expect(thinkingEntries[1]).toMatchObject({ intent: "debugging" }); + }); + + // 7. Agent backtracks from AgentNode + test("includes backtrack entries from agent node", () => { + const agent = makeAgentNode({ + backtracks: [ + makeBacktrack({ + type: "iteration_struggle", + tool_name: "Edit", + attempts: 3, + start_t: 6000, + end_t: 7000, + tool_use_ids: ["tu-x", "tu-y", "tu-z"], + }), + ], + }); + const result = buildConversationFromTranscript([], agent); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "backtrack", + t: 6000, + backtrack_type: "iteration_struggle", + attempt: 3, + reverted_tool_ids: ["tu-x", "tu-y", "tu-z"], + }); + }); + + test("includes multiple backtracks from agent node", () => { + const agent = makeAgentNode({ + backtracks: [ + makeBacktrack({ start_t: 1000, type: "failure_retry" }), + makeBacktrack({ start_t: 2000, type: "debugging_loop" }), + ], + }); + const result = buildConversationFromTranscript([], agent); + const backtracks = result.filter((e) => e.type === "backtrack"); + expect(backtracks).toHaveLength(2); + }); + + // 8. Agent messages from AgentNode + test("includes inter-agent messages from agent node", () => { + const agent = makeAgentNode({ + messages: [ + makeAgentMessage({ + t: 8000, + direction: "sent", + partner: "sub-agent", + msg_type: "task_assign", + summary: "Please fix the tests", + }), + ], + }); + const result = buildConversationFromTranscript([], agent); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "agent_message", + t: 8000, + direction: "sent", + partner: "sub-agent", + msg_type: "task_assign", + summary: "Please fix the tests", + }); + }); + + test("agent_message without summary omits summary field", () => { + const agent = makeAgentNode({ + messages: [ + makeAgentMessage({ t: 9000, summary: undefined }), + ], + }); + const result = buildConversationFromTranscript([], agent); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("agent_message"); + if (result[0].type === "agent_message") { + expect(result[0].summary).toBeUndefined(); + } + }); + + test("includes received and sent messages from agent node", () => { + const agent = makeAgentNode({ + messages: [ + makeAgentMessage({ t: 1000, direction: "received", partner: "orchestrator" }), + makeAgentMessage({ t: 2000, direction: "sent", partner: "sub-agent" }), + ], + }); + const result = buildConversationFromTranscript([], agent); + const agentMessages = result.filter((e) => e.type === "agent_message"); + expect(agentMessages).toHaveLength(2); + }); + + // 9. Task prompt injection + test("inserts task_prompt before other entries using agent start time", () => { + const agent = makeAgentNode({ + task_prompt: "Build the authentication module", + messages: [ + makeAgentMessage({ t: 5000, direction: "received", partner: "orchestrator" }), + ], + }); + const result = buildConversationFromTranscript([], agent); + const taskPromptEntries = result.filter((e) => e.type === "user_prompt"); + expect(taskPromptEntries).toHaveLength(1); + expect(taskPromptEntries[0]).toMatchObject({ + type: "user_prompt", + text: "Build the authentication module", + }); + // task prompt t should be before the earliest agent message (5000 - 1) + expect(taskPromptEntries[0].t).toBe(4999); + }); + + test("inserts task_prompt at t=0-1 when no agent data to derive start time", () => { + const agent = makeAgentNode({ task_prompt: "A task with no timing data" }); + const result = buildConversationFromTranscript([], agent); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "user_prompt", text: "A task with no timing data", t: -1 }); + }); + + test("does not insert task_prompt when agent has none", () => { + const agent = makeAgentNode({ task_prompt: undefined }); + const result = buildConversationFromTranscript([], agent); + expect(result).toEqual([]); + }); + + test("task_prompt is combined with transcript user prompts", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:05.000Z", + message: { role: "user", content: "Subsequent user message" }, + }), + ]; + const agent = makeAgentNode({ + task_prompt: "Initial task prompt", + task_events: [{ t: new Date("2024-01-01T00:00:05.000Z").getTime(), action: "create", task_id: "t-1" }], + }); + const result = buildConversationFromTranscript(entries, agent); + const prompts = result.filter((e) => e.type === "user_prompt"); + expect(prompts).toHaveLength(2); + }); + + // 10. Sorting — all entries sorted by timestamp + test("sorts all entry types by timestamp", () => { + const t5 = new Date("2024-01-01T00:00:05.000Z").getTime(); + const t3 = new Date("2024-01-01T00:00:03.000Z").getTime(); + const t1 = new Date("2024-01-01T00:00:01.000Z").getTime(); + + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:05.000Z", + message: { role: "user", content: "User message at t5" }, + }), + makeTranscriptEntry({ + uuid: "uuid-2", + type: "assistant", + timestamp: "2024-01-01T00:00:03.000Z", + message: { + role: "assistant", + content: [ + { type: "tool_use", id: "tu-a", name: "Read", input: { file_path: "/x.ts" } }, + ], + }, + }), + makeTranscriptEntry({ + uuid: "uuid-3", + type: "user", + timestamp: "2024-01-01T00:00:01.000Z", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu-a", content: "ok" }, + ], + }, + }), + ]; + + const agent = makeAgentNode({ + reasoning: [{ t: t3 + 500, thinking: "Mid-point reasoning.", intent_hint: "deciding" }], + backtracks: [makeBacktrack({ start_t: t5 + 1000 })], + messages: [makeAgentMessage({ t: t1 - 500 })], + }); + + const result = buildConversationFromTranscript(entries, agent); + const timestamps = result.map((e) => e.t); + const sorted = [...timestamps].sort((a, b) => a - b); + expect(timestamps).toEqual(sorted); + }); + + test("correctly sorts mixed entry types across multiple transcript entries", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + uuid: "uuid-user-1", + type: "user", + timestamp: "2024-01-01T00:00:10.000Z", + message: { role: "user", content: "Second user message" }, + }), + makeTranscriptEntry({ + uuid: "uuid-user-0", + type: "user", + timestamp: "2024-01-01T00:00:01.000Z", + message: { role: "user", content: "First user message" }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ type: "user_prompt", text: "First user message" }); + expect(result[1]).toMatchObject({ type: "user_prompt", text: "Second user message" }); + }); + + // Edge cases + test("ignores non-user/assistant entry types (progress, file-history-snapshot)", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "progress", + timestamp: "2024-01-01T00:00:01.000Z", + data: { progress: 50 }, + }), + makeTranscriptEntry({ + uuid: "uuid-2", + type: "file-history-snapshot", + timestamp: "2024-01-01T00:00:02.000Z", + data: { snapshot: {} }, + }), + ]; + const result = buildConversationFromTranscript(entries); + expect(result).toEqual([]); + }); + + test("handles user entry with only tool_result blocks (no text), produces no user_prompt", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:05.000Z", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu-x", content: "result text" }, + ], + }, + }), + ]; + const result = buildConversationFromTranscript(entries); + // tool_result blocks do not produce user_prompt entries + const prompts = result.filter((e) => e.type === "user_prompt"); + expect(prompts).toHaveLength(0); + // but they do produce tool_result entries + const results = result.filter((e) => e.type === "tool_result"); + expect(results).toHaveLength(1); + }); + + test("all agent data combined with transcript entries in single call", () => { + const entries: readonly TranscriptEntry[] = [ + makeTranscriptEntry({ + type: "user", + timestamp: "2024-01-01T00:00:02.000Z", + message: { role: "user", content: "User input" }, + }), + makeTranscriptEntry({ + uuid: "uuid-assistant", + type: "assistant", + timestamp: "2024-01-01T00:00:03.000Z", + message: { + role: "assistant", + content: [ + { type: "tool_use", id: "tu-1", name: "Edit", input: { file_path: "/main.ts" } }, + ], + }, + }), + ]; + const agent = makeAgentNode({ + task_prompt: "Task: update the module", + messages: [makeAgentMessage({ t: new Date("2024-01-01T00:00:02.000Z").getTime() })], + backtracks: [makeBacktrack({ start_t: new Date("2024-01-01T00:00:04.000Z").getTime() })], + reasoning: [ + { + t: new Date("2024-01-01T00:00:01.000Z").getTime(), + thinking: "Planning the approach.", + intent_hint: "planning", + }, + ], + }); + + const result = buildConversationFromTranscript(entries, agent); + + // All entry types should be present + const types = new Set(result.map((e) => e.type)); + expect(types.has("user_prompt")).toBe(true); + expect(types.has("tool_call")).toBe(true); + expect(types.has("thinking")).toBe(true); + expect(types.has("agent_message")).toBe(true); + expect(types.has("backtrack")).toBe(true); + + // Must be sorted + const timestamps = result.map((e) => e.t); + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); + }); +}); diff --git a/packages/cli/test/conversation.test.ts b/packages/cli/test/conversation.test.ts new file mode 100644 index 0000000..17aaae7 --- /dev/null +++ b/packages/cli/test/conversation.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, test } from "bun:test"; +import { buildConversation } from "../src/session/conversation"; +import type { DistilledSession, StoredEvent } from "../src/types"; + +const makeDistilled = (overrides: Partial = {}): DistilledSession => ({ + session_id: "test-session", + stats: { + total_events: 0, + duration_ms: 0, + events_by_type: {}, + tools_by_name: {}, + tool_call_count: 0, + failure_count: 0, + failure_rate: 0, + unique_files: [], + }, + backtracks: [], + decisions: [], + file_map: { files: [] }, + git_diff: { commits: [], hunks: [] }, + complete: true, + reasoning: [], + user_messages: [], + ...overrides, +}); + +const makeEvent = (overrides: Partial & Pick): StoredEvent => ({ + sid: "test-session", + data: {}, + ...overrides, +}); + +describe("buildConversation", () => { + test("returns empty array for empty inputs", () => { + const result = buildConversation(makeDistilled(), []); + expect(result).toEqual([]); + }); + + test("maps user_messages to user_prompt entries", () => { + const distilled = makeDistilled({ + user_messages: [ + { t: 1000, content: "Hello", is_tool_result: false, message_type: "prompt" }, + { t: 2000, content: "Fix bug", is_tool_result: false, message_type: "prompt" }, + ], + }); + const result = buildConversation(distilled, []); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ type: "user_prompt", t: 1000, text: "Hello", index: 0 }); + expect(result[1]).toMatchObject({ type: "user_prompt", t: 2000, text: "Fix bug", index: 1 }); + }); + + test("filters out tool_result and system user_messages", () => { + const distilled = makeDistilled({ + user_messages: [ + { t: 1000, content: "Hello", is_tool_result: false, message_type: "prompt" }, + { t: 1500, content: "tool output", is_tool_result: true }, + { t: 1800, content: "system msg", is_tool_result: false, message_type: "system" }, + ], + }); + const result = buildConversation(distilled, []); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "user_prompt", text: "Hello" }); + }); + + test("maps reasoning to thinking entries with intent", () => { + const distilled = makeDistilled({ + reasoning: [ + { t: 1000, thinking: "Let me plan this", intent_hint: "planning" }, + { t: 2000, thinking: "Investigating the bug", intent_hint: "debugging" }, + { t: 3000, thinking: "Hmm what to do" }, + ], + }); + const result = buildConversation(distilled, []); + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ type: "thinking", intent: "planning" }); + expect(result[1]).toMatchObject({ type: "thinking", intent: "debugging" }); + expect(result[2]).toMatchObject({ type: "thinking", intent: "general" }); + }); + + test("maps PreToolUse events to tool_call entries", () => { + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PreToolUse", + data: { + tool_name: "Read", + tool_use_id: "tu-1", + tool_input: { file_path: "/src/index.ts" }, + }, + }), + ]; + const result = buildConversation(makeDistilled(), events); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_call", + tool_name: "Read", + tool_use_id: "tu-1", + file_path: "/src/index.ts", + }); + }); + + test("extracts file_path from tool_input.path fallback", () => { + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PreToolUse", + data: { + tool_name: "Glob", + tool_use_id: "tu-2", + tool_input: { path: "/src" }, + }, + }), + ]; + const result = buildConversation(makeDistilled(), events); + expect(result[0]).toMatchObject({ type: "tool_call", file_path: "/src" }); + }); + + test("maps PostToolUse to success tool_result", () => { + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PostToolUse", + data: { tool_name: "Read", tool_use_id: "tu-1" }, + }), + ]; + const result = buildConversation(makeDistilled(), events); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_result", + outcome: "success", + tool_name: "Read", + tool_use_id: "tu-1", + }); + }); + + test("maps PostToolUseFailure to failure tool_result with error", () => { + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PostToolUseFailure", + data: { + tool_name: "Edit", + tool_use_id: "tu-3", + error: "old_string not found", + }, + }), + ]; + const result = buildConversation(makeDistilled(), events); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "tool_result", + outcome: "failure", + error: "old_string not found", + }); + }); + + test("maps backtracks at start_t", () => { + const distilled = makeDistilled({ + backtracks: [ + { + type: "failure_retry", + tool_name: "Edit", + attempts: 3, + start_t: 5000, + end_t: 8000, + tool_use_ids: ["tu-1", "tu-2", "tu-3"], + }, + ], + }); + const result = buildConversation(distilled, []); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "backtrack", + t: 5000, + backtrack_type: "failure_retry", + attempt: 3, + reverted_tool_ids: ["tu-1", "tu-2", "tu-3"], + }); + }); + + test("maps summary phases to phase_boundary entries", () => { + const distilled = makeDistilled({ + summary: { + narrative: "Test session", + phases: [ + { name: "Setup", start_t: 1000, end_t: 3000, tool_types: ["Read"], description: "Reading files" }, + { name: "Build", start_t: 3000, end_t: 7000, tool_types: ["Edit", "Write"], description: "Writing code" }, + ], + key_metrics: { + duration_human: "6s", + tool_calls: 5, + failures: 0, + files_modified: 2, + backtrack_count: 0, + }, + }, + }); + const result = buildConversation(distilled, []); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ type: "phase_boundary", t: 1000, phase_name: "Setup", phase_index: 0 }); + expect(result[1]).toMatchObject({ type: "phase_boundary", t: 3000, phase_name: "Build", phase_index: 1 }); + }); + + test("handles missing summary gracefully", () => { + const distilled = makeDistilled({ summary: undefined }); + const result = buildConversation(distilled, []); + expect(result).toEqual([]); + }); + + test("sorts all entry types by t", () => { + const distilled = makeDistilled({ + user_messages: [ + { t: 1000, content: "Start", is_tool_result: false, message_type: "prompt" }, + { t: 5000, content: "Next step", is_tool_result: false, message_type: "prompt" }, + ], + reasoning: [ + { t: 2000, thinking: "Planning...", intent_hint: "planning" }, + ], + backtracks: [ + { + type: "iteration_struggle", + tool_name: "Edit", + attempts: 2, + start_t: 4000, + end_t: 4500, + tool_use_ids: ["tu-x"], + }, + ], + summary: { + narrative: "test", + phases: [ + { name: "Init", start_t: 500, end_t: 1000, tool_types: [], description: "init" }, + ], + key_metrics: { + duration_human: "5s", + tool_calls: 2, + failures: 0, + files_modified: 1, + backtrack_count: 1, + }, + }, + }); + + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 3000, + event: "PreToolUse", + data: { tool_name: "Read", tool_use_id: "tu-r", tool_input: {} }, + }), + makeEvent({ + t: 3500, + event: "PostToolUse", + data: { tool_name: "Read", tool_use_id: "tu-r" }, + }), + ]; + + const result = buildConversation(distilled, events); + + // Verify sorted by t + const timestamps = result.map((e) => e.t); + const sorted = [...timestamps].sort((a, b) => a - b); + expect(timestamps).toEqual(sorted); + + // Verify all types present + const types = new Set(result.map((e) => e.type)); + expect(types.has("phase_boundary")).toBe(true); + expect(types.has("user_prompt")).toBe(true); + expect(types.has("thinking")).toBe(true); + expect(types.has("tool_call")).toBe(true); + expect(types.has("tool_result")).toBe(true); + expect(types.has("backtrack")).toBe(true); + }); + + test("skips events missing tool_use_id or tool_name", () => { + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PreToolUse", + data: { tool_name: "Read" }, // missing tool_use_id + }), + makeEvent({ + t: 2000, + event: "PostToolUse", + data: { tool_use_id: "tu-1" }, // missing tool_name + }), + ]; + const result = buildConversation(makeDistilled(), events); + expect(result).toEqual([]); + }); + + test("generates args_preview truncated to ~100 chars", () => { + const longInput = { file_path: "/a".repeat(200) }; + const events: readonly StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PreToolUse", + data: { + tool_name: "Read", + tool_use_id: "tu-1", + tool_input: longInput, + }, + }), + ]; + const result = buildConversation(makeDistilled(), events); + const entry = result[0]; + expect(entry.type).toBe("tool_call"); + if (entry.type === "tool_call") { + expect(entry.args_preview.length).toBeLessThanOrEqual(104); // 100 + "..." + } + }); +}); diff --git a/packages/cli/test/cost-basis.test.ts b/packages/cli/test/cost-basis.test.ts new file mode 100644 index 0000000..39041f9 --- /dev/null +++ b/packages/cli/test/cost-basis.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, test } from "bun:test"; +import { toSummaryRow } from "../src/distill/analytics-summary"; +import { PRICING_VERSION, extractStats, repriceCostEstimate } from "../src/distill/stats"; +import type { CostEstimate, DistilledSession, StoredEvent } from "../src/types"; + +// Cost-truth tiers (specs/analytics-truth-and-brush): the per-session cost is ALWAYS the +// API-equivalent value at full list price, tagged with how it was derived: +// Tier 0 measured — verbatim total_cost_usd/costUSD (>0) captured on an event. +// Tier 1/2 estimated — real token counts × API price table. +// Tier 3 heuristic — event/tool-call magic numbers (no real tokens). +// On-disk coverage of measured cost is ~0% today (transcripts carry only token `usage`), +// so the measured tier is forward-looking: these tests pin the resolution contract. + +const sessionContext = { + project_dir: "/test", + cwd: "/test", + git_branch: null, + git_remote: null, + git_commit: null, + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: "claude-opus-4-8-20260101", + agent_type: null, +} as const; + +const makeEvent = ( + overrides: Partial & { event: StoredEvent["event"] }, +): StoredEvent => ({ + t: Date.now(), + sid: "test", + data: {}, + ...overrides, +}); + +const usageEvent = ( + t: number, + usage: Readonly>, +): StoredEvent => + makeEvent({ + t, + event: "PostToolUse", + data: { tool_name: "Read", tool_use_id: `u${t}`, usage }, + }); + +describe("extractStats — cost_basis resolution tiers", () => { + test("Tier 3: heuristic when a known model has no real tokens", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read", tool_use_id: "t1" } }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const ce = extractStats(events).cost_estimate; + expect(ce?.cost_basis).toBe("heuristic"); + expect(ce?.is_estimated).toBe(true); + expect(ce?.estimated_cost_usd).toBeGreaterThan(0); + }); + + test("Tier 2: estimated when real hook token usage is present", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + usageEvent(2000, { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 200, + cache_creation_tokens: 100, + }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const ce = extractStats(events).cost_estimate; + expect(ce?.cost_basis).toBe("estimated"); + expect(ce?.is_estimated).toBe(false); + expect(ce?.estimated_input_tokens).toBe(1000); + }); + + test("Tier 1: estimated when transcript token usage is supplied", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "SessionEnd", data: {} }), + ]; + const transcriptUsage = { + input_tokens: 4000, + output_tokens: 2000, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + const ce = extractStats(events, [], transcriptUsage).cost_estimate; + expect(ce?.cost_basis).toBe("estimated"); + expect(ce?.is_estimated).toBe(false); + expect(ce?.estimated_input_tokens).toBe(4000); + }); + + test("Tier 0: measured when an event carries total_cost_usd (>0), used verbatim", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + // Real tokens present too — but measured cost takes priority and is used verbatim. + usageEvent(2000, { input_tokens: 1000, output_tokens: 500 }), + makeEvent({ t: 3000, event: "Stop", data: { total_cost_usd: 12.5 } }), + makeEvent({ t: 4000, event: "SessionEnd", data: {} }), + ]; + + const ce = extractStats(events).cost_estimate; + expect(ce?.cost_basis).toBe("measured"); + expect(ce?.is_estimated).toBe(false); + expect(ce?.estimated_cost_usd).toBe(12.5); // verbatim, not rounded away or repriced + }); + + test("Tier 0: measured also reads the camelCase costUSD field", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "Stop", data: { costUSD: 3.33 } }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const ce = extractStats(events).cost_estimate; + expect(ce?.cost_basis).toBe("measured"); + expect(ce?.estimated_cost_usd).toBe(3.33); + }); + + test("a zero/negative measured cost is ignored — falls through to a token/heuristic tier", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "Stop", data: { total_cost_usd: 0 } }), + usageEvent(2500, { input_tokens: 500, output_tokens: 250 }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const ce = extractStats(events).cost_estimate; + expect(ce?.cost_basis).toBe("estimated"); + expect(ce?.estimated_cost_usd).toBeGreaterThan(0); + }); + + test("measured picks the largest captured value (cumulative session total, not a delta)", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "PostToolUse", data: { tool_name: "Read", tool_use_id: "t1", total_cost_usd: 1.0 } }), + makeEvent({ t: 3000, event: "PostToolUse", data: { tool_name: "Edit", tool_use_id: "t2", total_cost_usd: 4.0 } }), + makeEvent({ t: 4000, event: "SessionEnd", data: {} }), + ]; + + const ce = extractStats(events).cost_estimate; + expect(ce?.cost_basis).toBe("measured"); + expect(ce?.estimated_cost_usd).toBe(4.0); + }); +}); + +describe("extractStats — stored cost is full list price (no subscription multiplier)", () => { + const buildTokenEvents = (): StoredEvent[] => [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + usageEvent(2000, { input_tokens: 1_000_000, output_tokens: 0 }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + test("the 'max' tier does NOT change the stored API-equivalent cost", () => { + const apiCost = extractStats(buildTokenEvents(), [], undefined, "api").cost_estimate?.estimated_cost_usd; + const maxCost = extractStats(buildTokenEvents(), [], undefined, "max").cost_estimate?.estimated_cost_usd; + expect(apiCost).toBeGreaterThan(0); + expect(maxCost).toBe(apiCost); + }); + + test("opus-4-8 input @ $5/MTok — 1M input tokens ⇒ $5 at full list price", () => { + const ce = extractStats(buildTokenEvents(), [], undefined, "max").cost_estimate; + expect(ce?.estimated_cost_usd).toBeCloseTo(5, 4); + }); + + test("the resolved tier is still recorded on pricing_tier for staleness detection", () => { + const ce = extractStats(buildTokenEvents(), [], undefined, "max").cost_estimate; + expect(ce?.pricing_tier).toBe("max"); + }); +}); + +const makeCostEstimate = (overrides: Partial = {}): CostEstimate => ({ + model: "claude-opus-4-8-20260101", + estimated_input_tokens: 1000, + estimated_output_tokens: 500, + estimated_cost_usd: 7.5, + is_estimated: false, + cost_basis: "estimated", + ...overrides, +}); + +const makeDistilled = (ce: CostEstimate | undefined): DistilledSession => ({ + session_id: "s1", + start_time: Date.parse("2026-06-15T12:00:00Z"), + stats: { + total_events: 4, + duration_ms: 1000, + events_by_type: {}, + tools_by_name: {}, + tool_call_count: 2, + failure_count: 0, + failure_rate: 0, + unique_files: [], + model: ce?.model, + cost_estimate: ce, + }, + backtracks: [], + decisions: [], + file_map: { files: [] }, + git_diff: { commits: [], hunks: [] }, + reasoning: [], + user_messages: [], + complete: true, +}); + +describe("repriceCostEstimate — read-time re-pricing against the current table", () => { + // Legacy Opus 4.0/4.1 ($15/$75) → current Opus 4.5+ ($5/$25) is exactly 3x on + // every component, so any token mix priced at the legacy table re-prices to 1/3. + test("a frozen distill-time cost re-prices from its own tokens (57.63 → 19.21)", () => { + // 3.842M input tokens × current $5/MTok = $19.21; same tokens × legacy $15 = $57.63. + const frozen = makeCostEstimate({ + model: "claude-opus-4-8-20260101", + estimated_input_tokens: 3_842_000, + estimated_output_tokens: 0, + estimated_cost_usd: 57.63, // frozen at the legacy table + cost_basis: "estimated", + }); + const repriced = repriceCostEstimate(frozen); + expect(repriced.estimated_cost_usd).toBeCloseTo(19.21, 4); + expect(repriced.pricing_version).toBe(PRICING_VERSION); + }); + + test("re-pricing recomputes from tokens, never transforms the frozen number", () => { + // A wrong frozen value must not survive — output is a function of tokens only. + const garbage = makeCostEstimate({ + model: "claude-opus-4-8", + estimated_input_tokens: 1_000_000, + estimated_output_tokens: 0, + estimated_cost_usd: 999.99, + cost_basis: "estimated", + }); + expect(repriceCostEstimate(garbage).estimated_cost_usd).toBeCloseTo(5, 4); + }); + + test("longest-prefix boundary holds: opus-4-5+ at $5, legacy opus-4.x at $15", () => { + const tokens = { estimated_input_tokens: 1_000_000, estimated_output_tokens: 0 }; + const current = repriceCostEstimate( + makeCostEstimate({ model: "claude-opus-4-5-20251101", ...tokens }), + ); + const legacy = repriceCostEstimate( + makeCostEstimate({ model: "claude-opus-4-1-20250805", ...tokens }), + ); + expect(current.estimated_cost_usd).toBeCloseTo(5, 4); + expect(legacy.estimated_cost_usd).toBeCloseTo(15, 4); + }); + + test("cache tokens are re-priced at current cache rates", () => { + const repriced = repriceCostEstimate( + makeCostEstimate({ + model: "claude-opus-4-8", + estimated_input_tokens: 0, + estimated_output_tokens: 0, + cache_read_tokens: 1_000_000, // × $0.5 + cache_creation_tokens: 1_000_000, // × $6.25 + estimated_cost_usd: 0, + }), + ); + expect(repriced.estimated_cost_usd).toBeCloseTo(6.75, 4); + }); + + test("a measured cost is NOT re-priced (returned verbatim)", () => { + const measured = makeCostEstimate({ + cost_basis: "measured", + is_estimated: false, + estimated_cost_usd: 12.5, + }); + const out = repriceCostEstimate(measured); + expect(out.estimated_cost_usd).toBe(12.5); + expect(out.pricing_version).toBeUndefined(); + }); + + test("an unknown model keeps the frozen value but stamps the version", () => { + const unknown = makeCostEstimate({ + model: "some-future-model", + estimated_cost_usd: 42, + cost_basis: "estimated", + }); + const out = repriceCostEstimate(unknown); + expect(out.estimated_cost_usd).toBe(42); + expect(out.pricing_version).toBe(PRICING_VERSION); + }); +}); + +describe("toSummaryRow — cost_basis + measured_cost_usd", () => { + test("carries cost_basis through to the row", () => { + const row = toSummaryRow(makeDistilled(makeCostEstimate({ cost_basis: "estimated" }))); + expect(row.cost_basis).toBe("estimated"); + }); + + test("measured_cost_usd equals cost_usd only when cost_basis === 'measured'", () => { + const measured = toSummaryRow( + makeDistilled(makeCostEstimate({ cost_basis: "measured", estimated_cost_usd: 9.99 })), + ); + expect(measured.cost_basis).toBe("measured"); + expect(measured.cost_usd).toBe(9.99); + expect(measured.measured_cost_usd).toBe(9.99); + }); + + test("measured_cost_usd is 0 for estimated rows", () => { + const row = toSummaryRow(makeDistilled(makeCostEstimate({ cost_basis: "estimated", estimated_cost_usd: 4.2 }))); + expect(row.cost_usd).toBe(4.2); + expect(row.measured_cost_usd).toBe(0); + }); + + test("measured_cost_usd is 0 for heuristic rows", () => { + const row = toSummaryRow( + makeDistilled(makeCostEstimate({ cost_basis: "heuristic", is_estimated: true, estimated_cost_usd: 1.1 })), + ); + expect(row.cost_basis).toBe("heuristic"); + expect(row.measured_cost_usd).toBe(0); + }); + + test("back-compat: untagged estimate with is_estimated=false ⇒ estimated (token-grounded, NOT measured)", () => { + // Rows distilled before the cost-truth work lack cost_basis. `is_estimated=false` + // historically meant token-grounded ("estimated") — the measured tier did not + // exist yet, so an untagged row can never be "measured". Mapping it to measured + // would inflate measured_cost_usd / measured_fraction and under-report the + // "X% estimated" badge; measured_cost_usd must therefore stay 0. + const row = toSummaryRow( + makeDistilled(makeCostEstimate({ cost_basis: undefined, is_estimated: false, estimated_cost_usd: 6.0 })), + ); + expect(row.cost_basis).toBe("estimated"); + expect(row.cost_usd).toBe(6.0); + expect(row.measured_cost_usd).toBe(0); + }); + + test("back-compat: untagged estimate with is_estimated=true ⇒ heuristic", () => { + const row = toSummaryRow( + makeDistilled(makeCostEstimate({ cost_basis: undefined, is_estimated: true, estimated_cost_usd: 2.0 })), + ); + expect(row.cost_basis).toBe("heuristic"); + expect(row.measured_cost_usd).toBe(0); + }); + + test("no cost estimate ⇒ heuristic basis, zero cost", () => { + const row = toSummaryRow(makeDistilled(undefined)); + expect(row.cost_basis).toBe("heuristic"); + expect(row.cost_usd).toBe(0); + expect(row.measured_cost_usd).toBe(0); + }); +}); diff --git a/test/decisions-command.test.ts b/packages/cli/test/decisions-command.test.ts similarity index 100% rename from test/decisions-command.test.ts rename to packages/cli/test/decisions-command.test.ts diff --git a/test/diff-attribution.test.ts b/packages/cli/test/diff-attribution.test.ts similarity index 51% rename from test/diff-attribution.test.ts rename to packages/cli/test/diff-attribution.test.ts index b9a5248..7924a38 100644 --- a/test/diff-attribution.test.ts +++ b/packages/cli/test/diff-attribution.test.ts @@ -541,3 +541,483 @@ describe("buildAgentEditIndex", () => { expect(entries.length).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// Tests: computeEditDiffLines +// --------------------------------------------------------------------------- + +describe("computeEditDiffLines", () => { + const importModule = () => import("../src/distill/diff-attribution"); + + test("edit with no shared lines — all old lines are deletions, all new lines are additions", async () => { + const { computeEditDiffLines } = await importModule(); + + const result = computeEditDiffLines( + { old_string: "const x = 1;\nconst y = 2;", new_string: "const a = 10;\nconst b = 20;" }, + "builder", + ); + + const deletions = result.filter((l) => l.type === "remove"); + const additions = result.filter((l) => l.type === "add"); + expect(deletions).toHaveLength(2); + expect(additions).toHaveLength(2); + expect(deletions.map((d) => d.content)).toContain("const x = 1;"); + expect(deletions.map((d) => d.content)).toContain("const y = 2;"); + expect(additions.map((a) => a.content)).toContain("const a = 10;"); + expect(additions.map((a) => a.content)).toContain("const b = 20;"); + expect(result.every((l) => l.agent_name === "builder")).toBe(true); + }); + + test("edit with some shared lines — only unique lines counted", async () => { + const { computeEditDiffLines } = await importModule(); + + const result = computeEditDiffLines( + { + old_string: "line1\nline2\nline3", + new_string: "line1\nline2_modified\nline3", + }, + "editor", + ); + + const deletions = result.filter((l) => l.type === "remove"); + const additions = result.filter((l) => l.type === "add"); + expect(deletions).toHaveLength(1); + expect(deletions[0].content).toBe("line2"); + expect(additions).toHaveLength(1); + expect(additions[0].content).toBe("line2_modified"); + }); + + test("edit with duplicate lines — multiset correctly counts excess", async () => { + const { computeEditDiffLines } = await importModule(); + + // old_string has "}" 3 times, new_string has "}" 2 times -> 1 deletion + const result = computeEditDiffLines( + { + old_string: "}\n}\n}", + new_string: "}\n}", + }, + "builder", + ); + + const deletions = result.filter((l) => l.type === "remove"); + const additions = result.filter((l) => l.type === "add"); + expect(deletions).toHaveLength(1); + expect(deletions[0].content).toBe("}"); + expect(additions).toHaveLength(0); + }); + + test("edit with empty old_string (pure insertion) — only additions", async () => { + const { computeEditDiffLines } = await importModule(); + + const result = computeEditDiffLines( + { old_string: "", new_string: "const x = 1;\nconst y = 2;" }, + "inserter", + ); + + const deletions = result.filter((l) => l.type === "remove"); + const additions = result.filter((l) => l.type === "add"); + expect(deletions).toHaveLength(0); + expect(additions).toHaveLength(2); + expect(additions.every((l) => l.agent_name === "inserter")).toBe(true); + }); + + test("edit with empty new_string (pure deletion) — only deletions", async () => { + const { computeEditDiffLines } = await importModule(); + + const result = computeEditDiffLines( + { old_string: "const x = 1;\nconst y = 2;", new_string: "" }, + "remover", + ); + + const deletions = result.filter((l) => l.type === "remove"); + const additions = result.filter((l) => l.type === "add"); + expect(deletions).toHaveLength(2); + expect(additions).toHaveLength(0); + expect(deletions.every((l) => l.agent_name === "remover")).toBe(true); + }); + + test("edit where old_string === new_string — no diff lines", async () => { + const { computeEditDiffLines } = await importModule(); + + const result = computeEditDiffLines( + { old_string: "const x = 1;\nconst y = 2;", new_string: "const x = 1;\nconst y = 2;" }, + "noop", + ); + + expect(result).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: computeWriteDiffLines +// --------------------------------------------------------------------------- + +describe("computeWriteDiffLines", () => { + const importModule = () => import("../src/distill/diff-attribution"); + + test("write with content — all non-empty lines are additions", async () => { + const { computeWriteDiffLines } = await importModule(); + + const result = computeWriteDiffLines( + { content: "line1\n\nline3\nline4" }, + "writer", + ); + + // Empty lines are filtered out + expect(result).toHaveLength(3); + expect(result.every((l) => l.type === "add")).toBe(true); + expect(result.every((l) => l.agent_name === "writer")).toBe(true); + expect(result.map((l) => l.content)).toEqual(["line1", "line3", "line4"]); + }); + + test("write with empty content — no diff lines", async () => { + const { computeWriteDiffLines } = await importModule(); + + const result = computeWriteDiffLines({ content: "" }, "writer"); + expect(result).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: computeToolSourcedDiff +// --------------------------------------------------------------------------- + +describe("computeToolSourcedDiff", () => { + const importModule = () => import("../src/distill/diff-attribution"); + + test("single file, one Edit — correct attribution and stats", async () => { + const { computeToolSourcedDiff } = await importModule(); + + const events: readonly StoredEvent[] = [ + makePreToolUseEvent({ + t: 2000, + tool_name: "Edit", + tool_use_id: "tu-1", + tool_input: { + file_path: "/project/src/foo.ts", + old_string: "const x = 1;", + new_string: "const x = 2;\nconst y = 3;", + }, + }), + makePostToolUseEvent({ t: 2500, tool_use_id: "tu-1" }), + ]; + + const editChains = makeEditChainsResult({ + chains: [ + makeEditChain({ + file_path: "/project/src/foo.ts", + agent_name: "builder", + steps: [makeEditStep({ tool_use_id: "tu-1", tool_name: "Edit" })], + }), + ], + }); + + const result = computeToolSourcedDiff(events, editChains, "/project"); + + expect(result).toHaveLength(1); + expect(result[0].file_path).toBe("src/foo.ts"); + expect(result[0].total_deletions).toBe(1); + expect(result[0].total_additions).toBe(2); + expect(result[0].lines.every((l) => l.agent_name === "builder")).toBe(true); + }); + + test("single file, multiple Edits — accumulated correctly", async () => { + const { computeToolSourcedDiff } = await importModule(); + + const events: readonly StoredEvent[] = [ + makePreToolUseEvent({ + t: 2000, + tool_name: "Edit", + tool_use_id: "tu-1", + tool_input: { old_string: "const x = 1;", new_string: "const x = 2;" }, + }), + makePostToolUseEvent({ t: 2500, tool_use_id: "tu-1" }), + makePreToolUseEvent({ + t: 3000, + tool_name: "Edit", + tool_use_id: "tu-2", + tool_input: { old_string: "const y = 1;", new_string: "const y = 2;" }, + }), + makePostToolUseEvent({ t: 3500, tool_use_id: "tu-2" }), + ]; + + const editChains = makeEditChainsResult({ + chains: [ + makeEditChain({ + file_path: "/project/src/foo.ts", + agent_name: "builder", + steps: [ + makeEditStep({ tool_use_id: "tu-1", t: 2000, tool_name: "Edit" }), + makeEditStep({ tool_use_id: "tu-2", t: 3000, tool_name: "Edit" }), + ], + }), + ], + }); + + const result = computeToolSourcedDiff(events, editChains, "/project"); + + expect(result).toHaveLength(1); + expect(result[0].total_additions).toBe(2); + expect(result[0].total_deletions).toBe(2); + }); + + test("multiple files — separate FileDiffAttribution entries", async () => { + const { computeToolSourcedDiff } = await importModule(); + + const events: readonly StoredEvent[] = [ + makePreToolUseEvent({ + t: 2000, + tool_name: "Edit", + tool_use_id: "tu-1", + tool_input: { old_string: "a", new_string: "b" }, + }), + makePreToolUseEvent({ + t: 3000, + tool_name: "Write", + tool_use_id: "tu-2", + tool_input: { content: "new file content\nline 2" }, + }), + ]; + + const editChains = makeEditChainsResult({ + chains: [ + makeEditChain({ + file_path: "/project/src/foo.ts", + agent_name: "builder", + steps: [makeEditStep({ tool_use_id: "tu-1", tool_name: "Edit" })], + }), + makeEditChain({ + file_path: "/project/src/bar.ts", + agent_name: "builder", + steps: [makeEditStep({ tool_use_id: "tu-2", tool_name: "Write" })], + }), + ], + }); + + const result = computeToolSourcedDiff(events, editChains, "/project"); + + expect(result).toHaveLength(2); + const paths = result.map((r) => r.file_path); + expect(paths).toContain("src/foo.ts"); + expect(paths).toContain("src/bar.ts"); + }); + + test("failed tool calls excluded (PostToolUseFailure)", async () => { + const { computeToolSourcedDiff } = await importModule(); + + const events: readonly StoredEvent[] = [ + makePreToolUseEvent({ + t: 2000, + tool_name: "Edit", + tool_use_id: "tu-fail", + tool_input: { old_string: "old", new_string: "new" }, + }), + { + t: 2500, + event: "PostToolUseFailure", + sid: "test-session", + data: { tool_name: "Edit", tool_use_id: "tu-fail", error: "old_string not found" }, + }, + ]; + + const editChains = makeEditChainsResult({ + chains: [ + makeEditChain({ + file_path: "/project/src/foo.ts", + steps: [makeEditStep({ tool_use_id: "tu-fail", tool_name: "Edit", outcome: "failure" })], + }), + ], + }); + + const result = computeToolSourcedDiff(events, editChains, "/project"); + + expect(result).toHaveLength(0); + }); + + test("agent name propagated from chain", async () => { + const { computeToolSourcedDiff } = await importModule(); + + const events: readonly StoredEvent[] = [ + makePreToolUseEvent({ + t: 2000, + tool_name: "Edit", + tool_use_id: "tu-1", + tool_input: { old_string: "old", new_string: "new" }, + }), + ]; + + const editChains = makeEditChainsResult({ + chains: [ + makeEditChain({ + file_path: "/project/src/foo.ts", + agent_name: "custom-agent", + steps: [makeEditStep({ tool_use_id: "tu-1", tool_name: "Edit" })], + }), + ], + }); + + const result = computeToolSourcedDiff(events, editChains, "/project"); + + expect(result).toHaveLength(1); + expect(result[0].lines.every((l) => l.agent_name === "custom-agent")).toBe(true); + }); + + test("empty edit chains — empty result", async () => { + const { computeToolSourcedDiff } = await importModule(); + + const events: readonly StoredEvent[] = [ + makeSessionStartEvent("abc123"), + ]; + + const editChains = makeEditChainsResult({ chains: [] }); + + const result = computeToolSourcedDiff(events, editChains, "/project"); + + expect(result).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: toBag and bagDiff +// --------------------------------------------------------------------------- + +describe("toBag", () => { + const importModule = () => import("../src/distill/diff-attribution"); + + test("counts occurrences of each line", async () => { + const { toBag } = await importModule(); + + const bag = toBag(["a", "b", "a", "c", "a"]); + expect(bag.get("a")).toBe(3); + expect(bag.get("b")).toBe(1); + expect(bag.get("c")).toBe(1); + }); + + test("empty input produces empty map", async () => { + const { toBag } = await importModule(); + + const bag = toBag([]); + expect(bag.size).toBe(0); + }); +}); + +describe("bagDiff", () => { + const importModule = () => import("../src/distill/diff-attribution"); + + test("returns lines that exceed their count in the other bag", async () => { + const { toBag, bagDiff } = await importModule(); + + const a = toBag(["x", "x", "x", "y"]); + const b = toBag(["x", "y"]); + + const diff = bagDiff(a, b); + expect(diff).toHaveLength(2); + expect(diff.every((l) => l === "x")).toBe(true); + }); + + test("returns empty when b contains all of a", async () => { + const { toBag, bagDiff } = await importModule(); + + const a = toBag(["x", "y"]); + const b = toBag(["x", "y", "z"]); + + const diff = bagDiff(a, b); + expect(diff).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: getStartCommit InstructionsLoaded fallback +// --------------------------------------------------------------------------- + +describe("getStartCommit InstructionsLoaded fallback", () => { + const importModule = () => import("../src/distill/diff-attribution"); + + test("falls back to InstructionsLoaded when no SessionStart present", async () => { + const { getStartCommit } = await importModule(); + + const events: readonly StoredEvent[] = [ + makePreToolUseEvent({ t: 1000, tool_name: "Edit" }), + { + t: 500, + event: "InstructionsLoaded", + sid: "agent-session", + context: { + project_dir: "/project", + cwd: "/project", + git_branch: "main", + git_remote: null, + git_commit: "instructions-commit-hash", + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: null, + agent_type: null, + }, + data: { + file_path: "/project/CLAUDE.md", + memory_type: "Project", + load_reason: "session_start", + }, + }, + ]; + + const result = getStartCommit(events); + expect(result).toBe("instructions-commit-hash"); + }); + + test("prefers SessionStart over InstructionsLoaded", async () => { + const { getStartCommit } = await importModule(); + + const events: readonly StoredEvent[] = [ + makeSessionStartEvent("session-start-commit"), + { + t: 500, + event: "InstructionsLoaded", + sid: "agent-session", + context: { + project_dir: "/project", + cwd: "/project", + git_branch: "main", + git_remote: null, + git_commit: "instructions-commit-hash", + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: null, + agent_type: null, + }, + data: { + file_path: "/project/CLAUDE.md", + memory_type: "Project", + load_reason: "session_start", + }, + }, + ]; + + const result = getStartCommit(events); + expect(result).toBe("session-start-commit"); + }); + + test("returns undefined when neither SessionStart nor InstructionsLoaded have git_commit", async () => { + const { getStartCommit } = await importModule(); + + const events: readonly StoredEvent[] = [ + { + t: 500, + event: "InstructionsLoaded", + sid: "agent-session", + data: { + file_path: "/project/CLAUDE.md", + memory_type: "Project", + load_reason: "nested_traversal", + }, + }, + ]; + + const result = getStartCommit(events); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/diff-lines-unified.test.ts b/packages/cli/test/diff-lines-unified.test.ts new file mode 100644 index 0000000..229de3e --- /dev/null +++ b/packages/cli/test/diff-lines-unified.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { diffLinesToUnified } from "../src/utils"; +import type { DiffLine } from "../src/types"; + +describe("diffLinesToUnified", () => { + test("returns empty string for empty lines", () => { + expect(diffLinesToUnified("src/foo.ts", [])).toBe(""); + }); + + test("generates correct --- and +++ headers", () => { + const lines: readonly DiffLine[] = [ + { type: "add", content: "hello" }, + ]; + const result = diffLinesToUnified("src/foo.ts", lines); + expect(result).toContain("--- a/src/foo.ts"); + expect(result).toContain("+++ b/src/foo.ts"); + }); + + test("generates correct @@ hunk header for adds only", () => { + const lines: readonly DiffLine[] = [ + { type: "add", content: "line1" }, + { type: "add", content: "line2" }, + ]; + const result = diffLinesToUnified("file.ts", lines); + // 0 old lines (no removes or context), 2 new lines + expect(result).toContain("@@ -1,0 +1,2 @@"); + }); + + test("generates correct @@ hunk header for removes only", () => { + const lines: readonly DiffLine[] = [ + { type: "remove", content: "old1" }, + { type: "remove", content: "old2" }, + { type: "remove", content: "old3" }, + ]; + const result = diffLinesToUnified("file.ts", lines); + // 3 old lines, 0 new lines + expect(result).toContain("@@ -1,3 +1,0 @@"); + }); + + test("generates correct @@ hunk header for mixed changes", () => { + const lines: readonly DiffLine[] = [ + { type: "context", content: "unchanged" }, + { type: "remove", content: "old" }, + { type: "add", content: "new" }, + { type: "context", content: "also unchanged" }, + ]; + const result = diffLinesToUnified("file.ts", lines); + // old: 2 context + 1 remove = 3, new: 2 context + 1 add = 3 + expect(result).toContain("@@ -1,3 +1,3 @@"); + }); + + test("prefixes add lines with +", () => { + const lines: readonly DiffLine[] = [ + { type: "add", content: "new line" }, + ]; + const result = diffLinesToUnified("f.ts", lines); + expect(result).toContain("+new line"); + }); + + test("prefixes remove lines with -", () => { + const lines: readonly DiffLine[] = [ + { type: "remove", content: "old line" }, + ]; + const result = diffLinesToUnified("f.ts", lines); + expect(result).toContain("-old line"); + }); + + test("prefixes context lines with space", () => { + const lines: readonly DiffLine[] = [ + { type: "context", content: "same line" }, + ]; + const result = diffLinesToUnified("f.ts", lines); + expect(result).toContain(" same line"); + }); + + test("full unified diff output for mixed changes", () => { + const lines: readonly DiffLine[] = [ + { type: "context", content: "const a = 1;" }, + { type: "remove", content: "const b = 2;" }, + { type: "add", content: "const b = 3;" }, + { type: "context", content: "const c = 4;" }, + ]; + const result = diffLinesToUnified("src/index.ts", lines); + const expected = [ + "--- a/src/index.ts", + "+++ b/src/index.ts", + "@@ -1,3 +1,3 @@", + " const a = 1;", + "-const b = 2;", + "+const b = 3;", + " const c = 4;", + ].join("\n"); + expect(result).toBe(expected); + }); + + test("all additions", () => { + const lines: readonly DiffLine[] = [ + { type: "add", content: "line 1" }, + { type: "add", content: "line 2" }, + { type: "add", content: "line 3" }, + ]; + const result = diffLinesToUnified("new-file.ts", lines); + const expected = [ + "--- a/new-file.ts", + "+++ b/new-file.ts", + "@@ -1,0 +1,3 @@", + "+line 1", + "+line 2", + "+line 3", + ].join("\n"); + expect(result).toBe(expected); + }); + + test("all deletions", () => { + const lines: readonly DiffLine[] = [ + { type: "remove", content: "gone 1" }, + { type: "remove", content: "gone 2" }, + ]; + const result = diffLinesToUnified("deleted.ts", lines); + const expected = [ + "--- a/deleted.ts", + "+++ b/deleted.ts", + "@@ -1,2 +1,0 @@", + "-gone 1", + "-gone 2", + ].join("\n"); + expect(result).toBe(expected); + }); +}); diff --git a/test/distill-active-duration.test.ts b/packages/cli/test/distill-active-duration.test.ts similarity index 100% rename from test/distill-active-duration.test.ts rename to packages/cli/test/distill-active-duration.test.ts diff --git a/test/distill-agent-enrich.test.ts b/packages/cli/test/distill-agent-enrich.test.ts similarity index 100% rename from test/distill-agent-enrich.test.ts rename to packages/cli/test/distill-agent-enrich.test.ts diff --git a/test/distill-agent-tree.test.ts b/packages/cli/test/distill-agent-tree.test.ts similarity index 55% rename from test/distill-agent-tree.test.ts rename to packages/cli/test/distill-agent-tree.test.ts index c78c894..c7a196d 100644 --- a/test/distill-agent-tree.test.ts +++ b/packages/cli/test/distill-agent-tree.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { attributeEventsToAgents, buildAgentTree, computeLinkBasedDuration, enrichNodeWithTranscript } from "../src/distill/agent-tree"; -import type { AgentNode, LinkEvent, SpawnLink, StopLink, StoredEvent, TranscriptEntry } from "../src/types"; +import type { DiffContext } from "../src/distill/agent-distill"; +import { attributeEventsToAgents, buildAgentTree, computeLinkBasedDuration, enrichNodeFromSessionEvents, enrichNodeWithTranscript, inferAgentsFromComms } from "../src/distill/agent-tree"; +import type { AgentNode, LinkEvent, SessionStartContext, SpawnLink, StopLink, StoredEvent, TranscriptEntry } from "../src/types"; // -- Helpers -- @@ -587,4 +588,527 @@ describe("buildAgentTree", () => { // Then the outer logic checks: enriched.tool_call_count === 0? No (it's 1), so uses enriched as-is expect(result[0].tool_call_count).toBe(1); }); + + // Regression: agent-tree-ignores-event-agent-id-ghost-zeroing. + // Raw hook events carry data.agent_id; the per-event tag must be the source of + // truth for tool-call attribution, and a tagged agent must NOT be ghost-zeroed. + test("attributes tool calls by data.agent_id and does not ghost-zero a tagged agent", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ t: 1000, agent_id: "agent-1", parent_session: "root-session" }), + makeStop({ t: 5000, agent_id: "agent-1", parent_session: "root-session" }), + ]; + const events = [ + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read", agent_id: "agent-1" } }), + makeEvent({ t: 3000, event: "PreToolUse", data: { tool_name: "Edit", agent_id: "agent-1" } }), + makeEvent({ t: 3500, event: "PreToolUse", data: { tool_name: "Bash", agent_id: "agent-1" } }), + ]; + + // No transcript path → enrichment unavailable; pre-fix this agent would be ghost-zeroed to 0. + const result = buildAgentTree("root-session", links, events, noopReadTranscript); + expect(result[0].tool_call_count).toBe(3); + }); + + test("data.agent_id tags override the spawn→stop time window (tags outside window still count)", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ t: 1000, agent_id: "agent-1", parent_session: "root-session" }), + // Naive window stops at t=2000, but the agent's tagged calls happen later. + makeStop({ t: 2000, agent_id: "agent-1", parent_session: "root-session" }), + ]; + const events = [ + makeEvent({ t: 4000, event: "PreToolUse", data: { tool_name: "Read", agent_id: "agent-1" } }), + makeEvent({ t: 6000, event: "PreToolUse", data: { tool_name: "Edit", agent_id: "agent-1" } }), + ]; + + const result = buildAgentTree("root-session", links, events, noopReadTranscript); + // Tagged attribution finds both, even though both fall after the naive stop time. + expect(result[0].tool_call_count).toBe(2); + }); + + test("only counts the agent's own tagged events, not sibling agents' tagged events", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ t: 1000, agent_id: "agent-1", parent_session: "root-session", agent_name: "builder-1" }), + makeSpawn({ t: 1000, agent_id: "agent-2", parent_session: "root-session", agent_name: "builder-2" }), + makeStop({ t: 9000, agent_id: "agent-1", parent_session: "root-session" }), + makeStop({ t: 9000, agent_id: "agent-2", parent_session: "root-session" }), + ]; + // Overlapping intervals: pure time-window attribution would count all 5 for each agent. + const events = [ + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read", agent_id: "agent-1" } }), + makeEvent({ t: 2500, event: "PreToolUse", data: { tool_name: "Edit", agent_id: "agent-1" } }), + makeEvent({ t: 3000, event: "PreToolUse", data: { tool_name: "Bash", agent_id: "agent-2" } }), + makeEvent({ t: 3500, event: "PreToolUse", data: { tool_name: "Grep", agent_id: "agent-2" } }), + makeEvent({ t: 4000, event: "PreToolUse", data: { tool_name: "Glob", agent_id: "agent-2" } }), + ]; + + const result = buildAgentTree("root-session", links, events, noopReadTranscript); + const agent1 = result.find((n) => n.session_id === "agent-1"); + const agent2 = result.find((n) => n.session_id === "agent-2"); + expect(agent1?.tool_call_count).toBe(2); + expect(agent2?.tool_call_count).toBe(3); + }); + + test("falls back to time window for fully untagged sessions (legacy behavior preserved)", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ t: 1000, agent_id: "agent-1", parent_session: "root-session" }), + makeStop({ t: 5000, agent_id: "agent-1", parent_session: "root-session" }), + ]; + // No agent_id tags → still ghost-zeroed without enrichment (unchanged behavior). + const events = [ + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read" } }), + makeEvent({ t: 3000, event: "PreToolUse", data: { tool_name: "Edit" } }), + ]; + + const result = buildAgentTree("root-session", links, events, noopReadTranscript); + expect(result[0].tool_call_count).toBe(0); // ghost agent: untagged + no enrichment + }); +}); + +// -- inferAgentsFromComms -- + +describe("inferAgentsFromComms", () => { + test("returns empty array when no msg_send links exist", () => { + const links: readonly LinkEvent[] = [ + makeSpawn(), + makeStop(), + ]; + const result = inferAgentsFromComms("root-session", links); + expect(result).toEqual([]); + }); + + test("infers agents from msg_send recipients", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + { + t: 2000, + type: "msg_send", + msg_id: "m2", + session_id: "root-session", + from: "root-session", + to: "builder-api", + msg_type: "text", + }, + ]; + const result = inferAgentsFromComms("root-session", links); + expect(result).toHaveLength(2); + expect(result[0].agent_name).toBe("builder-web"); + expect(result[1].agent_name).toBe("builder-api"); + }); + + test("resolves session_id from task links when no teamMemberSessions provided", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + { + t: 1500, + type: "task", + action: "create", + task_id: "t1", + session_id: "uuid-for-web", + owner: "builder-web", + subject: "build web", + }, + ]; + const result = inferAgentsFromComms("root-session", links); + expect(result).toHaveLength(1); + expect(result[0].session_id).toBe("uuid-for-web"); + expect(result[0].agent_name).toBe("builder-web"); + }); + + test("uses teamMemberSessions for session_id resolution with highest priority", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + { + t: 1500, + type: "task", + action: "create", + task_id: "t1", + session_id: "uuid-from-task", + owner: "builder-web", + subject: "build web", + }, + ]; + const teamMemberSessions = new Map([ + ["builder-web", "real-session-id-abc123"], + ]); + const result = inferAgentsFromComms("root-session", links, teamMemberSessions); + expect(result).toHaveLength(1); + // teamMemberSessions should take priority over task-link UUID + expect(result[0].session_id).toBe("real-session-id-abc123"); + expect(result[0].agent_name).toBe("builder-web"); + }); + + test("falls back to task-link UUID when agent not in teamMemberSessions", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + { + t: 1500, + type: "task", + action: "create", + task_id: "t1", + session_id: "uuid-from-task", + owner: "builder-web", + subject: "build web", + }, + ]; + const teamMemberSessions = new Map([ + ["builder-api", "session-for-api"], + ]); + const result = inferAgentsFromComms("root-session", links, teamMemberSessions); + expect(result).toHaveLength(1); + // builder-web not in teamMemberSessions, falls back to task-link UUID + expect(result[0].session_id).toBe("uuid-from-task"); + }); + + test("falls back to agent name when no session_id sources available", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + ]; + const result = inferAgentsFromComms("root-session", links, new Map()); + expect(result).toHaveLength(1); + expect(result[0].session_id).toBe("builder-web"); + }); + + test("computes duration from activity timestamps", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + { + t: 5000, + type: "task_complete", + task_id: "t1", + agent: "builder-web", + }, + ]; + const result = inferAgentsFromComms("root-session", links); + expect(result[0].duration_ms).toBe(4000); // 5000 - 1000 + }); + + test("deduplicates recipient names", () => { + const links: readonly LinkEvent[] = [ + { + t: 1000, + type: "msg_send", + msg_id: "m1", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + { + t: 2000, + type: "msg_send", + msg_id: "m2", + session_id: "root-session", + from: "root-session", + to: "builder-web", + msg_type: "text", + }, + ]; + const result = inferAgentsFromComms("root-session", links); + expect(result).toHaveLength(1); + }); +}); + +// -- enrichNodeFromSessionEvents -- + +describe("enrichNodeFromSessionEvents", () => { + test("returns unchanged node when events are empty", () => { + const baseNode: AgentNode = { + session_id: "agent-1", + agent_type: "builder", + agent_name: "builder-1", + duration_ms: 5000, + tool_call_count: 0, + children: [], + }; + const result = enrichNodeFromSessionEvents(baseNode, []); + expect(result).toEqual(baseNode); + }); + + test("enriches node with stats from session events", () => { + const baseNode: AgentNode = { + session_id: "agent-1", + agent_type: "builder", + agent_name: "builder-1", + duration_ms: 5000, + tool_call_count: 0, + children: [], + }; + const events: readonly StoredEvent[] = [ + makeStoredEvent({ + t: 1000, + event: "PreToolUse", + data: { tool_name: "Read", file_path: "/src/foo.ts" }, + }), + makeStoredEvent({ + t: 2000, + event: "PostToolUse", + data: { tool_name: "Read", file_path: "/src/foo.ts" }, + }), + makeStoredEvent({ + t: 3000, + event: "PreToolUse", + data: { tool_name: "Edit", file_path: "/src/bar.ts" }, + }), + ]; + const result = enrichNodeFromSessionEvents(baseNode, events); + expect(result.tool_call_count).toBeGreaterThan(0); + expect(result.stats).toBeDefined(); + }); + + test("returns unchanged node when events produce no stats", () => { + const baseNode: AgentNode = { + session_id: "agent-1", + agent_type: "builder", + agent_name: "builder-1", + duration_ms: 5000, + tool_call_count: 0, + children: [], + }; + // SessionStart alone won't produce tool_call_count > 0 or file_map + const events: readonly StoredEvent[] = [ + makeStoredEvent({ + t: 1000, + event: "SessionStart", + data: {}, + }), + ]; + const result = enrichNodeFromSessionEvents(baseNode, events); + expect(result.tool_call_count).toBe(0); + }); + + // Regression: agent-fallback-costs-ignore-pricing-tier. + // The fallback cost path must honor the resolved tier, not silently default to "api". + test("fallback cost is full-list (multiplier retired) but still records the resolved tier", () => { + const baseNode: AgentNode = { + session_id: "agent-1", + agent_type: "builder", + agent_name: "builder-1", + duration_ms: 5000, + tool_call_count: 0, + children: [], + }; + const events: readonly StoredEvent[] = [ + makeStoredEvent({ + t: 1000, + event: "SessionStart", + data: {}, + context: { ...makeSessionStartContext(), model: "claude-opus-4-8" }, + }), + makeStoredEvent({ + t: 2000, + event: "PreToolUse", + data: { + tool_name: "Read", + file_path: "/src/foo.ts", + usage: { input_tokens: 100000, output_tokens: 20000 }, + }, + }), + ]; + + const apiNode = enrichNodeFromSessionEvents(baseNode, events, "api"); + const maxNode = enrichNodeFromSessionEvents(baseNode, events, "max"); + + const apiCost = apiNode.cost_estimate?.estimated_cost_usd ?? 0; + const maxCost = maxNode.cost_estimate?.estimated_cost_usd ?? 0; + expect(apiCost).toBeGreaterThan(0); + expect(maxCost).toBeGreaterThan(0); + // Cost-truth contract: the stored cost is ALWAYS the API-equivalent value at full + // list price — the SUBSCRIPTION_MULTIPLIER is retired from the stored value, so the + // "max" tier no longer scales it. The resolved tier is still recorded for staleness. + expect(maxCost).toBe(apiCost); + expect(maxNode.cost_estimate?.pricing_tier).toBe("max"); + expect(apiNode.cost_estimate?.pricing_tier).toBe("api"); + }); +}); + +// -- Diff context helpers -- + +const makeSessionStartContext = (): SessionStartContext => ({ + project_dir: "/tmp/fake-project", + cwd: "/tmp/fake-project", + git_branch: "main", + git_remote: null, + git_commit: "abc123def", + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: null, + agent_type: null, +}); + +const makeParentSessionStartEvent = (): StoredEvent => ({ + t: 1000, + event: "SessionStart", + sid: "root-session", + data: {}, + context: makeSessionStartContext(), +}); + +const makeDiffContext = (): DiffContext => ({ + projectDir: "/tmp/fake-project", + parentEvents: [makeParentSessionStartEvent()], +}); + +const makeEditTranscriptReader = (): ((path: string) => readonly TranscriptEntry[]) => + (_path: string): readonly TranscriptEntry[] => [ + { + uuid: "uuid-u1", + parentUuid: null, + sessionId: "agent-1", + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", + message: { role: "user", content: "Fix the bug" }, + }, + { + uuid: "uuid-1", + parentUuid: null, + sessionId: "agent-1", + type: "assistant", + timestamp: "2024-01-01T00:00:01.000Z", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "t1", + name: "Edit", + input: { file_path: "/src/foo.ts", old_string: "const a = 1", new_string: "const a = 2" }, + }, + ], + model: "claude-sonnet-4-20250514", + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }, + ]; + +// -- enrichNodeWithTranscript + diffContext -- + +describe("enrichNodeWithTranscript with diffContext", () => { + test("passes diffContext to distillAgent and produces edit_chains", () => { + const baseNode: AgentNode = { + session_id: "agent-1", + agent_type: "builder", + duration_ms: 5000, + tool_call_count: 0, + children: [], + }; + + const result = enrichNodeWithTranscript( + baseNode, + "/path/to/transcript.jsonl", + makeEditTranscriptReader(), + makeDiffContext(), + ); + expect(result.transcript_path).toBe("/path/to/transcript.jsonl"); + expect(result.edit_chains).toBeDefined(); + expect(result.edit_chains?.chains.length).toBeGreaterThan(0); + }); + + test("diff_attribution computed from tool events even without diffContext", () => { + const baseNode: AgentNode = { + session_id: "agent-1", + agent_type: "builder", + duration_ms: 5000, + tool_call_count: 0, + children: [], + }; + + const result = enrichNodeWithTranscript( + baseNode, + "/path/to/transcript.jsonl", + makeEditTranscriptReader(), + ); + expect(result.edit_chains).toBeDefined(); + // Tool-sourced diff attribution is git-independent + expect(result.edit_chains?.diff_attribution).toBeDefined(); + }); +}); + +// -- buildAgentTree + diffContext -- + +describe("buildAgentTree with diffContext", () => { + test("passes diffContext through to enriched nodes with edit_chains", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ t: 1000, agent_id: "agent-1", parent_session: "root-session" }), + makeStop({ t: 5000, agent_id: "agent-1", parent_session: "root-session", transcript_path: "/tmp/t.jsonl" }), + ]; + + const result = buildAgentTree( + "root-session", + links, + [], + makeEditTranscriptReader(), + undefined, + makeDiffContext(), + ); + expect(result).toHaveLength(1); + expect(result[0].edit_chains).toBeDefined(); + expect(result[0].edit_chains?.chains.length).toBeGreaterThan(0); + }); + + test("diff_attribution computed from tool events without diffContext", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ t: 1000, agent_id: "agent-1", parent_session: "root-session" }), + makeStop({ t: 5000, agent_id: "agent-1", parent_session: "root-session", transcript_path: "/tmp/t.jsonl" }), + ]; + + const result = buildAgentTree( + "root-session", + links, + [], + makeEditTranscriptReader(), + ); + expect(result).toHaveLength(1); + expect(result[0].edit_chains).toBeDefined(); + // Tool-sourced diff attribution is git-independent + expect(result[0].edit_chains?.diff_attribution).toBeDefined(); + }); }); diff --git a/test/distill-agent.test.ts b/packages/cli/test/distill-agent.test.ts similarity index 88% rename from test/distill-agent.test.ts rename to packages/cli/test/distill-agent.test.ts index 050c675..886d0d0 100644 --- a/test/distill-agent.test.ts +++ b/packages/cli/test/distill-agent.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + type DiffContext, distillAgent, extractAgentModel, extractTaskPrompt, @@ -8,7 +9,7 @@ import { } from "../src/distill/agent-distill"; import { extractFileMap } from "../src/distill/file-map"; import { readTranscript } from "../src/session/transcript"; -import type { TranscriptEntry } from "../src/types"; +import type { StoredEvent, TranscriptEntry } from "../src/types"; const makeAssistantEntry = (overrides: Partial = {}): TranscriptEntry => ({ uuid: "uuid-1", @@ -903,3 +904,101 @@ describe("extractTaskPrompt", () => { expect(extractTaskPrompt(entries)).toBeUndefined(); }); }); + +describe("distillAgent with diffContext", () => { + const makeEditEntries = (): readonly TranscriptEntry[] => [ + makeUserEntry({ + message: { role: "user", content: "Fix the bug in foo.ts" }, + }), + makeAssistantEntry({ + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "t1", + name: "Edit", + input: { file_path: "/src/foo.ts", old_string: "const a = 1", new_string: "const a = 2" }, + }, + ], + model: "claude-sonnet-4-20250514", + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }), + ]; + + const makeParentSessionStart = (): StoredEvent => ({ + t: 1000, + event: "SessionStart", + sid: "root-session", + data: {}, + context: { + project_dir: "/tmp/fake-project", + cwd: "/tmp/fake-project", + git_branch: "main", + git_remote: null, + git_commit: "abc123def", + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: null, + agent_type: null, + }, + }); + + test("returns edit_chains when diffContext is provided (does not crash)", () => { + const entries = makeEditEntries(); + const diffContext: DiffContext = { + projectDir: "/tmp/fake-project", + parentEvents: [makeParentSessionStart()], + }; + + const result = distillAgent(entries, diffContext); + expect(result).toBeDefined(); + // edit_chains should exist because we have an Edit tool_use + expect(result?.edit_chains).toBeDefined(); + expect(result?.edit_chains?.chains.length).toBeGreaterThan(0); + }); + + test("diff_attribution is computed from tool events even without diffContext", () => { + const entries = makeEditEntries(); + + const result = distillAgent(entries); + expect(result).toBeDefined(); + expect(result?.edit_chains).toBeDefined(); + // Tool-sourced diff attribution works without diffContext (uses events, not git) + expect(result?.edit_chains?.diff_attribution).toBeDefined(); + expect(result?.edit_chains?.diff_attribution?.length).toBeGreaterThan(0); + }); + + test("diff_attribution is computed from tool events without git repo", () => { + const entries = makeEditEntries(); + const diffContext: DiffContext = { + projectDir: "/tmp/nonexistent-project-dir", + parentEvents: [makeParentSessionStart()], + }; + + const result = distillAgent(entries, diffContext); + expect(result).toBeDefined(); + expect(result?.edit_chains).toBeDefined(); + // Tool-sourced diff attribution is git-independent + expect(result?.edit_chains?.diff_attribution).toBeDefined(); + expect(result?.edit_chains?.diff_attribution?.length).toBeGreaterThan(0); + }); + + test("diff_attribution is computed even without SessionStart in parentEvents", () => { + const entries = makeEditEntries(); + const diffContext: DiffContext = { + projectDir: "/tmp/fake-project", + parentEvents: [], // No SessionStart — tool-sourced diffs don't need it + }; + + const result = distillAgent(entries, diffContext); + expect(result).toBeDefined(); + expect(result?.edit_chains).toBeDefined(); + // Tool-sourced diff attribution works without git_commit + expect(result?.edit_chains?.diff_attribution).toBeDefined(); + expect(result?.edit_chains?.diff_attribution?.length).toBeGreaterThan(0); + }); +}); diff --git a/test/distill-aggregate.test.ts b/packages/cli/test/distill-aggregate.test.ts similarity index 67% rename from test/distill-aggregate.test.ts rename to packages/cli/test/distill-aggregate.test.ts index b1d0d1a..1564348 100644 --- a/test/distill-aggregate.test.ts +++ b/packages/cli/test/distill-aggregate.test.ts @@ -7,6 +7,7 @@ import { mergeFileMaps, mergeStats, } from "../src/distill/aggregate"; +import { extractStats } from "../src/distill/stats"; import { flattenAgents } from "../src/utils"; import type { AgentNode, @@ -18,6 +19,8 @@ import type { FileMapEntry, FileMapResult, StatsResult, + StoredEvent, + TokenUsage, TranscriptReasoning, } from "../src/types"; @@ -98,6 +101,7 @@ const makeCostEstimate = ( estimated_input_tokens: 1000, estimated_output_tokens: 500, estimated_cost_usd: 0.05, + is_estimated: false, ...overrides, }); @@ -245,7 +249,12 @@ describe("mergeFileMaps", () => { // --------------------------------------------------------------------------- describe("mergeStats", () => { - test("parent + 2 agent stats: tool_call_count sums, unique_files is union, tools_by_name merged", () => { + // B4: subagent tool calls already live in the parent JSONL stream and are + // counted by parentStats. Per-agent stats are the SAME calls re-derived from + // each transcript, so re-adding them double-counts. Parent counts are + // authoritative for the session; only unique_files (union) and token_usage + // (sum) are still aggregated from agents. + test("parent counts are authoritative: tool/failure/per-tool NOT re-added from agents (B4)", () => { const parentStats = makeStatsResult({ tool_call_count: 5, failure_count: 1, @@ -271,25 +280,22 @@ describe("mergeStats", () => { const result = mergeStats(parentStats, [agent1, agent2]); - // tool_call_count: 5 + 8 + 3 = 16 - expect(result.tool_call_count).toBe(16); + // tool_call_count stays at parent value (NOT 5+8+3) — agent calls already counted + expect(result.tool_call_count).toBe(5); - // failure_count: 1 + 2 + 0 = 3 - expect(result.failure_count).toBe(3); + // failure_count stays at parent value (NOT 1+2+0) + expect(result.failure_count).toBe(1); - // failure_rate: 3 / 16 = 0.1875 - expect(result.failure_rate).toBe(3 / 16); + // failure_rate is the parent's, untouched + expect(result.failure_rate).toBe(0.2); + + // tools_by_name stays exactly the parent's map (NOT merged with agents) + expect(result.tools_by_name).toEqual({ Read: 3, Edit: 2 }); - // unique_files: union of all = [a, b, c, d] + // unique_files: still unioned across parent + agents (set union, never double-counts) const uniqueSorted = [...result.unique_files].sort(); expect(uniqueSorted).toEqual(["/src/a.ts", "/src/b.ts", "/src/c.ts", "/src/d.ts"]); - // tools_by_name: Read: 3+1+1=5, Edit: 2+3=5, Bash: 4, Write: 2 - expect(result.tools_by_name.Read).toBe(5); - expect(result.tools_by_name.Edit).toBe(5); - expect(result.tools_by_name.Bash).toBe(4); - expect(result.tools_by_name.Write).toBe(2); - // parent duration preserved expect(result.duration_ms).toBe(10000); }); @@ -316,6 +322,90 @@ describe("mergeStats", () => { const result = mergeStats(parentStats, []); expect(result.failure_rate).toBe(0); }); + + test("sums token_usage from parent + agents", () => { + const parentStats = makeStatsResult({ + tool_call_count: 5, + token_usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 100, + cache_creation_tokens: 50, + }, + }); + + const agent1: AgentStats = makeAgentStats({ + tool_call_count: 3, + token_usage: { + input_tokens: 2000, + output_tokens: 800, + cache_read_tokens: 200, + cache_creation_tokens: 75, + }, + }); + + const agent2: AgentStats = makeAgentStats({ + tool_call_count: 2, + token_usage: { + input_tokens: 500, + output_tokens: 200, + cache_read_tokens: 50, + cache_creation_tokens: 25, + }, + }); + + const result = mergeStats(parentStats, [agent1, agent2]); + expect(result.token_usage).toBeDefined(); + expect(result.token_usage?.input_tokens).toBe(3500); + expect(result.token_usage?.output_tokens).toBe(1500); + expect(result.token_usage?.cache_read_tokens).toBe(350); + expect(result.token_usage?.cache_creation_tokens).toBe(150); + }); + + test("returns parent token_usage when agents have none", () => { + const parentStats = makeStatsResult({ + token_usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 100, + cache_creation_tokens: 50, + }, + }); + + const agent: AgentStats = makeAgentStats({ tool_call_count: 2 }); + + const result = mergeStats(parentStats, [agent]); + expect(result.token_usage).toBeDefined(); + expect(result.token_usage?.input_tokens).toBe(1000); + expect(result.token_usage?.output_tokens).toBe(500); + }); + + test("returns agent token_usage when parent has none", () => { + const parentStats = makeStatsResult({ tool_call_count: 1 }); + + const agent: AgentStats = makeAgentStats({ + tool_call_count: 3, + token_usage: { + input_tokens: 2000, + output_tokens: 800, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }, + }); + + const result = mergeStats(parentStats, [agent]); + expect(result.token_usage).toBeDefined(); + expect(result.token_usage?.input_tokens).toBe(2000); + expect(result.token_usage?.output_tokens).toBe(800); + }); + + test("token_usage is undefined when neither parent nor agents have it", () => { + const parentStats = makeStatsResult({ tool_call_count: 5 }); + const agent: AgentStats = makeAgentStats({ tool_call_count: 3 }); + + const result = mergeStats(parentStats, [agent]); + expect(result.token_usage).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -682,8 +772,9 @@ describe("mergeCostEstimates", () => { const result = mergeCostEstimates(parent, agentCosts); expect(result).toBeDefined(); - // Model comes from parent - expect(result?.model).toBe("claude-opus-4-20250514"); + // Parent (opus) and agents (sonnet) use different models, so the merged label is + // "mixed" rather than mislabeling the multi-model sum as the parent's model. + expect(result?.model).toBe("mixed"); // Tokens summed: 5000 + 3000 + 2000 = 10000 expect(result?.estimated_input_tokens).toBe(10000); @@ -720,6 +811,104 @@ describe("mergeCostEstimates", () => { // 0.00001 + 0.00002 = 0.00003, rounded to 4 decimals = 0 expect(result?.estimated_cost_usd).toBe(0); }); + + test("all real costs → merged is_estimated: false", () => { + const parent = makeCostEstimate({ is_estimated: false, estimated_cost_usd: 0.5 }); + const agent1 = makeCostEstimate({ is_estimated: false, estimated_cost_usd: 0.3 }); + const agent2 = makeCostEstimate({ is_estimated: false, estimated_cost_usd: 0.2 }); + + const result = mergeCostEstimates(parent, [agent1, agent2]); + expect(result).toBeDefined(); + expect(result?.is_estimated).toBe(false); + }); + + test("one estimated + rest real → merged is_estimated: true", () => { + const parent = makeCostEstimate({ is_estimated: false, estimated_cost_usd: 0.5 }); + const agentReal = makeCostEstimate({ is_estimated: false, estimated_cost_usd: 0.3 }); + const agentEstimated = makeCostEstimate({ is_estimated: true, estimated_cost_usd: 0.2 }); + + const result = mergeCostEstimates(parent, [agentReal, agentEstimated]); + expect(result).toBeDefined(); + expect(result?.is_estimated).toBe(true); + }); + + test("all estimated → merged is_estimated: true", () => { + const parent = makeCostEstimate({ is_estimated: true, estimated_cost_usd: 0.5 }); + const agent1 = makeCostEstimate({ is_estimated: true, estimated_cost_usd: 0.3 }); + const agent2 = makeCostEstimate({ is_estimated: true, estimated_cost_usd: 0.2 }); + + const result = mergeCostEstimates(parent, [agent1, agent2]); + expect(result).toBeDefined(); + expect(result?.is_estimated).toBe(true); + }); + + test("parent estimated, agents real → merged is_estimated: true", () => { + const parent = makeCostEstimate({ is_estimated: true, estimated_cost_usd: 0.5 }); + const agent = makeCostEstimate({ is_estimated: false, estimated_cost_usd: 0.3 }); + + const result = mergeCostEstimates(parent, [agent]); + expect(result).toBeDefined(); + expect(result?.is_estimated).toBe(true); + }); + + test("cache tokens are summed across estimates", () => { + const parent = makeCostEstimate({ + cache_read_tokens: 100, + cache_creation_tokens: 50, + }); + const agent = makeCostEstimate({ + cache_read_tokens: 200, + cache_creation_tokens: 75, + }); + + const result = mergeCostEstimates(parent, [agent]); + expect(result).toBeDefined(); + expect(result?.cache_read_tokens).toBe(300); + expect(result?.cache_creation_tokens).toBe(125); + }); + + // Regression: merge-cost-estimates-drops-tier-and-mislabels-model. + test("preserves pricing_tier from the parent cost", () => { + const parent = makeCostEstimate({ pricing_tier: "max", estimated_cost_usd: 0.5 }); + const agent = makeCostEstimate({ pricing_tier: "max", estimated_cost_usd: 0.3 }); + + const result = mergeCostEstimates(parent, [agent]); + expect(result?.pricing_tier).toBe("max"); + }); + + test("preserves pricing_tier from an agent when the parent cost lacks it", () => { + const parent = makeCostEstimate({ estimated_cost_usd: 0.5 }); // no pricing_tier + const agent = makeCostEstimate({ pricing_tier: "max", estimated_cost_usd: 0.3 }); + + const result = mergeCostEstimates(parent, [agent]); + expect(result?.pricing_tier).toBe("max"); + }); + + test("omits pricing_tier when no contributing cost carries one", () => { + const parent = makeCostEstimate({ estimated_cost_usd: 0.5 }); + const agent = makeCostEstimate({ estimated_cost_usd: 0.3 }); + + const result = mergeCostEstimates(parent, [agent]); + expect(result?.pricing_tier).toBeUndefined(); + }); + + test("labels model 'mixed' when contributing costs use different models", () => { + const parent = makeCostEstimate({ model: "claude-opus-4-8", estimated_cost_usd: 0.5 }); + const agent1 = makeCostEstimate({ model: "claude-sonnet-4-6", estimated_cost_usd: 0.3 }); + const agent2 = makeCostEstimate({ model: "claude-haiku-4-5", estimated_cost_usd: 0.2 }); + + const result = mergeCostEstimates(parent, [agent1, agent2]); + // A single model label would mislabel a multi-model merge. + expect(result?.model).toBe("mixed"); + }); + + test("keeps the shared model label when every contributing cost agrees", () => { + const parent = makeCostEstimate({ model: "claude-opus-4-8", estimated_cost_usd: 0.5 }); + const agent = makeCostEstimate({ model: "claude-opus-4-8", estimated_cost_usd: 0.3 }); + + const result = mergeCostEstimates(parent, [agent]); + expect(result?.model).toBe("claude-opus-4-8"); + }); }); // --------------------------------------------------------------------------- @@ -875,20 +1064,20 @@ describe("aggregateTeamData", () => { agents: [agent1, agent2], }); - // -- stats -- - // tool_call_count: 6 + 12 + 7 = 25 - expect(result.stats.tool_call_count).toBe(25); - // failure_count: 1 + 3 + 1 = 5 - expect(result.stats.failure_count).toBe(5); - // failure_rate: 5 / 25 = 0.2 - expect(result.stats.failure_rate).toBe(0.2); - // unique_files: union + // -- stats (B4) -- + // Parent counts are authoritative: agent tool calls already live in the + // parent stream, so they are NOT re-added. + // tool_call_count stays at the parent's 6 (NOT 6+12+7) + expect(result.stats.tool_call_count).toBe(6); + // failure_count stays at the parent's 1 (NOT 1+3+1) + expect(result.stats.failure_count).toBe(1); + // failure_rate is the parent's, untouched + expect(result.stats.failure_rate).toBe(1 / 6); + // unique_files: still unioned across parent + agents const uniqueSorted = [...result.stats.unique_files].sort(); expect(uniqueSorted).toEqual(["/src/index.ts", "/src/types.ts", "/test/app.test.ts"]); - // tools_by_name merged - expect(result.stats.tools_by_name.Read).toBe(11); // 4 + 5 + 2 - expect(result.stats.tools_by_name.Edit).toBe(6); // 2 + 4 - expect(result.stats.tools_by_name.Bash).toBe(8); // 3 + 5 + // tools_by_name stays exactly the parent's map (NOT merged with agents) + expect(result.stats.tools_by_name).toEqual({ Read: 4, Edit: 2 }); // parent duration preserved expect(result.stats.duration_ms).toBe(15000); @@ -941,8 +1130,9 @@ describe("aggregateTeamData", () => { expect(result.cost_estimate?.estimated_output_tokens).toBe(8500); // 1.0 + 0.3 + 0.15 = 1.45 expect(result.cost_estimate?.estimated_cost_usd).toBe(1.45); - // Model from parent - expect(result.cost_estimate?.model).toBe("claude-opus-4-20250514"); + // Parent (opus) and agents (sonnet) differ, so the merged cost is labeled "mixed" + // rather than mislabeling the multi-model sum as the parent's model. + expect(result.cost_estimate?.model).toBe("mixed"); }); test("agents with missing optional fields still aggregate correctly", () => { @@ -1017,8 +1207,10 @@ describe("aggregateTeamData", () => { agents: [child], }); - // tool_call_count: 1 (parent) + 3 (child) + 5 (grandchild) = 9 - expect(result.stats.tool_call_count).toBe(9); + // B4: parent count is authoritative — nested agent calls already live in + // the parent stream and are NOT re-added. tool_call_count stays at parent's 1. + expect(result.stats.tool_call_count).toBe(1); + // unique_files are still unioned across the flattened agent tree. const uniqueSorted = [...result.stats.unique_files].sort(); expect(uniqueSorted).toEqual(["/deep/file.ts", "/mid/file.ts"]); }); @@ -1052,3 +1244,155 @@ describe("aggregateTeamData", () => { expect(result.edit_chains.chains[0].agent_name).toBe("builder"); }); }); + +// --------------------------------------------------------------------------- +// B4 regression: distilled tool/failure counts must equal raw event counts even +// with subagents. Subagent tool calls already live in the parent JSONL stream, +// so re-deriving them per-agent and re-adding them double-counts (observed: +// session c875e176 distilled tool_call_count 664 vs raw PreToolUse 336, +// failure_count 25 vs raw PostToolUseFailure 11). +// --------------------------------------------------------------------------- + +describe("aggregateTeamData — B4 no double-counting of subagent tool calls", () => { + // Build a raw parent event stream: every tool call in the session (parent + + // subagents) lands here as PreToolUse / PostToolUseFailure. + const makePreTool = (t: number, toolName: string, filePath?: string): StoredEvent => ({ + t, + event: "PreToolUse", + sid: "parent-session", + data: { + tool_name: toolName, + ...(filePath ? { tool_input: { file_path: filePath } } : {}), + }, + }); + + const makeFailure = (t: number, toolName: string): StoredEvent => ({ + t, + event: "PostToolUseFailure", + sid: "parent-session", + data: { tool_name: toolName }, + }); + + const repeat = (n: number, make: (i: number) => StoredEvent): readonly StoredEvent[] => + Array.from({ length: n }, (_unused, i) => make(i)); + + // Mirror the real session shape (scaled down): a realistic per-tool mix. + const rawPerTool = { Read: 12, Bash: 9, Edit: 7, Grep: 4 } as const; + const rawFailureCount = 3; // all Bash failures, like the real session + + const parentEvents: readonly StoredEvent[] = [ + ...Object.entries(rawPerTool).flatMap(([tool, count], group) => + repeat(count, (i) => makePreTool(1000 + group * 1000 + i, tool, `/src/${tool}-${i}.ts`)), + ), + ...repeat(rawFailureCount, (i) => makeFailure(9000 + i, "Bash")), + ]; + + // Raw oracle: counts straight from the event stream. + const rawPreToolCount = Object.values(rawPerTool).reduce((a, b) => a + b, 0); // 32 + + const parentStats = extractStats(parentEvents); + + // Three subagents whose per-agent stats re-derive subsets of the SAME calls + // (this is what agent-distill does from each subagent transcript). These must + // NOT be added on top of the parent counts. + const agents: readonly AgentNode[] = [ + makeAgentNode({ + session_id: "sub-1", + agent_type: "builder", + tool_call_count: 10, + stats: makeAgentStats({ + tool_call_count: 10, + failure_count: 1, + tools_by_name: { Read: 4, Edit: 4, Bash: 2 }, + unique_files: ["/src/Read-0.ts", "/src/Edit-0.ts"], + }), + }), + makeAgentNode({ + session_id: "sub-2", + agent_type: "validator", + tool_call_count: 8, + stats: makeAgentStats({ + tool_call_count: 8, + failure_count: 1, + tools_by_name: { Read: 5, Grep: 3 }, + unique_files: ["/src/Read-1.ts"], + }), + }), + makeAgentNode({ + session_id: "sub-3", + agent_type: "builder", + tool_call_count: 6, + stats: makeAgentStats({ + tool_call_count: 6, + failure_count: 1, + tools_by_name: { Bash: 5, Edit: 1 }, + unique_files: ["/src/Bash-0.ts"], + }), + }), + ]; + + test("sanity: parent extractStats matches the raw event stream", () => { + expect(parentStats.tool_call_count).toBe(rawPreToolCount); + expect(parentStats.failure_count).toBe(rawFailureCount); + expect(parentStats.tools_by_name).toEqual(rawPerTool); + }); + + test("merged tool_call_count == raw PreToolUse count (not parent + agents)", () => { + const result = aggregateTeamData({ + parentStats, + parentFileMap: { files: [] }, + parentEditChains: { chains: [] }, + parentBacktracks: [], + parentReasoning: [], + parentCost: undefined, + agents, + }); + + // Buggy behavior would yield 32 + 10 + 8 + 6 = 56; correct stays at 32. + expect(result.stats.tool_call_count).toBe(rawPreToolCount); + }); + + test("merged failure_count == raw PostToolUseFailure count", () => { + const result = aggregateTeamData({ + parentStats, + parentFileMap: { files: [] }, + parentEditChains: { chains: [] }, + parentBacktracks: [], + parentReasoning: [], + parentCost: undefined, + agents, + }); + + // Buggy behavior would yield 3 + 1 + 1 + 1 = 6; correct stays at 3. + expect(result.stats.failure_count).toBe(rawFailureCount); + }); + + test("merged per-tool counts == raw per-tool counts", () => { + const result = aggregateTeamData({ + parentStats, + parentFileMap: { files: [] }, + parentEditChains: { chains: [] }, + parentBacktracks: [], + parentReasoning: [], + parentCost: undefined, + agents, + }); + + // Each tool stays at its raw count — no agent contributions added. + expect(result.stats.tools_by_name).toEqual(rawPerTool); + }); + + test("failure_rate is computed from raw counts only", () => { + const result = aggregateTeamData({ + parentStats, + parentFileMap: { files: [] }, + parentEditChains: { chains: [] }, + parentBacktracks: [], + parentReasoning: [], + parentCost: undefined, + agents, + }); + + expect(result.stats.failure_rate).toBe(rawFailureCount / rawPreToolCount); + }); +}); diff --git a/test/distill-backtracks-dedup.test.ts b/packages/cli/test/distill-backtracks-dedup.test.ts similarity index 62% rename from test/distill-backtracks-dedup.test.ts rename to packages/cli/test/distill-backtracks-dedup.test.ts index e9b5c58..3183010 100644 --- a/test/distill-backtracks-dedup.test.ts +++ b/packages/cli/test/distill-backtracks-dedup.test.ts @@ -32,6 +32,12 @@ describe("extractBacktracks - deduplication", () => { event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "b3", tool_input: { command: "bun run typecheck" } }, }), + // Trailing failure: the loop keeps failing, so this is a genuine debugging_loop. + makeEvent({ + t: 3500, + event: "PostToolUseFailure", + data: { tool_name: "Bash", tool_use_id: "b3f", error: "exit 1", tool_input: { command: "bun run typecheck" } }, + }), ]; const backtracks = extractBacktracks(events); @@ -75,6 +81,12 @@ describe("extractBacktracks - deduplication", () => { event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "b3", tool_input: { command: "test3" } }, }), + // Trailing failure keeps the loop a genuine debugging_loop. + makeEvent({ + t: 7500, + event: "PostToolUseFailure", + data: { tool_name: "Bash", tool_use_id: "b3f", error: "exit 1", tool_input: { command: "test3" } }, + }), ]; const backtracks = extractBacktracks(events); @@ -233,6 +245,12 @@ describe("extractBacktracks - deduplication", () => { event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "b3", tool_input: { command: "test3" } }, }), + // Trailing failure keeps the chain a genuine debugging_loop (still before the gap). + makeEvent({ + t: 3500, + event: "PostToolUseFailure", + data: { tool_name: "Bash", tool_use_id: "b3f", error: "exit 1", tool_input: { command: "test3" } }, + }), // 6-minute gap: agent went to lunch makeEvent({ t: 3000 + 6 * 60 * 1000, @@ -301,6 +319,12 @@ describe("extractBacktracks - deduplication", () => { event: "PostToolUseFailure", data: { tool_name: "Bash", tool_use_id: "b0", error: "exit 1", tool_input: { command: "test" } }, }); + // A second failure early in the chain makes this a genuine debugging_loop. + const secondFailure = makeEvent({ + t: 1500, + event: "PostToolUseFailure" as const, + data: { tool_name: "Bash", tool_use_id: "b0b", error: "exit 1", tool_input: { command: "test" } }, + }); const bashRetries = Array.from({ length: 60 }, (_, i) => makeEvent({ t: 2000 + i * 1000, @@ -309,7 +333,7 @@ describe("extractBacktracks - deduplication", () => { }), ); - const events: StoredEvent[] = [initialFailure, ...bashRetries]; + const events: StoredEvent[] = [initialFailure, secondFailure, ...bashRetries]; const backtracks = extractBacktracks(events); const debugLoops = backtracks.filter((b) => b.type === "debugging_loop"); @@ -319,6 +343,81 @@ describe("extractBacktracks - deduplication", () => { expect(debugLoops[0].tool_use_ids).toHaveLength(51); }); + test("failure_retry does not cross agent boundaries (cross-agent false retries)", () => { + // Agent A fails an Edit; agent B then runs an Edit. These are different agents, + // so this is NOT a retry — events must be partitioned by data.agent_id. + const events: StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PostToolUseFailure", + data: { tool_name: "Edit", tool_use_id: "a1", error: "not found", agent_id: "agentA", tool_input: { file_path: "/a.ts" } }, + }), + makeEvent({ + t: 2000, + event: "PreToolUse", + data: { tool_name: "Edit", tool_use_id: "b1", agent_id: "agentB", tool_input: { file_path: "/b.ts" } }, + }), + ]; + + const backtracks = extractBacktracks(events); + expect(backtracks.filter((b) => b.type === "failure_retry")).toHaveLength(0); + }); + + test("failure_retry still matches within the same agent", () => { + const events: StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PostToolUseFailure", + data: { tool_name: "Edit", tool_use_id: "a1", error: "not found", agent_id: "agentA", tool_input: { file_path: "/a.ts" } }, + }), + makeEvent({ + t: 2000, + event: "PreToolUse", + data: { tool_name: "Edit", tool_use_id: "a2", agent_id: "agentA", tool_input: { file_path: "/a.ts" } }, + }), + ]; + + const retries = extractBacktracks(events).filter((b) => b.type === "failure_retry"); + expect(retries).toHaveLength(1); + expect(retries[0].tool_use_ids).toEqual(["a1", "a2"]); + }); + + test("debugging_loop does not span two agents interleaving bash failures", () => { + // Two agents each fail Bash once, interleaved. Without partitioning the global + // walk would chain them into a false 3+ attempt loop. + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "PostToolUseFailure", data: { tool_name: "Bash", tool_use_id: "a1", error: "e", agent_id: "A", tool_input: { command: "x" } } }), + makeEvent({ t: 1500, event: "PostToolUseFailure", data: { tool_name: "Bash", tool_use_id: "b1", error: "e", agent_id: "B", tool_input: { command: "y" } } }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "a2", agent_id: "A", tool_input: { command: "x2" } } }), + makeEvent({ t: 2500, event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "b2", agent_id: "B", tool_input: { command: "y2" } } }), + makeEvent({ t: 3000, event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "a3", agent_id: "A", tool_input: { command: "x3" } } }), + // Agent A's loop keeps failing — trailing failure makes it a genuine debugging_loop. + makeEvent({ t: 3200, event: "PostToolUseFailure", data: { tool_name: "Bash", tool_use_id: "a3f", error: "e", agent_id: "A", tool_input: { command: "x3" } } }), + ]; + + const loops = extractBacktracks(events).filter((b) => b.type === "debugging_loop"); + // Agent A: a1, a2, a3 = 3 attempts (a valid loop). Agent B has only b1, b2 = 2 (no loop). + expect(loops).toHaveLength(1); + expect(loops[0].tool_use_ids).toEqual(["a1", "a2", "a3"]); + }); + + test("debugging_loop end_t reflects a trailing failure, not the last retry pre", () => { + // Real loops keep failing: fail, retry-pre, retry-pre, then a final failure of the + // last retry. The loop's end must include that terminal failure. + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "PostToolUseFailure", data: { tool_name: "Bash", tool_use_id: "b1", error: "e", tool_input: { command: "a" } } }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "b2", tool_input: { command: "b" } } }), + makeEvent({ t: 3000, event: "PreToolUse", data: { tool_name: "Bash", tool_use_id: "b3", tool_input: { command: "c" } } }), + makeEvent({ t: 3500, event: "PostToolUseFailure", data: { tool_name: "Bash", tool_use_id: "b3f", error: "e", tool_input: { command: "c" } } }), + ]; + + const loops = extractBacktracks(events).filter((b) => b.type === "debugging_loop"); + expect(loops).toHaveLength(1); + expect(loops[0].attempts).toBe(3); // b1, b2, b3 (terminal failure is the same command as b3) + expect(loops[0].start_t).toBe(1000); + expect(loops[0].end_t).toBe(3500); // includes the trailing failure + }); + test("dedup with no debugging_loops preserves all retries", () => { const events: StoredEvent[] = [ // Two separate failure_retries for different tools @@ -352,4 +451,62 @@ describe("extractBacktracks - deduplication", () => { expect(debugLoops).toHaveLength(0); expect(retries).toHaveLength(2); }); + + test("no debugging_loop when one failure is followed only by successful bash commands", () => { + // Regression (debugging-loop-requires-no-subsequent-failures): + // A single Bash failure followed by two UNRELATED, SUCCESSFUL bash commands + // (git status, git add) is recovery, not a loop. With no subsequent failure, + // Pattern 3 must NOT fire even though there are 3 bash attempts. + const events: StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PostToolUseFailure", + data: { tool_name: "Bash", tool_use_id: "b1", error: "exit 1", tool_input: { command: "bun test" } }, + }), + makeEvent({ + t: 2000, + event: "PreToolUse", + data: { tool_name: "Bash", tool_use_id: "b2", tool_input: { command: "git status" } }, + }), + makeEvent({ + t: 3000, + event: "PreToolUse", + data: { tool_name: "Bash", tool_use_id: "b3", tool_input: { command: "git add -A" } }, + }), + ]; + + const debugLoops = extractBacktracks(events).filter((b) => b.type === "debugging_loop"); + expect(debugLoops).toHaveLength(0); + }); + + test("debugging_loop still fires when a subsequent bash command also fails", () => { + // Counterpart to the regression above: the same shape but with a second failure + // IS a genuine debugging_loop. + const events: StoredEvent[] = [ + makeEvent({ + t: 1000, + event: "PostToolUseFailure", + data: { tool_name: "Bash", tool_use_id: "b1", error: "exit 1", tool_input: { command: "bun test" } }, + }), + makeEvent({ + t: 2000, + event: "PreToolUse", + data: { tool_name: "Bash", tool_use_id: "b2", tool_input: { command: "bun test --rerun" } }, + }), + makeEvent({ + t: 2500, + event: "PostToolUseFailure", + data: { tool_name: "Bash", tool_use_id: "b2f", error: "exit 1", tool_input: { command: "bun test --rerun" } }, + }), + makeEvent({ + t: 3000, + event: "PreToolUse", + data: { tool_name: "Bash", tool_use_id: "b3", tool_input: { command: "bun test --bail" } }, + }), + ]; + + const debugLoops = extractBacktracks(events).filter((b) => b.type === "debugging_loop"); + expect(debugLoops).toHaveLength(1); + expect(debugLoops[0].tool_use_ids).toEqual(["b1", "b2", "b3"]); + }); }); diff --git a/test/distill-comm-graph.test.ts b/packages/cli/test/distill-comm-graph.test.ts similarity index 100% rename from test/distill-comm-graph.test.ts rename to packages/cli/test/distill-comm-graph.test.ts diff --git a/test/distill-comm-sequence.test.ts b/packages/cli/test/distill-comm-sequence.test.ts similarity index 100% rename from test/distill-comm-sequence.test.ts rename to packages/cli/test/distill-comm-sequence.test.ts diff --git a/test/distill-decisions-agent.test.ts b/packages/cli/test/distill-decisions-agent.test.ts similarity index 83% rename from test/distill-decisions-agent.test.ts rename to packages/cli/test/distill-decisions-agent.test.ts index f60ce0d..06a299a 100644 --- a/test/distill-decisions-agent.test.ts +++ b/packages/cli/test/distill-decisions-agent.test.ts @@ -214,4 +214,31 @@ describe("extractDecisions with links", () => { ); expect(agentTypes).toHaveLength(0); }); + + test("phase boundaries use team-aware detection when task links exist (consistent boundaries)", () => { + // Events have NO large timing gaps, so the gap-based fallback would yield a + // single phase (zero phase_boundary decisions). Team-aware detection splits + // Planning -> Build at the first task assignment (t=3000), producing one + // phase_boundary. The bug passed events without links here, forcing the fallback. + const events: readonly StoredEvent[] = [ + makeEvent({ t: 1000, event: "PreToolUse", data: { tool_name: "Read" } }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read" } }), + makeEvent({ t: 3000, event: "PreToolUse", data: { tool_name: "Edit" } }), + makeEvent({ t: 4000, event: "PreToolUse", data: { tool_name: "Edit" } }), + ]; + const links: readonly LinkEvent[] = [ + makeTaskAssignLink({ t: 3000, subject: "Build it" }), + ]; + + const decisions = extractDecisions(events, links); + const phaseBoundaries = decisions.filter((d) => d.type === "phase_boundary"); + + // Team-aware: Planning ends and Build starts at the assignment -> one boundary at 3000. + expect(phaseBoundaries).toHaveLength(1); + expect(phaseBoundaries[0].t).toBe(3000); + + // Without links the same events yield no phase boundary (gap-based fallback). + const fallback = extractDecisions(events).filter((d) => d.type === "phase_boundary"); + expect(fallback).toHaveLength(0); + }); }); diff --git a/test/distill-decisions-team.test.ts b/packages/cli/test/distill-decisions-team.test.ts similarity index 100% rename from test/distill-decisions-team.test.ts rename to packages/cli/test/distill-decisions-team.test.ts diff --git a/packages/cli/test/distill-duration-semantics.test.ts b/packages/cli/test/distill-duration-semantics.test.ts new file mode 100644 index 0000000..2a7cacb --- /dev/null +++ b/packages/cli/test/distill-duration-semantics.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { distill } from "../src/distill"; +import { listSessions } from "../src/session"; +import type { StoredEvent } from "../src/types"; + +// Regression tests for the duration-semantics bugs (specs/revive/bug-register.md): +// B3 — active duration double-subtracted idle gaps (>5min pause → "Active 0s") +// B2 — list duration_ms must be the wall-clock span, matching the web API +// B9 — cost_estimate must be mirrored at the top level of the distilled JSON + +const TEST_DIR = `/tmp/clens-test-duration-semantics-${Date.now()}`; +const SESSION_ID = "session-pause"; + +const PAUSE_MS = 40 * 60 * 1000; // one 40-minute pause (> 5min threshold) + +// Timeline: 4 min of work (events ≤ 2min apart, below the 5-min idle +// threshold), a 40-min pause, then 4 more minutes of work. +const MINUTE = 60_000; +const T0 = 1_000_000; +const WORK_1_END = T0 + 4 * MINUTE; +const WORK_2_START = WORK_1_END + PAUSE_MS; +const T_END = WORK_2_START + 4 * MINUTE; +const WALL_MS = T_END - T0; + +const makeEvents = (): readonly StoredEvent[] => [ + { t: T0, event: "SessionStart", sid: SESSION_ID, data: {} }, + { + t: T0 + 2 * MINUTE, + event: "PreToolUse", + sid: SESSION_ID, + data: { tool_name: "Read", tool_use_id: "t1", tool_input: { file_path: "/src/a.ts" } }, + }, + { t: T0 + 3 * MINUTE, event: "PostToolUse", sid: SESSION_ID, data: { tool_name: "Read", tool_use_id: "t1" } }, + { t: WORK_1_END, event: "Stop", sid: SESSION_ID, data: {} }, + // -- 40 minute pause -- + { t: WORK_2_START, event: "UserPromptSubmit", sid: SESSION_ID, data: { prompt: "continue" } }, + { + t: WORK_2_START + 2 * MINUTE, + event: "PreToolUse", + sid: SESSION_ID, + data: { + tool_name: "Edit", + tool_use_id: "t2", + tool_input: { file_path: "/src/a.ts" }, + usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + model: "claude-opus-4-6", + }, + }, + { t: WORK_2_START + 3 * MINUTE, event: "PostToolUse", sid: SESSION_ID, data: { tool_name: "Edit", tool_use_id: "t2" } }, + { t: T_END, event: "SessionEnd", sid: SESSION_ID, data: {} }, +]; + +describe("duration semantics (B2/B3/B9 regressions)", () => { + beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(`${TEST_DIR}/.clens/sessions`, { recursive: true }); + writeFileSync( + `${TEST_DIR}/.clens/sessions/${SESSION_ID}.jsonl`, + makeEvents() + .map((e) => JSON.stringify(e)) + .join("\n") + "\n", + ); + }); + + afterAll(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + test("stats carry both wall and idle-trimmed durations", async () => { + const result = await distill(SESSION_ID, TEST_DIR); + expect(result.stats.wall_duration_ms).toBe(WALL_MS); + expect(result.stats.duration_ms).toBe(WALL_MS - PAUSE_MS); + }); + + test("active duration is not zeroed by a long pause (B3)", async () => { + const result = await distill(SESSION_ID, TEST_DIR); + const active = result.summary?.key_metrics.active_duration_ms; + expect(active).toBeDefined(); + // Active = wall - pause gap; before the fix the pause was subtracted from + // the already-trimmed duration, clamping active to 0. + expect(active).toBeGreaterThan(0); + expect(active).toBe(WALL_MS - PAUSE_MS); + }); + + test("CLI list duration_ms is the wall-clock span (B2)", () => { + const sessions = listSessions(TEST_DIR); + const row = sessions.find((s) => s.session_id === SESSION_ID); + expect(row).toBeDefined(); + expect(row?.duration_ms).toBe(WALL_MS); + }); + + test("cost_estimate is mirrored at the top level of the distilled JSON (B9)", async () => { + const result = await distill(SESSION_ID, TEST_DIR); + expect(result.stats.cost_estimate).toBeDefined(); + expect(result.cost_estimate).toBeDefined(); + expect(result.cost_estimate?.estimated_cost_usd).toBe( + result.stats.cost_estimate?.estimated_cost_usd ?? Number.NaN, + ); + }); +}); diff --git a/test/distill-edit-chains.test.ts b/packages/cli/test/distill-edit-chains.test.ts similarity index 100% rename from test/distill-edit-chains.test.ts rename to packages/cli/test/distill-edit-chains.test.ts diff --git a/test/distill-enrichment.test.ts b/packages/cli/test/distill-enrichment.test.ts similarity index 95% rename from test/distill-enrichment.test.ts rename to packages/cli/test/distill-enrichment.test.ts index f272d40..d15501e 100644 --- a/test/distill-enrichment.test.ts +++ b/packages/cli/test/distill-enrichment.test.ts @@ -211,10 +211,12 @@ describe("extractUserMessages", () => { expect(result[0].message_type).toBe("image"); }); - test("classifies image messages with screenshot keyword", () => { + test("does NOT classify a prompt merely mentioning 'screenshot' as image", () => { + // Regression: the screenshot keyword misclassified text prompts as image + // messages (confirmed finding user-messages-screenshot-keyword-image-misclassification) const entries = [makeUserEntry("Please look at this screenshot of the error")]; const result = extractUserMessages(entries); - expect(result[0].message_type).toBe("image"); + expect(result[0].message_type).toBe("prompt"); }); test("classifies system messages with local-command", () => { @@ -269,7 +271,8 @@ describe("extractUserMessages", () => { test("does not set image_path for screenshot keyword without Image tag", () => { const entries = [makeUserEntry("Look at the screenshot I shared")]; const result = extractUserMessages(entries); - expect(result[0].message_type).toBe("image"); + // Keyword alone is a plain prompt (no Image tag — see regression above) + expect(result[0].message_type).toBe("prompt"); expect(result[0].image_path).toBeUndefined(); }); }); diff --git a/packages/cli/test/distill-file-map.test.ts b/packages/cli/test/distill-file-map.test.ts new file mode 100644 index 0000000..5d61053 --- /dev/null +++ b/packages/cli/test/distill-file-map.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, test } from "bun:test"; +import { extractFileMap } from "../src/distill/file-map"; +import type { StoredEvent } from "../src/types"; + +// -- Helper factories -- + +const CWD = "/Users/dev/repo"; + +const mkToolEvent = (overrides: Partial<{ + event: StoredEvent["event"]; + tool_name: string; + file_path: string; + tool_use_id: string; + cwd: string; +}> = {}): StoredEvent => ({ + t: 1000, + event: overrides.event ?? "PreToolUse", + sid: "s1", + data: { + tool_name: overrides.tool_name ?? "Edit", + tool_input: { file_path: overrides.file_path ?? `${CWD}/package.json` }, + tool_use_id: overrides.tool_use_id ?? "t1", + cwd: overrides.cwd ?? CWD, + }, +}); + +const mkBashEvent = (command: string, cwd: string = CWD): StoredEvent => ({ + t: 2000, + event: "PreToolUse", + sid: "s1", + data: { + tool_name: "Bash", + tool_input: { command }, + tool_use_id: "b1", + cwd, + }, +}); + +describe("extractFileMap path normalization (B23)", () => { + test("folds absolute tool path and relative bash path for the same file into one entry", () => { + // An Edit tool sees the absolute path; a Bash heuristic sees the relative one. + const events: readonly StoredEvent[] = [ + mkToolEvent({ tool_name: "Edit", file_path: `${CWD}/package.json`, tool_use_id: "t1" }), + mkBashEvent("cat package.json > package.json"), + ]; + + const result = extractFileMap(events); + const matches = result.files.filter((f) => f.file_path === "package.json"); + + // package.json must appear exactly once (not once absolute + once relative). + expect(matches.length).toBe(1); + expect(result.files.length).toBe(1); + expect(matches[0].edits).toBe(1); + }); + + test("merges edit counts across abs/rel duplicates of the same file", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ tool_name: "Edit", file_path: `${CWD}/src/app.ts`, tool_use_id: "t1" }), + // A relative path coming through a tool event (e.g. agent ran with cwd inside repo). + mkToolEvent({ tool_name: "Edit", file_path: "src/app.ts", tool_use_id: "t2", cwd: CWD }), + ]; + + const result = extractFileMap(events); + expect(result.files.length).toBe(1); + expect(result.files[0].file_path).toBe("src/app.ts"); + expect(result.files[0].edits).toBe(2); + expect(result.files[0].tool_use_ids).toEqual(["t1", "t2"]); + }); + + test("normalizes absolute tool paths to repo-relative", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ tool_name: "Write", file_path: `${CWD}/packages/web/index.ts`, tool_use_id: "w1" }), + ]; + + const result = extractFileMap(events); + expect(result.files[0].file_path).toBe("packages/web/index.ts"); + expect(result.files[0].writes).toBe(1); + }); + + test("leaves paths outside the session cwd untouched", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ tool_name: "Read", file_path: "/etc/hosts", tool_use_id: "r1" }), + ]; + + const result = extractFileMap(events); + expect(result.files[0].file_path).toBe("/etc/hosts"); + }); + + test("falls back to absolute paths when no cwd is captured", () => { + const events: readonly StoredEvent[] = [ + { + t: 1000, + event: "PreToolUse", + sid: "s1", + data: { + tool_name: "Edit", + tool_input: { file_path: "/some/abs/file.ts" }, + tool_use_id: "t1", + }, + }, + ]; + + const result = extractFileMap(events); + expect(result.files[0].file_path).toBe("/some/abs/file.ts"); + }); + + test("prefers SessionStart context cwd over event data cwd", () => { + const events: readonly StoredEvent[] = [ + { + t: 500, + event: "SessionStart", + sid: "s1", + context: { + project_dir: CWD, + cwd: CWD, + git_branch: null, + git_remote: null, + git_commit: null, + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: null, + agent_type: null, + }, + data: {}, + }, + mkToolEvent({ tool_name: "Edit", file_path: `${CWD}/main.ts`, tool_use_id: "t1", cwd: "/wrong/dir" }), + ]; + + const result = extractFileMap(events); + expect(result.files[0].file_path).toBe("main.ts"); + }); +}); + +// A failing tool op emits BOTH a PreToolUse and a PostToolUseFailure sharing the +// same tool_use_id (confirmed in real sessions); a successful op emits a +// PreToolUse + PostToolUse instead. +const mkFailPair = (overrides: Partial<{ + tool_name: string; + file_path: string; + tool_use_id: string; +}> = {}): readonly StoredEvent[] => { + const tool_name = overrides.tool_name ?? "Edit"; + const file_path = overrides.file_path ?? `${CWD}/src/app.ts`; + const tool_use_id = overrides.tool_use_id ?? "fail1"; + return [ + mkToolEvent({ event: "PreToolUse", tool_name, file_path, tool_use_id }), + mkToolEvent({ event: "PostToolUseFailure", tool_name, file_path, tool_use_id }), + ]; +}; + +describe("extractFileMap failed ops (file-map-failed-ops-counted-as-success-and-dup-ids)", () => { + test("a failed Edit records an error and NOT an edit", () => { + const result = extractFileMap(mkFailPair({ tool_name: "Edit", tool_use_id: "f1" })); + + expect(result.files.length).toBe(1); + expect(result.files[0].errors).toBe(1); + expect(result.files[0].edits).toBe(0); + }); + + test("a failed Read records an error and NOT a read", () => { + const result = extractFileMap(mkFailPair({ tool_name: "Read", tool_use_id: "f2" })); + + expect(result.files[0].errors).toBe(1); + expect(result.files[0].reads).toBe(0); + }); + + test("a failed Write records an error and NOT a write", () => { + const result = extractFileMap(mkFailPair({ tool_name: "Write", tool_use_id: "f3" })); + + expect(result.files[0].errors).toBe(1); + expect(result.files[0].writes).toBe(0); + }); + + test("does not duplicate the tool_use_id across the Pre/Failure pair", () => { + const result = extractFileMap(mkFailPair({ tool_use_id: "dup1" })); + + expect(result.files[0].tool_use_ids).toEqual(["dup1"]); + }); + + test("a successful Edit still counts once with no error", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ event: "PreToolUse", tool_name: "Edit", file_path: `${CWD}/ok.ts`, tool_use_id: "ok1" }), + ]; + + const result = extractFileMap(events); + expect(result.files[0].edits).toBe(1); + expect(result.files[0].errors).toBe(0); + expect(result.files[0].tool_use_ids).toEqual(["ok1"]); + }); + + test("mixed success and failure on the same file are counted independently", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ event: "PreToolUse", tool_name: "Edit", file_path: `${CWD}/m.ts`, tool_use_id: "okA" }), + ...mkFailPair({ tool_name: "Edit", file_path: `${CWD}/m.ts`, tool_use_id: "failB" }), + ]; + + const result = extractFileMap(events); + expect(result.files.length).toBe(1); + expect(result.files[0].edits).toBe(1); + expect(result.files[0].errors).toBe(1); + expect(result.files[0].tool_use_ids).toEqual(["okA", "failB"]); + }); + + test("an orphan PostToolUseFailure (no matching PreToolUse) still records the error", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ event: "PostToolUseFailure", tool_name: "Read", file_path: `${CWD}/orphan.ts`, tool_use_id: "orph1" }), + ]; + + const result = extractFileMap(events); + expect(result.files.length).toBe(1); + expect(result.files[0].errors).toBe(1); + expect(result.files[0].reads).toBe(0); + expect(result.files[0].tool_use_ids).toEqual(["orph1"]); + }); +}); + +describe("extractFileMap bash path heuristics (file-map-bash-regex-garbage-paths)", () => { + test("does not extract a path from an arrow function inside node -e", () => { + const result = extractFileMap([mkBashEvent('node -e "rows.map(r => r.id)"')]); + expect(result.files).toEqual([]); + }); + + test("does not extract a path from a >= comparison", () => { + const result = extractFileMap([mkBashEvent("if [ $x >= 5 ]; then echo hi; fi")]); + expect(result.files).toEqual([]); + }); + + test("does not extract a path from an arrow inside a quoted commit message", () => { + const result = extractFileMap([mkBashEvent('git commit -m "fix => bug"')]); + expect(result.files).toEqual([]); + }); + + test("still extracts a genuine redirect target", () => { + const result = extractFileMap([mkBashEvent("echo hello > out.txt")]); + expect(result.files.map((f) => f.file_path)).toEqual(["out.txt"]); + }); + + test("still extracts an append redirect target", () => { + const result = extractFileMap([mkBashEvent("echo data >> log.txt")]); + expect(result.files.map((f) => f.file_path)).toEqual(["log.txt"]); + }); + + test("still extracts mkdir and touch targets", () => { + const mk = extractFileMap([mkBashEvent("mkdir -p dist/assets")]); + const tch = extractFileMap([mkBashEvent("touch newfile.ts")]); + expect(mk.files.map((f) => f.file_path)).toEqual(["dist/assets"]); + expect(tch.files.map((f) => f.file_path)).toEqual(["newfile.ts"]); + }); + + test("rejects tokens carrying shell-syntax garbage characters", () => { + const result = extractFileMap([mkBashEvent('echo "$(date)" > "weird=name"')]); + // The quoted/garbage redirect target must not surface as a file. + const garbage = result.files.filter((f) => /["'`(){}=$<>;&|]/.test(f.file_path)); + expect(garbage).toEqual([]); + }); +}); + +describe("extractFileMap repo-root normalization aligns with git diff (cwd-in-subdir)", () => { + const ROOT = "/Users/dev/repo"; + const SUBDIR = "/Users/dev/repo/packages/web"; + + const subdirCtx = { + project_dir: ROOT, + cwd: SUBDIR, + git_branch: null, + git_remote: null, + git_commit: null, + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: null, + agent_type: null, + }; + + test("normalizes an absolute tool path under a subdir to a repo-root-relative key", () => { + // When a session runs in a subdirectory, project_dir is the repo root. + // git diff emits repo-root-relative paths (e.g. packages/web/src/app.ts), + // so the tool path must normalize to the SAME key, not the subdir-relative + // "src/app.ts". + const events: readonly StoredEvent[] = [ + { t: 500, event: "SessionStart", sid: "s1", context: subdirCtx, data: {} }, + mkToolEvent({ + event: "PreToolUse", + tool_name: "Edit", + file_path: `${SUBDIR}/src/app.ts`, + tool_use_id: "t1", + }), + ]; + + const result = extractFileMap(events); + expect(result.files[0].file_path).toBe("packages/web/src/app.ts"); + }); + + test("folds a tool event and a git-style repo-relative path onto one entry (merge dedup)", () => { + // Simulate the merge scenario: a tool event (absolute, under the subdir) + // and a bash heuristic emitting the repo-root-relative path git would use. + // Both must collapse onto a single repo-relative key. + const events: readonly StoredEvent[] = [ + { t: 500, event: "SessionStart", sid: "s1", context: subdirCtx, data: {} }, + mkToolEvent({ + event: "PreToolUse", + tool_name: "Edit", + file_path: `${SUBDIR}/src/app.ts`, + tool_use_id: "t1", + }), + // A bash redirect writing to the same file expressed repo-root-relative. + mkBashEvent("echo x > packages/web/src/app.ts", ROOT), + ]; + + const result = extractFileMap(events); + const matches = result.files.filter((f) => f.file_path === "packages/web/src/app.ts"); + expect(matches.length).toBe(1); + expect(result.files.length).toBe(1); + expect(matches[0].edits).toBe(1); + expect(matches[0].source).toBe("tool"); + }); + + test("falls back to subdir cwd only when no project_dir is captured", () => { + const events: readonly StoredEvent[] = [ + mkToolEvent({ + event: "PreToolUse", + tool_name: "Edit", + file_path: `${SUBDIR}/src/app.ts`, + tool_use_id: "t1", + cwd: SUBDIR, + }), + ]; + + const result = extractFileMap(events); + // Without a repo root, the subdir cwd is all we have; this is the + // degraded case the project_dir preference is designed to avoid. + expect(result.files[0].file_path).toBe("src/app.ts"); + }); +}); diff --git a/test/distill-git-diff.test.ts b/packages/cli/test/distill-git-diff.test.ts similarity index 100% rename from test/distill-git-diff.test.ts rename to packages/cli/test/distill-git-diff.test.ts diff --git a/packages/cli/test/distill-global.test.ts b/packages/cli/test/distill-global.test.ts new file mode 100644 index 0000000..ade8e43 --- /dev/null +++ b/packages/cli/test/distill-global.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DISTILL_SCHEMA_VERSION, distillAllGlobal, isDistilledFresh } from "../src/commands/distill"; +import type { GlobalSessionSummary } from "../src/types/distill"; + +// Two-project fixture, injected directly (no registry / HOME dependency): +// - project mode: /proj-a/.clens/sessions/.jsonl +// - repository mode: /repo-b/packages/web/.clens/sessions/.jsonl (nested) +const SESSION_A = "aaaaaaaa-1111-1111-1111-111111111111"; +const SESSION_B = "bbbbbbbb-1111-1111-1111-111111111111"; +const SESSION_MISSING = "cccccccc-1111-1111-1111-111111111111"; + +const makeEvent = ( + event: string, + t: number, + sid: string, + data: Record = {}, +): string => JSON.stringify({ event, t, sid, data, context: { git_branch: "main" } }); + +const writeSessionFile = (captureDir: string, sid: string): void => { + mkdirSync(join(captureDir, ".clens", "sessions"), { recursive: true }); + writeFileSync( + join(captureDir, ".clens", "sessions", `${sid}.jsonl`), + [ + makeEvent("SessionStart", 1000, sid, { source: "cli" }), + makeEvent("PreToolUse", 1500, sid, { tool_name: "Read", tool_use_id: "u1", tool_input: { file_path: "a.ts" } }), + makeEvent("PostToolUse", 1600, sid, { tool_name: "Read", tool_use_id: "u1", tool_input: { file_path: "a.ts" }, tool_response: "ok" }), + makeEvent("SessionEnd", 2000, sid, { reason: "done" }), + ].join("\n") + "\n", + ); +}; + +const makeGlobalSession = ( + sid: string, + captureDir: string, + projectName: string, +): GlobalSessionSummary => ({ + session_id: sid, + start_time: 1000, + duration_ms: 1000, + event_count: 4, + status: "complete", + file_size_bytes: 100, + project_id: projectName, + project_name: projectName, + capture_dir: captureDir, +}); + +const distilledPath = (captureDir: string, sid: string): string => + join(captureDir, ".clens", "distilled", `${sid}.json`); + +describe("distill-global batch driver", () => { + let tempDir: string; + let projAdir: string; + let nestedDir: string; + let sessions: readonly GlobalSessionSummary[]; + + beforeEach(() => { + tempDir = join( + tmpdir(), + `clens-test-distill-global-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + projAdir = join(tempDir, "proj-a"); + nestedDir = join(tempDir, "repo-b", "packages", "web"); + + writeSessionFile(projAdir, SESSION_A); + writeSessionFile(nestedDir, SESSION_B); + + sessions = [ + makeGlobalSession(SESSION_A, projAdir, "proj-a"), + makeGlobalSession(SESSION_B, nestedDir, "repo-b"), + ]; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("isDistilledFresh: false when distilled missing, true when newer + current schema", () => { + const sf = join(projAdir, ".clens", "sessions", `${SESSION_A}.jsonl`); + const df = distilledPath(projAdir, SESSION_A); + expect(isDistilledFresh(sf, df)).toBe(false); + mkdirSync(join(projAdir, ".clens", "distilled"), { recursive: true }); + // A distilled artifact with no schema_version is treated as stale (DIST-4). + writeFileSync(df, "{}"); + expect(isDistilledFresh(sf, df)).toBe(false); + // Newer than the session AND stamped with the current schema version -> fresh. + writeFileSync(df, JSON.stringify({ schema_version: DISTILL_SCHEMA_VERSION })); + expect(isDistilledFresh(sf, df)).toBe(true); + // A mismatched schema version (e.g. after a bump) -> stale even though mtime is fine. + writeFileSync(df, JSON.stringify({ schema_version: DISTILL_SCHEMA_VERSION + 1 })); + expect(isDistilledFresh(sf, df)).toBe(false); + // Pricing-tier drift relative to an explicit expected tier -> stale. + writeFileSync(df, JSON.stringify({ schema_version: DISTILL_SCHEMA_VERSION, pricing_tier: "max" })); + expect(isDistilledFresh(sf, df, "api")).toBe(false); + expect(isDistilledFresh(sf, df, "max")).toBe(true); + }); + + test("each session distills into its own capture dir (incl. nested repo)", async () => { + const counts = await distillAllGlobal({ deep: false, force: false, sessions }); + + expect(counts.distilled).toBe(2); + expect(counts.skipped).toBe(0); + expect(counts.failed).toBe(0); + expect(counts.projectCount).toBe(2); + + // Project-mode session writes to its own dir. + expect(existsSync(distilledPath(projAdir, SESSION_A))).toBe(true); + // Nested repo session routes to the nested capture dir, not the git root. + expect(existsSync(distilledPath(nestedDir, SESSION_B))).toBe(true); + expect(existsSync(join(tempDir, "repo-b", ".clens", "distilled", `${SESSION_B}.json`))).toBe(false); + }); + + test("second run with no changes skips all (incremental)", async () => { + await distillAllGlobal({ deep: false, force: false, sessions }); + const second = await distillAllGlobal({ deep: false, force: false, sessions }); + + expect(second.distilled).toBe(0); + expect(second.skipped).toBe(2); + expect(second.failed).toBe(0); + }); + + test("force re-distills all even when fresh", async () => { + await distillAllGlobal({ deep: false, force: false, sessions }); + const forced = await distillAllGlobal({ deep: false, force: true, sessions }); + + expect(forced.distilled).toBe(2); + expect(forced.skipped).toBe(0); + }); + + test("a session with no backing file is counted failed; others still complete", async () => { + const withBad = [ + ...sessions, + makeGlobalSession(SESSION_MISSING, join(tempDir, "ghost"), "ghost"), + ]; + const counts = await distillAllGlobal({ deep: false, force: false, sessions: withBad }); + + expect(counts.failed).toBe(1); + expect(counts.distilled).toBe(2); + expect(existsSync(distilledPath(projAdir, SESSION_A))).toBe(true); + expect(existsSync(distilledPath(nestedDir, SESSION_B))).toBe(true); + }); + + test("throws a clean error when no sessions exist", async () => { + await expect(distillAllGlobal({ deep: false, force: false, sessions: [] })).rejects.toThrow( + /No sessions found across registered projects/, + ); + }); +}); diff --git a/test/distill-journey.test.ts b/packages/cli/test/distill-journey.test.ts similarity index 100% rename from test/distill-journey.test.ts rename to packages/cli/test/distill-journey.test.ts diff --git a/test/distill-plan-drift.test.ts b/packages/cli/test/distill-plan-drift.test.ts similarity index 92% rename from test/distill-plan-drift.test.ts rename to packages/cli/test/distill-plan-drift.test.ts index 474d0da..011c6ec 100644 --- a/test/distill-plan-drift.test.ts +++ b/packages/cli/test/distill-plan-drift.test.ts @@ -186,8 +186,10 @@ describe("parseSpecExpectedFiles", () => { expect(parseSpecExpectedFiles(content)).toEqual(["src/real/path.ts"]); }); - test("extracts paths from table rows", () => { + test("extracts paths from table rows inside a files section", () => { const content = [ + "## Files", + "", "| File | Description |", "|------|-------------|", "| src/distill/stats.ts | Statistics extraction |", @@ -199,8 +201,22 @@ describe("parseSpecExpectedFiles", () => { ]); }); - test("extracts backtick-wrapped paths from table rows", () => { + test("does not extract table paths outside a files section (over-extraction)", () => { + // A table under a non-files heading is prose context, not a deliverables list. + const content = [ + "## Background", + "", + "| File | Description |", + "|------|-------------|", + "| src/distill/stats.ts | Statistics extraction |", + ].join("\n"); + expect(parseSpecExpectedFiles(content)).toEqual([]); + }); + + test("extracts backtick-wrapped paths from table rows inside a files section", () => { const content = [ + "## Files to Modify", + "", "| File | Action |", "|------|--------|", "| `src/distill/index.ts` | Modify |", @@ -212,9 +228,9 @@ describe("parseSpecExpectedFiles", () => { ]); }); - test("extracts inline backtick paths from non-bullet text", () => { + test("extracts inline backtick paths from non-bullet text inside a files section", () => { const content = [ - "# Overview", + "## Files", "", "We need to modify `src/distill/plan-drift.ts` and also update `src/types/distill.ts` accordingly.", ].join("\n"); @@ -224,6 +240,16 @@ describe("parseSpecExpectedFiles", () => { ]); }); + test("ignores inline backtick prose mentions outside a files section (over-extraction)", () => { + // Prose code-references and placeholder paths must NOT inflate the expected set. + const content = [ + "# Overview", + "", + "While debugging we touched `src/distill/plan-drift.ts`; see `path/to/file.ts` for the pattern.", + ].join("\n"); + expect(parseSpecExpectedFiles(content)).toEqual([]); + }); + test("does not double-extract backtick paths from bullet lines", () => { const content = [ "## Files to Create", @@ -233,8 +259,9 @@ describe("parseSpecExpectedFiles", () => { expect(parseSpecExpectedFiles(content)).toEqual(["src/new-file.ts"]); }); - test("skips command-like strings in inline backticks", () => { + test("skips command-like strings in inline backticks inside a files section", () => { const content = [ + "## Files", "Run `bun test` and `npm install` then check `src/app.ts`.", ].join("\n"); expect(parseSpecExpectedFiles(content)).toEqual(["src/app.ts"]); @@ -252,10 +279,12 @@ describe("parseSpecExpectedFiles", () => { }); test("handles mixed code blocks, tables, and inline backticks", () => { + // `src/distill/index.ts` is declared via an explicit Modify: prefix (honored + // anywhere). The table path is inside a files section; code-block paths are global. const content = [ "# Implementation Plan", "", - "Modify `src/distill/index.ts` as the main orchestrator.", + "Modify: `src/distill/index.ts`", "", "## Files", "", @@ -276,8 +305,9 @@ describe("parseSpecExpectedFiles", () => { ]); }); - test("inline backtick extraction requires / in path", () => { + test("inline backtick extraction requires / in path inside a files section", () => { const content = [ + "## Files", "Check `README.md` and `src/app.ts` for details.", ].join("\n"); // README.md has no /, so only src/app.ts is extracted diff --git a/test/distill-reasoning.test.ts b/packages/cli/test/distill-reasoning.test.ts similarity index 100% rename from test/distill-reasoning.test.ts rename to packages/cli/test/distill-reasoning.test.ts diff --git a/test/distill-summary.test.ts b/packages/cli/test/distill-summary.test.ts similarity index 100% rename from test/distill-summary.test.ts rename to packages/cli/test/distill-summary.test.ts diff --git a/packages/cli/test/distill-synthetic-links.test.ts b/packages/cli/test/distill-synthetic-links.test.ts new file mode 100644 index 0000000..a37be36 --- /dev/null +++ b/packages/cli/test/distill-synthetic-links.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, test } from "bun:test"; +import { + buildSyntheticLinks, + extractUnlinkedAgentCalls, + matchAgentCallsToSessions, + synthesizeSpawnLinks, + type ScanSessionFilesFn, +} from "../src/distill/synthetic-links"; +import type { LinkEvent, SpawnLink, StopLink, StoredEvent } from "../src/types"; + +/** No-op scan function for tests that never reach the scan phase. */ +const noopScan: ScanSessionFilesFn = () => []; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const makeStoredEvent = ( + overrides: Partial & { event: StoredEvent["event"] }, +): StoredEvent => ({ + t: 1000, + sid: "root-session", + data: {}, + ...overrides, +}); + +const makeAgentPreToolUse = ( + t: number, + name: string, + subagentType: string, + description = "", +): StoredEvent => + makeStoredEvent({ + t, + event: "PreToolUse", + data: { + tool_name: "Agent", + tool_input: { name, subagent_type: subagentType, description }, + }, + }); + +const makeSpawnLink = (overrides: Partial = {}): SpawnLink => ({ + t: 1000, + type: "spawn", + parent_session: "root-session", + agent_id: "agent-1", + agent_type: "builder", + ...overrides, +}); + +const makeStopLink = (overrides: Partial = {}): StopLink => ({ + t: 2000, + type: "stop", + parent_session: "root-session", + agent_id: "agent-1", + ...overrides, +}); + +interface AgentCall { + readonly t: number; + readonly name: string; + readonly agentType: string; + readonly description: string; +} + +interface SessionFileInfo { + readonly sessionId: string; + readonly startT: number; + readonly endT: number | undefined; +} + +interface AgentSessionMatch { + readonly call: AgentCall; + readonly session: SessionFileInfo; + readonly deltaMs: number; +} + +// --------------------------------------------------------------------------- +// extractUnlinkedAgentCalls +// --------------------------------------------------------------------------- + +describe("extractUnlinkedAgentCalls", () => { + test("returns empty when Agent call has matching SpawnLink within 2s", () => { + const events = [makeAgentPreToolUse(1000, "builder-data", "builder")]; + const links: readonly LinkEvent[] = [ + makeSpawnLink({ t: 1500, agent_type: "builder" }), + ]; + + const result = extractUnlinkedAgentCalls(events, links); + expect(result).toEqual([]); + }); + + test("returns AgentCall when no matching SubagentStart exists", () => { + const events = [makeAgentPreToolUse(1000, "builder-data", "builder", "build the data layer")]; + const links: readonly LinkEvent[] = []; + + const result = extractUnlinkedAgentCalls(events, links); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("builder-data"); + expect(result[0].agentType).toBe("builder"); + expect(result[0].description).toBe("build the data layer"); + expect(result[0].t).toBe(1000); + }); + + test("returns only unlinked calls when some are linked and some are not", () => { + const events = [ + makeAgentPreToolUse(1000, "builder-a", "builder"), + makeAgentPreToolUse(2000, "researcher-b", "researcher"), + makeAgentPreToolUse(3000, "builder-c", "builder"), + ]; + // Only builder at t=1000 and t=3000 have matching spawns + const links: readonly LinkEvent[] = [ + makeSpawnLink({ t: 1100, agent_type: "builder" }), + makeSpawnLink({ t: 3100, agent_type: "builder" }), + ]; + + const result = extractUnlinkedAgentCalls(events, links); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("researcher-b"); + }); + + test("filters out Agent call without name or subagent_type", () => { + const events = [ + makeStoredEvent({ + t: 1000, + event: "PreToolUse", + data: { + tool_name: "Agent", + tool_input: { description: "some work" }, + }, + }), + ]; + + const result = extractUnlinkedAgentCalls(events, []); + expect(result).toEqual([]); + }); + + test("Agent call with name but no subagent_type is included", () => { + const events = [ + makeStoredEvent({ + t: 1000, + event: "PreToolUse", + data: { + tool_name: "Agent", + tool_input: { name: "helper" }, + }, + }), + ]; + + const result = extractUnlinkedAgentCalls(events, []); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("helper"); + expect(result[0].agentType).toBe(""); + }); + + test("Agent call with subagent_type but no name is included", () => { + const events = [ + makeStoredEvent({ + t: 1000, + event: "PreToolUse", + data: { + tool_name: "Agent", + tool_input: { subagent_type: "builder" }, + }, + }), + ]; + + const result = extractUnlinkedAgentCalls(events, []); + expect(result).toHaveLength(1); + expect(result[0].agentType).toBe("builder"); + expect(result[0].name).toBe(""); + }); + + test("SpawnLink with wrong agent_type does not match", () => { + const events = [makeAgentPreToolUse(1000, "builder-x", "builder")]; + const links: readonly LinkEvent[] = [ + makeSpawnLink({ t: 1100, agent_type: "researcher" }), + ]; + + const result = extractUnlinkedAgentCalls(events, links); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("builder-x"); + }); + + test("SpawnLink outside 2s window does not match", () => { + const events = [makeAgentPreToolUse(1000, "builder-x", "builder")]; + const links: readonly LinkEvent[] = [ + makeSpawnLink({ t: 5000, agent_type: "builder" }), + ]; + + const result = extractUnlinkedAgentCalls(events, links); + expect(result).toHaveLength(1); + }); + + test("ignores non-Agent PreToolUse events", () => { + const events = [ + makeStoredEvent({ + t: 1000, + event: "PreToolUse", + data: { tool_name: "Edit", tool_input: {} }, + }), + ]; + + const result = extractUnlinkedAgentCalls(events, []); + expect(result).toEqual([]); + }); + + test("ignores non-PreToolUse events", () => { + const events = [ + makeStoredEvent({ t: 1000, event: "PostToolUse", data: { tool_name: "Agent" } }), + makeStoredEvent({ t: 1000, event: "SessionStart" }), + ]; + + const result = extractUnlinkedAgentCalls(events, []); + expect(result).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// matchAgentCallsToSessions +// --------------------------------------------------------------------------- + +describe("matchAgentCallsToSessions", () => { + test("single call matched to single candidate within window", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "builder-a", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-a", startT: 1200, endT: 5000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toHaveLength(1); + expect(result[0].call.name).toBe("builder-a"); + expect(result[0].session.sessionId).toBe("sess-a"); + expect(result[0].deltaMs).toBe(200); + }); + + test("candidate outside 15s window is not matched", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "builder-a", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-a", startT: 20_000, endT: 30_000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toEqual([]); + }); + + test("candidate startT before call.t is not matched", () => { + const calls: readonly AgentCall[] = [ + { t: 5000, name: "builder-a", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-a", startT: 4000, endT: 8000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toEqual([]); + }); + + test("multiple calls matched to multiple candidates by closest fit", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "builder-a", agentType: "builder", description: "" }, + { t: 5000, name: "researcher-b", agentType: "researcher", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-1", startT: 1100, endT: 3000 }, + { sessionId: "sess-2", startT: 5200, endT: 8000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toHaveLength(2); + expect(result[0].session.sessionId).toBe("sess-1"); + expect(result[0].deltaMs).toBe(100); + expect(result[1].session.sessionId).toBe("sess-2"); + expect(result[1].deltaMs).toBe(200); + }); + + test("duplicate agent names match sequentially without double-claim", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "builder-data", agentType: "builder", description: "" }, + { t: 3000, name: "builder-data", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-1", startT: 1100, endT: 2000 }, + { sessionId: "sess-2", startT: 3100, endT: 5000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toHaveLength(2); + expect(result[0].session.sessionId).toBe("sess-1"); + expect(result[1].session.sessionId).toBe("sess-2"); + }); + + test("more calls than candidates: matches what it can", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "a", agentType: "builder", description: "" }, + { t: 2000, name: "b", agentType: "builder", description: "" }, + { t: 3000, name: "c", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-1", startT: 1100, endT: 1500 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toHaveLength(1); + expect(result[0].call.name).toBe("a"); + expect(result[0].session.sessionId).toBe("sess-1"); + }); + + test("more candidates than calls: each call claims one", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "a", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-1", startT: 1050, endT: 2000 }, + { sessionId: "sess-2", startT: 1100, endT: 3000 }, + { sessionId: "sess-3", startT: 1200, endT: 4000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toHaveLength(1); + expect(result[0].session.sessionId).toBe("sess-1"); // closest + }); + + test("empty calls returns empty matches", () => { + const result = matchAgentCallsToSessions([], [ + { sessionId: "sess-1", startT: 1000, endT: 2000 }, + ]); + expect(result).toEqual([]); + }); + + test("empty candidates returns empty matches", () => { + const result = matchAgentCallsToSessions( + [{ t: 1000, name: "a", agentType: "builder", description: "" }], + [], + ); + expect(result).toEqual([]); + }); + + test("selects closest candidate when multiple are within window", () => { + const calls: readonly AgentCall[] = [ + { t: 1000, name: "a", agentType: "builder", description: "" }, + ]; + const candidates: readonly SessionFileInfo[] = [ + { sessionId: "sess-far", startT: 5000, endT: 8000 }, + { sessionId: "sess-close", startT: 1010, endT: 3000 }, + { sessionId: "sess-mid", startT: 2000, endT: 4000 }, + ]; + + const result = matchAgentCallsToSessions(calls, candidates); + expect(result).toHaveLength(1); + expect(result[0].session.sessionId).toBe("sess-close"); + expect(result[0].deltaMs).toBe(10); + }); +}); + +// --------------------------------------------------------------------------- +// buildSyntheticLinks +// --------------------------------------------------------------------------- + +describe("buildSyntheticLinks", () => { + const makeMatch = (overrides: Partial = {}): AgentSessionMatch => ({ + call: { t: 1000, name: "builder-a", agentType: "builder", description: "do stuff" }, + session: { sessionId: "child-sess", startT: 1200, endT: 5000 }, + deltaMs: 200, + ...overrides, + }); + + test("produces SpawnLink with correct fields and synthetic=true", () => { + const matches = [makeMatch()]; + + const { spawns, stops } = buildSyntheticLinks(matches, "parent-sess"); + expect(spawns).toHaveLength(1); + expect(spawns[0].type).toBe("spawn"); + expect(spawns[0].t).toBe(1000); + expect(spawns[0].parent_session).toBe("parent-sess"); + expect(spawns[0].agent_id).toBe("child-sess"); + expect(spawns[0].agent_type).toBe("builder"); + expect(spawns[0].agent_name).toBe("builder-a"); + expect(spawns[0].synthetic).toBe(true); + }); + + test("produces StopLink with correct fields from endT", () => { + const matches = [makeMatch()]; + + const { stops } = buildSyntheticLinks(matches, "parent-sess"); + expect(stops).toHaveLength(1); + expect(stops[0].type).toBe("stop"); + expect(stops[0].t).toBe(5000); + expect(stops[0].parent_session).toBe("parent-sess"); + expect(stops[0].agent_id).toBe("child-sess"); + expect(stops[0].synthetic).toBe(true); + }); + + test("no StopLink when session endT is undefined", () => { + const matches = [makeMatch({ + session: { sessionId: "child-sess", startT: 1200, endT: undefined }, + })]; + + const { spawns, stops } = buildSyntheticLinks(matches, "parent-sess"); + expect(spawns).toHaveLength(1); + expect(stops).toEqual([]); + }); + + test("empty matches produces empty results", () => { + const { spawns, stops } = buildSyntheticLinks([], "parent-sess"); + expect(spawns).toEqual([]); + expect(stops).toEqual([]); + }); + + test("multiple matches produce correct spawn and stop counts", () => { + const matches = [ + makeMatch({ + call: { t: 1000, name: "a", agentType: "builder", description: "" }, + session: { sessionId: "s1", startT: 1100, endT: 3000 }, + }), + makeMatch({ + call: { t: 2000, name: "b", agentType: "researcher", description: "" }, + session: { sessionId: "s2", startT: 2200, endT: undefined }, + }), + makeMatch({ + call: { t: 3000, name: "c", agentType: "builder", description: "" }, + session: { sessionId: "s3", startT: 3100, endT: 8000 }, + }), + ]; + + const { spawns, stops } = buildSyntheticLinks(matches, "parent-sess"); + expect(spawns).toHaveLength(3); + expect(stops).toHaveLength(2); // s2 has no endT + expect(spawns[1].agent_type).toBe("researcher"); + expect(stops[0].agent_id).toBe("s1"); + expect(stops[1].agent_id).toBe("s3"); + }); + + test("agent_name is undefined when call.name is empty string", () => { + const matches = [makeMatch({ + call: { t: 1000, name: "", agentType: "builder", description: "" }, + })]; + + const { spawns } = buildSyntheticLinks(matches, "parent-sess"); + expect(spawns[0].agent_name).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// synthesizeSpawnLinks (integration — no-op cases only, avoids filesystem) +// --------------------------------------------------------------------------- + +describe("synthesizeSpawnLinks", () => { + test("returns empty when all Agent calls are already linked", () => { + const events = [makeAgentPreToolUse(1000, "builder-a", "builder")]; + const links: readonly LinkEvent[] = [ + makeSpawnLink({ t: 1100, agent_type: "builder", agent_id: "child-1" }), + ]; + + const result = synthesizeSpawnLinks(events, links, "/tmp/proj", "root-session", noopScan); + expect(result.spawns).toEqual([]); + expect(result.stops).toEqual([]); + }); + + test("returns empty when no Agent PreToolUse events exist", () => { + const events = [ + makeStoredEvent({ t: 1000, event: "SessionStart" }), + makeStoredEvent({ t: 2000, event: "PostToolUse", data: { tool_name: "Edit" } }), + ]; + + const result = synthesizeSpawnLinks(events, [], "/tmp/proj", "root-session", noopScan); + expect(result.spawns).toEqual([]); + expect(result.stops).toEqual([]); + }); + + test("returns empty for empty events array", () => { + const result = synthesizeSpawnLinks([], [], "/tmp/proj", "root-session", noopScan); + expect(result.spawns).toEqual([]); + expect(result.stops).toEqual([]); + }); + + test("returns empty when events have t=0 timestamps", () => { + const events = [ + makeStoredEvent({ + t: 0, + event: "PreToolUse", + data: { + tool_name: "Agent", + tool_input: { name: "builder-a", subagent_type: "builder" }, + }, + }), + ]; + + const result = synthesizeSpawnLinks(events, [], "/tmp/proj", "root-session", noopScan); + expect(result.spawns).toEqual([]); + expect(result.stops).toEqual([]); + }); +}); diff --git a/packages/cli/test/distill-task-list.test.ts b/packages/cli/test/distill-task-list.test.ts new file mode 100644 index 0000000..ab8202c --- /dev/null +++ b/packages/cli/test/distill-task-list.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, test } from "bun:test"; +import { extractTaskList } from "../src/distill/task-list"; +import type { + LinkEvent, + MessageLink, + SpawnLink, + TaskCompleteLink, + TaskLink, +} from "../src/types"; + +// -- Helper factories -- + +const mkTaskCreate = (overrides: Partial = {}): TaskLink => ({ + t: 1000, + type: "task", + action: "create", + task_id: "task-1", + session_id: "session-1", + subject: "Build feature", + ...overrides, +}); + +const mkTaskUpdate = (overrides: Partial = {}): TaskLink => ({ + t: 2000, + type: "task", + action: "status_change", + task_id: "task-1", + session_id: "session-1", + ...overrides, +}); + +const mkTaskComplete = (overrides: Partial = {}): TaskCompleteLink => ({ + t: 5000, + type: "task_complete", + task_id: "task-1", + agent: "builder-1", + subject: "Build feature", + ...overrides, +}); + +const mkSpawn = (overrides: Partial = {}): SpawnLink => ({ + t: 500, + type: "spawn", + parent_session: "leader-session", + agent_id: "agent-1", + agent_type: "builder", + ...overrides, +}); + +const mkMessage = (overrides: Partial = {}): MessageLink => ({ + t: 1500, + type: "msg_send", + session_id: "session-1", + from: "leader", + to: "builder-1", + msg_type: "message", + ...overrides, +}); + +describe("extractTaskList", () => { + test("empty links returns empty result", () => { + const result = extractTaskList([]); + + expect(result.tasks).toEqual([]); + expect(result.total_count).toBe(0); + expect(result.completed_count).toBe(0); + expect(result.completion_rate).toBe(0); + }); + + test("task creation from create links", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ task_id: "task-1", subject: "Build feature", session_id: "sess-1" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].task_id).toBe("task-1"); + expect(result.tasks[0].subject).toBe("Build feature"); + expect(result.tasks[0].status).toBe("pending"); + expect(result.tasks[0].created_at).toBe(1000); + expect(result.tasks[0].created_by).toBe("sess-1"); + expect(result.total_count).toBe(1); + expect(result.completed_count).toBe(0); + expect(result.completion_rate).toBe(0); + }); + + test("task_id ordinal assignment for empty task_id", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "First task" }), + mkTaskCreate({ t: 2000, task_id: "", subject: "Second task" }), + mkTaskCreate({ t: 3000, task_id: "", subject: "Third task" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(3); + expect(result.tasks[0].task_id).toBe("task-1"); + expect(result.tasks[1].task_id).toBe("task-2"); + expect(result.tasks[2].task_id).toBe("task-3"); + }); + + test("full lifecycle: create, assign, status_change, complete", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "task-1", subject: "Build feature" }), + mkTaskUpdate({ t: 2000, task_id: "task-1", action: "assign", owner: "builder-1" }), + mkTaskUpdate({ t: 3000, task_id: "task-1", action: "status_change", status: "in_progress" }), + mkTaskComplete({ t: 5000, task_id: "task-1" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + const task = result.tasks[0]; + expect(task.task_id).toBe("task-1"); + expect(task.subject).toBe("Build feature"); + expect(task.owner).toBe("builder-1"); + expect(task.status).toBe("completed"); + expect(task.created_at).toBe(1000); + expect(task.completed_at).toBe(5000); + }); + + test("blocked_by propagation from TaskUpdate", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "task-1", subject: "Feature A" }), + mkTaskCreate({ t: 1100, task_id: "task-2", subject: "Feature B" }), + mkTaskUpdate({ + t: 2000, + task_id: "task-2", + action: "status_change", + blocked_by: ["task-1"], + }), + ]; + + const result = extractTaskList(links); + + const task2 = result.tasks.find((t) => t.task_id === "task-2"); + expect(task2).toBeDefined(); + expect(task2?.blocked_by).toEqual(["task-1"]); + }); + + test("completion_rate calculation", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "t1", subject: "Task 1" }), + mkTaskCreate({ t: 1100, task_id: "t2", subject: "Task 2" }), + mkTaskCreate({ t: 1200, task_id: "t3", subject: "Task 3" }), + mkTaskCreate({ t: 1300, task_id: "t4", subject: "Task 4" }), + mkTaskComplete({ t: 3000, task_id: "t1" }), + mkTaskComplete({ t: 4000, task_id: "t2" }), + ]; + + const result = extractTaskList(links); + + expect(result.total_count).toBe(4); + expect(result.completed_count).toBe(2); + expect(result.completion_rate).toBe(0.5); + }); + + test("mixed task and non-task links are filtered", () => { + const links: readonly LinkEvent[] = [ + mkSpawn(), + mkMessage(), + mkTaskCreate({ t: 1000, task_id: "task-1", subject: "Build it" }), + mkMessage({ t: 2500 }), + mkTaskComplete({ t: 5000, task_id: "task-1" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].task_id).toBe("task-1"); + expect(result.tasks[0].status).toBe("completed"); + expect(result.total_count).toBe(1); + expect(result.completed_count).toBe(1); + expect(result.completion_rate).toBe(1); + }); + + test("description and active_form preservation", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ + task_id: "task-1", + subject: "Build feature", + description: "Implement the widget with full tests", + active_form: "Building widget...", + }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks[0].description).toBe("Implement the widget with full tests"); + expect(result.tasks[0].active_form).toBe("Building widget..."); + }); + + test("task_complete without matching create produces minimal record", () => { + const links: readonly LinkEvent[] = [ + mkTaskComplete({ + t: 5000, + task_id: "orphan-task", + agent: "builder-1", + subject: "Orphan task", + session_id: "sess-orphan", + }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + const task = result.tasks[0]; + expect(task.task_id).toBe("orphan-task"); + expect(task.subject).toBe("Orphan task"); + expect(task.status).toBe("completed"); + expect(task.created_at).toBe(5000); + expect(task.completed_at).toBe(5000); + expect(task.owner).toBe("builder-1"); + expect(task.created_by).toBe("sess-orphan"); + }); + + test("tasks sorted by created_at", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 3000, task_id: "t3", subject: "Third" }), + mkTaskCreate({ t: 1000, task_id: "t1", subject: "First" }), + mkTaskCreate({ t: 2000, task_id: "t2", subject: "Second" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks[0].task_id).toBe("t1"); + expect(result.tasks[1].task_id).toBe("t2"); + expect(result.tasks[2].task_id).toBe("t3"); + }); + + test("multiple tasks with mixed statuses", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "t1", subject: "Task 1" }), + mkTaskCreate({ t: 1100, task_id: "t2", subject: "Task 2" }), + mkTaskCreate({ t: 1200, task_id: "t3", subject: "Task 3" }), + mkTaskUpdate({ t: 2000, task_id: "t2", action: "status_change", status: "in_progress" }), + mkTaskComplete({ t: 3000, task_id: "t1" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(3); + expect(result.tasks.find((t) => t.task_id === "t1")?.status).toBe("completed"); + expect(result.tasks.find((t) => t.task_id === "t2")?.status).toBe("in_progress"); + expect(result.tasks.find((t) => t.task_id === "t3")?.status).toBe("pending"); + }); + + test("ID reconciliation: task-N created tasks match numeric N completions", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "First task" }), + mkTaskCreate({ t: 1100, task_id: "", subject: "Second task" }), + mkTaskCreate({ t: 1200, task_id: "", subject: "Third task" }), + mkTaskComplete({ t: 3000, task_id: "1", subject: "First task" }), + mkTaskComplete({ t: 4000, task_id: "2", subject: "Second task" }), + mkTaskComplete({ t: 5000, task_id: "3", subject: "Third task" }), + ]; + + const result = extractTaskList(links); + + // Should produce 3 tasks (not 6 duplicates) + expect(result.tasks).toHaveLength(3); + expect(result.total_count).toBe(3); + expect(result.completed_count).toBe(3); + expect(result.completion_rate).toBe(1); + // All should be completed with proper subjects + expect(result.tasks[0].subject).toBe("First task"); + expect(result.tasks[0].status).toBe("completed"); + expect(result.tasks[1].subject).toBe("Second task"); + expect(result.tasks[1].status).toBe("completed"); + expect(result.tasks[2].subject).toBe("Third task"); + expect(result.tasks[2].status).toBe("completed"); + }); + + test("ID reconciliation: task-N created tasks match numeric N updates", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "Build feature" }), + mkTaskUpdate({ t: 2000, task_id: "1", action: "status_change", status: "in_progress" }), + mkTaskUpdate({ t: 3000, task_id: "1", action: "assign", owner: "builder-1" }), + mkTaskComplete({ t: 5000, task_id: "1", subject: "Build feature" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + const task = result.tasks[0]; + expect(task.task_id).toBe("1"); + expect(task.subject).toBe("Build feature"); + expect(task.status).toBe("completed"); + expect(task.owner).toBe("builder-1"); + }); + + test("ID reconciliation: completion without subject preserves create subject", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "Named task" }), + mkTaskComplete({ t: 3000, task_id: "1" }), // no subject + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].subject).toBe("Named task"); + expect(result.tasks[0].status).toBe("completed"); + }); + + test("ID reconciliation: does not affect tasks with explicit IDs", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "custom-id", subject: "Custom task" }), + mkTaskComplete({ t: 3000, task_id: "custom-id" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].task_id).toBe("custom-id"); + expect(result.tasks[0].status).toBe("completed"); + }); + + test("status_change to completed marks task completed with completed_at (B12)", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "task-1", subject: "Build feature" }), + mkTaskUpdate({ + t: 4200, + task_id: "task-1", + action: "status_change", + status: "completed", + }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + const task = result.tasks[0]; + expect(task.status).toBe("completed"); + expect(task.completed_at).toBe(4200); + expect(result.completed_count).toBe(1); + expect(result.completion_rate).toBe(1); + }); + + test("status_change to completed counts toward completion_rate (B12)", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "t1", subject: "Task 1" }), + mkTaskCreate({ t: 1100, task_id: "t2", subject: "Task 2" }), + // t1 completed via status_change (no task_complete link emitted) + mkTaskUpdate({ t: 2000, task_id: "t1", action: "status_change", status: "completed" }), + ]; + + const result = extractTaskList(links); + + expect(result.total_count).toBe(2); + expect(result.completed_count).toBe(1); + expect(result.completion_rate).toBe(0.5); + expect(result.tasks.find((t) => t.task_id === "t1")?.status).toBe("completed"); + expect(result.tasks.find((t) => t.task_id === "t2")?.status).toBe("pending"); + }); + + test("status_change to deleted removes the task (B12)", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "t1", subject: "Keep" }), + mkTaskCreate({ t: 1100, task_id: "t2", subject: "Remove" }), + mkTaskUpdate({ t: 2000, task_id: "t2", action: "status_change", status: "deleted" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].task_id).toBe("t1"); + expect(result.total_count).toBe(1); + }); + + test("status_change deleted via reconciled ordinal id removes the task (B12)", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "Only task" }), + mkTaskUpdate({ t: 2000, task_id: "1", action: "status_change", status: "deleted" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(0); + expect(result.total_count).toBe(0); + }); + + test("update for non-existent task is ignored", () => { + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "task-1", subject: "Real task" }), + mkTaskUpdate({ t: 2000, task_id: "nonexistent", action: "assign", owner: "ghost" }), + ]; + + const result = extractTaskList(links); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].task_id).toBe("task-1"); + expect(result.tasks[0].owner).toBeUndefined(); + }); +}); diff --git a/test/distill-team.test.ts b/packages/cli/test/distill-team.test.ts similarity index 84% rename from test/distill-team.test.ts rename to packages/cli/test/distill-team.test.ts index b7507a6..06fdce6 100644 --- a/test/distill-team.test.ts +++ b/packages/cli/test/distill-team.test.ts @@ -6,6 +6,7 @@ import type { SpawnLink, StopLink, TaskCompleteLink, + TaskLink, TeamLink, TeammateIdleLink, } from "../src/types"; @@ -37,6 +38,16 @@ const mkTaskComplete = (overrides: Partial = {}): TaskComplete ...overrides, }); +const mkTaskCreate = (overrides: Partial = {}): TaskLink => ({ + t: 1000, + type: "task", + action: "create", + task_id: "", + session_id: "session-1", + subject: "Implement feature X", + ...overrides, +}); + const mkTeammateIdle = (overrides: Partial = {}): TeammateIdleLink => ({ t: 4000, type: "teammate_idle", @@ -115,12 +126,14 @@ describe("extractTeamMetrics", () => { task_id: "t1", agent: "builder-1", subject: "Task A", + status: "completed", t: 3000, }); expect(result.tasks[1]).toEqual({ task_id: "t2", agent: "builder-2", subject: "Task B", + status: "completed", t: 4000, }); expect(result.idle_transitions).toHaveLength(2); @@ -390,3 +403,51 @@ describe("extractTeamMetrics - utilization with agent_name=undefined (BUG-12)", expect(result.utilization_ratio ?? -1).toBeLessThan(1.0); }); }); + +describe("extractTeamMetrics - task correlation by subject (persistent lists)", () => { + test("correlates create→complete by subject when ids are persistent (non-ordinal)", () => { + // Regression (team-task-ordinal-correlation-breaks-on-persistent-lists): + // TaskCreate links fire at PreToolUse with empty task_id; the real id ("42") only + // appears on the complete link. The OLD code keyed creates by 1..N ordinal and + // looked up creates[Number(id)-1], so a persistent id like "42" never correlated. + // Correlation must run through the stable `subject`, not the ordinal. + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "Build the parser" }), + mkTaskComplete({ t: 5000, task_id: "42", agent: "builder-1", subject: "Build the parser" }), + ]; + + const result = extractTeamMetrics(links, new Set(), undefined); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]).toEqual({ + task_id: "42", + agent: "builder-1", + subject: "Build the parser", + status: "completed", + // creation time comes from the matched create link, not the complete link + t: 1000, + }); + }); + + test("does not mis-correlate by ordinal when create order differs from id order", () => { + // Two creates whose subjects intentionally do NOT line up with their persistent ids + // in ordinal order. Ordinal lookup (creates[Number(id)-1]) would pair the wrong + // timestamps; subject correlation pairs them correctly. + const links: readonly LinkEvent[] = [ + mkTaskCreate({ t: 1000, task_id: "", subject: "Alpha task" }), + mkTaskCreate({ t: 2000, task_id: "", subject: "Beta task" }), + // ids are 100 and 7 — neither equals an ordinal 1 or 2 + mkTaskComplete({ t: 9000, task_id: "100", agent: "a", subject: "Beta task" }), + mkTaskComplete({ t: 9500, task_id: "7", agent: "b", subject: "Alpha task" }), + ]; + + const result = extractTeamMetrics(links, new Set(), undefined); + + const byId = new Map(result.tasks.map((t) => [t.task_id, t] as const)); + // "Beta task" was created at t=2000; "Alpha task" at t=1000 + expect(byId.get("100")?.t).toBe(2000); + expect(byId.get("100")?.subject).toBe("Beta task"); + expect(byId.get("7")?.t).toBe(1000); + expect(byId.get("7")?.subject).toBe("Alpha task"); + }); +}); diff --git a/test/distill-timeline.test.ts b/packages/cli/test/distill-timeline.test.ts similarity index 100% rename from test/distill-timeline.test.ts rename to packages/cli/test/distill-timeline.test.ts diff --git a/test/distill.test.ts b/packages/cli/test/distill.test.ts similarity index 81% rename from test/distill.test.ts rename to packages/cli/test/distill.test.ts index e297f11..4a950e4 100644 --- a/test/distill.test.ts +++ b/packages/cli/test/distill.test.ts @@ -7,7 +7,7 @@ import { extractFileMap } from "../src/distill/file-map"; import { parseNumstatOutput } from "../src/distill/git-diff"; import { distill } from "../src/distill/index"; import { estimateCostFromTokens, extractStats } from "../src/distill/stats"; -import type { LinkEvent, StoredEvent, TranscriptReasoning } from "../src/types"; +import type { LinkEvent, StoredEvent, TokenUsage, TranscriptReasoning } from "../src/types"; const makeEvent = ( overrides: Partial & { event: StoredEvent["event"] }, @@ -114,6 +114,18 @@ describe("extractBacktracks", () => { tool_input: { command: "bun run typecheck" }, }, }), + // Loop keeps failing — a genuine debugging_loop requires a subsequent failure, + // not just one failure followed by commands that succeed. + makeEvent({ + t: 3500, + event: "PostToolUseFailure", + data: { + tool_name: "Bash", + tool_use_id: "b3f", + error: "type errors remain", + tool_input: { command: "bun run typecheck" }, + }, + }), ]; const backtracks = extractBacktracks(events); @@ -791,6 +803,40 @@ describe("extractStats - real token usage", () => { expect(stats.cost_estimate?.is_estimated).toBe(true); }); + test("token_usage is returned on StatsResult when usage data is present", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ + t: 2000, + event: "PostToolUse", + data: { + tool_name: "Read", + tool_use_id: "t1", + usage: { input_tokens: 1000, output_tokens: 500, cache_read_tokens: 200, cache_creation_tokens: 100 }, + }, + }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const stats = extractStats(events); + expect(stats.token_usage).toBeDefined(); + expect(stats.token_usage?.input_tokens).toBe(1000); + expect(stats.token_usage?.output_tokens).toBe(500); + expect(stats.token_usage?.cache_read_tokens).toBe(200); + expect(stats.token_usage?.cache_creation_tokens).toBe(100); + }); + + test("token_usage is undefined when no usage data", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read", tool_use_id: "t1" } }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const stats = extractStats(events); + expect(stats.token_usage).toBeUndefined(); + }); + test("reads token_usage field as alternative to usage", () => { const events: StoredEvent[] = [ makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), @@ -809,6 +855,229 @@ describe("extractStats - real token usage", () => { }); }); +// --------------------------------------------------------------------------- +// 3-tier cost resolution: transcript tokens > hook event tokens > heuristic +// --------------------------------------------------------------------------- + +describe("extractStats - 3-tier cost resolution", () => { + const sessionContext = { + project_dir: "/test", + cwd: "/test", + git_branch: null, + git_remote: null, + git_commit: null, + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model: "claude-sonnet-4-20250514", + agent_type: null, + } as const; + + test("Tier 1: transcriptTokenUsage takes priority over hook event tokens", () => { + const transcriptTokenUsage: TokenUsage = { + input_tokens: 5000, + output_tokens: 2000, + cache_read_tokens: 1000, + cache_creation_tokens: 500, + }; + + // Events with hook-level usage data (should be overridden by transcript) + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ + t: 2000, + event: "PostToolUse", + data: { + tool_name: "Read", + tool_use_id: "t1", + usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 10 }, + }, + }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const stats = extractStats(events, [], transcriptTokenUsage); + expect(stats.cost_estimate).toBeDefined(); + expect(stats.cost_estimate?.is_estimated).toBe(false); + // Should use transcript tokens, not hook tokens + expect(stats.cost_estimate?.estimated_input_tokens).toBe(5000); + expect(stats.cost_estimate?.estimated_output_tokens).toBe(2000); + expect(stats.cost_estimate?.cache_read_tokens).toBe(1000); + expect(stats.cost_estimate?.cache_creation_tokens).toBe(500); + // token_usage on StatsResult should be transcript's usage (resolved) + expect(stats.token_usage).toEqual(transcriptTokenUsage); + }); + + test("Tier 2: hook event tokens used when no transcriptTokenUsage", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ + t: 2000, + event: "PostToolUse", + data: { + tool_name: "Read", + tool_use_id: "t1", + usage: { input_tokens: 800, output_tokens: 400, cache_read_tokens: 50, cache_creation_tokens: 25 }, + }, + }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + const stats = extractStats(events, []); + expect(stats.cost_estimate).toBeDefined(); + expect(stats.cost_estimate?.is_estimated).toBe(false); + expect(stats.cost_estimate?.estimated_input_tokens).toBe(800); + expect(stats.cost_estimate?.estimated_output_tokens).toBe(400); + expect(stats.cost_estimate?.cache_read_tokens).toBe(50); + expect(stats.cost_estimate?.cache_creation_tokens).toBe(25); + // token_usage should reflect hook event tokens + expect(stats.token_usage?.input_tokens).toBe(800); + expect(stats.token_usage?.output_tokens).toBe(400); + }); + + test("Tier 3: heuristic used when no usage data at all, is_estimated: true", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read", tool_use_id: "t1" } }), + makeEvent({ t: 3000, event: "PostToolUse", data: { tool_name: "Read", tool_use_id: "t1" } }), + makeEvent({ t: 4000, event: "SessionEnd", data: {} }), + ]; + + const stats = extractStats(events, []); + expect(stats.cost_estimate).toBeDefined(); + expect(stats.cost_estimate?.is_estimated).toBe(true); + // Heuristic: totalEvents * 500 input, toolCallCount * 200 output + expect(stats.cost_estimate?.estimated_input_tokens).toBe(4 * 500); + expect(stats.cost_estimate?.estimated_output_tokens).toBe(1 * 200); + // No token_usage when heuristic is used + expect(stats.token_usage).toBeUndefined(); + }); + + test("transcriptTokenUsage with zero tokens: tier-1 path but is_estimated true (B26)", () => { + const transcriptTokenUsage: TokenUsage = { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: sessionContext }), + makeEvent({ t: 2000, event: "PreToolUse", data: { tool_name: "Read", tool_use_id: "t1" } }), + makeEvent({ t: 3000, event: "SessionEnd", data: {} }), + ]; + + // transcriptTokenUsage is defined (not undefined), so the tier-1 code path is + // taken and the zero tokens are reported back. But zero tokens are NOT real + // usage, so the estimate must not claim to be measured (B26): is_estimated true. + const stats = extractStats(events, [], transcriptTokenUsage); + expect(stats.cost_estimate).toBeDefined(); + expect(stats.cost_estimate?.is_estimated).toBe(true); + expect(stats.cost_estimate?.estimated_input_tokens).toBe(0); + expect(stats.cost_estimate?.estimated_output_tokens).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Model prefix matching for new model variants +// --------------------------------------------------------------------------- + +describe("extractStats - model prefix matching", () => { + const makeSessionContext = (model: string) => ({ + project_dir: "/test", + cwd: "/test", + git_branch: null, + git_remote: null, + git_commit: null, + git_worktree: null, + team_name: null, + task_list_dir: null, + claude_entrypoint: null, + model, + agent_type: null, + }) as const; + + test("claude-opus-4-6 matches claude-opus-4 pricing", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: makeSessionContext("claude-opus-4-6") }), + makeEvent({ t: 2000, event: "SessionEnd", data: {} }), + ]; + + const transcriptTokenUsage: TokenUsage = { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + const stats = extractStats(events, [], transcriptTokenUsage); + expect(stats.cost_estimate).toBeDefined(); + expect(stats.cost_estimate?.model).toBe("claude-opus-4-6"); + // Opus 4.6 pricing: $5/M input + $25/M output = $30 for 1M each + // (regression for bug B8 — 4.6 was billed at Opus 4.0's $15/$75 rates) + expect(stats.cost_estimate?.estimated_cost_usd).toBe(30); + expect(stats.cost_estimate?.is_estimated).toBe(false); + }); + + test("claude-sonnet-4-6 matches claude-sonnet-4 pricing", () => { + const events: StoredEvent[] = [ + makeEvent({ t: 1000, event: "SessionStart", data: {}, context: makeSessionContext("claude-sonnet-4-6") }), + makeEvent({ t: 2000, event: "SessionEnd", data: {} }), + ]; + + const transcriptTokenUsage: TokenUsage = { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + const stats = extractStats(events, [], transcriptTokenUsage); + expect(stats.cost_estimate).toBeDefined(); + expect(stats.cost_estimate?.model).toBe("claude-sonnet-4-6"); + // Sonnet pricing: $3/M input + $15/M output = $18 for 1M each + expect(stats.cost_estimate?.estimated_cost_usd).toBe(18); + expect(stats.cost_estimate?.is_estimated).toBe(false); + }); + + test("estimateCostFromTokens with claude-opus-4-6 uses opus 4.6 pricing", () => { + const result = estimateCostFromTokens("claude-opus-4-6", 1_000_000, 1_000_000); + expect(result).toBeDefined(); + expect(result?.model).toBe("claude-opus-4-6"); + // $5/M input + $25/M output = $30 (longest-prefix match beats claude-opus-4) + expect(result?.estimated_cost_usd).toBe(30); + expect(result?.is_estimated).toBe(false); + }); + + test("claude-opus-4-0 keeps legacy opus pricing (family fallback)", () => { + const result = estimateCostFromTokens("claude-opus-4-20250514", 1_000_000, 1_000_000); + // $15/M input + $75/M output = $90 + expect(result?.estimated_cost_usd).toBe(90); + }); + + test("claude-fable-5 is priced ($10/$50)", () => { + const result = estimateCostFromTokens("claude-fable-5[1m]", 1_000_000, 1_000_000); + // Regression for bug B8: the current model had no pricing entry → $0.00 shown as fact + expect(result?.estimated_cost_usd).toBe(60); + }); + + test("claude-haiku-4-5 is priced at haiku 4.5 rates, not haiku 3.5", () => { + const result = estimateCostFromTokens("claude-haiku-4-5-20251001", 1_000_000, 1_000_000); + // $1/M input + $5/M output = $6 (was $0.80/$4 via the claude-haiku-4 fallback) + expect(result?.estimated_cost_usd).toBe(6); + }); + + test("estimateCostFromTokens with claude-sonnet-4-6 uses sonnet pricing", () => { + const result = estimateCostFromTokens("claude-sonnet-4-6", 1_000_000, 1_000_000); + expect(result).toBeDefined(); + expect(result?.model).toBe("claude-sonnet-4-6"); + // $3/M input + $15/M output = $18 + expect(result?.estimated_cost_usd).toBe(18); + expect(result?.is_estimated).toBe(false); + }); +}); + describe("buildAgentTree - sub-agent hierarchy", () => { // We test the buildAgentTree logic indirectly since it's not exported. // Instead, we test that format types are correct and the tree shape makes sense. @@ -1254,6 +1523,28 @@ describe("distill - session-scoped link filtering", () => { expect(agentIds).not.toContain("agent-b1"); expect(agentIds).not.toContain("agent-b2"); }); + + // Regression: tier-change-never-recomputes-stale-tier-mixing. + // The distilled output must record the resolved tier it was priced at so a + // staleness layer can detect when the configured tier changed since distill. + test("records resolved pricing_tier (defaults to api with no config/override)", async () => { + const result = await distill(SESSION_A, TEST_DIR); + expect(result.pricing_tier).toBe("api"); + }); + + test("records the CLI-override pricing tier in the distilled output", async () => { + const result = await distill(SESSION_A, TEST_DIR, { pricingTier: "max" }); + expect(result.pricing_tier).toBe("max"); + }); + + test("records the config-file pricing tier when no CLI override", async () => { + const clensDir = `${TEST_DIR}/.clens`; + writeFileSync(`${clensDir}/config.json`, JSON.stringify({ capture: true, pricing: "max" })); + const result = await distill(SESSION_A, TEST_DIR); + expect(result.pricing_tier).toBe("max"); + // Cleanup so other tests in this describe see no config file. + rmSync(`${clensDir}/config.json`, { force: true }); + }); }); // --------------------------------------------------------------------------- diff --git a/test/drift-command.test.ts b/packages/cli/test/drift-command.test.ts similarity index 100% rename from test/drift-command.test.ts rename to packages/cli/test/drift-command.test.ts diff --git a/test/e2e/cli-json.test.ts b/packages/cli/test/e2e/cli-json.test.ts similarity index 100% rename from test/e2e/cli-json.test.ts rename to packages/cli/test/e2e/cli-json.test.ts diff --git a/test/e2e/cli-output.test.ts b/packages/cli/test/e2e/cli-output.test.ts similarity index 94% rename from test/e2e/cli-output.test.ts rename to packages/cli/test/e2e/cli-output.test.ts index feedf93..d0450cb 100644 --- a/test/e2e/cli-output.test.ts +++ b/packages/cli/test/e2e/cli-output.test.ts @@ -50,7 +50,6 @@ describe("CLI Text Output Format", () => { "--force", "--deep", "--json", - "--otel", "--comms", "--version", "--help", @@ -67,7 +66,9 @@ describe("CLI Text Output Format", () => { const plain = stripAnsi(r.stdout); expect(plain).toContain("ID"); expect(plain).toContain("Branch"); - expect(plain).toContain("Duration"); + // "Span" = wall-clock span (NUM-11: standardized Span/Active vocabulary; + // list per-row duration_ms remains wall-clock span, label renamed from "Duration") + expect(plain).toContain("Span"); expect(plain).toContain("Events"); expect(plain).toContain("Status"); }); diff --git a/test/e2e/cli-smoke.test.ts b/packages/cli/test/e2e/cli-smoke.test.ts similarity index 74% rename from test/e2e/cli-smoke.test.ts rename to packages/cli/test/e2e/cli-smoke.test.ts index 4d401bd..9fb3fc9 100644 --- a/test/e2e/cli-smoke.test.ts +++ b/packages/cli/test/e2e/cli-smoke.test.ts @@ -1,4 +1,7 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { cleanupTestProject, createTestProject, runCli, stripAnsi } from "./helpers"; describe("CLI Smoke Tests", () => { @@ -219,3 +222,70 @@ describe("CLI Smoke Tests", () => { } }); }); + +// ── distill --global (cross-repo batch) ───────────────── +// Isolated via a temp HOME so the seeded registry/config drive `--global` +// without touching the real `~/.clens`. A subprocess reads HOME at startup, +// so the override is honored (unlike an in-process os.homedir() override). +describe("CLI distill --global", () => { + let tempHome: string; + let projA: string; + let projB: string; + + const seedRegistry = (paths: readonly string[], mode: string): void => { + const clensDir = join(tempHome, ".clens"); + mkdirSync(clensDir, { recursive: true }); + writeFileSync( + join(clensDir, "projects.json"), + JSON.stringify({ + version: 1, + projects: paths.map((p) => ({ + id: p.split("/").pop() ?? p, + path: p, + name: p.split("/").pop() ?? p, + added_at: Date.now(), + })), + }), + ); + writeFileSync(join(clensDir, "config.json"), JSON.stringify({ global_mode: mode })); + }; + + beforeEach(() => { + tempHome = join(tmpdir(), `clens-e2e-home-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempHome, { recursive: true }); + // Two project-mode repos with sessions but no distilled artifacts yet. + projA = createTestProject({ sessionCount: 2, withLinks: false, withDistilled: false }); + projB = createTestProject({ sessionCount: 1, withLinks: false, withDistilled: false }); + seedRegistry([projA, projB], "project"); + }); + + afterEach(() => { + cleanupTestProject(projA); + cleanupTestProject(projB); + rmSync(tempHome, { recursive: true, force: true }); + }); + + test("distill --global runs, prints cross-project summary, exits 0", async () => { + const r = await runCli(["distill", "--global"], projA, { HOME: tempHome }); + expect(r.exitCode).toBe(0); + const plain = stripAnsi(r.stdout); + expect(plain).toMatch(/across \d+ projects\./); + // Each repo's distilled artifacts now exist. + expect(existsSync(join(projA, ".clens", "distilled"))).toBe(true); + expect(existsSync(join(projB, ".clens", "distilled"))).toBe(true); + }); + + test("second distill --global skips fresh sessions", async () => { + await runCli(["distill", "--global"], projA, { HOME: tempHome }); + const r = await runCli(["distill", "--global"], projA, { HOME: tempHome }); + expect(r.exitCode).toBe(0); + const plain = stripAnsi(r.stdout); + expect(plain).toMatch(/skipped [1-9]/); + }); + + test("distill --global --bogus rejected with unknown-flag message", async () => { + const r = await runCli(["distill", "--global", "--bogus"], projA, { HOME: tempHome }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Unknown flag --bogus"); + }); +}); diff --git a/test/e2e/helpers.ts b/packages/cli/test/e2e/helpers.ts similarity index 92% rename from test/e2e/helpers.ts rename to packages/cli/test/e2e/helpers.ts index c161a09..cf42a34 100644 --- a/test/e2e/helpers.ts +++ b/packages/cli/test/e2e/helpers.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; import type { AgentNode, CommunicationEdge, @@ -38,15 +39,28 @@ export type CliResult = { readonly duration_ms: number; }; -export const runCli = async (args: readonly string[], projectDir: string): Promise => { - const cliPath = `${import.meta.dir}/../../src/cli.ts`; +/** + * Path to the standalone COMPILED binary produced by `bun run build:bin` + * (`bun build --compile src/cli.ts --outfile bin/clens`). The publish smoke + * test exercises this exact artifact rather than the TypeScript source. + */ +export const COMPILED_BIN_PATH = resolve(import.meta.dir, "../../bin/clens"); + +// Shared spawn+capture core. `command` is the executable prefix (e.g. +// ["bun", "src/cli.ts"] for source or [COMPILED_BIN_PATH] for the binary). +const spawnAndCapture = async ( + command: readonly string[], + args: readonly string[], + projectDir: string, + envOverride: Readonly>, +): Promise => { const start = performance.now(); - const proc = Bun.spawn(["bun", cliPath, ...args], { + const proc = Bun.spawn([...command, ...args], { cwd: projectDir, stdout: "pipe", stderr: "pipe", - env: { ...process.env, NO_COLOR: "1" }, + env: { ...process.env, NO_COLOR: "1", ...envOverride }, }); const stdout = await new Response(proc.stdout).text(); @@ -69,6 +83,22 @@ export const runCli = async (args: readonly string[], projectDir: string): Promi return { stdout, stderr, exitCode, json, duration_ms }; }; +// Run the CLI from TypeScript source (fast; used by the dev/unit smoke suite). +export const runCli = ( + args: readonly string[], + projectDir: string, + envOverride: Readonly> = {}, +): Promise => + spawnAndCapture(["bun", `${import.meta.dir}/../../src/cli.ts`], args, projectDir, envOverride); + +// Run the standalone COMPILED binary (bin/clens) — the artifact users install. +// Build it first with `bun run build:bin`. Used by the publish smoke test. +export const runCompiledCli = ( + args: readonly string[], + projectDir: string, + envOverride: Readonly> = {}, +): Promise => spawnAndCapture([COMPILED_BIN_PATH], args, projectDir, envOverride); + // ── ANSI Helpers ──────────────────────────────────────── // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences require control characters @@ -389,6 +419,7 @@ const makeDistilledSession = (sessionId: string): DistilledSession => { estimated_input_tokens: 25000, estimated_output_tokens: 8000, estimated_cost_usd: 0.15, + is_estimated: false, }, }, ], @@ -400,6 +431,7 @@ const makeDistilledSession = (sessionId: string): DistilledSession => { estimated_input_tokens: 80000, estimated_output_tokens: 20000, estimated_cost_usd: 0.42, + is_estimated: false, }, }, ] @@ -466,6 +498,7 @@ const makeDistilledSession = (sessionId: string): DistilledSession => { estimated_input_tokens: sessionId === SESSION_1_ID ? 80000 : 20000, estimated_output_tokens: sessionId === SESSION_1_ID ? 20000 : 5000, estimated_cost_usd: sessionId === SESSION_1_ID ? 0.42 : 0.1, + is_estimated: false, }, }, backtracks: @@ -585,6 +618,7 @@ const makeDistilledSession = (sessionId: string): DistilledSession => { estimated_input_tokens: sessionId === SESSION_1_ID ? 80000 : 20000, estimated_output_tokens: sessionId === SESSION_1_ID ? 20000 : 5000, estimated_cost_usd: sessionId === SESSION_1_ID ? 0.42 : 0.1, + is_estimated: false, }, team_metrics: teamMetrics, communication_graph: commGraph, diff --git a/packages/cli/test/e2e/publish-smoke.test.ts b/packages/cli/test/e2e/publish-smoke.test.ts new file mode 100644 index 0000000..0777ff5 --- /dev/null +++ b/packages/cli/test/e2e/publish-smoke.test.ts @@ -0,0 +1,62 @@ +/** + * Publish smoke test — exercises the COMPILED standalone binary (bin/clens), + * NOT the TypeScript source. This is what `npm install`-ed users actually run, + * so the publish pipeline must verify it boots and answers basic commands. + * + * Gated behind CLENS_PUBLISH_SMOKE so a normal `bun test` never pays the cost + * of compiling a ~58MB standalone binary. The `smoke:publish` script + * (wired into `prepublishOnly`) runs `bun run build:bin` first, then invokes + * this file with the env flag set. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { + cleanupTestProject, + COMPILED_BIN_PATH, + createTestProject, + runCompiledCli, + stripAnsi, +} from "./helpers"; + +const PUBLISH_SMOKE = process.env.CLENS_PUBLISH_SMOKE === "1"; + +describe.skipIf(!PUBLISH_SMOKE)("Publish smoke (compiled bin/clens)", () => { + let projectDir: string; + + beforeAll(() => { + if (!existsSync(COMPILED_BIN_PATH)) { + throw new Error( + `Compiled binary not found at ${COMPILED_BIN_PATH}. Run \`bun run build:bin\` before the publish smoke test.`, + ); + } + projectDir = createTestProject({ sessionCount: 2, withLinks: true, withDistilled: true }); + }); + + afterAll(() => { + cleanupTestProject(projectDir); + }); + + test("--version exits 0 and outputs version string", async () => { + const r = await runCompiledCli(["--version"], projectDir); + expect(r.exitCode).toBe(0); + expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + test("--help exits 0 and shows usage", async () => { + const r = await runCompiledCli(["--help"], projectDir); + expect(r.exitCode).toBe(0); + expect(stripAnsi(r.stdout)).toContain("Usage:"); + }); + + test("list --json exits 0 and produces a JSON array", async () => { + const r = await runCompiledCli(["list", "--json"], projectDir); + expect(r.exitCode).toBe(0); + expect(Array.isArray(r.json)).toBe(true); + }); + + test("report --last --json exits 0 and produces a JSON object", async () => { + const r = await runCompiledCli(["report", "--last", "--json"], projectDir); + expect(r.exitCode).toBe(0); + expect(typeof r.json).toBe("object"); + }); +}); diff --git a/test/e2e/tui-states.test.ts b/packages/cli/test/e2e/tui-states.test.ts similarity index 100% rename from test/e2e/tui-states.test.ts rename to packages/cli/test/e2e/tui-states.test.ts diff --git a/test/edits.test.ts b/packages/cli/test/edits.test.ts similarity index 100% rename from test/edits.test.ts rename to packages/cli/test/edits.test.ts diff --git a/test/effective-duration.test.ts b/packages/cli/test/effective-duration.test.ts similarity index 100% rename from test/effective-duration.test.ts rename to packages/cli/test/effective-duration.test.ts diff --git a/test/export.test.ts b/packages/cli/test/export.test.ts similarity index 100% rename from test/export.test.ts rename to packages/cli/test/export.test.ts diff --git a/packages/cli/test/feature-usage.test.ts b/packages/cli/test/feature-usage.test.ts new file mode 100644 index 0000000..f8e0de9 --- /dev/null +++ b/packages/cli/test/feature-usage.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { detectFeatureFlags, extractFeatureUsage } from "../src/distill/feature-usage"; +import type { StoredEvent } from "../src/types"; + +const SID = "test-session"; + +const event = ( + eventType: string, + data: Record, + t = 1000, +): StoredEvent => ({ + t, + event: eventType as StoredEvent["event"], + sid: SID, + data, +}); + +const preTool = (toolName: string, toolInput: Record, t = 1000): StoredEvent => + event("PreToolUse", { tool_name: toolName, tool_input: toolInput }, t); + +const prompt = (text: string, t = 1000): StoredEvent => + event("UserPromptSubmit", { prompt: text }, t); + +const readEvent = preTool("Read", { file_path: "/tmp/x.ts" }); + +describe("extractFeatureUsage", () => { + test("returns undefined when no features used", () => { + expect(extractFeatureUsage([readEvent, prompt("fix the bug")])).toBeUndefined(); + }); + + test("detects loop from ScheduleWakeup events", () => { + const events = [ + preTool("ScheduleWakeup", { delaySeconds: 270, reason: "watching CI", prompt: "<>" }, 1000), + preTool("ScheduleWakeup", { delaySeconds: 180, reason: "deploy in progress", prompt: "/loop check status" }, 2000), + ]; + const usage = extractFeatureUsage(events); + expect(usage?.flags).toEqual(["loop"]); + expect(usage?.loop?.wakeup_count).toBe(2); + expect(usage?.loop?.total_scheduled_wait_s).toBe(450); + expect(usage?.loop?.autonomous).toBe(true); + expect(usage?.loop?.wakeups[0]).toEqual({ t: 1000, delay_seconds: 270, reason: "watching CI" }); + }); + + test("detects loop from Skill invocation and /loop prompt", () => { + const usage = extractFeatureUsage([ + preTool("Skill", { skill: "loop", args: "5m /test" }), + prompt("/loop 5m run tests"), + ]); + expect(usage?.flags).toEqual(["loop"]); + expect(usage?.loop?.skill_invocations).toBe(2); + expect(usage?.loop?.autonomous).toBe(false); + }); + + test("detects autonomous loop from CronCreate sentinel", () => { + const usage = extractFeatureUsage([ + preTool("CronCreate", { schedule: "0 * * * *", prompt: "<>" }), + ]); + expect(usage?.flags).toEqual(["loop"]); + expect(usage?.loop?.autonomous).toBe(true); + }); + + test("detects goal from /goal token in prompts", () => { + const usage = extractFeatureUsage([ + prompt("fix the doc limit. /goal make sure tests pass and the biome check is green"), + ]); + expect(usage?.flags).toEqual(["goal"]); + expect(usage?.goal?.goals[0]).toStartWith("/goal make sure tests pass"); + }); + + test("does not flag goal for paths containing /goal", () => { + const usage = extractFeatureUsage([ + prompt("read src/goals/index.ts and refactor"), + preTool("Read", { file_path: "/app/goal/config.ts" }), + ]); + expect(usage).toBeUndefined(); + }); + + test("truncates long goal text", () => { + const usage = extractFeatureUsage([prompt(`/goal ${"x".repeat(500)}`)]); + const goal = usage?.goal?.goals[0] ?? ""; + expect(goal.length).toBeLessThanOrEqual(201); + expect(goal.endsWith("…")).toBe(true); + }); + + test("detects workflow and extracts meta from script", () => { + const script = "export const meta = {\n name: 'pr-audit',\n description: 'Audit the PR',\n phases: [],\n}\nconst x = await agent('go')"; + const usage = extractFeatureUsage([preTool("Workflow", { script }, 5000)]); + expect(usage?.flags).toEqual(["workflow"]); + expect(usage?.workflow?.invocation_count).toBe(1); + expect(usage?.workflow?.runs[0]).toEqual({ t: 5000, name: "pr-audit", description: "Audit the PR" }); + }); + + test("workflow run from named workflow and scriptPath", () => { + const usage = extractFeatureUsage([ + preTool("Workflow", { name: "review-changes" }), + preTool("Workflow", { scriptPath: "/tmp/session/wf-find-bugs.mjs" }), + ]); + expect(usage?.workflow?.runs.map((r) => r.name)).toEqual(["review-changes", "wf-find-bugs"]); + }); + + test("combines multiple features with stable flag order", () => { + const usage = extractFeatureUsage([ + preTool("Workflow", { name: "audit" }), + prompt("/goal ship it"), + preTool("ScheduleWakeup", { delaySeconds: 60, reason: "poll" }), + ]); + expect(usage?.flags).toEqual(["loop", "goal", "workflow"]); + }); + + test("ignores PostToolUse duplicates for counts", () => { + const usage = extractFeatureUsage([ + preTool("Workflow", { name: "audit" }), + event("PostToolUse", { tool_name: "Workflow", tool_input: { name: "audit" } }), + ]); + expect(usage?.workflow?.invocation_count).toBe(1); + }); +}); + +describe("detectFeatureFlags", () => { + const line = (e: StoredEvent): string => JSON.stringify(e); + + test("empty content has no flags", () => { + expect(detectFeatureFlags("")).toEqual([]); + expect(detectFeatureFlags(line(readEvent))).toEqual([]); + }); + + test("detects loop from ScheduleWakeup marker", () => { + const content = line(preTool("ScheduleWakeup", { delaySeconds: 60, reason: "poll" })); + expect(detectFeatureFlags(content)).toEqual(["loop"]); + }); + + test("detects loop from loop skill marker", () => { + const content = line(preTool("Skill", { skill: "loop" })); + expect(detectFeatureFlags(content)).toEqual(["loop"]); + }); + + test("detects workflow from tool marker", () => { + const content = line(preTool("Workflow", { name: "audit" })); + expect(detectFeatureFlags(content)).toEqual(["workflow"]); + }); + + test("detects goal only in UserPromptSubmit prompts", () => { + const goalContent = line(prompt("please /goal finish the migration")); + expect(detectFeatureFlags(goalContent)).toEqual(["goal"]); + + // /goal inside a file path in a tool event must NOT flag + const pathContent = line(preTool("Read", { file_path: "/app/goal/config.ts" })); + expect(detectFeatureFlags(pathContent)).toEqual([]); + + // /goal inside a non-prompt string field of another event must NOT flag + const bashContent = line(preTool("Bash", { command: "cat docs/goal notes.md" })); + expect(detectFeatureFlags(bashContent)).toEqual([]); + }); + + test("multi-line content combines flags", () => { + const content = [ + line(preTool("ScheduleWakeup", { delaySeconds: 60 })), + line(prompt("/goal all tests green")), + line(preTool("Workflow", { name: "audit" })), + ].join("\n"); + expect(detectFeatureFlags(content)).toEqual(["loop", "goal", "workflow"]); + }); + + test("autonomous-loop sentinel inside read file content does NOT flag loop", () => { + // Regression (feature-flag-substring-false-positive): + // The < { + // Counterpart: the sentinel riding inside an actual loop tool call is a real signal. + const content = line( + preTool("ScheduleWakeup", { delaySeconds: 0, reason: "<>" }), + ); + expect(detectFeatureFlags(content)).toEqual(["loop"]); + }); +}); diff --git a/test/filter-links.test.ts b/packages/cli/test/filter-links.test.ts similarity index 60% rename from test/filter-links.test.ts rename to packages/cli/test/filter-links.test.ts index 22911d9..9b231f9 100644 --- a/test/filter-links.test.ts +++ b/packages/cli/test/filter-links.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { filterLinksForSession } from "../src/utils"; +import { buildTeamMemberSessionMap, filterLinksForSession } from "../src/utils"; import type { ConfigChangeLink, LinkEvent, @@ -419,4 +419,241 @@ describe("filterLinksForSession", () => { expect(result).toContain(taskByName); expect(result).not.toContain(foreignTaskByName); }); + + test("includes team member session links when teammate name matches an agent in the session", () => { + const rootId = "session-leader"; + const childId = "agent-builder-1"; + const childName = "builder-alpha"; + const teamMemberSessionId = "sess-tm-alpha"; + + // Leader spawns child agent + const spawnChild = makeSpawn({ + t: 1000, + parent_session: rootId, + agent_id: childId, + agent_name: childName, + }); + + // teammate_idle link from the team member session (has same agent name) + const idleLink = makeTeammateIdle({ + t: 2000, + teammate: childName, + session_id: teamMemberSessionId, + }); + + // Links from the team member's session that should now be included + const tmSessionEnd = makeSessionEnd({ t: 5000, session: teamMemberSessionId }); + const tmTask = makeTask({ t: 3000, session_id: teamMemberSessionId, task_id: "t-tm" }); + const tmMsg = makeMessage({ + t: 3500, + from: teamMemberSessionId, + to: "leader", + session_id: teamMemberSessionId, + }); + + // Foreign session links that should NOT be included + const foreignEnd = makeSessionEnd({ t: 6000, session: "foreign-sess" }); + + const links: readonly LinkEvent[] = [ + spawnChild, idleLink, tmSessionEnd, tmTask, tmMsg, foreignEnd, + ]; + + const result = filterLinksForSession(rootId, links); + expect(result).toContain(spawnChild); + expect(result).toContain(idleLink); + expect(result).toContain(tmSessionEnd); + expect(result).toContain(tmTask); + expect(result).toContain(tmMsg); + expect(result).not.toContain(foreignEnd); + }); + + test("includes team member session links when parent sends msg_send to teammate", () => { + const rootId = "session-leader"; + const teammateName = "builder-beta"; + const teammateSessionId = "sess-tm-beta"; + + // Leader sends a message to the teammate by name + const msgToTeammate = makeMessage({ + t: 1000, + from: rootId, + to: teammateName, + session_id: rootId, + }); + + // teammate_idle link maps teammate name to their session + const idleLink = makeTeammateIdle({ + t: 1500, + teammate: teammateName, + session_id: teammateSessionId, + }); + + // Links from the teammate's session + const tmEnd = makeSessionEnd({ t: 5000, session: teammateSessionId }); + const tmTaskComplete = makeTaskComplete({ + t: 3000, + agent: teammateName, + task_id: "t-beta", + session_id: teammateSessionId, + }); + + const links: readonly LinkEvent[] = [ + msgToTeammate, idleLink, tmEnd, tmTaskComplete, + ]; + + const result = filterLinksForSession(rootId, links); + expect(result).toContain(msgToTeammate); + expect(result).toContain(idleLink); + expect(result).toContain(tmEnd); + expect(result).toContain(tmTaskComplete); + }); + + test("does NOT include team member sessions that do not belong to this parent session", () => { + const rootId = "session-a"; + const otherRootId = "session-b"; + const otherChildId = "agent-other-child"; + const otherChildName = "builder-gamma"; + const otherTeammateSessionId = "sess-tm-gamma"; + + // session B spawns a child + const spawnOtherChild = makeSpawn({ + t: 1000, + parent_session: otherRootId, + agent_id: otherChildId, + agent_name: otherChildName, + }); + + // teammate_idle for session B's child + const otherIdleLink = makeTeammateIdle({ + t: 2000, + teammate: otherChildName, + session_id: otherTeammateSessionId, + }); + + // Links from the other team member's session + const otherTmEnd = makeSessionEnd({ t: 5000, session: otherTeammateSessionId }); + + const links: readonly LinkEvent[] = [ + spawnOtherChild, otherIdleLink, otherTmEnd, + ]; + + const result = filterLinksForSession(rootId, links); + expect(result).not.toContain(spawnOtherChild); + expect(result).not.toContain(otherIdleLink); + expect(result).not.toContain(otherTmEnd); + }); + + test("team member session expansion works alongside spawn chain expansion", () => { + const rootId = "session-root"; + const childId = "agent-child-1"; + const childName = "builder-worker"; + const grandchildId = "agent-grandchild-1"; + const grandchildName = "builder-sub"; + const teammateSessionId = "sess-tm-worker"; + + // Root spawns child, child spawns grandchild + const spawnChild = makeSpawn({ + t: 1000, + parent_session: rootId, + agent_id: childId, + agent_name: childName, + }); + const spawnGrandchild = makeSpawn({ + t: 2000, + parent_session: childId, + agent_id: grandchildId, + agent_name: grandchildName, + }); + + // teammate_idle maps childName to a different session + const idleLink = makeTeammateIdle({ + t: 2500, + teammate: childName, + session_id: teammateSessionId, + }); + + // Links from both the grandchild and the teammate's session + const gcEnd = makeSessionEnd({ t: 4000, session: grandchildId }); + const tmEnd = makeSessionEnd({ t: 5000, session: teammateSessionId }); + const rootEnd = makeSessionEnd({ t: 6000, session: rootId }); + + const links: readonly LinkEvent[] = [ + spawnChild, spawnGrandchild, idleLink, gcEnd, tmEnd, rootEnd, + ]; + + const result = filterLinksForSession(rootId, links); + expect(result).toContain(spawnChild); + expect(result).toContain(spawnGrandchild); + expect(result).toContain(idleLink); + expect(result).toContain(gcEnd); + expect(result).toContain(tmEnd); + expect(result).toContain(rootEnd); + expect(result.length).toBe(6); + }); +}); + +describe("buildTeamMemberSessionMap", () => { + test("returns empty map for empty links", () => { + const result = buildTeamMemberSessionMap([]); + expect(result.size).toBe(0); + }); + + test("returns empty map when no teammate_idle links exist", () => { + const links: readonly LinkEvent[] = [ + makeSpawn({ parent_session: "root", agent_id: "child-1" }), + makeSessionEnd({ session: "root" }), + ]; + const result = buildTeamMemberSessionMap(links); + expect(result.size).toBe(0); + }); + + test("maps teammate name to session_id from teammate_idle links", () => { + const links: readonly LinkEvent[] = [ + makeTeammateIdle({ teammate: "builder-alpha", session_id: "sess-alpha" }), + makeTeammateIdle({ teammate: "builder-beta", session_id: "sess-beta" }), + ]; + const result = buildTeamMemberSessionMap(links); + expect(result.size).toBe(2); + expect(result.get("builder-alpha")).toBe("sess-alpha"); + expect(result.get("builder-beta")).toBe("sess-beta"); + }); + + test("excludes entries with empty teammate name", () => { + const links: readonly LinkEvent[] = [ + makeTeammateIdle({ teammate: "", session_id: "sess-empty" }), + makeTeammateIdle({ teammate: "builder-valid", session_id: "sess-valid" }), + ]; + const result = buildTeamMemberSessionMap(links); + expect(result.size).toBe(1); + expect(result.get("builder-valid")).toBe("sess-valid"); + }); + + test("excludes entries with undefined session_id", () => { + const links: readonly LinkEvent[] = [ + makeTeammateIdle({ teammate: "builder-no-session" }), + makeTeammateIdle({ teammate: "builder-valid", session_id: "sess-valid" }), + ]; + const result = buildTeamMemberSessionMap(links); + expect(result.size).toBe(1); + expect(result.get("builder-valid")).toBe("sess-valid"); + }); + + test("excludes entries with empty session_id", () => { + const links: readonly LinkEvent[] = [ + makeTeammateIdle({ teammate: "builder-empty-sid", session_id: "" }), + makeTeammateIdle({ teammate: "builder-ok", session_id: "sess-ok" }), + ]; + const result = buildTeamMemberSessionMap(links); + expect(result.size).toBe(1); + expect(result.get("builder-ok")).toBe("sess-ok"); + }); + + test("later entries overwrite earlier ones for same teammate name", () => { + const links: readonly LinkEvent[] = [ + makeTeammateIdle({ teammate: "builder-dup", session_id: "sess-old" }), + makeTeammateIdle({ teammate: "builder-dup", session_id: "sess-new" }), + ]; + const result = buildTeamMemberSessionMap(links); + expect(result.size).toBe(1); + expect(result.get("builder-dup")).toBe("sess-new"); + }); }); diff --git a/test/fixtures/hooks/allow.sh b/packages/cli/test/fixtures/hooks/allow.sh similarity index 100% rename from test/fixtures/hooks/allow.sh rename to packages/cli/test/fixtures/hooks/allow.sh diff --git a/test/fixtures/hooks/crash.sh b/packages/cli/test/fixtures/hooks/crash.sh similarity index 100% rename from test/fixtures/hooks/crash.sh rename to packages/cli/test/fixtures/hooks/crash.sh diff --git a/test/fixtures/hooks/deny.sh b/packages/cli/test/fixtures/hooks/deny.sh similarity index 100% rename from test/fixtures/hooks/deny.sh rename to packages/cli/test/fixtures/hooks/deny.sh diff --git a/test/fixtures/hooks/malformed.sh b/packages/cli/test/fixtures/hooks/malformed.sh similarity index 100% rename from test/fixtures/hooks/malformed.sh rename to packages/cli/test/fixtures/hooks/malformed.sh diff --git a/test/fixtures/transcripts/simple-session.jsonl b/packages/cli/test/fixtures/transcripts/simple-session.jsonl similarity index 100% rename from test/fixtures/transcripts/simple-session.jsonl rename to packages/cli/test/fixtures/transcripts/simple-session.jsonl diff --git a/test/format.test.ts b/packages/cli/test/format.test.ts similarity index 95% rename from test/format.test.ts rename to packages/cli/test/format.test.ts index f7b5ed1..69351ca 100644 --- a/test/format.test.ts +++ b/packages/cli/test/format.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import { HOOK_EVENTS, type HookEventType, type LinkEvent, type StoredEvent } from "../src/types"; describe("format types", () => { - test("HOOK_EVENTS has 17 entries", () => { - expect(HOOK_EVENTS.length).toBe(17); + test("HOOK_EVENTS has 18 entries", () => { + expect(HOOK_EVENTS.length).toBe(18); }); test("HOOK_EVENTS contains expected event types", () => { diff --git a/packages/cli/test/global-read.test.ts b/packages/cli/test/global-read.test.ts new file mode 100644 index 0000000..55871f7 --- /dev/null +++ b/packages/cli/test/global-read.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + globalConfigPath, + registerProject, + unregisterProject, + writeGlobalConfig, +} from "../src/session/registry"; +import { + listGlobalSessions, + resolveProjectForSession, +} from "../src/session/global-read"; + +const SESSION_A1 = "aaaaaaaa-1111-1111-1111-111111111111"; +const SESSION_A2 = "aaaaaaaa-2222-2222-2222-222222222222"; +const SESSION_B1 = "bbbbbbbb-1111-1111-1111-111111111111"; + +const makeEvent = (event: string, t: number, data: Record = {}, sid: string = SESSION_A1) => + JSON.stringify({ event, t, sid, data, context: { git_branch: "main" } }); + +describe("global-read", () => { + let tempDir: string; + let projectA: string; + let projectB: string; + + beforeEach(() => { + tempDir = join( + tmpdir(), + `clens-test-global-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + projectA = join(tempDir, "project-alpha"); + projectB = join(tempDir, "project-beta"); + + mkdirSync(join(projectA, ".clens", "sessions"), { recursive: true }); + mkdirSync(join(projectA, ".clens", "distilled"), { recursive: true }); + mkdirSync(join(projectB, ".clens", "sessions"), { recursive: true }); + mkdirSync(join(projectB, ".clens", "distilled"), { recursive: true }); + + // Project A: two sessions + writeFileSync( + join(projectA, ".clens", "sessions", `${SESSION_A1}.jsonl`), + [ + makeEvent("SessionStart", 1000, { source: "cli" }, SESSION_A1), + makeEvent("SessionEnd", 2000, { reason: "done" }, SESSION_A1), + ].join("\n") + "\n", + ); + writeFileSync( + join(projectA, ".clens", "sessions", `${SESSION_A2}.jsonl`), + [ + makeEvent("SessionStart", 3000, { source: "cli" }, SESSION_A2), + makeEvent("SessionEnd", 5000, { reason: "done" }, SESSION_A2), + ].join("\n") + "\n", + ); + + // Project B: one session (most recent) + writeFileSync( + join(projectB, ".clens", "sessions", `${SESSION_B1}.jsonl`), + [ + makeEvent("SessionStart", 6000, { source: "cli" }, SESSION_B1), + makeEvent("SessionEnd", 8000, { reason: "done" }, SESSION_B1), + ].join("\n") + "\n", + ); + + registerProject(projectA); + registerProject(projectB); + }); + + afterEach(() => { + unregisterProject(projectA); + unregisterProject(projectB); + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("listGlobalSessions", () => { + test("returns sessions from all registered projects", () => { + const sessions = listGlobalSessions(); + + const testSessions = sessions.filter( + (s) => s.project_name === "project-alpha" || s.project_name === "project-beta", + ); + expect(testSessions.length).toBe(3); + }); + + test("tags sessions with project_id and project_name", () => { + const sessions = listGlobalSessions(); + + const alphaSession = sessions.find((s) => s.session_id === SESSION_A1); + expect(alphaSession).toBeDefined(); + expect(alphaSession?.project_id).toBe("project-alpha"); + expect(alphaSession?.project_name).toBe("project-alpha"); + + const betaSession = sessions.find((s) => s.session_id === SESSION_B1); + expect(betaSession).toBeDefined(); + expect(betaSession?.project_id).toBe("project-beta"); + expect(betaSession?.project_name).toBe("project-beta"); + }); + + test("sorts by start_time descending", () => { + const sessions = listGlobalSessions(); + + const testSessions = sessions.filter( + (s) => s.project_name === "project-alpha" || s.project_name === "project-beta", + ); + + // Most recent first (project-beta t=6000 > project-alpha t=3000 > t=1000) + expect(testSessions.length).toBeGreaterThanOrEqual(2); + expect(testSessions[0].start_time).toBeGreaterThanOrEqual(testSessions[1].start_time); + + if (testSessions.length >= 3) { + expect(testSessions[1].start_time).toBeGreaterThanOrEqual(testSessions[2].start_time); + } + }); + + test("includes session metadata from JSONL", () => { + const sessions = listGlobalSessions(); + const session = sessions.find((s) => s.session_id === SESSION_A1); + + expect(session).toBeDefined(); + expect(session?.start_time).toBe(1000); + expect(session?.status).toBe("complete"); + }); + + test("attaches capture_dir pointing at the owning dir", () => { + const sessions = listGlobalSessions(); + const alphaSession = sessions.find((s) => s.session_id === SESSION_A1); + expect(alphaSession?.capture_dir).toBe(projectA); + + const betaSession = sessions.find((s) => s.session_id === SESSION_B1); + expect(betaSession?.capture_dir).toBe(projectB); + }); + }); + + describe("resolveProjectForSession", () => { + test("finds the correct project for a session ID", () => { + const projectForA = resolveProjectForSession(SESSION_A1); + expect(projectForA).toBeDefined(); + expect(projectForA?.name).toBe("project-alpha"); + + const projectForB = resolveProjectForSession(SESSION_B1); + expect(projectForB).toBeDefined(); + expect(projectForB?.name).toBe("project-beta"); + }); + + test("returns undefined for unknown session ID", () => { + const result = resolveProjectForSession("nonexistent-session-id-12345"); + expect(result).toBeUndefined(); + }); + }); +}); + +const SESSION_PROJ = "cccccccc-1111-1111-1111-111111111111"; +const SESSION_NESTED = "dddddddd-1111-1111-1111-111111111111"; + +describe("global-read capture_dir routing", () => { + let tempDir: string; + let projModeDir: string; + let gitRootDir: string; + let nestedCaptureDir: string; + let savedConfig: string | undefined; + + beforeEach(() => { + // Preserve the real global config so the mode override is non-destructive. + const cfgPath = globalConfigPath(); + savedConfig = existsSync(cfgPath) ? readFileSync(cfgPath, "utf-8") : undefined; + + tempDir = join( + tmpdir(), + `clens-test-capdir-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + // Project-mode source: `.clens/sessions/` directly at the registered path. + projModeDir = join(tempDir, "proj-mode"); + mkdirSync(join(projModeDir, ".clens", "sessions"), { recursive: true }); + writeFileSync( + join(projModeDir, ".clens", "sessions", `${SESSION_PROJ}.jsonl`), + [ + makeEvent("SessionStart", 1000, { source: "cli" }, SESSION_PROJ), + makeEvent("SessionEnd", 2000, { reason: "done" }, SESSION_PROJ), + ].join("\n") + "\n", + ); + + // Repository-mode source: `.clens/sessions/` nested below the git root. + gitRootDir = join(tempDir, "repo-root"); + nestedCaptureDir = join(gitRootDir, "packages", "x"); + mkdirSync(join(nestedCaptureDir, ".clens", "sessions"), { recursive: true }); + writeFileSync( + join(nestedCaptureDir, ".clens", "sessions", `${SESSION_NESTED}.jsonl`), + [ + makeEvent("SessionStart", 3000, { source: "cli" }, SESSION_NESTED), + makeEvent("SessionEnd", 4000, { reason: "done" }, SESSION_NESTED), + ].join("\n") + "\n", + ); + }); + + afterEach(() => { + unregisterProject(projModeDir); + unregisterProject(gitRootDir); + // Restore original global config. + const cfgPath = globalConfigPath(); + if (savedConfig === undefined) { + rmSync(cfgPath, { force: true }); + } else { + writeFileSync(cfgPath, savedConfig); + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("project mode: capture_dir equals project.path", () => { + writeGlobalConfig({ global_mode: "project" }); + registerProject(projModeDir); + + const session = listGlobalSessions().find((s) => s.session_id === SESSION_PROJ); + expect(session).toBeDefined(); + expect(session?.capture_dir).toBe(projModeDir); + }); + + test("repository mode: capture_dir is the nested capture dir, not the git root", () => { + writeGlobalConfig({ global_mode: "repository" }); + registerProject(gitRootDir); + + const session = listGlobalSessions().find((s) => s.session_id === SESSION_NESTED); + expect(session).toBeDefined(); + expect(session?.capture_dir).toBe(nestedCaptureDir); + expect(session?.capture_dir).not.toBe(gitRootDir); + }); +}); diff --git a/test/hook-proxy.test.ts b/packages/cli/test/hook-proxy.test.ts similarity index 92% rename from test/hook-proxy.test.ts rename to packages/cli/test/hook-proxy.test.ts index 3fbf043..f1b10d1 100644 --- a/test/hook-proxy.test.ts +++ b/packages/cli/test/hook-proxy.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { delegateToUserHooks } from "../src/capture/proxy"; const TEST_DIR = "/tmp/clens-test-proxy"; -const FIXTURES = `${process.cwd()}/test/fixtures/hooks`; +const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const FIXTURES = `${PKG_ROOT}/test/fixtures/hooks`; beforeEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); diff --git a/packages/cli/test/hook.test.ts b/packages/cli/test/hook.test.ts new file mode 100644 index 0000000..4eb1e91 --- /dev/null +++ b/packages/cli/test/hook.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { StoredEvent } from "../src/types"; + +const TEST_DIR = "/tmp/clens-test-hook"; +const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HOOK_SCRIPT = resolve(PKG_ROOT, "src/hook.ts"); + +beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe("hook handler", () => { + test("writes JSONL for PreToolUse event", async () => { + const payload = JSON.stringify({ + session_id: "test-session-1", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "ls" }, + tool_use_id: "t1", + cwd: TEST_DIR, + transcript_path: "/tmp/t.jsonl", + permission_mode: "default", + }); + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(payload), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + await proc.exited; + + const sessionFile = `${TEST_DIR}/.clens/sessions/test-session-1.jsonl`; + expect(existsSync(sessionFile)).toBe(true); + + const content = readFileSync(sessionFile, "utf-8").trim(); + const stored: StoredEvent = JSON.parse(content); + expect(stored.event).toBe("PreToolUse"); + expect(stored.sid).toBe("test-session-1"); + expect(stored.data.tool_name).toBe("Bash"); + expect(typeof stored.t).toBe("number"); + }); + + test("handles empty stdin gracefully", async () => { + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(""), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + }); + + test("handles invalid JSON gracefully", async () => { + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response("not json"), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + }); + + test("non-JSON input exits cleanly without error log entry", async () => { + const invalidInput = "not valid json at all"; + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(invalidInput), + stdout: "pipe", + stderr: "pipe", + cwd: TEST_DIR, + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + // Guard catches non-JSON input before JSON.parse — no error log created + const errorLog = `${TEST_DIR}/.clens/errors.log`; + expect(existsSync(errorLog)).toBe(false); + }); + + test("valid JSON input still processes normally", async () => { + const payload = JSON.stringify({ + session_id: "valid-session", + cwd: TEST_DIR, + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: "/foo.ts" }, + tool_use_id: "t2", + transcript_path: "/tmp/t.jsonl", + permission_mode: "default", + }); + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(payload), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const sessionFile = `${TEST_DIR}/.clens/sessions/valid-session.jsonl`; + expect(existsSync(sessionFile)).toBe(true); + + const content = readFileSync(sessionFile, "utf-8").trim(); + const stored: StoredEvent = JSON.parse(content); + expect(stored.event).toBe("PreToolUse"); + expect(stored.sid).toBe("valid-session"); + }); + + test("creates directory on first event", async () => { + const payload = JSON.stringify({ + session_id: "new-session", + cwd: TEST_DIR, + hook_event_name: "SessionStart", + transcript_path: "/tmp/t.jsonl", + permission_mode: "default", + }); + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "SessionStart"], { + stdin: new Response(payload), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + await proc.exited; + expect(existsSync(`${TEST_DIR}/.clens/sessions`)).toBe(true); + }); + + // B28: a subagent running with cwd inside a subdirectory must NOT fragment + // session capture into a nested `.clens/`. The hook walks up to the project + // root (nearest `.clens/`) and appends there. + test("nested cwd writes to the project-root .clens, not a nested one", async () => { + // Seed the project root so resolveProjectRoot can find the marker. + mkdirSync(`${TEST_DIR}/.clens/sessions`, { recursive: true }); + const nestedCwd = `${TEST_DIR}/packages/web/src/client/assets`; + mkdirSync(nestedCwd, { recursive: true }); + + const payload = JSON.stringify({ + session_id: "nested-session", + cwd: nestedCwd, + hook_event_name: "PreToolUse", + tool_name: "Edit", + tool_input: { file_path: "/foo.ts" }, + tool_use_id: "t-nested", + transcript_path: "/tmp/t.jsonl", + permission_mode: "default", + }); + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(payload), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + await proc.exited; + + // Event lands in the project-root .clens. + const rootFile = `${TEST_DIR}/.clens/sessions/nested-session.jsonl`; + expect(existsSync(rootFile)).toBe(true); + + // And NOT in a fragmented nested .clens under the subdirectory. + expect(existsSync(`${nestedCwd}/.clens`)).toBe(false); + + const stored: StoredEvent = JSON.parse(readFileSync(rootFile, "utf-8").trim()); + expect(stored.sid).toBe("nested-session"); + expect(stored.event).toBe("PreToolUse"); + }); + + test("nested cwd falls back to .git root when no .clens exists yet", async () => { + // Project root has a .git but no .clens yet (first event of a session). + mkdirSync(`${TEST_DIR}/.git`, { recursive: true }); + const nestedCwd = `${TEST_DIR}/packages/cli/src`; + mkdirSync(nestedCwd, { recursive: true }); + + const payload = JSON.stringify({ + session_id: "git-root-session", + cwd: nestedCwd, + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: "/bar.ts" }, + tool_use_id: "t-git", + transcript_path: "/tmp/t.jsonl", + permission_mode: "default", + }); + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(payload), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + await proc.exited; + + expect(existsSync(`${TEST_DIR}/.clens/sessions/git-root-session.jsonl`)).toBe(true); + expect(existsSync(`${nestedCwd}/.clens`)).toBe(false); + }); + + // Regression: a resolved root nested under `.clens/` produced a recursive + // `.clens/sessions/.clens/sessions` capture dir that self-perpetuated. The hook + // must refuse to write any path nested under a `.clens/` segment. + test("refuses to write when the resolved root is nested under .clens", async () => { + // A capture dir already nested under `.clens/` (the recursion seed). + const nestedRoot = `${TEST_DIR}/.clens/sessions`; + mkdirSync(`${nestedRoot}/.clens/sessions`, { recursive: true }); + + const payload = JSON.stringify({ + session_id: "recursive-session", + cwd: nestedRoot, + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: "/baz.ts" }, + tool_use_id: "t-recursive", + transcript_path: "/tmp/t.jsonl", + permission_mode: "default", + }); + + const proc = Bun.spawn(["bun", "run", HOOK_SCRIPT, "PreToolUse"], { + stdin: new Response(payload), + stdout: "pipe", + stderr: "pipe", + cwd: PKG_ROOT, + }); + + await proc.exited; + + // No event written anywhere under the nested `.clens/`. + expect(existsSync(`${nestedRoot}/.clens/sessions/recursive-session.jsonl`)).toBe(false); + expect(existsSync(`${TEST_DIR}/.clens/sessions/recursive-session.jsonl`)).toBe(false); + }); +}); diff --git a/test/init.test.ts b/packages/cli/test/init.test.ts similarity index 98% rename from test/init.test.ts rename to packages/cli/test/init.test.ts index e138f6e..c11ba65 100644 --- a/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -19,7 +19,7 @@ const TEST_DIR = "/tmp/clens-test-init"; const makeFlags = (overrides: Partial = {}): Flags => ({ last: false, force: false, - otel: false, + yes: false, deep: false, json: false, help: false, @@ -61,7 +61,7 @@ describe("init", () => { expect(existsSync(`${TEST_DIR}/.claude/settings.local.json`)).toBe(true); const settings = JSON.parse(readFileSync(`${TEST_DIR}/.claude/settings.local.json`, "utf-8")); expect(settings.hooks).toBeDefined(); - expect(Object.keys(settings.hooks).length).toBe(17); + expect(Object.keys(settings.hooks).length).toBe(18); }); test("does not modify settings.json", () => { @@ -82,10 +82,10 @@ describe("init", () => { expect(backup.permissions).toBeDefined(); }); - test("writes hooks for all 17 event types", () => { + test("writes hooks for all 18 event types", () => { init(TEST_DIR); const settings = JSON.parse(readFileSync(`${TEST_DIR}/.claude/settings.local.json`, "utf-8")); - expect(Object.keys(settings.hooks).length).toBe(17); + expect(Object.keys(settings.hooks).length).toBe(18); expect(settings.hooks.PreToolUse).toBeDefined(); expect(settings.hooks.PostToolUse).toBeDefined(); expect(settings.hooks.SessionStart).toBeDefined(); diff --git a/test/links.test.ts b/packages/cli/test/links.test.ts similarity index 89% rename from test/links.test.ts rename to packages/cli/test/links.test.ts index 24a0189..640951c 100644 --- a/test/links.test.ts +++ b/packages/cli/test/links.test.ts @@ -235,7 +235,33 @@ describe("extractLinkEvent", () => { } }); - test("TeammateIdle falls back to agent_id when agent_name is missing", () => { + test("TeammateIdle prefers teammate_name over agent_name", () => { + const link = extractLinkEvent("TeammateIdle", { + session_id: "s1", + teammate_name: "builder-alpha", + agent_name: "some-agent", + team_name: "my-team", + }); + expect(link.type).toBe("teammate_idle"); + if (link.type === "teammate_idle") { + expect(link.teammate).toBe("builder-alpha"); + expect(link.team).toBe("my-team"); + } + }); + + test("TeammateIdle falls back to agent_name when teammate_name is absent", () => { + const link = extractLinkEvent("TeammateIdle", { + session_id: "sess-idle-fallback", + agent_name: "builder-1", + team_name: "my-team", + }); + expect(link.type).toBe("teammate_idle"); + if (link.type === "teammate_idle") { + expect(link.teammate).toBe("builder-1"); + } + }); + + test("TeammateIdle falls back to agent_id when both teammate_name and agent_name are missing", () => { const link = extractLinkEvent("TeammateIdle", { session_id: "sess-idle2", agent_id: "agent-fallback", @@ -246,12 +272,12 @@ describe("extractLinkEvent", () => { } }); - test("TaskCompleted produces task_complete link", () => { + test("TaskCompleted produces task_complete link with task_subject", () => { const link = extractLinkEvent("TaskCompleted", { session_id: "sess-done", task_id: "task-77", agent_name: "builder-agent", - subject: "Fix the tests", + task_subject: "Fix the tests", }); expect(link.type).toBe("task_complete"); if (link.type === "task_complete") { @@ -261,6 +287,19 @@ describe("extractLinkEvent", () => { } }); + test("TaskCompleted falls back to subject when task_subject is absent", () => { + const link = extractLinkEvent("TaskCompleted", { + session_id: "sess-done-legacy", + task_id: "task-88", + agent_name: "builder-agent", + subject: "Legacy subject field", + }); + expect(link.type).toBe("task_complete"); + if (link.type === "task_complete") { + expect(link.subject).toBe("Legacy subject field"); + } + }); + test("TaskCompleted falls back to session_id when agent_name is missing", () => { const link = extractLinkEvent("TaskCompleted", { session_id: "sess-done2", diff --git a/test/messages.test.ts b/packages/cli/test/messages.test.ts similarity index 100% rename from test/messages.test.ts rename to packages/cli/test/messages.test.ts diff --git a/packages/cli/test/name-command.test.ts b/packages/cli/test/name-command.test.ts new file mode 100644 index 0000000..215d3da --- /dev/null +++ b/packages/cli/test/name-command.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { nameCommand } from "../src/commands/name"; +import { enrichSessionSummaries, listSessions } from "../src/session/read"; +import { readSessionMeta } from "../src/session/session-meta"; + +const TMP_ROOT = join(import.meta.dir, "tmp-name-cmd"); + +let counter = 0; +const setupProject = (sessionId: string, prompt: string): string => { + counter += 1; + const dir = join(TMP_ROOT, `case-${counter}`); + try { + rmSync(dir, { recursive: true }); + } catch { + /* ignore */ + } + mkdirSync(join(dir, ".clens", "sessions"), { recursive: true }); + const now = Date.now(); + writeFileSync( + join(dir, ".clens", "sessions", `${sessionId}.jsonl`), + [ + JSON.stringify({ event: "SessionStart", t: now, data: { source: "startup" }, context: {} }), + JSON.stringify({ event: "UserPromptSubmit", t: now + 10, data: { prompt }, context: {} }), + JSON.stringify({ event: "SessionEnd", t: now + 20, data: { reason: "clear" }, context: {} }), + ].join("\n"), + ); + return dir; +}; + +const resolveRow = (dir: string, sessionId: string) => + enrichSessionSummaries(listSessions(dir), dir).find((s) => s.session_id === sessionId); + +describe("nameCommand", () => { + test("computed name derives from first prompt when no label (AC1)", () => { + const sid = "aaaa1111-2222-3333"; + const dir = setupProject(sid, "Fix the analyze session button"); + const row = resolveRow(dir, sid); + expect(row?.display_name).toBe("Fix the analyze session button"); + expect(row?.name_source).toBe("computed"); + }); + + test("setting a label makes it the display name (AC7)", () => { + const sid = "bbbb1111-2222-3333"; + const dir = setupProject(sid, "Fix the analyze session button"); + nameCommand({ sessionArg: "bbbb", projectDir: dir, label: "Auth refactor", color: "amber", clear: false, json: false }); + const meta = readSessionMeta(dir)[sid]; + expect(meta?.label).toBe("Auth refactor"); + expect(meta?.color).toBe("amber"); + const row = resolveRow(dir, sid); + expect(row?.display_name).toBe("Auth refactor"); + expect(row?.name_source).toBe("label"); + expect(row?.color).toBe("amber"); + }); + + test("--clear reverts to computed name and unflags (AC7)", () => { + const sid = "cccc1111-2222-3333"; + const dir = setupProject(sid, "Implement plan drift view"); + nameCommand({ sessionArg: "cccc", projectDir: dir, label: "Temp", color: "blue", clear: false, json: false }); + nameCommand({ sessionArg: "cccc", projectDir: dir, label: undefined, color: undefined, clear: true, json: false }); + expect(readSessionMeta(dir)[sid]).toBeUndefined(); + const row = resolveRow(dir, sid); + expect(row?.display_name).toBe("Implement plan drift view"); + expect(row?.name_source).toBe("computed"); + expect(row?.color).toBeUndefined(); + }); + + test("rejects an invalid color (R14)", () => { + const sid = "dddd1111-2222-3333"; + const dir = setupProject(sid, "Hello"); + expect(() => + nameCommand({ sessionArg: "dddd", projectDir: dir, label: undefined, color: "rainbow", clear: false, json: false }), + ).toThrow(); + expect(readSessionMeta(dir)[sid]).toBeUndefined(); + }); + + test("label survives when raw session data is removed (AC5)", () => { + const sid = "eeee1111-2222-3333"; + const dir = setupProject(sid, "Hello"); + nameCommand({ sessionArg: "eeee", projectDir: dir, label: "Keeper", color: "green", clear: false, json: false }); + // Simulate `clens clean`: remove the raw session JSONL only. + rmSync(join(dir, ".clens", "sessions", `${sid}.jsonl`)); + const meta = readSessionMeta(dir)[sid]; + expect(meta?.label).toBe("Keeper"); + expect(meta?.color).toBe("green"); + }); +}); diff --git a/test/plugin.test.ts b/packages/cli/test/plugin.test.ts similarity index 98% rename from test/plugin.test.ts rename to packages/cli/test/plugin.test.ts index 5bb54c5..c626137 100644 --- a/test/plugin.test.ts +++ b/packages/cli/test/plugin.test.ts @@ -377,10 +377,10 @@ describe("plugin hooks", () => { expect(() => JSON.parse(content)).not.toThrow(); }); - test("hooks.json contains all 17 events", () => { + test("hooks.json contains all 18 events", () => { const hooksFile = JSON.parse(readFileSync(HOOKS_JSON_PATH, "utf-8")); expect(hooksFile.hooks).toBeDefined(); - expect(Object.keys(hooksFile.hooks).length).toBe(17); + expect(Object.keys(hooksFile.hooks).length).toBe(18); }); test("hooks.json events match HOOK_EVENTS constant", () => { @@ -456,7 +456,7 @@ describe("plugin hook installation", () => { test("installPlugin returns correct hooks_installed count", () => { const result = installPlugin(TEST_INSTALL_DIR); - expect(result.hooks_installed).toBe(17); + expect(result.hooks_installed).toBe(18); }); test("installPlugin copies hooks/hooks.json to install dir", () => { @@ -464,7 +464,7 @@ describe("plugin hook installation", () => { const hooksJsonPath = join(TEST_INSTALL_DIR, "hooks", "hooks.json"); expect(existsSync(hooksJsonPath)).toBe(true); const hooksFile = JSON.parse(readFileSync(hooksJsonPath, "utf-8")); - expect(Object.keys(hooksFile.hooks).length).toBe(17); + expect(Object.keys(hooksFile.hooks).length).toBe(18); }); test("installPlugin installs capture hooks in user settings", () => { diff --git a/packages/cli/test/pricing-tiers.test.ts b/packages/cli/test/pricing-tiers.test.ts new file mode 100644 index 0000000..acd2c27 --- /dev/null +++ b/packages/cli/test/pricing-tiers.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from "bun:test"; +import { extractTokenUsage, extractUserType } from "../src/distill/agent-distill"; +import { estimateCostFromTokens, getPricing } from "../src/distill/stats"; +import type { TranscriptEntry } from "../src/types"; + +const makeAssistantEntry = (overrides: Partial = {}): TranscriptEntry => ({ + uuid: "uuid-1", + parentUuid: null, + sessionId: "session-1", + type: "assistant", + timestamp: "2024-01-01T00:00:01.000Z", + message: { + role: "assistant", + content: [], + }, + ...overrides, +}); + +const makeUserEntry = (overrides: Partial = {}): TranscriptEntry => ({ + uuid: "uuid-u1", + parentUuid: null, + sessionId: "session-1", + type: "user", + timestamp: "2024-01-01T00:00:00.000Z", + message: { + role: "user", + content: "Hello", + }, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Token dedup by requestId +// --------------------------------------------------------------------------- + +describe("extractTokenUsage — requestId dedup", () => { + test("4 entries with same requestId → usage counted once (last wins)", () => { + const entries: readonly TranscriptEntry[] = [ + makeAssistantEntry({ + uuid: "a1", + requestId: "req-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 }, + }, + }), + makeAssistantEntry({ + uuid: "a2", + requestId: "req-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 200, output_tokens: 100, cache_read_input_tokens: 20, cache_creation_input_tokens: 10 }, + }, + }), + makeAssistantEntry({ + uuid: "a3", + requestId: "req-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 300, output_tokens: 150, cache_read_input_tokens: 30, cache_creation_input_tokens: 15 }, + }, + }), + makeAssistantEntry({ + uuid: "a4", + requestId: "req-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 400, output_tokens: 200, cache_read_input_tokens: 40, cache_creation_input_tokens: 20 }, + }, + }), + ]; + + const usage = extractTokenUsage(entries); + // Only the last entry (a4) should be counted since all share req-1 + expect(usage.input_tokens).toBe(400); + expect(usage.output_tokens).toBe(200); + expect(usage.cache_read_tokens).toBe(40); + expect(usage.cache_creation_tokens).toBe(20); + }); + + test("entries without requestId → each counted separately (fallback to uuid)", () => { + const entries: readonly TranscriptEntry[] = [ + makeAssistantEntry({ + uuid: "unique-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }), + makeAssistantEntry({ + uuid: "unique-2", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 200, output_tokens: 80 }, + }, + }), + ]; + + const usage = extractTokenUsage(entries); + // Each entry has a unique uuid and no requestId → both counted + expect(usage.input_tokens).toBe(300); + expect(usage.output_tokens).toBe(130); + }); + + test("mixed: some entries share requestId, others have unique uuids", () => { + const entries: readonly TranscriptEntry[] = [ + makeAssistantEntry({ + uuid: "a1", + requestId: "req-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }), + makeAssistantEntry({ + uuid: "a2", + requestId: "req-1", + message: { + role: "assistant", + content: [], + usage: { input_tokens: 200, output_tokens: 100 }, + }, + }), + makeAssistantEntry({ + uuid: "a3", + // no requestId → falls back to uuid "a3" + message: { + role: "assistant", + content: [], + usage: { input_tokens: 300, output_tokens: 150 }, + }, + }), + ]; + + const usage = extractTokenUsage(entries); + // req-1 group: last wins → 200 input, 100 output + // a3 standalone: 300 input, 150 output + // total: 500 input, 250 output + expect(usage.input_tokens).toBe(500); + expect(usage.output_tokens).toBe(250); + }); +}); + +// --------------------------------------------------------------------------- +// Pricing tiers +// --------------------------------------------------------------------------- + +describe("getPricing", () => { + test("API tier returns published rates (full price)", () => { + const pricing = getPricing("claude-sonnet-4-20250514", "api"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBe(3); + expect(pricing?.output).toBe(15); + expect(pricing?.cache_read).toBe(0.3); + expect(pricing?.cache_write).toBe(3.75); + }); + + test("Max tier returns 1/3 rates", () => { + const pricing = getPricing("claude-sonnet-4-20250514", "max"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBeCloseTo(1, 4); + expect(pricing?.output).toBeCloseTo(5, 4); + expect(pricing?.cache_read).toBeCloseTo(0.1, 4); + expect(pricing?.cache_write).toBeCloseTo(1.25, 4); + }); + + test("auto tier returns same as api (full price)", () => { + const apiPricing = getPricing("claude-opus-4-20250514", "api"); + const autoPricing = getPricing("claude-opus-4-20250514", "auto"); + expect(autoPricing).toEqual(apiPricing); + }); + + test("defaults to api tier when omitted", () => { + const pricing = getPricing("claude-opus-4-20250514"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBe(15); + expect(pricing?.output).toBe(75); + }); + + test("returns undefined for unknown model", () => { + const pricing = getPricing("gpt-4-turbo", "api"); + expect(pricing).toBeUndefined(); + }); +}); + +describe("estimateCostFromTokens with tier", () => { + test("API tier: known tokens + sonnet model → expected USD", () => { + const result = estimateCostFromTokens( + "claude-sonnet-4-20250514", + 1_000_000, + 1_000_000, + undefined, + undefined, + "api", + ); + expect(result).toBeDefined(); + // $3/M input + $15/M output = $18 + expect(result?.estimated_cost_usd).toBe(18); + expect(result?.pricing_tier).toBe("api"); + expect(result?.is_estimated).toBe(false); + }); + + test("Max tier: known tokens + sonnet model → 1/3 of API cost", () => { + const result = estimateCostFromTokens( + "claude-sonnet-4-20250514", + 1_000_000, + 1_000_000, + undefined, + undefined, + "max", + ); + expect(result).toBeDefined(); + // ($3/M * 1/3) input + ($15/M * 1/3) output = $1 + $5 = $6 + expect(result?.estimated_cost_usd).toBe(6); + expect(result?.pricing_tier).toBe("max"); + }); + + test("API tier: opus model with cache tokens", () => { + const result = estimateCostFromTokens( + "claude-opus-4-20250514", + 500_000, // input + 200_000, // output + 1_000_000, // cache read + 100_000, // cache creation + "api", + ); + expect(result).toBeDefined(); + // (0.5M * $15) + (0.2M * $75) + (1M * $1.5) + (0.1M * $18.75) + // = $7.5 + $15 + $1.5 + $1.875 = $25.875 + expect(result?.estimated_cost_usd).toBe(25.875); + expect(result?.pricing_tier).toBe("api"); + }); + + test("default tier is api when omitted", () => { + const withDefault = estimateCostFromTokens("claude-sonnet-4-20250514", 1_000_000, 1_000_000); + const withExplicit = estimateCostFromTokens("claude-sonnet-4-20250514", 1_000_000, 1_000_000, undefined, undefined, "api"); + expect(withDefault?.estimated_cost_usd).toBe(withExplicit?.estimated_cost_usd); + expect(withDefault?.pricing_tier).toBe("api"); + }); + + test("pricing_tier is included in CostEstimate", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 1000, 500, undefined, undefined, "max"); + expect(result?.pricing_tier).toBe("max"); + }); +}); + +// --------------------------------------------------------------------------- +// B26: is_estimated reflects whether real token usage backed the estimate +// --------------------------------------------------------------------------- + +describe("estimateCostFromTokens — is_estimated flag (B26)", () => { + test("real input+output tokens → is_estimated false", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 1000, 500); + expect(result?.is_estimated).toBe(false); + }); + + test("real input only (output 0) → is_estimated false", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 1000, 0); + expect(result?.is_estimated).toBe(false); + }); + + test("real output only (input 0) → is_estimated false", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 0, 500); + expect(result?.is_estimated).toBe(false); + }); + + test("only cache tokens present → is_estimated false (cache usage is real)", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 0, 0, 1000, 0); + expect(result?.is_estimated).toBe(false); + }); + + test("zero tokens across the board → is_estimated true (no real usage)", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 0, 0, 0, 0); + expect(result).toBeDefined(); + // Not grounded in real usage: must not claim to be measured. + expect(result?.is_estimated).toBe(true); + expect(result?.estimated_cost_usd).toBe(0); + }); + + test("zero tokens with no cache args → is_estimated true", () => { + const result = estimateCostFromTokens("claude-sonnet-4-20250514", 0, 0); + expect(result?.is_estimated).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// extractUserType +// --------------------------------------------------------------------------- + +describe("extractUserType", () => { + test("detects userType from transcript entries", () => { + const entries: readonly TranscriptEntry[] = [ + makeUserEntry(), + makeAssistantEntry({ + userType: "external", + }), + ]; + + const result = extractUserType(entries); + expect(result).toBe("external"); + }); + + test("returns undefined when no userType present", () => { + const entries: readonly TranscriptEntry[] = [ + makeUserEntry(), + makeAssistantEntry(), + ]; + + const result = extractUserType(entries); + expect(result).toBeUndefined(); + }); + + test("returns first userType found", () => { + const entries: readonly TranscriptEntry[] = [ + makeAssistantEntry({ + uuid: "a1", + userType: "pro", + }), + makeAssistantEntry({ + uuid: "a2", + userType: "external", + }), + ]; + + const result = extractUserType(entries); + expect(result).toBe("pro"); + }); +}); diff --git a/packages/cli/test/pricing.test.ts b/packages/cli/test/pricing.test.ts new file mode 100644 index 0000000..a0c15f7 --- /dev/null +++ b/packages/cli/test/pricing.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { getPricing } from "../src/distill/stats"; + +// --------------------------------------------------------------------------- +// Per-tier published rates (locked semantics, SHARED-CONTEXT): +// Fable 5 $10 / $50 +// Opus 4.5+ $5 / $25 (4.5, 4.6, 4.7, 4.8 ...) +// Opus 4.0/4.1 $15 / $75 (legacy family fallback) +// Haiku 4.5 $1 / $5 +// Longest matching prefix wins, so version-specific entries override the +// family fallback. These tests pin each tier so a table edit can't silently +// regress a published rate. +// --------------------------------------------------------------------------- + +describe("getPricing — per-tier published rates", () => { + test("Opus 4.8 → $5 / $25 (current tier)", () => { + const pricing = getPricing("claude-opus-4-8", "api"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBe(5); + expect(pricing?.output).toBe(25); + expect(pricing?.cache_read).toBe(0.5); + expect(pricing?.cache_write).toBe(6.25); + }); + + test("Opus 4.5 → $5 / $25 (first version-specific entry above legacy)", () => { + const pricing = getPricing("claude-opus-4-5", "api"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBe(5); + expect(pricing?.output).toBe(25); + }); + + test("Fable 5 → $10 / $50", () => { + const pricing = getPricing("claude-fable-5", "api"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBe(10); + expect(pricing?.output).toBe(50); + expect(pricing?.cache_read).toBe(1.0); + expect(pricing?.cache_write).toBe(12.5); + }); + + test("Haiku 4.5 → $1 / $5", () => { + const pricing = getPricing("claude-haiku-4-5", "api"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBe(1); + expect(pricing?.output).toBe(5); + expect(pricing?.cache_read).toBe(0.1); + expect(pricing?.cache_write).toBe(1.25); + }); +}); + +// --------------------------------------------------------------------------- +// Longest-prefix boundary: 4 -> 4-5 +// The legacy `claude-opus-4` entry ($15/$75) must NOT swallow versioned ids +// that have their own longer prefix entry ($5/$25), while versions WITHOUT a +// specific entry (4.0 / 4.1) must fall back to the legacy family rate. +// --------------------------------------------------------------------------- + +describe("getPricing — Opus 4 -> 4-5 longest-prefix boundary", () => { + test("dated Opus 4.5 id resolves to the 4-5 entry, not the legacy family", () => { + const pricing = getPricing("claude-opus-4-5-20251101", "api"); + // Longest prefix is `claude-opus-4-5` → new tier, NOT legacy `claude-opus-4`. + expect(pricing?.input).toBe(5); + expect(pricing?.output).toBe(25); + }); + + test("Opus 4.1 falls back to legacy family rate ($15/$75)", () => { + // No `claude-opus-4-1` entry exists → longest match is `claude-opus-4`. + const pricing = getPricing("claude-opus-4-1-20250805", "api"); + expect(pricing?.input).toBe(15); + expect(pricing?.output).toBe(75); + }); + + test("bare legacy `claude-opus-4` resolves to legacy family rate", () => { + const pricing = getPricing("claude-opus-4", "api"); + expect(pricing?.input).toBe(15); + expect(pricing?.output).toBe(75); + }); + + test("boundary is strict: opus 4.1 (legacy) and opus 4.5 (new) differ 3x", () => { + const legacy = getPricing("claude-opus-4-1", "api"); + const current = getPricing("claude-opus-4-5", "api"); + expect(legacy?.input).toBe(15); + expect(current?.input).toBe(5); + // The 4.5+ drop is exactly 3x on input and output. + expect((legacy?.input ?? 0) / (current?.input ?? 1)).toBe(3); + expect((legacy?.output ?? 0) / (current?.output ?? 1)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// NUM-2: bare-alias normalization +// Claude Code may emit bare aliases (`opus`, `sonnet`, `haiku`, `fable`) +// instead of fully-qualified ids. Each must normalize to its CURRENT canonical +// tier so the longest-prefix match never falls through to an unpriced $0. +// --------------------------------------------------------------------------- + +describe("getPricing — NUM-2 alias normalization", () => { + test("`opus` alias resolves to current Opus tier ($5/$25)", () => { + const aliased = getPricing("opus", "api"); + const canonical = getPricing("claude-opus-4-8", "api"); + expect(aliased).toBeDefined(); + expect(aliased).toEqual(canonical); + expect(aliased?.input).toBe(5); + expect(aliased?.output).toBe(25); + }); + + test("`sonnet` alias resolves to current Sonnet tier ($3/$15)", () => { + const aliased = getPricing("sonnet", "api"); + const canonical = getPricing("claude-sonnet-4-6", "api"); + expect(aliased).toEqual(canonical); + expect(aliased?.input).toBe(3); + expect(aliased?.output).toBe(15); + }); + + test("`haiku` alias resolves to current Haiku tier ($1/$5)", () => { + const aliased = getPricing("haiku", "api"); + const canonical = getPricing("claude-haiku-4-5", "api"); + expect(aliased).toEqual(canonical); + expect(aliased?.input).toBe(1); + expect(aliased?.output).toBe(5); + }); + + test("`fable` alias resolves to Fable tier ($10/$50)", () => { + const aliased = getPricing("fable", "api"); + const canonical = getPricing("claude-fable-5", "api"); + expect(aliased).toEqual(canonical); + expect(aliased?.input).toBe(10); + expect(aliased?.output).toBe(50); + }); + + test("alias matching is case-insensitive", () => { + expect(getPricing("OPUS", "api")).toEqual(getPricing("opus", "api")); + expect(getPricing("Haiku", "api")).toEqual(getPricing("haiku", "api")); + }); + + test("alias never falls through to an unpriced $0 (all known aliases priced)", () => { + for (const alias of ["opus", "sonnet", "haiku", "fable"]) { + const pricing = getPricing(alias, "api"); + expect(pricing).toBeDefined(); + expect(pricing?.input).toBeGreaterThan(0); + expect(pricing?.output).toBeGreaterThan(0); + } + }); +}); diff --git a/test/reasoning-command.test.ts b/packages/cli/test/reasoning-command.test.ts similarity index 100% rename from test/reasoning-command.test.ts rename to packages/cli/test/reasoning-command.test.ts diff --git a/packages/cli/test/risk-score.test.ts b/packages/cli/test/risk-score.test.ts new file mode 100644 index 0000000..370a255 --- /dev/null +++ b/packages/cli/test/risk-score.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect } from "bun:test"; +import { computeFileRiskScores } from "../src/distill/risk-score"; +import type { DistilledSession } from "../src/types"; + +const makeDistilled = (overrides: Partial = {}): DistilledSession => ({ + session_id: "test-session", + stats: { + total_events: 10, + duration_ms: 5000, + events_by_type: {}, + tools_by_name: {}, + tool_call_count: 5, + failure_count: 0, + failure_rate: 0, + unique_files: [], + }, + backtracks: [], + decisions: [], + file_map: { files: [] }, + git_diff: { commits: [], hunks: [] }, + reasoning: [], + user_messages: [], + complete: true, + ...overrides, +}); + +describe("computeFileRiskScores", () => { + test("returns empty array for empty file_map", () => { + const result = computeFileRiskScores(makeDistilled()); + expect(result).toEqual([]); + }); + + test("returns low risk for clean files", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/clean.ts", reads: 2, edits: 1, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + ], + }, + }), + ); + + expect(result.length).toBe(1); + expect(result[0].file_path).toBe("src/clean.ts"); + expect(result[0].risk_level).toBe("low"); + expect(result[0].backtrack_count).toBe(0); + expect(result[0].abandoned_edit_count).toBe(0); + }); + + test("returns medium risk for 1-2 backtracks", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/risky.ts", reads: 3, edits: 4, writes: 0, errors: 1, tool_use_ids: ["t1"] }, + ], + }, + backtracks: [ + { type: "failure_retry", tool_name: "Edit", file_path: "src/risky.ts", attempts: 2, start_t: 100, end_t: 200, tool_use_ids: ["t1", "t2"] }, + ], + }), + ); + + expect(result.length).toBe(1); + expect(result[0].risk_level).toBe("medium"); + expect(result[0].backtrack_count).toBe(1); + }); + + test("returns medium risk for some abandoned edits", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/abandoned.ts", reads: 1, edits: 3, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + ], + }, + edit_chains: { + chains: [ + { + file_path: "src/abandoned.ts", + steps: [], + total_edits: 4, + total_failures: 0, + total_reads: 1, + effort_ms: 1000, + has_backtrack: false, + surviving_edit_ids: ["e1", "e2", "e3"], + abandoned_edit_ids: ["e4"], + }, + ], + }, + }), + ); + + expect(result.length).toBe(1); + expect(result[0].risk_level).toBe("medium"); + expect(result[0].abandoned_edit_count).toBe(1); + expect(result[0].total_edit_count).toBe(4); + }); + + test("returns high risk for 3+ backtracks", () => { + const backtracks = Array.from({ length: 3 }, (_, i) => ({ + type: "failure_retry" as const, + tool_name: "Edit", + file_path: "src/bad.ts", + attempts: 2, + start_t: i * 100, + end_t: i * 100 + 50, + tool_use_ids: [`t${i}`], + })); + + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/bad.ts", reads: 5, edits: 8, writes: 0, errors: 3, tool_use_ids: ["t1"] }, + ], + }, + backtracks, + }), + ); + + expect(result.length).toBe(1); + expect(result[0].risk_level).toBe("high"); + expect(result[0].backtrack_count).toBe(3); + }); + + test("returns high risk for >50% abandoned edits", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/messy.ts", reads: 1, edits: 6, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + ], + }, + edit_chains: { + chains: [ + { + file_path: "src/messy.ts", + steps: [], + total_edits: 6, + total_failures: 0, + total_reads: 1, + effort_ms: 2000, + has_backtrack: false, + surviving_edit_ids: ["e1", "e2"], + abandoned_edit_ids: ["e3", "e4", "e5", "e6"], + }, + ], + }, + }), + ); + + expect(result.length).toBe(1); + expect(result[0].risk_level).toBe("high"); + expect(result[0].abandoned_edit_count).toBe(4); + }); + + test("returns high risk for failure rate >30%", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/failing.ts", reads: 1, edits: 5, writes: 0, errors: 2, tool_use_ids: ["t1"] }, + ], + }, + edit_chains: { + chains: [ + { + file_path: "src/failing.ts", + steps: [], + total_edits: 5, + total_failures: 2, + total_reads: 1, + effort_ms: 1500, + has_backtrack: false, + surviving_edit_ids: ["e1", "e2", "e3"], + abandoned_edit_ids: [], + }, + ], + }, + }), + ); + + expect(result.length).toBe(1); + expect(result[0].risk_level).toBe("high"); + expect(result[0].failure_rate).toBeCloseTo(0.4); + }); + + test("handles multiple files with different risk levels", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/clean.ts", reads: 1, edits: 1, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + { file_path: "src/risky.ts", reads: 3, edits: 4, writes: 0, errors: 1, tool_use_ids: ["t2"] }, + ], + }, + backtracks: [ + { type: "debugging_loop", tool_name: "Edit", file_path: "src/risky.ts", attempts: 3, start_t: 100, end_t: 200, tool_use_ids: ["t2"] }, + ], + }), + ); + + expect(result.length).toBe(2); + + const clean = result.find((r) => r.file_path === "src/clean.ts"); + const risky = result.find((r) => r.file_path === "src/risky.ts"); + + expect(clean?.risk_level).toBe("low"); + expect(risky?.risk_level).toBe("medium"); + }); + + test("counts backtracks only for matching file_path", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/a.ts", reads: 1, edits: 1, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + { file_path: "src/b.ts", reads: 1, edits: 1, writes: 0, errors: 0, tool_use_ids: ["t2"] }, + ], + }, + backtracks: [ + { type: "failure_retry", tool_name: "Edit", file_path: "src/b.ts", attempts: 2, start_t: 100, end_t: 200, tool_use_ids: ["t2"] }, + ], + }), + ); + + const a = result.find((r) => r.file_path === "src/a.ts"); + const b = result.find((r) => r.file_path === "src/b.ts"); + + expect(a?.backtrack_count).toBe(0); + expect(a?.risk_level).toBe("low"); + expect(b?.backtrack_count).toBe(1); + expect(b?.risk_level).toBe("medium"); + }); + + test("file with no edit chains has 0 edit metrics", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/readonly.ts", reads: 5, edits: 0, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + ], + }, + }), + ); + + expect(result[0].total_edit_count).toBe(0); + expect(result[0].abandoned_edit_count).toBe(0); + expect(result[0].failure_rate).toBe(0); + expect(result[0].edit_chain_length).toBe(0); + expect(result[0].risk_level).toBe("low"); + }); + + test("edit_chain_length counts chains per file", () => { + const result = computeFileRiskScores( + makeDistilled({ + file_map: { + files: [ + { file_path: "src/multi.ts", reads: 1, edits: 4, writes: 0, errors: 0, tool_use_ids: ["t1"] }, + ], + }, + edit_chains: { + chains: [ + { file_path: "src/multi.ts", steps: [], total_edits: 2, total_failures: 0, total_reads: 1, effort_ms: 500, has_backtrack: false, surviving_edit_ids: ["e1", "e2"], abandoned_edit_ids: [] }, + { file_path: "src/multi.ts", steps: [], total_edits: 2, total_failures: 0, total_reads: 0, effort_ms: 300, has_backtrack: false, surviving_edit_ids: ["e3", "e4"], abandoned_edit_ids: [] }, + ], + }, + }), + ); + + expect(result[0].edit_chain_length).toBe(2); + expect(result[0].total_edit_count).toBe(4); + }); +}); diff --git a/packages/cli/test/session-config.test.ts b/packages/cli/test/session-config.test.ts new file mode 100644 index 0000000..22cd7a5 --- /dev/null +++ b/packages/cli/test/session-config.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { extractSessionConfig } from "../src/distill/session-config"; +import type { ClaudeMdInEffect, HookEventType, StoredEvent } from "../src/types"; + +const ev = ( + event: HookEventType, + data: Record, + t = 1000, +): StoredEvent => ({ t, event, sid: "s1", data }); + +describe("extractSessionConfig — permission_mode + effort (CFG-2)", () => { + test("lifts recognized permission_mode + effort as typed values (latest wins)", () => { + const cfg = extractSessionConfig([ + ev("UserPromptSubmit", { permission_mode: "plan" }, 1), + ev("PreToolUse", { permission_mode: "acceptEdits", effort: "low", tool_name: "Read" }, 2), + ev("Stop", { permission_mode: "acceptEdits", effort: "high" }, 3), + ]); + expect(cfg.permission_mode).toBe("acceptEdits"); + expect(cfg.effort).toBe("high"); + }); + + test("drops unrecognized raw values instead of surfacing or throwing", () => { + const cfg = extractSessionConfig([ + ev("PreToolUse", { permission_mode: "weirdMode", effort: "extreme", tool_name: "Read" }, 1), + ]); + expect(cfg.permission_mode).toBeUndefined(); + expect(cfg.effort).toBeUndefined(); + }); + + test("falls back to an earlier recognized value when the latest is unknown", () => { + const cfg = extractSessionConfig([ + ev("PreToolUse", { permission_mode: "default", effort: "medium", tool_name: "Read" }, 1), + ev("Stop", { permission_mode: "???", effort: "???" }, 2), + ]); + expect(cfg.permission_mode).toBe("default"); + expect(cfg.effort).toBe("medium"); + }); +}); + +describe("extractSessionConfig — MCP server aggregation (CFG-4)", () => { + test("dedupes + counts servers from mcp__ tool names, sorted by count then name", () => { + const cfg = extractSessionConfig([ + ev("PreToolUse", { tool_name: "mcp__filesystem__read_file" }, 1), + ev("PreToolUse", { tool_name: "mcp__filesystem__write_file" }, 2), + ev("PreToolUse", { tool_name: "mcp__claude_ai_Atlassian__editJiraIssue" }, 3), + ev("PreToolUse", { tool_name: "Read" }, 4), + ]); + expect(cfg.mcp_servers).toEqual([ + { name: "filesystem", count: 2 }, + { name: "claude_ai_Atlassian", count: 1 }, + ]); + }); + + test("server name with underscores is parsed up to the first __ delimiter", () => { + const cfg = extractSessionConfig([ + ev("PreToolUse", { tool_name: "mcp__claude_ai_Atlassian__search" }, 1), + ]); + expect(cfg.mcp_servers[0]?.name).toBe("claude_ai_Atlassian"); + }); + + test("does not double-count Post/Failure events for a single call", () => { + const cfg = extractSessionConfig([ + ev("PreToolUse", { tool_name: "mcp__ide__executeCode" }, 1), + ev("PostToolUse", { tool_name: "mcp__ide__executeCode" }, 2), + ]); + expect(cfg.mcp_servers).toEqual([{ name: "ide", count: 1 }]); + }); + + test("empty MCP list when no mcp__ tools were used", () => { + const cfg = extractSessionConfig([ev("PreToolUse", { tool_name: "Bash" }, 1)]); + expect(cfg.mcp_servers).toEqual([]); + }); +}); + +describe("extractSessionConfig — CLAUDE.md realization (CFG-5)", () => { + test("realizes claude_md_in_effect from InstructionsLoaded events (deduped)", () => { + const cfg = extractSessionConfig([ + ev("InstructionsLoaded", { + file_path: "/p/CLAUDE.md", + memory_type: "Project", + load_reason: "session_start", + }, 1), + ev("InstructionsLoaded", { file_path: "/p/CLAUDE.md", memory_type: "Project" }, 2), + ev("InstructionsLoaded", { file_path: "/u/CLAUDE.md", memory_type: "User" }, 3), + ]); + expect(cfg.claude_md_in_effect).toEqual([ + { file_path: "/p/CLAUDE.md", memory_type: "Project", load_reason: "session_start" }, + { file_path: "/u/CLAUDE.md", memory_type: "User" }, + ]); + }); + + test("uses inferred fallback only when no InstructionsLoaded events were captured", () => { + const fallback: readonly ClaudeMdInEffect[] = [ + { file_path: "/p/CLAUDE.md", memory_type: "inferred" }, + ]; + const cfg = extractSessionConfig([ev("PreToolUse", { tool_name: "Read" }, 1)], { + claudeMdFallback: fallback, + }); + expect(cfg.claude_md_in_effect).toEqual(fallback); + }); + + test("realized events take precedence over the inferred fallback", () => { + const cfg = extractSessionConfig( + [ev("InstructionsLoaded", { file_path: "/p/CLAUDE.md", memory_type: "Project" }, 1)], + { claudeMdFallback: [{ file_path: "/x/CLAUDE.md", memory_type: "inferred" }] }, + ); + expect(cfg.claude_md_in_effect).toEqual([ + { file_path: "/p/CLAUDE.md", memory_type: "Project" }, + ]); + }); + + test("omits claude_md_in_effect entirely when neither source yields entries", () => { + const cfg = extractSessionConfig([ev("PreToolUse", { tool_name: "Read" }, 1)]); + expect(cfg.claude_md_in_effect).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/session-meta.test.ts b/packages/cli/test/session-meta.test.ts new file mode 100644 index 0000000..0298f44 --- /dev/null +++ b/packages/cli/test/session-meta.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + readSessionMeta, + sessionMetaPath, + setSessionMeta, + writeSessionMeta, +} from "../src/session/session-meta"; + +const TMP_ROOT = join(import.meta.dir, "tmp-session-meta"); + +let counter = 0; +const freshDir = (): string => { + counter += 1; + const dir = join(TMP_ROOT, `case-${counter}`); + try { + rmSync(dir, { recursive: true }); + } catch { + /* ignore */ + } + mkdirSync(dir, { recursive: true }); + return dir; +}; + +describe("readSessionMeta", () => { + test("returns {} when sidecar is missing", () => { + const dir = freshDir(); + expect(readSessionMeta(dir)).toEqual({}); + }); + + test("returns {} when sidecar is malformed (R15)", () => { + const dir = freshDir(); + mkdirSync(join(dir, ".clens"), { recursive: true }); + writeFileSync(sessionMetaPath(dir), "{ not valid json ::::"); + expect(readSessionMeta(dir)).toEqual({}); + }); + + test("returns {} when sidecar JSON is not an object", () => { + const dir = freshDir(); + mkdirSync(join(dir, ".clens"), { recursive: true }); + writeFileSync(sessionMetaPath(dir), "[1,2,3]"); + expect(readSessionMeta(dir)).toEqual({}); + }); + + test("ignores malformed individual entries but keeps valid ones", () => { + const dir = freshDir(); + mkdirSync(join(dir, ".clens"), { recursive: true }); + writeFileSync( + sessionMetaPath(dir), + JSON.stringify({ + good: { label: "Keep me", color: "amber", updated_at: 1 }, + badColor: { color: "rainbow", updated_at: 2 }, + notObject: "nope", + }), + ); + const meta = readSessionMeta(dir); + expect(meta.good).toEqual({ label: "Keep me", color: "amber", updated_at: 1 }); + // invalid color dropped, entry still present without it + expect(meta.badColor?.color).toBeUndefined(); + expect(meta.notObject).toBeUndefined(); + }); +}); + +describe("writeSessionMeta (atomic round-trip)", () => { + test("writes and reads back the same map", () => { + const dir = freshDir(); + const map = { + s1: { label: "First", color: "green" as const, updated_at: 100 }, + s2: { color: "red" as const, updated_at: 200 }, + }; + writeSessionMeta(dir, map); + expect(readSessionMeta(dir)).toEqual(map); + }); + + test("creates the .clens directory if missing", () => { + const dir = freshDir(); + writeSessionMeta(dir, { s1: { label: "x", updated_at: 1 } }); + expect(existsSync(sessionMetaPath(dir))).toBe(true); + }); + + test("leaves no temp files behind", () => { + const dir = freshDir(); + writeSessionMeta(dir, { s1: { label: "x", updated_at: 1 } }); + const files = readdirSync(join(dir, ".clens")); + expect(files.filter((f) => f.includes("tmp") || f.endsWith(".tmp"))).toHaveLength(0); + }); +}); + +describe("setSessionMeta", () => { + test("sets a label (R6)", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { label: "My Label" }); + expect(readSessionMeta(dir).s1?.label).toBe("My Label"); + }); + + test("sets a color (R10)", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { color: "violet" }); + expect(readSessionMeta(dir).s1?.color).toBe("violet"); + }); + + test("clears label when passed null (R7)", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { label: "x", color: "blue" }); + setSessionMeta(dir, "s1", { label: null }); + const meta = readSessionMeta(dir).s1; + expect(meta?.label).toBeUndefined(); + expect(meta?.color).toBe("blue"); // color untouched + }); + + test("treats whitespace-only label as a clear (R8)", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { label: "x" }); + setSessionMeta(dir, "s1", { label: " " }); + expect(readSessionMeta(dir).s1?.label).toBeUndefined(); + }); + + test("clears color when set to none (R13)", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { color: "red" }); + setSessionMeta(dir, "s1", { color: "none" }); + expect(readSessionMeta(dir).s1?.color).toBeUndefined(); + }); + + test("clears color when passed null", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { color: "red" }); + setSessionMeta(dir, "s1", { color: null }); + expect(readSessionMeta(dir).s1?.color).toBeUndefined(); + }); + + test("rejects an invalid color and leaves metadata unchanged (R14)", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { label: "Keep", color: "green" }); + expect(() => setSessionMeta(dir, "s1", { color: "rainbow" as never })).toThrow(); + const meta = readSessionMeta(dir).s1; + expect(meta?.label).toBe("Keep"); + expect(meta?.color).toBe("green"); + }); + + test("removes the entry entirely when both fields cleared", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { label: "x", color: "amber" }); + setSessionMeta(dir, "s1", { label: null, color: "none" }); + expect(readSessionMeta(dir).s1).toBeUndefined(); + }); + + test("does not disturb other sessions' metadata", () => { + const dir = freshDir(); + setSessionMeta(dir, "s1", { label: "one" }); + setSessionMeta(dir, "s2", { color: "blue" }); + const meta = readSessionMeta(dir); + expect(meta.s1?.label).toBe("one"); + expect(meta.s2?.color).toBe("blue"); + }); + + test("bumps updated_at on write", () => { + const dir = freshDir(); + const before = Date.now(); + setSessionMeta(dir, "s1", { label: "x" }); + const ts = readSessionMeta(dir).s1?.updated_at ?? 0; + expect(ts).toBeGreaterThanOrEqual(before); + }); +}); diff --git a/packages/cli/test/session-name-compute.test.ts b/packages/cli/test/session-name-compute.test.ts new file mode 100644 index 0000000..6147ba4 --- /dev/null +++ b/packages/cli/test/session-name-compute.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { computeSessionName, resolveDisplayName } from "../src/session/session-name"; + +describe("computeSessionName", () => { + test("returns null for null/undefined/empty input", () => { + expect(computeSessionName(null)).toBeNull(); + expect(computeSessionName(undefined)).toBeNull(); + expect(computeSessionName("")).toBeNull(); + expect(computeSessionName(" ")).toBeNull(); + }); + + test("returns a plain first prompt unchanged", () => { + expect(computeSessionName("Fix the login bug")).toBe("Fix the login bug"); + }); + + test("collapses internal whitespace and trims", () => { + expect(computeSessionName(" Fix\n\tthe login\n bug ")).toBe("Fix the login bug"); + }); + + test("strips blocks", () => { + const prompt = + "As you answer, use this context: foo bar\nFix the login bug"; + expect(computeSessionName(prompt)).toBe("Fix the login bug"); + }); + + test("strips multiple system-reminder blocks including multiline", () => { + const prompt = + "\nline one\nline two\nReal requesttrailing"; + expect(computeSessionName(prompt)).toBe("Real request"); + }); + + test("strips // wrappers but keeps slash text", () => { + const prompt = + "/prime\nprime is running\nthis project"; + // wrappers removed, inner text collapsed + const result = computeSessionName(prompt); + expect(result).not.toContain(" { + expect(computeSessionName("/prime & explore this project")).toBe( + "/prime & explore this project", + ); + }); + + test("truncates to <= 60 chars with ellipsis when longer", () => { + const long = "a".repeat(80); + const result = computeSessionName(long); + expect(result).not.toBeNull(); + expect([...(result as string)].length).toBeLessThanOrEqual(60); + expect((result as string).endsWith("…")).toBe(true); + }); + + test("does not truncate exactly-60-char prompts", () => { + const exact = "b".repeat(60); + expect(computeSessionName(exact)).toBe(exact); + }); + + test("returns null when only harness noise remains after stripping", () => { + const prompt = "only noise here"; + expect(computeSessionName(prompt)).toBeNull(); + }); +}); + +describe("resolveDisplayName", () => { + test("prefers user label over everything (R6)", () => { + expect( + resolveDisplayName({ + label: "My Label", + customTitle: "CC Title", + computed: "computed name", + id: "a288eefb-1234", + }), + ).toEqual({ display_name: "My Label", name_source: "label" }); + }); + + test("falls back to custom_title when no label", () => { + expect( + resolveDisplayName({ + label: null, + customTitle: "CC Title", + computed: "computed name", + id: "a288eefb-1234", + }), + ).toEqual({ display_name: "CC Title", name_source: "custom_title" }); + }); + + test("falls back to computed when no label or custom_title", () => { + expect( + resolveDisplayName({ + computed: "computed name", + id: "a288eefb-1234", + }), + ).toEqual({ display_name: "computed name", name_source: "computed" }); + }); + + test("falls back to short id (8 chars) when nothing else (R4)", () => { + expect( + resolveDisplayName({ + id: "a288eefb-1234-5678", + }), + ).toEqual({ display_name: "a288eefb", name_source: "id" }); + }); + + test("treats whitespace-only label as absent (R8)", () => { + expect( + resolveDisplayName({ + label: " ", + computed: "computed name", + id: "a288eefb-1234", + }), + ).toEqual({ display_name: "computed name", name_source: "computed" }); + }); + + test("treats empty/whitespace customTitle as absent", () => { + expect( + resolveDisplayName({ + customTitle: " ", + computed: "computed name", + id: "a288eefb-1234", + }), + ).toEqual({ display_name: "computed name", name_source: "computed" }); + }); +}); diff --git a/test/session-name.test.ts b/packages/cli/test/session-name.test.ts similarity index 100% rename from test/session-name.test.ts rename to packages/cli/test/session-name.test.ts diff --git a/packages/cli/test/session-registry.test.ts b/packages/cli/test/session-registry.test.ts new file mode 100644 index 0000000..ffe9533 --- /dev/null +++ b/packages/cli/test/session-registry.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readRegistry, + writeRegistry, + registerProject, + unregisterProject, + resolveProjectEntries, +} from "../src/session/registry"; + +const makeTempDir = (): string => { + const dir = join( + tmpdir(), + `clens-test-registry-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +}; + +describe("session-registry", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = makeTempDir(); + }); + + afterEach(() => { + // Clean up any test projects we registered + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("readRegistry", () => { + test("returns valid registry structure", () => { + const registry = readRegistry(); + expect(registry.version).toBe(1); + expect(Array.isArray(registry.projects)).toBe(true); + }); + }); + + describe("writeRegistry / readRegistry roundtrip", () => { + test("write and re-read preserves structure", () => { + const original = readRegistry(); + writeRegistry(original); + const reread = readRegistry(); + expect(reread.version).toBe(1); + expect(Array.isArray(reread.projects)).toBe(true); + }); + }); + + describe("registerProject", () => { + test("registers a project with correct fields", () => { + const projectDir = join(tempDir, "my-test-project"); + mkdirSync(join(projectDir, ".clens"), { recursive: true }); + + const entry = registerProject(projectDir); + expect(entry.id).toBe("my-test-project"); + expect(entry.path).toBe(projectDir); + expect(entry.name).toBe("my-test-project"); + expect(typeof entry.added_at).toBe("number"); + + // Cleanup + unregisterProject(projectDir); + }); + + test("is idempotent — returns same entry on duplicate register", () => { + const projectDir = join(tempDir, "idempotent-project"); + mkdirSync(join(projectDir, ".clens"), { recursive: true }); + + const entry1 = registerProject(projectDir); + const entry2 = registerProject(projectDir); + expect(entry2.path).toBe(entry1.path); + expect(entry2.id).toBe(entry1.id); + expect(entry2.added_at).toBe(entry1.added_at); + + // Registry should have exactly one entry for this path + const registry = readRegistry(); + const matches = registry.projects.filter((p) => p.path === projectDir); + expect(matches.length).toBe(1); + + // Cleanup + unregisterProject(projectDir); + }); + + test("derives kebab-case id from directory name", () => { + const projectDir = join(tempDir, "My Cool Project"); + mkdirSync(join(projectDir, ".clens"), { recursive: true }); + + const entry = registerProject(projectDir); + expect(entry.id).toBe("my-cool-project"); + + // Cleanup + unregisterProject(projectDir); + }); + + test("handles directory names with special characters", () => { + const projectDir = join(tempDir, "project_v2.0--beta"); + mkdirSync(join(projectDir, ".clens"), { recursive: true }); + + const entry = registerProject(projectDir); + // Special chars become hyphens, leading/trailing hyphens stripped + expect(entry.id).toBe("project-v2-0-beta"); + + // Cleanup + unregisterProject(projectDir); + }); + }); + + describe("unregisterProject", () => { + test("removes a registered project", () => { + const projectDir = join(tempDir, "remove-me"); + mkdirSync(join(projectDir, ".clens"), { recursive: true }); + + registerProject(projectDir); + const removed = unregisterProject(projectDir); + expect(removed).toBe(true); + + const registry = readRegistry(); + expect(registry.projects.find((p) => p.path === projectDir)).toBeUndefined(); + }); + + test("returns false for unknown project path", () => { + const result = unregisterProject(`/nonexistent/path/${Date.now()}`); + expect(result).toBe(false); + }); + }); + + describe("resolveProjectEntries", () => { + test("keeps repository-mode entries whose .clens is nested below the git root", () => { + // Repository mode registers path=gitRoot, but a monorepo may capture into a + // nested package (gitRoot/packages/web/.clens/sessions). The old existsSync + // check on `${path}/.clens` dropped these (bug repo-mode-nested-clens-projects-dropped). + const gitRoot = join(tempDir, "nested-repo"); + const nestedClens = join(gitRoot, "packages", "web", ".clens", "sessions"); + mkdirSync(nestedClens, { recursive: true }); + // Deliberately NO `.clens` directly at gitRoot. + expect(existsSync(join(gitRoot, ".clens"))).toBe(false); + + const registry = readRegistry(); + writeRegistry({ + ...registry, + projects: [ + ...registry.projects, + { id: "nested-repo", path: gitRoot, name: "nested-repo", added_at: Date.now() }, + ], + }); + + const entries = resolveProjectEntries(); + expect(entries.find((e) => e.path === gitRoot)).toBeDefined(); + + // Cleanup + unregisterProject(gitRoot); + }); + + test("filters out entries whose .clens dir no longer exists", () => { + const goodProject = join(tempDir, "good-project"); + mkdirSync(join(goodProject, ".clens"), { recursive: true }); + registerProject(goodProject); + + const badProject = join(tempDir, "bad-project"); + mkdirSync(badProject, { recursive: true }); + // Manually insert an entry without .clens dir + const registry = readRegistry(); + writeRegistry({ + ...registry, + projects: [ + ...registry.projects, + { + id: "bad-project", + path: badProject, + name: "bad-project", + added_at: Date.now(), + }, + ], + }); + + const entries = resolveProjectEntries(); + const goodEntry = entries.find((e) => e.path === goodProject); + const badEntry = entries.find((e) => e.path === badProject); + + expect(goodEntry).toBeDefined(); + expect(badEntry).toBeUndefined(); + + // Cleanup + unregisterProject(goodProject); + unregisterProject(badProject); + }); + }); +}); diff --git a/packages/cli/test/session-status.test.ts b/packages/cli/test/session-status.test.ts new file mode 100644 index 0000000..d8c8ea2 --- /dev/null +++ b/packages/cli/test/session-status.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { ACTIVE_THRESHOLD_MS, deriveSessionStatus, SESSION_STATUSES } from "../src/types"; + +// Regression coverage for the status model (bugs B5/B6). A session is only +// "complete" when it ended cleanly (SessionEnd). Otherwise it is "active" while +// its last event is recent, and "idle" once it has gone quiet past the window. +describe("deriveSessionStatus", () => { + const NOW = 1_700_000_000_000; + + test("SessionEnd always means complete, regardless of age", () => { + expect(deriveSessionStatus(true, NOW, NOW)).toBe("complete"); + // Even an ancient ended session stays complete. + expect(deriveSessionStatus(true, 0, NOW)).toBe("complete"); + }); + + test("non-ended + last event within the window is active", () => { + expect(deriveSessionStatus(false, NOW, NOW)).toBe("active"); + expect(deriveSessionStatus(false, NOW - 60_000, NOW)).toBe("active"); + }); + + test("non-ended + last event exactly at the threshold boundary is active", () => { + expect(deriveSessionStatus(false, NOW - ACTIVE_THRESHOLD_MS, NOW)).toBe("active"); + }); + + test("non-ended + last event past the window is idle", () => { + expect(deriveSessionStatus(false, NOW - ACTIVE_THRESHOLD_MS - 1, NOW)).toBe("idle"); + // A session whose last event was hours ago is idle, not active (bug B6). + expect(deriveSessionStatus(false, NOW - 5 * 60 * 60_000, NOW)).toBe("idle"); + }); + + test("threshold is 10 minutes", () => { + expect(ACTIVE_THRESHOLD_MS).toBe(600_000); + }); + + test("status set is exactly complete/active/idle", () => { + expect([...SESSION_STATUSES]).toEqual(["complete", "active", "idle"]); + }); +}); diff --git a/test/session.test.ts b/packages/cli/test/session.test.ts similarity index 81% rename from test/session.test.ts rename to packages/cli/test/session.test.ts index dbb4463..bc5789b 100644 --- a/test/session.test.ts +++ b/packages/cli/test/session.test.ts @@ -63,7 +63,8 @@ describe("listSessions", () => { expect(sessions[0].event_count).toBe(2); }); - test("marks incomplete sessions", () => { + test("marks non-ended sessions with an old last event as idle (bug B6)", () => { + // Last event is ancient (epoch 3000ms), so well past the 10-minute active window. const events = [ JSON.stringify(makeStoredEvent({ t: 1000, event: "SessionStart", sid: "sess-2" })), JSON.stringify(makeStoredEvent({ t: 3000, event: "PreToolUse", sid: "sess-2" })), @@ -71,7 +72,19 @@ describe("listSessions", () => { writeFileSync(`${TEST_DIR}/.clens/sessions/sess-2.jsonl`, `${events}\n`); const sessions = listSessions(TEST_DIR); - expect(sessions[0].status).toBe("incomplete"); + expect(sessions[0].status).toBe("idle"); + }); + + test("marks non-ended sessions with a recent last event as active (bug B6)", () => { + const now = Date.now(); + const events = [ + JSON.stringify(makeStoredEvent({ t: now - 60_000, event: "SessionStart", sid: "sess-2b" })), + JSON.stringify(makeStoredEvent({ t: now - 30_000, event: "PreToolUse", sid: "sess-2b" })), + ].join("\n"); + writeFileSync(`${TEST_DIR}/.clens/sessions/sess-2b.jsonl`, `${events}\n`); + + const sessions = listSessions(TEST_DIR); + expect(sessions[0].status).toBe("active"); }); test("excludes _links.jsonl", () => { @@ -173,15 +186,31 @@ describe("listSessions", () => { expect(sessions[2].session_id).toBe("sess-old"); }); - test("marks session complete when last event is Stop", () => { + test("does NOT mark complete on trailing Stop — Stop fires every turn (bug B6)", () => { + // A Stop event used to be treated as session completion, freezing dead + // sessions as "complete". Stop now only ends a turn; without SessionEnd the + // session is idle (old timestamp here) or active (recent). const events = [ JSON.stringify(makeStoredEvent({ t: 1000, event: "SessionStart", sid: "sess-stop" })), JSON.stringify(makeStoredEvent({ t: 5000, event: "Stop", sid: "sess-stop" })), ].join("\n"); writeFileSync(`${TEST_DIR}/.clens/sessions/sess-stop.jsonl`, `${events}\n`); + const sessions = listSessions(TEST_DIR); + expect(sessions[0].status).toBe("idle"); + expect(sessions[0].end_time).toBeUndefined(); + }); + + test("marks complete only on SessionEnd", () => { + const events = [ + JSON.stringify(makeStoredEvent({ t: 1000, event: "SessionStart", sid: "sess-end" })), + JSON.stringify(makeStoredEvent({ t: 5000, event: "SessionEnd", sid: "sess-end" })), + ].join("\n"); + writeFileSync(`${TEST_DIR}/.clens/sessions/sess-end.jsonl`, `${events}\n`); + const sessions = listSessions(TEST_DIR); expect(sessions[0].status).toBe("complete"); + expect(sessions[0].end_time).toBe(5000); }); }); @@ -259,6 +288,53 @@ describe("enrichSessionSummaries", () => { expect(enriched[0].agent_count).toBe(2); }); + test("deduplicates spawn links by agent_id (bug B15: resumed agents count once)", () => { + const { enrichSessionSummaries } = require("../src/session/read"); + const sid = "sess-enrich-dedup"; + const events = [ + JSON.stringify(makeStoredEvent({ t: 1000, event: "SessionStart", sid })), + JSON.stringify(makeStoredEvent({ t: 5000, event: "SessionEnd", sid })), + ].join("\n"); + writeFileSync(`${TEST_DIR}/.clens/sessions/${sid}.jsonl`, `${events}\n`); + + // child-1 was spawned twice (resumed) — must count as ONE agent. + const links = [ + JSON.stringify({ t: 2000, type: "spawn", parent_session: sid, agent_id: "child-1", agent_type: "builder" }), + JSON.stringify({ t: 2500, type: "spawn", parent_session: sid, agent_id: "child-1", agent_type: "builder" }), + JSON.stringify({ t: 3000, type: "spawn", parent_session: sid, agent_id: "child-2", agent_type: "builder" }), + ].join("\n"); + writeFileSync(`${TEST_DIR}/.clens/sessions/_links.jsonl`, `${links}\n`); + + const sessions = listSessions(TEST_DIR); + const enriched = enrichSessionSummaries(sessions, TEST_DIR); + expect(enriched[0].agent_count).toBe(2); + }); + + test("distilled agent count is authoritative over spawn links (bug B15)", () => { + const { enrichSessionSummaries } = require("../src/session/read"); + const sid = "sess-enrich-distilled-count"; + const events = [ + JSON.stringify(makeStoredEvent({ t: 1000, event: "SessionStart", sid })), + JSON.stringify(makeStoredEvent({ t: 5000, event: "SessionEnd", sid })), + ].join("\n"); + writeFileSync(`${TEST_DIR}/.clens/sessions/${sid}.jsonl`, `${events}\n`); + + // One spawn link, but the distilled tree records 3 agents (incl. nesting). + const links = JSON.stringify({ t: 2000, type: "spawn", parent_session: sid, agent_id: "child-1", agent_type: "builder" }); + writeFileSync(`${TEST_DIR}/.clens/sessions/_links.jsonl`, `${links}\n`); + writeFileSync(`${TEST_DIR}/.clens/distilled/${sid}.json`, JSON.stringify({ + session_id: sid, + agents: [ + { session_id: "a1", children: [{ session_id: "a2", children: [] }] }, + { session_id: "a3", children: [] }, + ], + })); + + const sessions = listSessions(TEST_DIR); + const enriched = enrichSessionSummaries(sessions, TEST_DIR); + expect(enriched[0].agent_count).toBe(3); + }); + test("returns 0 agent_count for sessions with no spawn links", () => { const { enrichSessionSummaries } = require("../src/session/read"); const sid = "sess-enrich-2"; diff --git a/test/transcript.test.ts b/packages/cli/test/transcript.test.ts similarity index 100% rename from test/transcript.test.ts rename to packages/cli/test/transcript.test.ts diff --git a/test/tui-renderers.test.ts b/packages/cli/test/tui-renderers.test.ts similarity index 99% rename from test/tui-renderers.test.ts rename to packages/cli/test/tui-renderers.test.ts index 5d6df68..b2df532 100644 --- a/test/tui-renderers.test.ts +++ b/packages/cli/test/tui-renderers.test.ts @@ -139,6 +139,7 @@ describe("formatAgentRow", () => { estimated_input_tokens: 10000, estimated_output_tokens: 5000, estimated_cost_usd: 0.45, + is_estimated: false, }, }); const row = formatAgentRow(agent, false, 80); @@ -333,6 +334,7 @@ describe("formatAgentDetail", () => { estimated_input_tokens: 10000, estimated_output_tokens: 5000, estimated_cost_usd: 1.23, + is_estimated: false, }, }); const lines = formatAgentDetail(agent); diff --git a/test/tui-state.test.ts b/packages/cli/test/tui-state.test.ts similarity index 100% rename from test/tui-state.test.ts rename to packages/cli/test/tui-state.test.ts diff --git a/test/tui-tabs.test.ts b/packages/cli/test/tui-tabs.test.ts similarity index 100% rename from test/tui-tabs.test.ts rename to packages/cli/test/tui-tabs.test.ts diff --git a/test/tui.test.ts b/packages/cli/test/tui.test.ts similarity index 99% rename from test/tui.test.ts rename to packages/cli/test/tui.test.ts index 4f762ae..c300d23 100644 --- a/test/tui.test.ts +++ b/packages/cli/test/tui.test.ts @@ -756,6 +756,7 @@ describe("formatAgentRow", () => { estimated_input_tokens: 10000, estimated_output_tokens: 5000, estimated_cost_usd: 0.45, + is_estimated: false, }, }); const row = formatAgentRow(agent, false, 80); diff --git a/test/utils.test.ts b/packages/cli/test/utils.test.ts similarity index 57% rename from test/utils.test.ts rename to packages/cli/test/utils.test.ts index 524865a..c5c5255 100644 --- a/test/utils.test.ts +++ b/packages/cli/test/utils.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { formatBytes, formatDuration, isUuidLike, sanitizeAgentName } from "../src/utils"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { formatBytes, formatDuration, isUuidLike, resolveProjectRoot, sanitizeAgentName } from "../src/utils"; describe("formatDuration", () => { test("0ms", () => { @@ -96,3 +97,42 @@ describe("sanitizeAgentName", () => { expect(sanitizeAgentName("", "a28e35948fb8bc659")).toBe("a28e3594"); }); }); + +describe("resolveProjectRoot", () => { + const ROOT = `/tmp/clens-test-resolve-root-${process.pid}`; + + beforeEach(() => { + rmSync(ROOT, { recursive: true, force: true }); + mkdirSync(`${ROOT}/.clens/sessions`, { recursive: true }); + mkdirSync(`${ROOT}/packages/web/src/client/assets`, { recursive: true }); + }); + + afterEach(() => { + rmSync(ROOT, { recursive: true, force: true }); + }); + + test("walks up from a nested cwd to the .clens project root", () => { + expect(resolveProjectRoot(`${ROOT}/packages/web/src/client/assets`)).toBe(ROOT); + }); + + test("returns the start dir when it already contains .clens", () => { + expect(resolveProjectRoot(ROOT)).toBe(ROOT); + }); + + test("prefers a nearer .clens over a farther .clens parent", () => { + mkdirSync(`${ROOT}/packages/web/.clens`, { recursive: true }); + expect(resolveProjectRoot(`${ROOT}/packages/web/src/client/assets`)).toBe(`${ROOT}/packages/web`); + }); + + test("falls back to .git when no .clens exists on the path", () => { + rmSync(`${ROOT}/.clens`, { recursive: true, force: true }); + mkdirSync(`${ROOT}/.git`, { recursive: true }); + expect(resolveProjectRoot(`${ROOT}/packages/web/src/client/assets`)).toBe(ROOT); + }); + + test("falls back to the start dir when neither .clens nor .git is found", () => { + const orphan = `${ROOT}/packages/web/src/client/assets`; + rmSync(`${ROOT}/.clens`, { recursive: true, force: true }); + expect(resolveProjectRoot(orphan)).toBe(orphan); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..1b8e9b5 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["bun-types"], + "composite": true + }, + "include": ["src/**/*"] +} diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..82807c8 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "@clens/web", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "cLens web dashboard — SolidJS + Hono", + "exports": { + "./server": "./src/server/index.ts" + }, + "scripts": { + "dev:api": "bun --watch run src/server/index.ts", + "dev:api:global": "bun --watch run src/server/index.ts --global", + "dev:web": "vite dev", + "build": "vite build", + "test": "bun test test/", + "test:api": "bun test test/", + "lint": "bunx biome check src/ test/", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "clens": "workspace:*", + "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@kobalte/core": "^0.13.0", + "@solidjs/router": "^0.15.0", + "diff2html": "^3.4.0", + "hono": "^4.0.0", + "lucide-solid": "^0.577.0", + "snarkdown": "^2.0.0", + "solid-js": "^1.9.0" + }, + "devDependencies": { + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vite-plugin-solid": "^2.10.0" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 0000000..7b75c83 --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/web/src/client/App.tsx b/packages/web/src/client/App.tsx new file mode 100644 index 0000000..2dd6613 --- /dev/null +++ b/packages/web/src/client/App.tsx @@ -0,0 +1,354 @@ +import type { RouteSectionProps } from "@solidjs/router"; +import { useNavigate, useLocation } from "@solidjs/router"; +import { createEffect, createSignal, ErrorBoundary, For, onCleanup, onMount, Show, type Component } from "solid-js"; +import { Database, Calendar, Activity, Clock, DollarSign, BarChart3, Lightbulb, ChevronDown } from "lucide-solid"; +import { ErrorFallback } from "./components/ErrorFallback"; +import { KeyboardHelp } from "./components/KeyboardHelp"; +import { headerStats } from "./lib/analytics-store"; +import { initSSE } from "./lib/events"; +import { formatCost, formatDuration } from "./lib/format"; +import { toggleHelp, setKeyboardNavigate } from "./lib/keyboard"; +import { preferences } from "./lib/settings"; +import { theme, toggleTheme, initThemeListener } from "./lib/theme"; + +// ── Icons ─────────────────────────────────────────────────────────── + +const SunIcon: Component = () => ( + + + + +); + +const MoonIcon: Component = () => ( + + + +); + +const GearIcon: Component = () => ( + + + + +); + +const LogoIcon: Component = () => ( + cLens +); + +// ── Navigation items ──────────────────────────────────────────────── + +type NavItem = { + readonly label: string; + readonly path: string; + readonly matchPrefix: string; +}; + +const NAV_ITEMS: readonly NavItem[] = [ + { label: "Sessions", path: "/", matchPrefix: "/session" }, +] as const; + +const isNavActive = (item: NavItem, pathname: string): boolean => { + if (item.label === "Sessions") { + return pathname === "/" || pathname.startsWith("/session"); + } + return pathname.startsWith(item.matchPrefix); +}; + +const isAnalyticsActive = (pathname: string): boolean => + pathname === "/usage" || pathname === "/insights"; + +// ── Font size mapping ──────────────────────────────────────────────── + +const FONT_SIZE_MAP: Readonly> = { + sm: "13px", + base: "14px", + lg: "15px", +} as const; + +// ── Analytics Dropdown ─────────────────────────────────────────────── + +type AnalyticsDropdownProps = { + readonly active: boolean; + readonly sessionCount: number; + readonly todayCount: number; + readonly totalEvents: number; + readonly avgDuration: number; + readonly totalCost: number; + readonly sessionsWithCost: number; + readonly loaded: boolean; + readonly onNavigate: (path: string) => void; +}; + +const AnalyticsDropdown: Component = (props) => { + const [open, setOpen] = createSignal(false); + let containerRef: HTMLDivElement | undefined; + + // Pure click-toggle menu (FE-30): one unambiguous open interaction. The trigger + // no longer navigates on click — it opens/closes the menu; navigation happens + // only via the menu items. Click outside closes. + const handleOutsideClick = (e: MouseEvent) => { + if (open() && containerRef && !containerRef.contains(e.target as Node)) { + setOpen(false); + } + }; + + onMount(() => document.addEventListener("click", handleOutsideClick)); + onCleanup(() => document.removeEventListener("click", handleOutsideClick)); + + const navigate = (path: string) => { + setOpen(false); + props.onNavigate(path); + }; + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown panel */} + + + +
+ ); +}; + +// ── App ───────────────────────────────────────────────────────────── + +export const App: Component = (props) => { + const navigate = useNavigate(); + const location = useLocation(); + setKeyboardNavigate(navigate); + + // ── KPI derivations (from analytics API — server-aggregated, no limit) ── + const stats = () => headerStats() ?? { totalSessions: 0, todaySessions: 0, totalEvents: 0, avgDurationMs: 0, totalCostUsd: 0, sessionsWithCost: 0 }; + + let disconnectSSE: (() => void) | undefined; + let removeThemeListener: (() => void) | undefined; + + createEffect(() => { + document.documentElement.style.fontSize = FONT_SIZE_MAP[preferences().fontSize] ?? "13px"; + }); + + onMount(() => { + disconnectSSE = initSSE(); + removeThemeListener = initThemeListener(); + }); + + onCleanup(() => { + disconnectSSE?.(); + removeThemeListener?.(); + }); + + return ( +
+
+ {/* Logo + wordmark */} + + + {/* Right: Nav + Live + separator + Actions */} +
+ + +
+ + + + + Live +
+ +
+ + + + +
+
+ {/* Tick-mark ruler motif — oscilloscope graticule under the header */} + + ); +}; diff --git a/packages/web/src/client/assets/logo.png b/packages/web/src/client/assets/logo.png new file mode 100644 index 0000000..96d2db9 Binary files /dev/null and b/packages/web/src/client/assets/logo.png differ diff --git a/packages/web/src/client/components/BottomPanel.tsx b/packages/web/src/client/components/BottomPanel.tsx new file mode 100644 index 0000000..212598d --- /dev/null +++ b/packages/web/src/client/components/BottomPanel.tsx @@ -0,0 +1,347 @@ +import { + createMemo, + createSignal, + For, + Show, + type Component, +} from "solid-js"; +import type { + BacktrackResult, + TimelineEntry, + EditChainsResult, + EditChain, + CommunicationSequenceEntry, + AgentLifetime, + DistilledSession, + TranscriptReasoning, +} from "../../shared/types"; +import { CommunicationTimeline } from "./CommunicationTimeline"; +import { formatRelTime } from "../lib/format"; +import { getSeverityStyle } from "../lib/severity"; + +// ── Types ──────────────────────────────────────────────────────────── + +type TabId = "backtracks" | "timeline" | "edits" | "comms"; + +type BottomPanelProps = { + readonly session: DistilledSession; + readonly isMultiAgent: boolean; + readonly activeTab?: TabId; + readonly onBacktrackClick?: (startT: number) => void; +}; + +// ── Backtracks tab ─────────────────────────────────────────────────── + +const BacktracksTab: Component<{ + readonly backtracks: readonly BacktrackResult[]; + readonly startTime: number; + readonly onBacktrackClick?: (startT: number) => void; +}> = (props) => ( + 0} + fallback={} + > +
+ + {(bt) => ( + + )} + +
+
+); + +// ── Timeline tab ───────────────────────────────────────────────────── + +const TIMELINE_TYPES = [ + "user_prompt", "thinking", "tool_call", "tool_result", "failure", + "backtrack", "phase_boundary", "agent_spawn", "agent_stop", + "task_create", "task_assign", "task_complete", "msg_send", +] as const; + +const TIMELINE_TYPE_COLORS: Readonly> = { + user_prompt: "text-secondary", + thinking: "text-muted", + tool_call: "text-secondary", + tool_result: "text-muted", + failure: "text-[var(--clens-danger)]", + backtrack: "text-[var(--clens-warning)]", + phase_boundary: "text-secondary", + agent_spawn: "text-[var(--clens-success)]", + agent_stop: "text-[var(--clens-success)]", + task_create: "text-secondary", + task_assign: "text-secondary", + task_complete: "text-[var(--clens-success)]", + teammate_idle: "text-muted", + msg_send: "text-secondary", +}; + +const TimelineTab: Component<{ + readonly timeline: readonly TimelineEntry[]; + readonly startTime: number; +}> = (props) => { + const [activeFilters, setActiveFilters] = createSignal>( + new Set(TIMELINE_TYPES), + ); + + const toggleFilter = (type: string) => { + setActiveFilters((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }; + + const filtered = createMemo(() => + props.timeline.filter((e) => activeFilters().has(e.type)), + ); + + return ( + 0} + fallback={} + > +
+ {/* Filters */} +
+ + {(type) => ( + + )} + + {filtered().length} events +
+ + {/* Event list */} +
+ + {(entry) => ( +
+ + {formatRelTime(entry.t, props.startTime)} + + + {entry.type.replaceAll("_", " ")} + + + {(tn) => {tn()}} + + + {(cp) => ( + {cp()} + )} + +
+ )} +
+
+
+
+ ); +}; + +// ── Edits tab ──────────────────────────────────────────────────────── + +/** Build a lookup map from tool_use_id to reasoning entry. */ +const buildReasoningLookup = ( + reasoning: readonly TranscriptReasoning[], +): ReadonlyMap => + new Map( + reasoning + .filter((r) => r.tool_use_id !== undefined) + .map((r) => [r.tool_use_id!, r]), + ); + +/** Truncate text to maxLen characters with ellipsis. */ +const truncateText = (text: string, maxLen: number): string => + text.length <= maxLen ? text : `${text.slice(0, maxLen)}...`; + +const EditsTab: Component<{ + readonly editChains: EditChainsResult; + readonly reasoning?: readonly TranscriptReasoning[]; +}> = (props) => { + const chains = createMemo(() => props.editChains.chains); + const reasoningMap = createMemo(() => + buildReasoningLookup(props.reasoning ?? []), + ); + const [expandedStep, setExpandedStep] = createSignal(); + + const toggleStep = (toolUseId: string) => { + setExpandedStep((prev) => (prev === toolUseId ? undefined : toolUseId)); + }; + + return ( + 0} + fallback={} + > +
+ + {(chain) => ( +
+ {/* File header */} +
+ + {chain.file_path} + + + {chain.total_edits} edit{chain.total_edits !== 1 ? "s" : ""} + + + {chain.total_reads} read{chain.total_reads !== 1 ? "s" : ""} + + + + backtrack + + +
+ + {/* Step breakdown */} +
+ + {(step) => { + const isAbandoned = chain.abandoned_edit_ids.includes(step.tool_use_id); + const hasThinking = () => reasoningMap().has(step.tool_use_id); + const isExpanded = () => expandedStep() === step.tool_use_id; + + return ( +
+ + + {(r) => ( +
+ {truncateText(r().thinking, 200)} +
+ )} +
+
+ ); + }} +
+
+ + {/* Abandoned count */} + 0}> +
+ {chain.abandoned_edit_ids.length} abandoned edit{chain.abandoned_edit_ids.length !== 1 ? "s" : ""} +
+
+
+ )} +
+
+
+ ); +}; + +// ── Empty tab state ────────────────────────────────────────────────── + +const EmptyTab: Component<{ readonly message: string }> = (props) => ( +
+ + {props.message} + +
+); + +// ── Main BottomPanel component (now a simple tab content renderer) ──── + +export const BottomPanel: Component = (props) => { + const startTime = () => props.session.start_time ?? 0; + const currentTab = () => props.activeTab ?? "backtracks"; + + const renderTabContent = () => { + switch (currentTab()) { + case "backtracks": + return ( + + ); + case "timeline": + return ( + + ); + case "edits": + return ( + + ); + case "comms": + return ( + + ); + } + }; + + return ( +
+ {renderTabContent()} +
+ ); +}; diff --git a/packages/web/src/client/components/CommunicationTimeline.tsx b/packages/web/src/client/components/CommunicationTimeline.tsx new file mode 100644 index 0000000..2e77177 --- /dev/null +++ b/packages/web/src/client/components/CommunicationTimeline.tsx @@ -0,0 +1,217 @@ +import { createMemo, For, Show, type Component } from "solid-js"; +import type { + CommunicationSequenceEntry, + AgentLifetime, +} from "../../shared/types"; + +// ── Types ──────────────────────────────────────────────────────────── + +type CommunicationTimelineProps = { + readonly sequence: readonly CommunicationSequenceEntry[]; + readonly lifetimes?: readonly AgentLifetime[]; + readonly sessionStartTime?: number; +}; + +// ── Message type colors ────────────────────────────────────────────── + +const MSG_TYPE_COLORS: Readonly> = { + message: "bg-brand-500", + task_complete: "bg-[var(--clens-success)]", + idle_notify: "bg-muted", + task_assign: "bg-[var(--clens-text-secondary)]", + shutdown_request: "bg-[var(--clens-danger)]", + shutdown_response: "bg-[var(--clens-danger)]", + broadcast: "bg-[var(--clens-warning)]", +}; + +const getMsgColor = (msgType: string): string => + MSG_TYPE_COLORS[msgType] ?? "bg-muted"; + +const getMsgBorderColor = (msgType: string): string => + getMsgColor(msgType).replace("bg-", "border-"); + +// ── Legend entries ─────────────────────────────────────────────────── + +const LEGEND_ITEMS: readonly { + readonly label: string; + readonly color: string; +}[] = [ + { label: "message", color: "bg-brand-500" }, + { label: "task assign", color: "bg-[var(--clens-text-secondary)]" }, + { label: "task complete", color: "bg-[var(--clens-success)]" }, + { label: "shutdown", color: "bg-[var(--clens-danger)]" }, + { label: "broadcast", color: "bg-[var(--clens-warning)]" }, + { label: "other", color: "bg-muted" }, +] as const; + +// ── Time formatting ────────────────────────────────────────────────── + +const formatRelativeTime = (t: number, start: number): string => { + const delta = Math.max(0, t - start); + const s = Math.floor(delta / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const CommunicationTimeline: Component = (props) => { + const startTime = () => props.sessionStartTime ?? (props.sequence[0]?.t ?? 0); + + // Unique agent names from lifetimes or sequence + const agents = createMemo(() => { + if (props.lifetimes && props.lifetimes.length > 0) { + return props.lifetimes.map((lt) => lt.agent_name ?? lt.agent_id); + } + return [...new Set(props.sequence.flatMap((e) => [e.from_name, e.to_name]))]; + }); + + // Lane index for each agent + const laneIndex = createMemo( + () => new Map(agents().map((name, idx) => [name, idx])), + ); + + const getLane = (name: string): number => laneIndex().get(name) ?? 0; + + return ( +
+ 0} + fallback={ +
+ No data + No communication data +
+ } + > + {/* Legend */} +
+ + + Sender (filled) + + + + Receiver (hollow) + + | + + {(item) => ( + + + {item.label} + + )} + +
+ + {/* Swim lane headers */} +
+
+ Time +
+ + {(name) => ( +
+ {name} +
+ )} +
+
+ + {/* Message rows */} +
+ + {(msg) => { + const fromLane = getLane(msg.from_name); + const toLane = getLane(msg.to_name); + const leftLane = Math.min(fromLane, toLane); + const rightLane = Math.max(fromLane, toLane); + const isLeftToRight = fromLane < toLane; + + return ( +
+ {/* Timestamp */} +
+ {formatRelativeTime(msg.t, startTime())} +
+ + {/* Swim lanes */} +
+ + {(_, idx) => { + const i = idx(); + const isFrom = i === fromLane; + const isTo = i === toLane; + const isBetween = i > leftLane && i < rightLane; + + return ( +
+ {/* Dot for sender/receiver */} + +
+ + +
+ + + {/* Connecting line — spans between from/to lanes */} + +
{ + if (isBetween) return "0"; + // From dot or To dot on the left side of the span + if (i === leftLane) return "50%"; + // From dot or To dot on the right side of the span + return "0"; + })(), + right: (() => { + if (isBetween) return "0"; + // From dot or To dot on the right side of the span + if (i === rightLane) return "50%"; + // From dot or To dot on the left side of the span + return "0"; + })(), + }} + /> + + + {/* Vertical lane marker */} + +
+ +
+ ); + }} + +
+ + {/* Message preview tooltip — absolutely positioned, no layout shift */} +
+
+ {msg.summary ?? msg.content_preview ?? msg.msg_type} +
+
+
+ ); + }} + +
+
+
+ ); +}; diff --git a/packages/web/src/client/components/ConfigEnvironment.tsx b/packages/web/src/client/components/ConfigEnvironment.tsx new file mode 100644 index 0000000..8f6eac3 --- /dev/null +++ b/packages/web/src/client/components/ConfigEnvironment.tsx @@ -0,0 +1,165 @@ +import { For, Show, type Component } from "solid-js"; +import type { DistilledSession } from "../../shared/types"; +import { Card } from "./ui/Card"; +import { MetaRow } from "./ui/MetaRow"; + +// -- Types ---------------------------------------------------------------- + +// Type the panel off the field on DistilledSession so it tracks the CLI's +// SessionConfig contract without an extra named import hop (the shared barrel +// re-exports the interface as part of DistilledSession already). +type SessionConfig = NonNullable; +type McpServerUsage = SessionConfig["mcp_servers"][number]; + +type ConfigEnvironmentProps = { + readonly session: DistilledSession; +}; + +const SECTION_HEADING = "instrument-microcaps text-[10px] text-muted mb-1.5"; + +// -- Pure helpers --------------------------------------------------------- + +// Relaxed permission postures drop the operator's guardrails, so they light an +// amber (warning) LED; default/plan/acceptEdits stay signal-green (safe/ok). +// Maps to the locked palette: amber = warning, signal-green = ok/active. +const RELAXED_MODES: ReadonlySet = new Set(["bypassPermissions", "dontAsk"]); +const isRelaxedPermission = (mode: string): boolean => RELAXED_MODES.has(mode); + +// The hook-supplied model id carries a context-window suffix (e.g. +// `claude-opus-4-8[1m]`) that the transcript drops. Surface it as a small chip +// rather than inline noise. Returns the bare model id + an optional suffix. +const SUFFIX_PATTERN = /\[(\d+m)\]\s*$/i; +const parseModel = ( + model: string | undefined, +): { readonly id?: string; readonly suffix?: string } => { + if (!model) return {}; + const match = SUFFIX_PATTERN.exec(model); + if (!match) return { id: model }; + return { id: model.replace(SUFFIX_PATTERN, "").trim(), suffix: match[1].toUpperCase() }; +}; + +// Whether the panel has any signal worth rendering. mcp_servers is always +// present (possibly empty), so an otherwise-bare config renders nothing rather +// than an empty frame. +const hasSignal = (config: SessionConfig, model: string | undefined): boolean => + Boolean(model) || + Boolean(config.permission_mode) || + Boolean(config.effort) || + config.mcp_servers.length > 0; + +// -- Sub-components -------------------------------------------------------- + +const PermissionRow: Component<{ readonly mode: string }> = (props) => ( +
+ Permission + + + {props.mode} + +
+); + +const ModelRow: Component<{ readonly model: string }> = (props) => { + const parsed = () => parseModel(props.model); + return ( +
+ Model + + {parsed().id} + + {(suffix) => ( + + {suffix()} + + )} + + +
+ ); +}; + +const McpServerRow: Component<{ readonly server: McpServerUsage }> = (props) => ( +
+ {/* Server names are identifiers → mono, never microcaps-uppercased. */} + + {props.server.name} + + {props.server.count} +
+); + +// -- Component ------------------------------------------------------------ + +/** + * Config / Environment panel — answers "what was this session actually run + * with?" from the purely-derived SessionConfig (zero-I/O distill scan): model, + * permission posture, effort level, and the MCP servers whose tools were called. + * + * Honesty: only fields that were actually captured are shown — never a + * fabricated "default". Sessions distilled before SessionConfig shipped have no + * `session_config` and render nothing (graceful absence, not an error). + * + * OPEN-DECISION: the settings-snapshot tier (output style + derived TTS badge, + * statusline, plugins, configured hooks, CLAUDE.md-in-effect) and the + * permission-transition trail / staleness banners from the CFG design are not + * present on the shipped SessionConfig shape, so those groupings are omitted + * rather than rendered empty. Re-add the "Style / Output" column and banners + * once SettingsSnapshot lands on SessionConfig. + */ +export const ConfigEnvironment: Component = (props) => { + const config = () => props.session.session_config; + const model = () => props.session.stats.model; + + return ( + + {(cfg) => ( + + +
+ {/* Runtime */} +
+

Runtime

+
+ + {(m) => } + + + {(effort) => } + + + {(mode) => } + +
+
+ + {/* Extensions */} +
+

MCP Servers

+ 0} + fallback={ +

+ None used +

+ } + > +
+ + {(server) => } + +
+
+
+
+
+
+ )} +
+ ); +}; diff --git a/packages/web/src/client/components/ContextChart.tsx b/packages/web/src/client/components/ContextChart.tsx new file mode 100644 index 0000000..303316e --- /dev/null +++ b/packages/web/src/client/components/ContextChart.tsx @@ -0,0 +1,173 @@ +import { type Component, createMemo, createUniqueId, For, Show } from "solid-js"; +import type { ContextConsumption } from "../../shared/types"; +import { Card } from "./ui/Card"; + +// ── Pure helpers ───────────────────────────────────────────────────── + +const WIDTH = 300; +const HEIGHT = 120; +const PADDING = { top: 4, right: 4, bottom: 4, left: 4 }; +const PLOT_W = WIDTH - PADDING.left - PADDING.right; +const PLOT_H = HEIGHT - PADDING.top - PADDING.bottom; + +const scaleX = (turnIndex: number, maxTurn: number): number => + PADDING.left + (maxTurn > 0 ? (turnIndex / maxTurn) * PLOT_W : 0); + +const scaleY = (pct: number): number => + PADDING.top + PLOT_H * (1 - Math.min(pct, 110) / 110); + +const buildAreaPath = ( + points: readonly { readonly turn_index: number; readonly context_pct: number }[], + maxTurn: number, +): string => { + if (points.length === 0) return ""; + const baseline = scaleY(0); + const segments = points.map( + (p) => `${scaleX(p.turn_index, maxTurn)},${scaleY(p.context_pct)}`, + ); + const firstX = scaleX(points[0].turn_index, maxTurn); + const lastX = scaleX(points[points.length - 1].turn_index, maxTurn); + return `M${firstX},${baseline} L${segments.join(" L")} L${lastX},${baseline} Z`; +}; + +const buildLinePath = ( + points: readonly { readonly turn_index: number; readonly context_pct: number }[], + maxTurn: number, +): string => { + if (points.length === 0) return ""; + const segments = points.map( + (p, i) => + `${i === 0 ? "M" : "L"}${scaleX(p.turn_index, maxTurn)},${scaleY(p.context_pct)}`, + ); + return segments.join(" "); +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const ContextChart: Component<{ + readonly consumption: ContextConsumption; +}> = (props) => { + const uid = createUniqueId(); + const gradientId = `ctx-gradient-${uid}`; + const lineGradientId = `ctx-line-gradient-${uid}`; + + const points = createMemo(() => props.consumption.points); + const maxTurn = createMemo(() => { + const pts = points(); + return pts.length > 0 ? pts[pts.length - 1].turn_index : 1; + }); + + const areaPath = createMemo(() => buildAreaPath(points(), maxTurn())); + const linePath = createMemo(() => buildLinePath(points(), maxTurn())); + + const compactionPoints = createMemo(() => + points().filter((p) => p.is_compaction), + ); + + const limitY = scaleY(100); + + // Gradient stop positions (map pct thresholds to SVG y-axis fraction) + // SVG gradient goes top-to-bottom: offset 0 = top (110%), offset 1 = bottom (0%) + const greenStop = ((110 - 50) / 110) * 100; // 50% threshold + const yellowStop = ((110 - 75) / 110) * 100; // 75% threshold + + return ( + +
+ 0} + fallback={ +
+ No data +
+ } + > + + + + + + + + + + + + + + + + + + {/* Area fill */} + + + {/* Line stroke */} + + + {/* 100% limit dashed line */} + + + {/* Compaction event markers */} + + {(p) => ( + + )} + + +
+ + {/* Summary stats */} +
+ + Peak:{" "} + + {Math.round(props.consumption.peak_context_pct)}% + + + 0}> + + Compactions:{" "} + + {props.consumption.compaction_count} + + + + + Velocity:{" "} + + {props.consumption.context_velocity_per_min.toFixed(1)}%/min + + +
+
+
+ ); +}; diff --git a/packages/web/src/client/components/ConversationPanel.tsx b/packages/web/src/client/components/ConversationPanel.tsx new file mode 100644 index 0000000..b1f864a --- /dev/null +++ b/packages/web/src/client/components/ConversationPanel.tsx @@ -0,0 +1,501 @@ +import { + createSignal, + For, + Match, + onCleanup, + Show, + Switch, + type Component, +} from "solid-js"; +import { + AlertTriangle, + Brain, + Check, + ChevronDown, + ChevronRight, + Milestone, + Send, + Terminal, + User, + X, +} from "lucide-solid"; +import { createConversationStore } from "../lib/stores"; +import type { ConversationEntry } from "../../shared/types"; +import { renderMarkdown, renderPlainText } from "../lib/markdown"; +import { Badge } from "./ui/Badge"; +import { Spinner } from "./ui/Spinner"; + +/** Render markdown string to sanitized HTML (agent/assistant prose only) */ +const renderMd = renderMarkdown; + +// ── Types ──────────────────────────────────────────────────────────── + +type ConversationPanelProps = { + readonly sessionId: string; +}; + +// ── Helpers ────────────────────────────────────────────────────────── + +const formatTimestamp = (t: number): string => { + const d = new Date(t); + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +}; + +const INTENT_VARIANT: Readonly> = { + planning: "info", + debugging: "warning", + deciding: "info", + research: "success", + general: "default", +}; + +const intentVariant = (intent: string) => + INTENT_VARIANT[intent] ?? "default"; + +const truncate = (text: string, max: number): string => + text.length > max ? `${text.slice(0, max)}...` : text; + +// ── User prompt content parsing ───────────────────────────────────── + +type TeammateMessage = { + readonly teammate_id: string; + readonly color: string; + readonly summary?: string; + readonly content: string; +}; + +type CommandInvocation = { + readonly command_name: string; + readonly command_message: string; + readonly command_args: string; +}; + +type TextSegment = + | { readonly kind: "text"; readonly value: string } + | { readonly kind: "teammate"; readonly msg: TeammateMessage } + | { readonly kind: "command"; readonly cmd: CommandInvocation }; + +const ATTR_RE = /(\w+)="([^"]*)"/g; + +/** Tags to strip entirely (system metadata, not user-visible) */ +const SYSTEM_TAG_RE = /<(?:system-reminder|antml_thinking|available-deferred-tools|context-window-status)[^>]*>[\s\S]*?<\/(?:system-reminder|antml_thinking|available-deferred-tools|context-window-status)>/g; + +/** Command invocation block: ... with optional siblings */ +const COMMAND_BLOCK_RE = /([\s\S]*?)<\/command-message>\s*([\s\S]*?)<\/command-name>\s*([\s\S]*?)<\/command-args>/g; +const COMMAND_BLOCK_RE2 = /([\s\S]*?)<\/command-name>\s*([\s\S]*?)<\/command-message>\s*([\s\S]*?)<\/command-args>/g; + +/** Teammate message */ +const TEAMMATE_RE = /]*)>([\s\S]*?)<\/teammate-message>/g; + +/** Catch-all for any remaining unrecognized XML-like tags */ +const STRAY_TAG_RE = /<\/?(?:command-name|command-message|command-args|system-reminder|antml_thinking)[^>]*>/g; + +const parseUserPromptSegments = (raw: string): readonly TextSegment[] => { + // Phase 1: strip system tags + const text = raw.replace(SYSTEM_TAG_RE, ""); + + // Phase 2: extract command blocks + const commands = [COMMAND_BLOCK_RE, COMMAND_BLOCK_RE2].flatMap((re) => + [...text.matchAll(re)].map((m) => { + const isReversed = re === COMMAND_BLOCK_RE; + return { + index: m.index ?? 0, + length: m[0].length, + segment: { + kind: "command" as const, + cmd: { + command_message: (isReversed ? m[1] : m[2]).trim(), + command_name: (isReversed ? m[2] : m[1]).trim(), + command_args: m[3].trim(), + }, + }, + }; + }), + ); + + // Phase 3: extract teammate messages + const teammates = [...text.matchAll(TEAMMATE_RE)].map((m) => { + const attrs = Object.fromEntries([...m[1].matchAll(ATTR_RE)].map((a) => [a[1], a[2]])); + return { + index: m.index ?? 0, + length: m[0].length, + segment: { + kind: "teammate" as const, + msg: { + teammate_id: attrs.teammate_id ?? "unknown", + color: attrs.color ?? "gray", + summary: attrs.summary, + content: m[2].trim(), + }, + }, + }; + }); + + // Phase 4: merge all parsed spans sorted by position, then interleave with text gaps + type Span = { readonly index: number; readonly length: number; readonly segment: TextSegment }; + const spans: readonly Span[] = [...commands, ...teammates].sort((a, b) => a.index - b.index); + + const { segments, cursor: finalCursor } = spans.reduce<{ readonly segments: readonly TextSegment[]; readonly cursor: number }>( + (acc, span) => { + const before = text.slice(acc.cursor, span.index).replace(STRAY_TAG_RE, "").trim(); + return { + segments: [...acc.segments, ...(before ? [{ kind: "text" as const, value: before }] : []), span.segment], + cursor: span.index + span.length, + }; + }, + { segments: [], cursor: 0 }, + ); + + const tail = text.slice(finalCursor).replace(STRAY_TAG_RE, "").trim(); + return tail ? [...segments, { kind: "text" as const, value: tail }] : segments; +}; + +// Teammate accent — a single restrained left-rail tick per harness color. +// Instrument language: hairline frame + faint colored rail, never a saturated wash. +const RAIL_COLOR_MAP: Readonly> = { + orange: "var(--clens-warning)", + pink: "var(--clens-danger)", + blue: "var(--clens-brand)", + green: "var(--clens-success)", + purple: "var(--clens-text-secondary)", + red: "var(--clens-danger)", +}; + +const railColor = (color: string): string => + RAIL_COLOR_MAP[color] ?? "var(--clens-tick)"; + +const isJsonContent = (s: string): boolean => + s.startsWith("{") || s.startsWith("["); + +const TeammateCard: Component<{ readonly msg: TeammateMessage }> = (props) => ( +
+
+ + {props.msg.teammate_id} + + + {(s) => ( + {s()} + )} + +
+ + {truncate(props.msg.content, 200)} + + } + > +
+ +
+); + +const CommandCard: Component<{ readonly cmd: CommandInvocation }> = (props) => ( +
+ + {props.cmd.command_name.startsWith("/") ? props.cmd.command_name : `/${props.cmd.command_name}`} + + {props.cmd.command_args} + +
+); + +// ── Entry renderers ────────────────────────────────────────────────── + +const UserPromptRow: Component<{ readonly entry: ConversationEntry & { type: "user_prompt" } }> = (props) => { + const segments = () => parseUserPromptSegments(props.entry.text); + const hasStructured = () => segments().some((s) => s.kind !== "text"); + + return ( +
+
+ +
+
+
+ User + {formatTimestamp(props.entry.t)} +
+ + } + > +
+ + {(seg) => ( + + + {(s) => ( + /* Raw user input — verbatim, never markdown (bug B16) */ +
+ )} + + + {(s) => ( + + )} + + + {(s) => ( + + )} + + + )} + +
+ +
+
+ ); +}; + +const ThinkingRow: Component<{ readonly entry: ConversationEntry & { type: "thinking" } }> = (props) => { + const [expanded, setExpanded] = createSignal(false); + const hasText = () => props.entry.text.trim().length > 0; + + return ( +
+
+ +
+
+ + {props.entry.intent} +
+ } + > + + +
+ {props.entry.text} +
+
+ +
+
+ ); +}; + +const ToolCallRow: Component<{ readonly entry: ConversationEntry & { type: "tool_call" } }> = (props) => ( +
+
+ +
+
+ + {props.entry.tool_name} + + + {(fp) => ( + + {fp()} + + )} + + {formatTimestamp(props.entry.t)} +
+
+); + +const ToolResultRow: Component<{ readonly entry: ConversationEntry & { type: "tool_result" } }> = (props) => { + const isSuccess = () => props.entry.outcome === "success"; + + return ( +
+ + } + > + + + {props.entry.tool_name} + + {(err) => ( + + {truncate(err(), 80)} + + )} + +
+ ); +}; + +const BacktrackRow: Component<{ readonly entry: ConversationEntry & { type: "backtrack" } }> = (props) => ( +
+
+ +
+ {props.entry.backtrack_type} + + Attempt {props.entry.attempt} + + 0}> + + ({props.entry.reverted_tool_ids.length} reverted) + + + {formatTimestamp(props.entry.t)} +
+); + +const PhaseBoundaryRow: Component<{ readonly entry: ConversationEntry & { type: "phase_boundary" } }> = (props) => ( +
+
+
+ + + {props.entry.phase_name} + +
+
+
+); + +const AgentMessageRow: Component<{ readonly entry: ConversationEntry & { type: "agent_message" } }> = (props) => { + const isSent = () => props.entry.direction === "sent"; + + return ( +
+
+ +
+ + {isSent() ? "Sent to" : "Received from"}{" "} + {props.entry.partner} + + + {(s) => {s()}} + + {formatTimestamp(props.entry.t)} +
+ ); +}; + +// ── Entry dispatcher ──────────────────────────────────────────────── + +const isType = ( + entry: ConversationEntry, + type: T, +): (ConversationEntry & { readonly type: T }) | false => + entry.type === type ? (entry as ConversationEntry & { readonly type: T }) : false; + +const ConversationEntryRow: Component<{ readonly entry: ConversationEntry }> = (props) => ( + + + {(e) => } + + + {(e) => } + + + {(e) => } + + + {(e) => } + + + {(e) => } + + + {(e) => } + + + {(e) => } + + +); + +// ── Main component ────────────────────────────────────────────────── + +export const ConversationPanel: Component = (props) => { + const store = createConversationStore(() => props.sessionId); + const [scrollRef, setScrollRef] = createSignal(); + + // Infinite scroll: detect near bottom + const handleScroll = () => { + const el = scrollRef(); + if (!el || store.loading() || !store.hasMore()) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; + if (nearBottom) store.loadMore(); + }; + + // Attach/detach scroll listener + const attachListener = (el: HTMLDivElement) => { + setScrollRef(el); + el.addEventListener("scroll", handleScroll, { passive: true }); + onCleanup(() => el.removeEventListener("scroll", handleScroll)); + }; + + return ( +
+ {/* Header bar */} +
+

Conversation

+ 0}> + + {store.total()} entries + + + + + +
+ + {/* Scrollable entries */} +
+ 0} + fallback={ + +
+ No data + No conversation data available +
+
+ } + > +
+ + {(entry) => } + +
+
+ + {/* Load more indicator */} + 0}> +
+ +
+
+
+
+ ); +}; diff --git a/packages/web/src/client/components/CostDrilldown.tsx b/packages/web/src/client/components/CostDrilldown.tsx new file mode 100644 index 0000000..cf3e726 --- /dev/null +++ b/packages/web/src/client/components/CostDrilldown.tsx @@ -0,0 +1,140 @@ +import { createEffect, onCleanup, Show, type Component } from "solid-js"; +import type { CostEstimate, DistilledSession, TokenUsage } from "../../shared/types"; + +// ── Types ──────────────────────────────────────────────────────────── + +type CostDrilldownProps = { + readonly session: DistilledSession; + readonly open: boolean; + readonly onClose: () => void; +}; + +// ── Pure helpers ───────────────────────────────────────────────────── + +const fmt = (n: number): string => n.toLocaleString(); + +const cacheEfficiency = (input: number, cacheRead: number): string => { + const total = input + cacheRead; + if (total === 0) return "0%"; + return `${Math.round((cacheRead / total) * 100)}%`; +}; + +/** + * Derive a token breakdown from a CostEstimate (NUM-15). + * + * `stats.token_usage` is absent on ~92% of sessions (the cost is token-estimated + * rather than from a verbatim usage object), which left the drilldown blank. The + * CostEstimate that backs every priced session still carries its own token + * counts, so fall back to those. The estimated banner already flags provenance. + * Returns undefined when there is no cost_estimate, so the "not available" + * branch still covers the truly-empty case. + */ +const tokensFromCost = (cost: CostEstimate | undefined): TokenUsage | undefined => + cost + ? { + input_tokens: cost.estimated_input_tokens, + output_tokens: cost.estimated_output_tokens, + cache_read_tokens: cost.cache_read_tokens ?? 0, + cache_creation_tokens: cost.cache_creation_tokens ?? 0, + } + : undefined; + +// ── Token row ──────────────────────────────────────────────────────── + +const TokenRow: Component<{ + readonly label: string; + readonly value: number; +}> = (props) => ( +
+ {props.label} + + {fmt(props.value)} + +
+); + +// ── Component ──────────────────────────────────────────────────────── + +export const CostDrilldown: Component = (props) => { + const cost = () => props.session.cost_estimate; + // Prefer a verbatim token_usage object; fall back to the CostEstimate's own + // token counts when it's absent (NUM-15) so the breakdown isn't blank. + const tokens = () => props.session.stats.token_usage ?? tokensFromCost(cost()); + + // Close on outside click via backdrop + // Close on Escape key + createEffect(() => { + if (!props.open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") props.onClose(); + }; + document.addEventListener("keydown", handler); + onCleanup(() => document.removeEventListener("keydown", handler)); + }); + + return ( + + {/* Invisible backdrop to catch outside clicks */} +
+ + {/* Popover */} +
+ {/* Estimated banner — token warning, square hairline */} + +
+ + Rough estimate — real token data unavailable +
+
+ + {/* Token breakdown */} + + Token breakdown not available for this session +

+ } + > + {(tu) => ( +
+ + + + + + {/* Cache efficiency */} +
+
+ Cache hit rate + + {cacheEfficiency(tu().input_tokens, tu().cache_read_tokens)} + +
+
+
+ )} +
+ + {/* Pricing tier */} + + {(tier) => ( +
+
+ Pricing tier + {tier()} +
+
+ )} +
+ + {/* Confidence label when estimated */} + +
+ Confidence: low — based on heuristic token estimates +
+
+
+ + ); +}; diff --git a/packages/web/src/client/components/CustomRangeChip.tsx b/packages/web/src/client/components/CustomRangeChip.tsx new file mode 100644 index 0000000..680a605 --- /dev/null +++ b/packages/web/src/client/components/CustomRangeChip.tsx @@ -0,0 +1,33 @@ +import { Show, type Component } from "solid-js"; +import { X } from "lucide-solid"; +import { customRange, clearCustomRange } from "../lib/analytics-store"; +import { formatShortDate } from "./charts/shared"; + +// ── Custom range chip (AC8) ───────────────────────────────────────── +// +// Single shared chip used by both Usage and Insights so a brush window on +// either tab reads identically. Surfaces the resolved inclusive dates in one +// canonical format (en-dash, short M/D) with a control to clear back to the +// active preset. Replaces the two divergent inline chips (Usage ISO `from..to` +// vs Insights en-dash) with a single source of truth. +export const CustomRangeChip: Component = () => ( + + {(range) => ( +
+ + Custom + + {formatShortDate(range().from)}–{formatShortDate(range().to)} + + +
+ )} +
+); diff --git a/packages/web/src/client/components/DecisionsSection.tsx b/packages/web/src/client/components/DecisionsSection.tsx new file mode 100644 index 0000000..39d0e63 --- /dev/null +++ b/packages/web/src/client/components/DecisionsSection.tsx @@ -0,0 +1,168 @@ +import { For, Match, Show, Switch, type Component } from "solid-js"; +import { GitBranch, Clock, Users, Layers } from "lucide-solid"; +import type { DecisionPoint } from "../../shared/types"; +import { Card } from "./ui/Card"; + +// ── Types ──────────────────────────────────────────────────────────── + +type DecisionsSectionProps = { + readonly decisions: readonly DecisionPoint[]; +}; + +// ── Pure helpers ───────────────────────────────────────────────────── + +const formatMs = (ms: number): string => { + if (ms < 1_000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`; + if (ms < 3_600_000) return `${(ms / 60_000).toFixed(1)}m`; + return `${(ms / 3_600_000).toFixed(1)}h`; +}; + +const gapClassificationColor = ( + classification: "user_idle" | "session_pause" | "agent_thinking", +): string => { + const colors: Readonly> = { + user_idle: "text-muted", + session_pause: "text-[var(--clens-warning)]", + agent_thinking: "text-brand-500", + }; + return colors[classification] ?? "text-muted"; +}; + +const formatClassification = (c: string): string => + c.replace(/_/g, " "); + +const isDecisionType = ( + type: T, +) => (d: DecisionPoint): d is Extract => + d.type === type; + +// ── Component ──────────────────────────────────────────────────────── + +export const DecisionsSection: Component = (props) => { + const toolPivots = () => props.decisions.filter(isDecisionType("tool_pivot")); + const timingGaps = () => props.decisions.filter(isDecisionType("timing_gap")); + const taskDelegations = () => props.decisions.filter(isDecisionType("task_delegation")); + const phaseBoundaries = () => props.decisions.filter(isDecisionType("phase_boundary")); + + return ( + +
+ +

+ Decision Points +

+ + {props.decisions.length} + +
+ +
+ {/* Tool Pivots */} + 0}> +
+
+ + Changed approach ({toolPivots().length}) +
+
+ + {(d) => ( +
+ + {d.from_tool} + + + + {d.to_tool} + + + + + after failure + + +
+ )} +
+
+
+
+ + {/* Timing Gaps */} + 0}> +
+
+ + Timing gaps ({timingGaps().length}) +
+
+ + {(d) => ( +
+ + {formatMs(d.gap_ms)} + + + {formatClassification(d.classification)} + +
+ )} +
+
+
+
+ + {/* Task Delegations */} + 0}> +
+
+ + Task delegations ({taskDelegations().length}) +
+
+ + {(d) => ( +
+ + {d.agent_name} + + + + {d.subject} + + +
+ )} +
+
+
+
+ + {/* Phase Boundaries */} + 0}> +
+
+ + Phase boundaries ({phaseBoundaries().length}) +
+
+ + {(d) => ( +
+ + Phase {d.phase_index + 1} + + + {d.phase_name} + +
+ )} +
+
+
+
+
+
+ ); +}; diff --git a/packages/web/src/client/components/DetailHeader.tsx b/packages/web/src/client/components/DetailHeader.tsx new file mode 100644 index 0000000..eae9f0f --- /dev/null +++ b/packages/web/src/client/components/DetailHeader.tsx @@ -0,0 +1,36 @@ +import { Show, type Component, type JSX } from "solid-js"; + +type DetailHeaderProps = { + readonly title: string; + /** Extra elements after the title (status badge, stat pills, etc.) */ + readonly children?: JSX.Element; + /** Optional right-aligned action area */ + readonly action?: JSX.Element; + /** Optional second row content (stat pills, timeline, etc.) */ + readonly bottomRow?: JSX.Element; +}; + +export const DetailHeader: Component = (props) => ( +
+ {/* Row 1: title + inline children + action */} +
+

+ {props.title} +

+ {!props.bottomRow && props.children} + +
+ {props.action} +
+
+
+ {/* Row 2: bottom row content */} + +
+ {props.bottomRow} +
+
+ {/* Instrument graticule tick strip beneath the header */} +
+
+); diff --git a/packages/web/src/client/components/DetailNav.tsx b/packages/web/src/client/components/DetailNav.tsx new file mode 100644 index 0000000..6c41fcd --- /dev/null +++ b/packages/web/src/client/components/DetailNav.tsx @@ -0,0 +1,25 @@ +import type { Component, JSX } from "solid-js"; + +type DetailNavProps = { + /** Top nav items (Overview, Conversation, etc.) */ + readonly topItems: JSX.Element; + /** Titled tree sections (Agents, Sessions, etc.) */ + readonly sections?: JSX.Element; + readonly ariaLabel?: string; +}; + +export const DetailNav: Component = (props) => ( + +); diff --git a/packages/web/src/client/components/ErrorFallback.tsx b/packages/web/src/client/components/ErrorFallback.tsx new file mode 100644 index 0000000..8adb2d4 --- /dev/null +++ b/packages/web/src/client/components/ErrorFallback.tsx @@ -0,0 +1,80 @@ +import { createSignal, type Component } from "solid-js"; +import { AlertTriangle } from "lucide-solid"; + +type ErrorFallbackProps = { + readonly error: Error; + readonly reset: () => void; + readonly componentName?: string; + /** "full" for top-level, "panel" for per-component */ + readonly variant?: "full" | "panel"; +}; + +const copyErrorToClipboard = async (error: Error): Promise => { + try { + await navigator.clipboard.writeText(`${error.name}: ${error.message}\n${error.stack ?? ""}`); + return true; + } catch { + return false; + } +}; + +/** + * Reusable error fallback UI for ErrorBoundary components. + * Shows error message with retry + copy error buttons. + */ +export const ErrorFallback: Component = (props) => { + const [copied, setCopied] = createSignal(false); + const variant = () => props.variant ?? "full"; + + console.error( + `[cLens] Error in ${props.componentName ?? "component"}:`, + props.error, + ); + + const handleCopy = async () => { + const ok = await copyErrorToClipboard(props.error); + if (ok) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
+
+
+ +
+

+

+

+ {props.error.message || "An unexpected error occurred."} +

+
+ + +
+
+
+ ); +}; diff --git a/packages/web/src/client/components/FeatureBadges.tsx b/packages/web/src/client/components/FeatureBadges.tsx new file mode 100644 index 0000000..471e21d --- /dev/null +++ b/packages/web/src/client/components/FeatureBadges.tsx @@ -0,0 +1,49 @@ +import { Repeat, Target, Workflow } from "lucide-solid"; +import { For, Show, type Component } from "solid-js"; +import type { FeatureFlag } from "../../shared/types"; + +// ── Feature badges (loop / goal / workflow) ───────────────────────── + +// Instrument style: square hairline tags, monochrome graphite/phosphor. +// No colored pills — the tag reads like a panel-mounted label. +const BADGE_STYLES: Record = { + loop: { + label: "loop", + classes: "border border-clens text-secondary bg-surface-inset", + title: "Used /loop — recurring or self-paced wakeups", + }, + goal: { + label: "goal", + classes: "border border-clens text-secondary bg-surface-inset", + title: "Used /goal — completion-condition driven session", + }, + workflow: { + label: "workflow", + classes: "border border-clens text-secondary bg-surface-inset", + title: "Used Workflow — multi-agent orchestration", + }, +}; + +const BadgeIcon: Component<{ readonly flag: FeatureFlag }> = (props) => ( + <> + + + + +); + +export const FeatureBadge: Component<{ readonly flag: FeatureFlag }> = (props) => ( + + + {BADGE_STYLES[props.flag].label} + +); + +export const FeatureBadges: Component<{ readonly features?: readonly FeatureFlag[] }> = (props) => ( + 0}> + {(flag) => } + +); diff --git a/packages/web/src/client/components/FeatureUsageSection.tsx b/packages/web/src/client/components/FeatureUsageSection.tsx new file mode 100644 index 0000000..88e7c53 --- /dev/null +++ b/packages/web/src/client/components/FeatureUsageSection.tsx @@ -0,0 +1,102 @@ +import { Repeat, Target, Workflow, Sparkles } from "lucide-solid"; +import { For, Show, type Component } from "solid-js"; +import type { FeatureUsage, LoopWakeup, WorkflowRun } from "../../shared/types"; +import { Card } from "./ui/Card"; +import { formatDuration } from "../lib/format"; + +// ── Feature usage card (loop / goal / workflow) ───────────────────── + +const MAX_WAKEUPS_SHOWN = 8; + +const WakeupRow: Component<{ readonly wakeup: LoopWakeup }> = (props) => ( +
  • + + {formatDuration(props.wakeup.delay_seconds * 1000)} + + + {props.wakeup.reason ?? "—"} + +
  • +); + +const LoopBlock: Component<{ readonly loop: NonNullable }> = (props) => ( +
    +
    + + Loop + + {props.loop.wakeup_count} wakeup{props.loop.wakeup_count !== 1 ? "s" : ""} + 0}> + {" · "}{formatDuration(props.loop.total_scheduled_wait_s * 1000)} scheduled wait + + {" · autonomous"} + +
    + 0}> +
      + + {(w) => } + + MAX_WAKEUPS_SHOWN}> +
    • …and {props.loop.wakeups.length - MAX_WAKEUPS_SHOWN} more
    • +
      +
    +
    +
    +); + +const GoalBlock: Component<{ readonly goal: NonNullable }> = (props) => ( +
    +
    + + Goal +
    +
      + + {(g) =>
    • {g}
    • } +
      +
    +
    +); + +const WorkflowRunRow: Component<{ readonly run: WorkflowRun }> = (props) => ( +
  • + + {props.run.name ?? "unnamed"} + + + {props.run.description} + +
  • +); + +const WorkflowBlock: Component<{ readonly workflow: NonNullable }> = (props) => ( +
    +
    + + Workflow + + {props.workflow.invocation_count} run{props.workflow.invocation_count !== 1 ? "s" : ""} + +
    +
      + + {(run) => } + +
    +
    +); + +export const FeatureUsageSection: Component<{ readonly usage: FeatureUsage }> = (props) => ( + +
    + +

    Harness Features

    +
    +
    + {(loop) => } + {(goal) => } + {(workflow) => } +
    +
    +); diff --git a/packages/web/src/client/components/FileList.tsx b/packages/web/src/client/components/FileList.tsx new file mode 100644 index 0000000..1f497e8 --- /dev/null +++ b/packages/web/src/client/components/FileList.tsx @@ -0,0 +1,242 @@ +import { html } from "diff2html"; +import { ChevronRight } from "lucide-solid"; +import { createMemo, createSignal, For, Show, type Component } from "solid-js"; +import type { AgentNode, DiffLine, DistilledSession, FileMapEntry, RiskLevel } from "../../shared/types"; +import { isFilePath } from "../../shared/paths"; +import { diffLinesToUnified } from "../lib/diff-utils"; +import { riskBadgeClass } from "../lib/risk"; + +// ── Types ──────────────────────────────────────────────────────────── + +export type FileRow = { + readonly filePath: string; + readonly reads: number; + readonly edits: number; + readonly additions: number; + readonly deletions: number; + readonly diffLines: readonly DiffLine[]; + readonly riskLevel?: RiskLevel; +}; + +type FileListProps = { + readonly rows: readonly FileRow[]; + readonly emptyMessage?: string; +}; + +// ── Pure helpers ───────────────────────────────────────────────────── + +export const truncatePath = (path: string, maxLen = 60): string => + path.length <= maxLen ? path : `...${path.slice(-(maxLen - 3))}`; + +/** + * Build file rows from session-level file_map + edit_chains diff attribution. + * Filters non-file paths, enriches with diff data, sorts edits-first then alpha. + */ +export const buildFileRows = ( + session: DistilledSession, + riskMap?: ReadonlyMap, +): readonly FileRow[] => { + const attrMap = new Map( + (session.edit_chains?.diff_attribution ?? []).map((a) => [a.file_path, a] as const), + ); + + const rows = session.file_map.files + .filter((f) => isFilePath(f.file_path)) + .map((f) => { + const attr = + attrMap.get(f.file_path) ?? + [...attrMap.values()].find( + (a) => f.file_path.endsWith(a.file_path) || a.file_path.endsWith(f.file_path), + ); + return { + filePath: f.file_path, + reads: f.reads, + edits: f.edits, + additions: attr?.total_additions ?? 0, + deletions: attr?.total_deletions ?? 0, + diffLines: attr?.lines ?? [], + riskLevel: riskMap?.get(f.file_path), + }; + }); + + return [...rows].sort((a, b) => { + if (a.edits !== b.edits) return a.edits > 0 ? -1 : 1; + return a.filePath.localeCompare(b.filePath); + }); +}; + +/** + * Build file rows from agent-level file_map + edit_chains diff attribution. + * Same logic as buildFileRows but sources data from an AgentNode. + */ +export const buildAgentFileRows = (agent: AgentNode): readonly FileRow[] => { + const files = agent.file_map?.files ?? []; + if (files.length === 0) return []; + + const attrMap = new Map( + (agent.edit_chains?.diff_attribution ?? []).map((a) => [a.file_path, a] as const), + ); + + const rows = files.map((f: FileMapEntry) => { + const attr = + attrMap.get(f.file_path) ?? + [...attrMap.values()].find( + (a) => f.file_path.endsWith(a.file_path) || a.file_path.endsWith(f.file_path), + ); + return { + filePath: f.file_path, + reads: f.reads, + edits: f.edits, + additions: attr?.total_additions ?? 0, + deletions: attr?.total_deletions ?? 0, + diffLines: attr?.lines ?? [], + }; + }); + + return [...rows].sort((a, b) => { + if (a.edits !== b.edits) return a.edits > 0 ? -1 : 1; + return a.filePath.localeCompare(b.filePath); + }); +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const FileList: Component = (props) => { + const [expandedFiles, setExpandedFiles] = createSignal>(new Set()); + + const isExpanded = (path: string) => expandedFiles().has(path); + + // Immutable-copy-then-mutate: creates a new Set from prev, then mutates the copy. + // This is safe because the copy is never shared before mutation completes. + const toggleFile = (path: string) => { + setExpandedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const allExpanded = createMemo(() => { + const rows = props.rows; + return rows.length > 0 && expandedFiles().size === rows.length; + }); + + const toggleAll = () => { + if (allExpanded()) setExpandedFiles(new Set()); + else setExpandedFiles(new Set(props.rows.map((r) => r.filePath))); + }; + + return ( + <> + {/* Header row with expand/collapse toggle */} +
    +
    + + {props.rows.length} + +
    + 0}> + + +
    + + {/* File rows */} + 0} + fallback={ +
    + No data + {props.emptyMessage ?? "No file changes detected"} +
    + } + > +
    + + {(row) => { + const expanded = () => isExpanded(row.filePath); + const diffHtml = createMemo(() => { + if (!expanded() || row.diffLines.length === 0) return ""; + return html(diffLinesToUnified(row.filePath, row.diffLines), { + outputFormat: "line-by-line", + drawFileList: false, + }); + }); + return ( +
    + + +
    + 0} + fallback={ +
    + {row.edits > 0 + ? "Diff not captured" + : "Read only \u2014 no changes"} +
    + } + > +
    + +
    +
    +
    + ); + }} + +
    + + + ); +}; diff --git a/packages/web/src/client/components/FilterBar.tsx b/packages/web/src/client/components/FilterBar.tsx new file mode 100644 index 0000000..1f0f470 --- /dev/null +++ b/packages/web/src/client/components/FilterBar.tsx @@ -0,0 +1,186 @@ +import { For, Show, createSignal, createEffect, onCleanup, type JSX } from "solid-js"; +import { Search, RefreshCw, ChevronDown, SlidersHorizontal, X } from "lucide-solid"; +import { SegmentedControl } from "./ui/SegmentedControl"; + +// ── Types ──────────────────────────────────────────────────────────── + +type FilterOption = { readonly label: string; readonly value: string }; + +type FilterGroup = { + readonly key: string; + readonly options: readonly FilterOption[]; + readonly value: string; + readonly onChange: (value: string) => void; + readonly variant?: "segmented" | "dropdown"; + readonly label?: string; // dropdown label shown when value is "all" +}; + +type FilterBarProps = { + readonly searchPlaceholder: string; + readonly searchValue: string; + readonly onSearch: (value: string) => void; + readonly searchRef?: (el: HTMLInputElement) => void; + readonly filters: readonly FilterGroup[]; + readonly resultCount: number; + readonly resultLabel: string; + readonly onRefresh: () => void; + // FE-9: advanced facets collapse into a single "Filters" popover instead of a + // stack of segmented-control rows. These props are optional so callers that + // don't need advanced facets render the plain primary row unchanged. + readonly advancedContent?: JSX.Element; // popover body (labelled facet controls) + readonly advancedCount?: number; // count badge of active advanced facets + readonly chips?: JSX.Element; // active-filter chips rendered below the primary row + readonly onClear?: () => void; // when present, render a Clear-all control +}; + +// ── Dropdown filter ────────────────────────────────────────────────── + +const FilterDropdown = (props: { + readonly options: readonly FilterOption[]; + readonly value: string; + readonly onChange: (value: string) => void; + readonly label?: string; +}) => { + const selectedLabel = () => { + if (props.value === "all" && props.label) return props.label; + return props.options.find((o) => o.value === props.value)?.label ?? props.value; + }; + + return ( +
    + + +
    + ); +}; + +// ── Filters popover (FE-9) ─────────────────────────────────────────── + +const FiltersPopover = (props: { readonly count: number; readonly children: JSX.Element }) => { + const [open, setOpen] = createSignal(false); + + // Escape closes — matches the ColorFlag popover pattern. + createEffect(() => { + if (!open()) return; + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; + document.addEventListener("keydown", handler); + onCleanup(() => document.removeEventListener("keydown", handler)); + }); + + const active = () => props.count > 0; + + return ( +
    + + + + {/* Backdrop catches outside clicks. */} +
    setOpen(false)} /> +
    + {props.children} +
    + +
    + ); +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const FilterBar = (props: FilterBarProps) => ( +
    +
    +
    + + props.onSearch(e.currentTarget.value)} + class="w-full sm:w-64 rounded-none border border-clens bg-surface-raised py-1.5 pl-8 pr-3 text-sm font-mono text-primary placeholder:font-sans placeholder:text-muted focus:border-brand-500 focus:outline-none" + /> +
    + + {(group) => ( + + } + > + + + )} + + + {props.advancedContent} + + + {props.resultCount} + {props.resultLabel} + + +
    + {/* Active-filter chips + Clear (FE-9/FE-10). */} + +
    + {props.chips} + + + +
    +
    +
    +); diff --git a/packages/web/src/client/components/IssuesPanel.tsx b/packages/web/src/client/components/IssuesPanel.tsx new file mode 100644 index 0000000..7662782 --- /dev/null +++ b/packages/web/src/client/components/IssuesPanel.tsx @@ -0,0 +1,166 @@ +import { For, Show, createMemo, type Component } from "solid-js"; +import { AlertTriangle } from "lucide-solid"; +import type { DistilledSession } from "../../shared/types"; +import { classifySeverity } from "../lib/format"; +import { getBacktrackBadgeClass } from "../lib/severity"; +import { Card } from "./ui/Card"; + +// ── Types ──────────────────────────────────────────────────────────── + +type IssuesPanelProps = { + readonly session: DistilledSession; +}; + +// ── Pure helpers ───────────────────────────────────────────────────── + +const truncate = (text: string, maxLen: number): string => + text.length <= maxLen ? text : `${text.slice(0, maxLen - 1)}...`; + +const formatBacktrackType = (type: string): string => + type.replace(/_/g, " "); + +// ── Backtrack grouping ────────────────────────────────────────────── + +type GroupedBacktrack = { + readonly type: string; + readonly tool_name: string; + readonly error_message?: string; + readonly count: number; +}; + +const groupBacktracks = ( + backtracks: readonly { readonly type: string; readonly tool_name: string; readonly error_message?: string }[], +): readonly GroupedBacktrack[] => { + const grouped = backtracks.reduce>( + (acc, bt) => { + const key = `${bt.type}::${bt.tool_name}`; + const existing = acc.get(key); + return new Map([ + ...acc, + [key, existing + ? { ...existing, count: existing.count + 1 } + : { type: bt.type, tool_name: bt.tool_name, error_message: bt.error_message, count: 1 }], + ]); + }, + new Map(), + ); + return [...grouped.values()].sort((a, b) => b.count - a.count); +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const IssuesPanel: Component = (props) => { + const backtracks = () => props.session.backtracks; + const grouped = createMemo(() => groupBacktracks(backtracks())); + const topErrors = () => props.session.summary?.top_errors ?? []; + const backtrackCount = () => backtracks().length; + + const severity = createMemo(() => classifySeverity(backtrackCount())); + + const hasContent = createMemo( + () => backtrackCount() > 0 || topErrors().length > 0, + ); + + return ( + +
    + +

    + Issues & Errors +

    +
    + + + + + Clean session — no backtracks or errors + +
    + } + > + {/* Backtracks section */} +
    +
    + + Backtracks ({backtrackCount()}) + + + {severity().label} + +
    + + 0} + fallback={ +

    + No backtracks — clean session +

    + } + > +
    + + {(group) => ( +
    + 1}> + + {group.count}x + + + + {formatBacktrackType(group.type)} + + + {group.tool_name} + + + {(msg) => ( + + {truncate(msg(), 80)} + + )} + +
    + )} +
    +
    +
    +
    + + {/* Top Errors section */} + 0}> +
    + + Top Errors + +
    + + {(err) => ( +
    + + {err.tool_name} + + + {err.count} + + + {(msg) => ( + + {truncate(msg(), 100)} + + )} + +
    + )} +
    +
    +
    +
    + + + ); +}; diff --git a/packages/web/src/client/components/KeyboardHelp.tsx b/packages/web/src/client/components/KeyboardHelp.tsx new file mode 100644 index 0000000..7273c88 --- /dev/null +++ b/packages/web/src/client/components/KeyboardHelp.tsx @@ -0,0 +1,128 @@ +import { createMemo, For, onCleanup, Show, type Component } from "solid-js"; +import { Keyboard } from "lucide-solid"; +import { KbdShortcut } from "./ui/KbdShortcut"; +import { showHelp, setShowHelp, activeShortcuts, GLOBAL_SHORTCUTS } from "../lib/keyboard"; + +// ── Group shortcuts by context ────────────────────────────────────── + +type GroupedShortcuts = readonly { readonly context: string; readonly entries: readonly typeof GLOBAL_SHORTCUTS[number][] }[]; + +const groupByContext = ( + shortcuts: readonly typeof GLOBAL_SHORTCUTS[number][], +): GroupedShortcuts => + Array.from( + shortcuts.reduce((map, s) => { + const existing = map.get(s.context) ?? []; + return new Map(map).set(s.context, [...existing, s]); + }, new Map()), + ).map(([context, entries]) => ({ context, entries })); + +// ── Component ─────────────────────────────────────────────────────── + +export const KeyboardHelp: Component = () => { + let closeButtonRef: HTMLButtonElement | undefined; + let overlayRef: HTMLDivElement | undefined; + let previouslyFocused: Element | null = null; + + const allShortcuts = createMemo(() => [...GLOBAL_SHORTCUTS, ...activeShortcuts()]); + const grouped = createMemo(() => groupByContext(allShortcuts())); + + // ── Focus trap ────────────────────────────────────────────── + + const handleFocusTrap = (e: KeyboardEvent) => { + if (e.key !== "Tab" || !overlayRef) return; + + const focusable = overlayRef.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + // Focus close button when shown changes + const handleDialogMount = () => { + previouslyFocused = document.activeElement; + requestAnimationFrame(() => closeButtonRef?.focus()); + + const trapHandler = (e: KeyboardEvent) => handleFocusTrap(e); + document.addEventListener("keydown", trapHandler); + onCleanup(() => { + document.removeEventListener("keydown", trapHandler); + if (previouslyFocused instanceof HTMLElement) { + previouslyFocused.focus(); + } + }); + }; + + return ( + + {(() => { + handleDialogMount(); + return ( + + ); + })()} + + ); +}; diff --git a/packages/web/src/client/components/LiveSessionView.tsx b/packages/web/src/client/components/LiveSessionView.tsx new file mode 100644 index 0000000..fe9dcec --- /dev/null +++ b/packages/web/src/client/components/LiveSessionView.tsx @@ -0,0 +1,337 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Component } from "solid-js" +import type { LiveSessionState, LiveAgentState, PendingTool } from "../lib/live-store" +import { extractFilePath } from "../lib/live-store" +import { formatDuration } from "../lib/format" +import { Spinner } from "./ui/Spinner" +import type { StoredEvent } from "../../shared/types" + +// ── Helpers ───────────────────────────────────────────────────────── + +const formatTime = (t: number): string => { + const d = new Date(t) + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}` +} + +// ── Event Config ──────────────────────────────────────────────────── + +// Instrument palette: graphite/phosphor by default, signal-green for OK/result, +// amber for warnings/in-progress, danger red strictly for failures. +const EVENT_CONFIG: Readonly> = { + SessionStart: { label: "Session", color: "text-secondary", icon: "S" }, + SessionEnd: { label: "End", color: "text-muted", icon: "X" }, + UserPromptSubmit: { label: "Prompt", color: "text-secondary", icon: "U" }, + PreToolUse: { label: "Tool", color: "text-secondary", icon: "T" }, + PostToolUse: { label: "Result", color: "text-[var(--clens-success)]", icon: "+" }, + PostToolUseFailure: { label: "Failure", color: "text-[var(--clens-danger)]", icon: "!" }, + SubagentStart: { label: "Agent", color: "text-secondary", icon: "A" }, + SubagentStop: { label: "Agent End", color: "text-muted", icon: "a" }, + PermissionRequest: { label: "Permission", color: "text-[var(--clens-warning)]", icon: "P" }, + Stop: { label: "Stop", color: "text-[var(--clens-danger)]", icon: "X" }, + PreCompact: { label: "Compact", color: "text-muted", icon: "C" }, + TaskCompleted: { label: "Task", color: "text-[var(--clens-success)]", icon: "D" }, + TeammateIdle: { label: "Idle", color: "text-muted", icon: "I" }, + InstructionsLoaded: { label: "Config", color: "text-muted", icon: "L" }, + ConfigChange: { label: "Config", color: "text-muted", icon: "C" }, + Notification: { label: "Notice", color: "text-muted", icon: "N" }, + WorktreeCreate: { label: "Worktree", color: "text-secondary", icon: "W" }, + WorktreeRemove: { label: "Worktree", color: "text-muted", icon: "w" }, +} + +// ── Sub-components ────────────────────────────────────────────────── + +const KpiChip: Component<{ readonly label: string; readonly value: string; readonly variant?: "danger" }> = (props) => ( +
    + + {props.value} + + {props.label} +
    +) + +const LiveHeader: Component<{ readonly state: LiveSessionState; readonly elapsed: number }> = (props) => ( +
    + {/* Status indicator — square phosphor LED, pulsing while live */} +
    + } + > + + + + + + + {props.state.status} + +
    + + {/* KPI chips */} + + + + + + 0}> + + + + {/* Model + branch */} +
    + + {props.state.model} + + + {props.state.git_branch} + +
    +
    +) + +const AgentTreeSection: Component<{ readonly agents: ReadonlyMap }> = (props) => ( +
    +

    Agents

    + 0} + fallback={Solo session} + > +
    + + {(agent) => ( +
    + + + {agent.agent_name ?? agent.agent_id.slice(0, 8)} + + + {agent.agent_type} + +
    + )} +
    +
    +
    +
    +) + +const FilesSection: Component<{ readonly files: ReadonlyMap }> = (props) => { + const sorted = createMemo(() => + [...props.files.entries()] + .sort(([, a], [, b]) => b - a) + .slice(0, 20) + ) + + return ( +
    +

    + Files ({props.files.size}) +

    +
    + + {([path, count]) => ( +
    + + {path.split("/").pop()} + + + ({count}) + +
    + )} +
    +
    +
    + ) +} + +const extractDetail = (event: StoredEvent): string => { + const d = event.data + switch (event.event) { + case "PreToolUse": + case "PostToolUse": + case "PostToolUseFailure": { + const tool = typeof d.tool_name === "string" ? d.tool_name : "" + const fp = extractFilePath(d) + return fp ? `${tool} ${fp.split("/").pop()}` : tool + } + case "UserPromptSubmit": { + const text = typeof d.text === "string" ? d.text : "" + return text.length > 80 ? text.slice(0, 77) + "..." : text + } + case "SubagentStart": + case "SubagentStop": { + const name = typeof d.agent_name === "string" ? d.agent_name : typeof d.agent_id === "string" ? d.agent_id.slice(0, 8) : "" + return name + } + default: + return "" + } +} + +const TimelineRow: Component<{ readonly event: StoredEvent }> = (props) => { + const cfg = () => EVENT_CONFIG[props.event.event] ?? { label: props.event.event, color: "text-muted", icon: "?" } + + return ( +
    + + {formatTime(props.event.t)} + + + {cfg().icon} + + + {cfg().label} + + + {extractDetail(props.event)} + +
    + ) +} + +const LiveTimeline: Component<{ + readonly events: readonly StoredEvent[] + readonly pendingTools: ReadonlyMap +}> = (props) => { + // Pragmatic exception: mutable ref for DOM element scrolling behavior + let scrollRef: HTMLDivElement | undefined // eslint-disable-line prefer-const + const [autoScroll, setAutoScroll] = createSignal(true) + + createEffect(() => { + const _ = props.events.length + if (autoScroll() && scrollRef) { + requestAnimationFrame(() => { + scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" }) + }) + } + }) + + const handleScroll = () => { + if (!scrollRef) return + const nearBottom = scrollRef.scrollHeight - scrollRef.scrollTop - scrollRef.clientHeight < 100 + setAutoScroll(nearBottom) + } + + return ( +
    +
    +

    + Live Timeline +

    + + + +
    + +
    { + scrollRef = el + el.addEventListener("scroll", handleScroll, { passive: true }) + onCleanup(() => el.removeEventListener("scroll", handleScroll)) + }} + class="flex-1 overflow-y-auto p-2 space-y-px" + > + + {(event) => } + + + + {([_toolUseId, tool]) => ( +
    + + {formatTime(tool.started_at)} + + + + + {tool.name} + + {tool.file_path} + +
    + )} +
    +
    +
    + ) +} + +const UserPromptsSection: Component<{ readonly prompts: readonly string[] }> = (props) => ( +
    +

    + User Prompts ({props.prompts.length}) +

    +
    + + {(prompt, i) => ( +
    + {i() + 1}. + {prompt} +
    + )} +
    +
    +
    +) + +// ── Main Component ────────────────────────────────────────────────── + +type LiveSessionViewProps = { + readonly state: LiveSessionState + readonly elapsed: number +} + +const LiveSessionView: Component = (props) => ( +
    + + +
    +
    + + +
    + +
    + +
    +
    + + 0}> + + +
    +) + +export { LiveSessionView } diff --git a/packages/web/src/client/components/NarrativeSection.tsx b/packages/web/src/client/components/NarrativeSection.tsx new file mode 100644 index 0000000..d60b53a --- /dev/null +++ b/packages/web/src/client/components/NarrativeSection.tsx @@ -0,0 +1,77 @@ +import { For, Show, createMemo, type Component } from "solid-js"; +import { BookOpen } from "lucide-solid"; +import type { DistilledSession } from "../../shared/types"; +import { formatDuration, formatRelTime } from "../lib/format"; +import { renderPlainText } from "../lib/markdown"; +import { Card } from "./ui/Card"; + +// ── Types ──────────────────────────────────────────────────────────── + +type NarrativeSectionProps = { + readonly session: DistilledSession; +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const NarrativeSection: Component = (props) => { + const narrative = () => props.session.summary?.narrative; + const phases = () => props.session.summary?.phases ?? []; + const startTime = () => props.session.start_time; + + const hasContent = createMemo(() => { + const n = narrative(); + return n !== undefined && n.length > 0; + }); + + return ( + + +
    + +

    + What Happened +

    +
    + + {/* Narrative text — generated prose interpolating raw model ids and + file paths; rendered verbatim, never as markdown (bug B16) */} +
    + + {/* Phase milestones */} + 0}> +
    + + {(phase) => ( +
    +
    + + {phase.name} + + + {formatDuration(phase.end_t - phase.start_t)} + + + + ({formatRelTime(phase.start_t, startTime() ?? 0)} + {" \u2013 "} + {formatRelTime(phase.end_t, startTime() ?? 0)}) + + +
    + 0}> +

    + {phase.description} +

    +
    +
    + )} +
    +
    +
    + + + ); +}; diff --git a/packages/web/src/client/components/NavSection.tsx b/packages/web/src/client/components/NavSection.tsx new file mode 100644 index 0000000..71efd64 --- /dev/null +++ b/packages/web/src/client/components/NavSection.tsx @@ -0,0 +1,27 @@ +import { Show, type Component, type JSX } from "solid-js"; + +type NavSectionProps = { + readonly title: string; + readonly count?: number; + readonly children: JSX.Element; + readonly ariaLabel?: string; +}; + +export const NavSection: Component = (props) => ( + <> +
    +
    +

    + {props.title} +

    + + + {props.count} + + +
    +
    + {props.children} +
    + +); diff --git a/packages/web/src/client/components/PageShell.tsx b/packages/web/src/client/components/PageShell.tsx new file mode 100644 index 0000000..a404fdf --- /dev/null +++ b/packages/web/src/client/components/PageShell.tsx @@ -0,0 +1,56 @@ +import { Show, type Component, type JSX } from "solid-js"; +import { Spinner } from "./ui/Spinner"; +import { globalError, clearError } from "../lib/stores"; + +// ── Error banner ──────────────────────────────────────────────────── + +export const ErrorBanner: Component<{ + readonly message: string; + readonly onDismiss: () => void; +}> = (props) => ( +
    + + + +
    +); + +// ── Loading skeleton ──────────────────────────────────────────────── + +export const LoadingSkeleton: Component<{ + readonly label?: string; +}> = (props) => ( +
    +
    + + {props.label ?? "Loading…"} +
    +
    +); + +// ── Skeleton block (shimmer placeholder) ───────────────────────────── + +export const SkeletonBlock: Component<{ readonly class?: string }> = (props) => ( +
    +
    +
    +); + +// ── Page shell ────────────────────────────────────────────────────── + +export const PageShell: Component<{ + readonly children: JSX.Element; +}> = (props) => ( +
    + + {(err) => ( + + )} + + {props.children} +
    +); diff --git a/packages/web/src/client/components/PlanDriftSection.tsx b/packages/web/src/client/components/PlanDriftSection.tsx new file mode 100644 index 0000000..87872b1 --- /dev/null +++ b/packages/web/src/client/components/PlanDriftSection.tsx @@ -0,0 +1,136 @@ +import { For, Show, type Component } from "solid-js"; +import { Check, X, PlusCircle, GitBranch } from "lucide-solid"; +import type { DistilledSession } from "../../shared/types"; +import { Card } from "./ui/Card"; + +// ── Types ──────────────────────────────────────────────────────────── + +type PlanDriftSectionProps = { + readonly session: DistilledSession; +}; + +// ── Pure helpers ───────────────────────────────────────────────────── + +const alignmentScore = ( + expectedCount: number, + missingCount: number, +): number => { + if (expectedCount === 0) return 100; + return Math.round(((expectedCount - missingCount) / expectedCount) * 100); +}; + +const alignmentColorClass = (score: number): string => { + if (score > 80) return "text-[var(--clens-success)]"; + if (score >= 50) return "text-[var(--clens-warning)]"; + return "text-[var(--clens-danger)]"; +}; + +// ── Component ──────────────────────────────────────────────────────── + +export const PlanDriftSection: Component = (props) => { + const drift = () => props.session.plan_drift; + const alignment = () => { + const d = drift(); + if (d == null) return 0; + return alignmentScore(d.expected_files.length, d.missing_files.length); + }; + const driftPct = () => { + const d = drift(); + if (d == null) return 0; + return Math.round(d.drift_score * 100); + }; + const missingSet = () => new Set(drift()?.missing_files ?? []); + + return ( + + {(d) => ( + + {/* Header */} +
    +
    +
    + +

    + Plan Fidelity +

    +
    + {d().spec_path} +
    + {/* Scores */} +
    +
    +
    + {alignment()}% +
    +
    aligned
    +
    +
    +
    + {driftPct()}% +
    +
    drift
    +
    +
    +
    + + {/* Expected files */} +
    +
    + Expected files ({d().expected_files.length}) +
    +
    + + {(file) => { + const isMissing = () => missingSet().has(file); + return ( +
    + + } + > + + + + {file} + +
    + ); + }} +
    +
    +
    + + {/* Unexpected files */} + 0}> +
    +
    + Unexpected files ({d().unexpected_files.length}) +
    +
    + + {(file) => ( +
    + + + {file} + +
    + )} +
    +
    +
    +
    +
    + )} +
    + ); +}; diff --git a/packages/web/src/client/components/ProjectDropdown.tsx b/packages/web/src/client/components/ProjectDropdown.tsx new file mode 100644 index 0000000..7343369 --- /dev/null +++ b/packages/web/src/client/components/ProjectDropdown.tsx @@ -0,0 +1,30 @@ +import { For, Show } from "solid-js"; +import { ChevronDown } from "lucide-solid"; +import { + projectList, + selectedProjectId, + setSelectedProjectId, + isGlobalMode, + projectColor, +} from "../lib/project-store"; + +/** Compact project dropdown for dashboard pages. Only renders in global mode. */ +export const ProjectDropdown = () => ( + +
    + + +
    +
    +); diff --git a/packages/web/src/client/components/ProjectFilter.tsx b/packages/web/src/client/components/ProjectFilter.tsx new file mode 100644 index 0000000..3811849 --- /dev/null +++ b/packages/web/src/client/components/ProjectFilter.tsx @@ -0,0 +1,16 @@ +import { projectColor } from "../lib/project-store"; + +/** Small project badge for table rows and cards. */ +const ProjectBadge = (props: { readonly projectId: string; readonly projectName: string }) => { + return ( + + + {props.projectName} + + ); +}; + +export { ProjectBadge }; diff --git a/packages/web/src/client/components/SessionDetailNav.tsx b/packages/web/src/client/components/SessionDetailNav.tsx new file mode 100644 index 0000000..459bd40 --- /dev/null +++ b/packages/web/src/client/components/SessionDetailNav.tsx @@ -0,0 +1,187 @@ +import { createSignal, For, Show, type Component } from "solid-js"; +import { LayoutDashboard, MessageSquare } from "lucide-solid"; +import type { AgentNode, DistilledSession } from "../../shared/types"; +import { getTypeBadgeClass } from "../lib/agent-colors"; +import { countAllAgents, sumDiffStats } from "../lib/agent-utils"; +import { formatCost, formatDuration } from "../lib/format"; +import { NavButton } from "./ui/NavButton"; +import { TreeToggle } from "./ui/TreeToggle"; +import { DetailNav } from "./DetailNav"; +import { NavSection } from "./NavSection"; + +// ── Types ──────────────────────────────────────────────────────────── + +type SessionDetailNavProps = { + readonly session: DistilledSession; + readonly sessionId: string; + readonly currentView: string; + readonly selectedAgentId?: string; + readonly onSelectView: (view: string, agentId?: string) => void; +}; + +// ── Pure helpers ───────────────────────────────────────────────────── + +const isLeadAgent = (agent: AgentNode, index: number): boolean => + index === 0 || agent.agent_type === "leader" || agent.children.length > 0; + +// ── Collapsible agent row ──────────────────────────────────────────── + +const AgentNavRow: Component<{ + readonly agent: AgentNode; + readonly depth: number; + readonly selectedAgentId?: string; + readonly isLead: boolean; + readonly onSelect: (agentId: string) => void; +}> = (props) => { + const hasChildren = () => props.agent.children.length > 0; + const [expanded, setExpanded] = createSignal(true); + const isSelected = () => props.selectedAgentId === props.agent.session_id; + const isGhost = () => props.agent.duration_ms === 0 && props.agent.tool_call_count === 0; + + const handleClick = () => { + if (!props.agent.session_id) return; + props.onSelect(props.agent.session_id); + }; + + const handleToggle = (e: MouseEvent) => { + e.stopPropagation(); + setExpanded((prev) => !prev); + }; + + return ( +
    0, + }} + > + + + {/* Children */} + + + {(child) => ( + + )} + + +
    + ); +}; + +// ── Main component ─────────────────────────────────────────────────── + +export const SessionDetailNav: Component = (props) => { + const agents = () => props.session.agents ?? []; + const agentCount = () => countAllAgents(agents()); + + const handleAgentSelect = (agentId: string) => { + props.onSelectView("agent", agentId); + }; + + return ( + + props.onSelectView("overview")} + shortcut="1" + /> + props.onSelectView("conversation")} + shortcut="c" + /> + + } + sections={ + 0}> + + + {(agent, i) => ( + + )} + + + + } + /> + ); +}; diff --git a/packages/web/src/client/components/SessionHeader.tsx b/packages/web/src/client/components/SessionHeader.tsx new file mode 100644 index 0000000..04207ff --- /dev/null +++ b/packages/web/src/client/components/SessionHeader.tsx @@ -0,0 +1,124 @@ +import { Show, createSignal, type Component } from "solid-js"; +import { Pencil } from "lucide-solid"; +import type { ColorName, DistilledSession, SessionStatus, SessionSummary } from "../../shared/types"; +import { StatusBadge } from "./ui/StatusBadge"; +import { ColorFlag } from "./ui/ColorFlag"; +import { setSessionMeta } from "../lib/stores"; +import { DetailHeader } from "./DetailHeader"; + +// -- Types ---------------------------------------------------------------- + +type SessionHeaderProps = { + readonly session: DistilledSession; + // Raw-derived live status from the session list (bug B5/B6). When provided it + // is authoritative over the distilled `complete` flag, which is frozen at + // distill time and goes stale while a live session keeps running. + readonly status?: SessionStatus; + // Lightweight list row for this session — carries the resolved + // display_name/label/color (sidecar-backed) the distilled snapshot lacks. + // When present, the header renders rename + color controls (R18); absent, it + // falls back to the distilled session_name with no controls. + readonly summary?: SessionSummary; + readonly onRedistill?: () => Promise; +}; + +// -- Display-name resolution --------------------------------------------- + +const resolveTitle = (props: SessionHeaderProps): string => + props.summary?.display_name + ?? props.session.session_name + ?? props.session.session_id.slice(0, 12); + +// -- Component ------------------------------------------------------------ + +export const SessionHeader: Component = (props) => { + const [distilling, setDistilling] = createSignal(false); + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(""); + let inputRef: HTMLInputElement | undefined; + + const sessionId = () => props.session.session_id; + const flag = (): ColorName => props.summary?.color ?? "none"; + + const beginEdit = () => { + // Seed with the stored custom label only, so an untouched save is a no-op + // rather than freezing a computed/custom-title name as a label. + setDraft(props.summary?.label ?? ""); + setEditing(true); + queueMicrotask(() => inputRef?.focus()); + }; + + const commit = () => { + if (!editing()) return; + setEditing(false); + const trimmed = draft().trim(); + const currentLabel = (props.summary?.label ?? "").trim(); + if (trimmed === currentLabel) return; + // Blank clears the label (R7/R8); otherwise set it (R6). + void setSessionMeta(sessionId(), { label: trimmed.length > 0 ? trimmed : null }); + }; + + const cancel = () => { + setEditing(false); + setDraft(""); + }; + + return ( + + + + } + > + {/* Rename + color controls (R18) — only when the list row is available */} + + void setSessionMeta(sessionId(), { color })} /> + + setDraft(e.currentTarget.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") { e.preventDefault(); commit(); } + else if (e.key === "Escape") { e.preventDefault(); cancel(); } + }} + onBlur={commit} + class="w-64 rounded-none border border-brand-500 bg-surface-raised px-1.5 py-0.5 font-mono text-xs text-primary focus:outline-none" + /> + + + + + + + } + > + {(status) => } + + + ); +}; diff --git a/packages/web/src/client/components/SessionOverview.tsx b/packages/web/src/client/components/SessionOverview.tsx new file mode 100644 index 0000000..ba988a5 --- /dev/null +++ b/packages/web/src/client/components/SessionOverview.tsx @@ -0,0 +1,248 @@ +import { createMemo, createSignal, Show, type Component } from "solid-js"; +import type { DistilledSession } from "../../shared/types"; +import { formatDuration, formatPercentage, formatCost, truncateMultiline } from "../lib/format"; +import { renderPlainText } from "../lib/markdown"; +import { Card } from "./ui/Card"; +import { MetaRow } from "./ui/MetaRow"; +import { CostDrilldown } from "./CostDrilldown"; +import { TimelineBar } from "./TimelineBar"; + +// -- Types ---------------------------------------------------------------- + +type SessionOverviewProps = { + readonly session: DistilledSession; + readonly onRedistill?: () => Promise; +}; + +// -- Pure helpers --------------------------------------------------------- + +const stripHtml = (text: string): string => text.replace(/<[^>]+>/g, ""); + +const findFirstPrompt = ( + messages: DistilledSession["user_messages"], +): string | undefined => { + const msg = messages.find( + (m) => !m.message_type || m.message_type === "prompt", + ); + if (!msg) return undefined; + const clean = stripHtml(msg.content).trim(); + return clean.length > 0 ? clean : undefined; +}; + +const formatTokenCount = (tokens: number): string => { + if (tokens >= 1_000_000) return `${Math.round(tokens / 1_000)}K`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`; + return String(tokens); +}; + +const SECTION_HEADING = "instrument-microcaps text-[10px] text-muted mb-1.5"; + +// -- Component ------------------------------------------------------------ + +export const SessionOverview: Component = (props) => { + const session = () => props.session; + const [expanded, setExpanded] = createSignal(false); + const [costOpen, setCostOpen] = createSignal(false); + + // -- Request ---------------------------------------------------------- + const rawPrompt = createMemo(() => findFirstPrompt(session().user_messages)); + const truncated = createMemo(() => { + const text = rawPrompt(); + if (!text) return { text: "", truncated: false }; + return truncateMultiline(text, 3); + }); + const displayText = () => (expanded() ? rawPrompt() ?? "" : truncated().text); + + // -- Duration --------------------------------------------------------- + // Wall-clock span so the detail page agrees with the session list (bug B2); + // older distills without wall_duration_ms fall back to the idle-trimmed value. + const totalMs = createMemo( + () => session().stats.wall_duration_ms ?? session().stats.duration_ms, + ); + const activeMs = createMemo( + () => session().summary?.key_metrics.active_duration_ms, + ); + + // -- Cost ------------------------------------------------------------- + const cost = createMemo( + () => (session().cost_estimate ?? session().stats.cost_estimate)?.estimated_cost_usd, + ); + const costIsEstimated = createMemo( + () => (session().cost_estimate ?? session().stats.cost_estimate)?.is_estimated, + ); + const pricingTier = createMemo( + () => (session().cost_estimate ?? session().stats.cost_estimate)?.pricing_tier, + ); + + // -- Quality ---------------------------------------------------------- + const toolCallCount = createMemo( + () => session().summary?.key_metrics.tool_calls ?? session().stats.tool_call_count, + ); + const backtrackCount = createMemo(() => session().backtracks.length); + const failureRatePct = createMemo( + () => `${Math.round(session().stats.failure_rate * 100)}%`, + ); + + // -- Context ---------------------------------------------------------- + const ctx = createMemo(() => session().context_consumption); + const hasContext = createMemo(() => ctx() !== undefined); + + // -- Outcome ---------------------------------------------------------- + const commitCount = createMemo(() => session().git_diff.commits.length); + const filesModified = createMemo( + () => session().file_map.files.filter((f) => f.edits > 0 || f.writes > 0).length, + ); + const workingTreeChanges = createMemo( + () => session().git_diff.working_tree_changes?.length ?? 0, + ); + + // -- Spec / Related --------------------------------------------------- + const specPath = createMemo(() => session().plan_drift?.spec_path); + const phases = createMemo(() => session().summary?.phases ?? []); + + return ( + + {/* Spec bar */} + + {(path) => ( +
    +
    + Spec + + {path()} + +
    +
    + )} +
    + + {/* Request section */} +
    +

    Request

    + + No prompt captured +

    + } + > + {/* + The request is the raw user prompt — render it VERBATIM, not as + markdown, so model ids like `claude-fable-5[1m]` and file paths + like `src/_internal_/foo.ts` survive intact (bug B16). + */} +
    + + + + +
    + + {/* KPI Grid - Row 1 */} +
    + {/* Timing: Span = wall-clock, Active = working time (idle excluded) */} +
    +

    Timing

    +
    + + + + +
    +
    + + {/* Cost */} +
    +

    Cost

    +
    + +
    + + setCostOpen(false)} /> +
    +
    + + + {(tier) => } + +
    +
    + + {/* Quality */} +
    +

    Quality

    +
    + + + + 0}> + (b as number) - (a as number)) + .map(([tool, count]) => `${tool} (${count})`) + .join(", ")} + /> + +
    +
    +
    + + {/* KPI Grid - Row 2 */} +
    + {/* Context (conditional) */} + + {(consumption) => ( +
    +

    Context

    +
    + + 0}> + + + 0}> + + + +
    +
    + )} +
    + + {/* Outcome */} +
    +

    Outcome

    +
    + + + +
    +
    +
    + + {/* Phase timeline */} + 0}> +
    + +
    +
    + + ); +}; diff --git a/packages/web/src/client/components/SplitPane.tsx b/packages/web/src/client/components/SplitPane.tsx new file mode 100644 index 0000000..564e6ac --- /dev/null +++ b/packages/web/src/client/components/SplitPane.tsx @@ -0,0 +1,297 @@ +import { + createSignal, + createEffect, + onCleanup, + Show, + type Component, + type JSX, +} from "solid-js"; +import { ChevronDown, ChevronUp, ChevronRight, ChevronLeft } from "lucide-solid"; + +// ── Constants ──────────────────────────────────────────────────────── + +const DEFAULT_STORAGE_KEY = "clens-split-ratio"; +const MIN_WIDTH_PX = 300; +const HANDLE_WIDTH_PX = 4; +const RESIZE_STEP = 0.01; +const DEFAULT_RATIO = 0.5; + +// ── LocalStorage helpers ───────────────────────────────────────────── + +const loadRatio = (key: string, fallback: number): number => { + try { + const stored = localStorage.getItem(key); + if (stored === null) return fallback; + const parsed = Number.parseFloat(stored); + return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : fallback; + } catch { + return fallback; + } +}; + +const saveRatio = (key: string, ratio: number): void => { + try { + localStorage.setItem(key, String(ratio)); + } catch { + // Storage full or unavailable — silently ignore + } +}; + +// ── Types ──────────────────────────────────────────────────────────── + +type CollapseState = "none" | "left" | "right"; + +type SplitPaneProps = { + readonly left: JSX.Element; + readonly right: JSX.Element; + readonly defaultRatio?: number; + readonly direction?: "horizontal" | "vertical"; + readonly id?: string; +}; + +// ── Component ──────────────────────────────────────────────────────── + +// ── Responsive direction hook ─────────────────────────────────────── + +const BREAKPOINT = "(min-width: 1024px)"; + +const createResponsiveDirection = ( + directionProp: () => "horizontal" | "vertical" | undefined, +): (() => "horizontal" | "vertical") => { + const mql = window.matchMedia(BREAKPOINT); + const [isWide, setIsWide] = createSignal(mql.matches); + + const handler = (e: MediaQueryListEvent) => setIsWide(e.matches); + mql.addEventListener("change", handler); + onCleanup(() => mql.removeEventListener("change", handler)); + + return () => directionProp() ?? (isWide() ? "horizontal" : "vertical"); +}; + +export const SplitPane: Component = (props) => { + const storageKey = props.id ? `clens-split-${props.id}` : DEFAULT_STORAGE_KEY; + const initialRatio = loadRatio(storageKey, props.defaultRatio ?? DEFAULT_RATIO); + const [ratio, setRatio] = createSignal(initialRatio); + const [dragging, setDragging] = createSignal(false); + const [collapsed, setCollapsed] = createSignal("none"); + const direction = createResponsiveDirection(() => props.direction); + const isVertical = () => direction() === "vertical"; + + let containerRef: HTMLDivElement | undefined; + + // Persist ratio changes + createEffect(() => { + const r = ratio(); + if (collapsed() === "none") { + saveRatio(storageKey, r); + } + }); + + // ── Clamp ratio to respect min widths ──────────────────────────── + + const clampRatio = (r: number): number => { + if (!containerRef) return r; + const totalSize = (isVertical() ? containerRef.offsetHeight : containerRef.offsetWidth) - HANDLE_WIDTH_PX; + if (totalSize <= 0) return r; + const minRatio = MIN_WIDTH_PX / totalSize; + const maxRatio = 1 - minRatio; + return Math.max(minRatio, Math.min(maxRatio, r)); + }; + + // ── Drag handlers ──────────────────────────────────────────────── + + const onMouseMove = (e: MouseEvent) => { + if (!containerRef) return; + const rect = containerRef.getBoundingClientRect(); + const pos = isVertical() ? e.clientY - rect.top : e.clientX - rect.left; + const totalSize = (isVertical() ? rect.height : rect.width) - HANDLE_WIDTH_PX; + if (totalSize <= 0) return; + const newRatio = clampRatio(pos / totalSize); + setRatio(newRatio); + }; + + const onMouseUp = () => { + setDragging(false); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + const onMouseDown = (e: MouseEvent) => { + e.preventDefault(); + setCollapsed("none"); + setDragging(true); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = isVertical() ? "row-resize" : "col-resize"; + document.body.style.userSelect = "none"; + }; + + // ── Keyboard resize ────────────────────────────────────────────── + + const onKeyDown = (e: KeyboardEvent) => { + const shrinkKey = isVertical() ? "ArrowUp" : "ArrowLeft"; + const growKey = isVertical() ? "ArrowDown" : "ArrowRight"; + if (e.key === shrinkKey) { + e.preventDefault(); + setCollapsed("none"); + setRatio((r) => clampRatio(r - RESIZE_STEP)); + } else if (e.key === growKey) { + e.preventDefault(); + setCollapsed("none"); + setRatio((r) => clampRatio(r + RESIZE_STEP)); + } + }; + + // ── Collapse toggles ───────────────────────────────────────────── + + const toggleCollapseLeft = () => { + setCollapsed((c) => (c === "left" ? "none" : "left")); + }; + + const toggleCollapseRight = () => { + setCollapsed((c) => (c === "right" ? "none" : "right")); + }; + + // ── Computed styles ────────────────────────────────────────────── + + const leftStyle = (): JSX.CSSProperties => { + const c = collapsed(); + const v = isVertical(); + const sizeProp = v ? "height" : "width"; + const minProp = v ? "min-height" : "min-width"; + if (c === "left") return { [sizeProp]: "0px", [minProp]: "0px", overflow: "hidden" }; + if (c === "right") return { [sizeProp]: "100%", [minProp]: "0px" }; + return { [sizeProp]: `${ratio() * 100}%`, [minProp]: `${MIN_WIDTH_PX}px` }; + }; + + const rightStyle = (): JSX.CSSProperties => { + const c = collapsed(); + const v = isVertical(); + const sizeProp = v ? "height" : "width"; + const minProp = v ? "min-height" : "min-width"; + if (c === "right") return { [sizeProp]: "0px", [minProp]: "0px", overflow: "hidden" }; + if (c === "left") return { [sizeProp]: "100%", [minProp]: "0px" }; + return { [sizeProp]: `${(1 - ratio()) * 100}%`, [minProp]: `${MIN_WIDTH_PX}px` }; + }; + + // Cleanup on unmount + onCleanup(() => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }); + + return ( +
    + {/* First pane (left or top) */} +
    + {props.left} +
    + + {/* Drag handle */} +