From 0e29586724eea5b81be890a24f831f7c50f67e30 Mon Sep 17 00:00:00 2001 From: Brendan McMullen Date: Mon, 27 Jul 2026 13:54:53 -0700 Subject: [PATCH 1/3] Show the user their own rows at the end of `hyp init` (#389, #390) Setup ended on a suggested query that could not run (`select count(*) from logs` against a dataset the install does not create) and on no evidence that capture worked at all. Both are now one thing: a rendered block of the user's own traffic, printed by the wizard and re-runnable as `hyp query overview`. The block is four sections - providers and models, sessions and tokens per day, repos, tools - counted in input/cached/output tokens rather than rows, because a "part" is an internal grain nobody outside the codebase should have to learn. Bars are two-tone over input+output with cache excluded; it would swamp both (cache is 98.9% of tokens). Both callers share one planner. A timed probe measures this machine, then picks the widest window that fits a 5s/150k-row budget, and the chosen window is always stated so a short one reads as a stated scope rather than a wrong answer. They diverge only on overrun: the wizard holds a deadline and renders whatever finished (naming the unfinished sections as unfinished), because a stall at the end of a successful install reads as a broken install; the command has no deadline, because there the user asked and is watching, and holds --days either way. Nothing in the wizard's half can fail setup: the whole step - queries, render, and write - sits in one try, and the caller discards its result. Withheld rows (LLP 0105) are disclosed on both surfaces, deduped across the five statements and worded by the query verb's own renderer so the two cannot drift. The freshness line is dropped in the wizard only: mid-install it names a condition the user cannot act on. Also fixes a null-propagation bug the block would have inherited: an unguarded `cache_read + cache_write` is NULL for every OpenAI row, silently zeroing their cache reads. Per-term coalesce, pinned by a test, corrected in LLP 0035's canonical SQL and in both report skills. Closes #389 Closes #390 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 +- .../skills/hypaware-ai-usage-report/SKILL.md | 24 +- .../claude/skills/hypaware-query/SKILL.md | 9 +- .../skills/hypaware-ai-usage-report/SKILL.md | 24 +- .../codex/skills/hypaware-query/SKILL.md | 9 +- ...0035-token-usage-normalization.decision.md | 34 +- ...0135-install-experience-overhaul.design.md | 324 +++++- src/core/cli/core_commands.js | 9 +- src/core/cli/walkthrough.js | 10 +- src/core/cli/wizard/first_look.js | 209 ++++ src/core/cli/wizard/index.js | 17 +- src/core/cli/wizard/types.d.ts | 16 + src/core/commands/query.js | 114 ++ src/core/query/overview.js | 994 ++++++++++++++++++ src/core/query/types.d.ts | 62 ++ test/core/cli/wizard/first-look.test.js | 228 ++++ test/core/cli/wizard/index.test.js | 73 +- test/core/command-dispatch.test.js | 2 +- test/core/query-overview.test.js | 783 ++++++++++++++ 19 files changed, 2931 insertions(+), 33 deletions(-) create mode 100644 src/core/cli/wizard/first_look.js create mode 100644 src/core/query/overview.js create mode 100644 test/core/cli/wizard/first-look.test.js create mode 100644 test/core/query-overview.test.js diff --git a/README.md b/README.md index c8e94f9b..8ae10622 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ On a TTY this launches the interactive walkthrough: persistent daemon (launchd on macOS, systemd `--user` on Linux), attaches the selected clients, and starts capturing. 5. The walkthrough finishes by printing the config path, daemon status, - per-client attach results, and a first `hyp query` command to run. + per-client attach results, and a first look at what was captured: token + volume per model, activity per day, which repos the sessions ran in, and + which tools got called. Reprint it any time with `hyp query overview`. For unattended installs (CI, scripted bootstraps, dotfiles) use the non-interactive flags: @@ -178,6 +180,25 @@ before invoking the CLI or the daemon. ## Querying captured data +Start with the overview: input, cached and output tokens per provider and +model, the same per day, which repos the sessions ran in, and which tools +get called - the same block `hyp init` ends on. + +```sh +hyp query overview # --json to script it, --sql to print the queries +hyp query overview --days 90 # widen the window past what fits by default +``` + +The block states the period it covers. It picks a window it can summarize +quickly, so a large cache narrows the period rather than hanging - and the +line under the title always says which days the numbers describe. + +Input is prompt sent fresh and cached is prompt served from (or written +to) the cache, so `input + cached` is the whole prompt; output is what the +model generated. + +Then query anything directly: + ```sh hyp query sql "select count(*) from ai_gateway_messages" hyp query sql "select count(*) from traces" diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-ai-usage-report/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-ai-usage-report/SKILL.md index fa564475..c1944a14 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-ai-usage-report/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-ai-usage-report/SKILL.md @@ -65,16 +65,32 @@ plain `SUM` over assistant rows is correct with no dedup (the one-carrier rule, `input_tokens` is net of cache, so it never double-counts. Report the four types separately (cache-read is usually the bulk; output the scarce slice). +**A missing provider field NULLs your arithmetic, it does not zero it.** Not every +provider emits every usage field - `cache_write_tokens` is Claude-only - and both +SQL layers turn that into silent loss, not an error: + +- *Per row:* `CAST(...cache_read...) + CAST(...cache_write...)` is NULL for every + OpenAI row, so `sum()` skips those rows entirely and the provider's whole cache-read + total reads 0. COALESCE each term *inside* the addition, not just around the sum. +- *Per aggregate:* `sum()` over all-NULL returns NULL, so a Codex-scoped slice yields + `t_cw: null` and any `t_in + t_cr + t_cw` total is NULL. + +Both were measured on a real install: 25,581,312 OpenAI cache-read tokens silently +became 0. COALESCE every token sum, and every term of every token addition. + ```sql SELECT - sum(CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT)) t_in, - sum(CAST(JSON_EXTRACT(attributes,'$.usage.output_tokens') AS BIGINT)) t_out, - sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_write_tokens') AS BIGINT)) t_cw, - sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_read_tokens') AS BIGINT)) t_cr + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT)), 0) t_in, + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.output_tokens') AS BIGINT)), 0) t_out, + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_write_tokens') AS BIGINT)), 0) t_cw, + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_read_tokens') AS BIGINT)), 0) t_cr FROM ai_gateway_messages WHERE date BETWEEN '' AND '' AND role='assistant' AND JSON_EXTRACT(attributes,'$.usage') IS NOT NULL; -- One carrier row per response (LLP 0035): a plain SUM is correct, no dedup. +-- COALESCE is NOT decorative: a field a provider never emits (cache_write_tokens +-- on OpenAI/ChatGPT rows) makes sum() return NULL, and NULL poisons any total +-- built from it -- t_in + t_cr + t_cw goes NULL and the real cache reads vanish. -- Slice by adding gateway_id / model / repo_root / date to SELECT + GROUP BY. -- Defensive equivalent: max(...) GROUP BY session_id, message_id -- session_id is the -- uniform key; conversation_id is null for Claude and only separates Codex threads. diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md index dab4d8ea..2845fc52 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md @@ -20,6 +20,7 @@ Use `hyp query` to inspect local HypAware recordings. By default it reads local ## Common Commands ```bash +hyp query overview # orientation: tokens per model/day/repo/tool (--sql prints its queries, --json for machine output) hyp query status hyp query schema --format json hyp query sql "" --format json @@ -27,7 +28,9 @@ hyp query sql "" --format jsonl --output # full result, lossless hyp query refresh ``` -These are the only subcommands in the installed CLI (`hyp query`: schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. +**`hyp query overview` totals are windowed, not all-time.** It probes the cache, times that probe to measure this machine, and picks the widest recent window it can summarize quickly — so on a large cache it silently covers a subset. The line under the title always states the period (`2026-07-24 to 2026-07-27 - showing 3 of 31 active days …`); read it before quoting any number, and pass `--days ` to widen (that overrides the budget, whatever it costs). Never report its totals as the full history without checking that line. + +These are the only subcommands in the installed CLI (`hyp query`: overview, schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. ## Remote queries (other HypAware hosts) @@ -76,11 +79,11 @@ Recorded AI-gateway traffic is exposed through one dataset: `ai_gateway_messages Key columns: - `session_id`, `conversation_id`, `message_id`, `message_index`, `part_id`, `part_index` — stable identity. `session_id` is the always-present session key (group/scope on it); `conversation_id` is a nullable thread within a session (a Codex thread; null for Claude). -- `provider`, `model`, `role`, `part_type`, `content_text` — normalized provider/message content fields. +- `provider`, `model`, `role`, `part_type`, `content_text` — normalized provider/message content fields. `part_type` is HypAware's own vocabulary, NOT the provider's wire name: `text`, `reasoning`, `tool_call`, `tool_result`, `image`, `fallback`. Tool calls are `part_type='tool_call'` — Anthropic's `tool_use` matches no row and returns a silently empty result. `role` is `user` / `assistant` / `tool` / `system` / `developer`. - `tool_name`, `tool_call_id`, `tool_args`, `status` — tool-call/result joins and sparse status such as `finish_reason`. - `attributes` (JSON) — request settings, usage, propagated `dev_run_id`, and gateway diagnostics under `attributes.gateway`. -**Token counts** live under `attributes.usage` on `role='assistant'` rows (NOT in `raw_frame`): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`. Codex (`provider='openai'`) omits `cache_write_tokens` and adds `reasoning_tokens` + `total_tokens`. Extract with `CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT)`. Usage rides exactly one row per response (the last assistant part; non-carrier parts are null), so a plain `SUM` over assistant rows is correct with no dedup (the one-carrier rule, LLP 0035). If you prefer a defensive dedup, `max(...) GROUP BY session_id, message_id` returns the same number: key on `session_id` (`conversation_id` is null for Claude, and only separates threads within a Codex session). +**Token counts** live under `attributes.usage` on `role='assistant'` rows (NOT in `raw_frame`): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`. Codex (`provider='openai'`) omits `cache_write_tokens` and adds `reasoning_tokens` + `total_tokens`. Extract with `COALESCE(CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT), 0)` - **always COALESCE**: a field the provider never emits is NULL, and NULL propagates instead of zeroing. Per row, `CAST(...cache_read...) + CAST(...cache_write...)` is NULL for every OpenAI row, so `sum()` skips them and that provider's whole cache-read total silently reads 0 (measured: 25,581,312 -> 0). Per aggregate, `sum()` over all-NULL returns NULL, so a Codex-scoped `t_in + t_cr + t_cw` total is NULL. COALESCE each term inside an addition, and each sum. Usage rides exactly one row per response (the last assistant part; non-carrier parts are null), so a plain `SUM` over assistant rows is correct with no dedup (the one-carrier rule, LLP 0035). If you prefer a defensive dedup, `max(...) GROUP BY session_id, message_id` returns the same number: key on `session_id` (`conversation_id` is null for Claude, and only separates threads within a Codex session). Claude transcript enrichment adds `provider_uuid`, `parent_uuid`, `request_id`, `entrypoint`, `client_version`, `user_type`, `permission_mode`, and `hook_event` when the local Claude Code JSONL transcript can be matched. diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-ai-usage-report/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-ai-usage-report/SKILL.md index fa564475..c1944a14 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-ai-usage-report/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-ai-usage-report/SKILL.md @@ -65,16 +65,32 @@ plain `SUM` over assistant rows is correct with no dedup (the one-carrier rule, `input_tokens` is net of cache, so it never double-counts. Report the four types separately (cache-read is usually the bulk; output the scarce slice). +**A missing provider field NULLs your arithmetic, it does not zero it.** Not every +provider emits every usage field - `cache_write_tokens` is Claude-only - and both +SQL layers turn that into silent loss, not an error: + +- *Per row:* `CAST(...cache_read...) + CAST(...cache_write...)` is NULL for every + OpenAI row, so `sum()` skips those rows entirely and the provider's whole cache-read + total reads 0. COALESCE each term *inside* the addition, not just around the sum. +- *Per aggregate:* `sum()` over all-NULL returns NULL, so a Codex-scoped slice yields + `t_cw: null` and any `t_in + t_cr + t_cw` total is NULL. + +Both were measured on a real install: 25,581,312 OpenAI cache-read tokens silently +became 0. COALESCE every token sum, and every term of every token addition. + ```sql SELECT - sum(CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT)) t_in, - sum(CAST(JSON_EXTRACT(attributes,'$.usage.output_tokens') AS BIGINT)) t_out, - sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_write_tokens') AS BIGINT)) t_cw, - sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_read_tokens') AS BIGINT)) t_cr + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT)), 0) t_in, + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.output_tokens') AS BIGINT)), 0) t_out, + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_write_tokens') AS BIGINT)), 0) t_cw, + COALESCE(sum(CAST(JSON_EXTRACT(attributes,'$.usage.cache_read_tokens') AS BIGINT)), 0) t_cr FROM ai_gateway_messages WHERE date BETWEEN '' AND '' AND role='assistant' AND JSON_EXTRACT(attributes,'$.usage') IS NOT NULL; -- One carrier row per response (LLP 0035): a plain SUM is correct, no dedup. +-- COALESCE is NOT decorative: a field a provider never emits (cache_write_tokens +-- on OpenAI/ChatGPT rows) makes sum() return NULL, and NULL poisons any total +-- built from it -- t_in + t_cr + t_cw goes NULL and the real cache reads vanish. -- Slice by adding gateway_id / model / repo_root / date to SELECT + GROUP BY. -- Defensive equivalent: max(...) GROUP BY session_id, message_id -- session_id is the -- uniform key; conversation_id is null for Claude and only separates Codex threads. diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md index f27da653..14aeda82 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md @@ -20,6 +20,7 @@ Use `hyp query` to inspect local HypAware recordings. By default it reads local ## Common Commands ```bash +hyp query overview # orientation: tokens per model/day/repo/tool (--sql prints its queries, --json for machine output) hyp query status hyp query schema
--format json hyp query sql "" --format json @@ -27,7 +28,9 @@ hyp query sql "" --format jsonl --output # full result, lossless hyp query refresh ``` -These are the only subcommands in the installed CLI (`hyp query`: schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. +**`hyp query overview` totals are windowed, not all-time.** It probes the cache, times that probe to measure this machine, and picks the widest recent window it can summarize quickly — so on a large cache it silently covers a subset. The line under the title always states the period (`2026-07-24 to 2026-07-27 - showing 3 of 31 active days …`); read it before quoting any number, and pass `--days ` to widen (that overrides the budget, whatever it costs). Never report its totals as the full history without checking that line. + +These are the only subcommands in the installed CLI (`hyp query`: overview, schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. ## Remote queries (other HypAware hosts) @@ -76,11 +79,11 @@ Recorded AI-gateway traffic is exposed through one dataset: `ai_gateway_messages Key columns: - `session_id`, `conversation_id`, `message_id`, `message_index`, `part_id`, `part_index` — stable identity. `session_id` is the always-present session key (group/scope on it); `conversation_id` is a nullable thread within a session (a Codex thread; null for Claude). -- `provider`, `model`, `role`, `part_type`, `content_text` — normalized provider/message content fields. +- `provider`, `model`, `role`, `part_type`, `content_text` — normalized provider/message content fields. `part_type` is HypAware's own vocabulary, NOT the provider's wire name: `text`, `reasoning`, `tool_call`, `tool_result`, `image`, `fallback`. Tool calls are `part_type='tool_call'` — Anthropic's `tool_use` matches no row and returns a silently empty result. `role` is `user` / `assistant` / `tool` / `system` / `developer`. - `tool_name`, `tool_call_id`, `tool_args`, `status` — tool-call/result joins and sparse status such as `finish_reason`. - `attributes` (JSON) — request settings, usage, propagated `dev_run_id`, and gateway diagnostics under `attributes.gateway`. -**Token counts** live under `attributes.usage` on `role='assistant'` rows (NOT in `raw_frame`): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`. Codex (`provider='openai'`) omits `cache_write_tokens` and adds `reasoning_tokens` + `total_tokens`. Extract with `CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT)`. Usage rides exactly one row per response (the last assistant part; non-carrier parts are null), so a plain `SUM` over assistant rows is correct with no dedup (the one-carrier rule, LLP 0035). If you prefer a defensive dedup, `max(...) GROUP BY session_id, message_id` returns the same number: key on `session_id` (`conversation_id` is null for Claude, and only separates threads within a Codex session). +**Token counts** live under `attributes.usage` on `role='assistant'` rows (NOT in `raw_frame`): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`. Codex (`provider='openai'`) omits `cache_write_tokens` and adds `reasoning_tokens` + `total_tokens`. Extract with `COALESCE(CAST(JSON_EXTRACT(attributes,'$.usage.input_tokens') AS BIGINT), 0)` - **always COALESCE**: a field the provider never emits is NULL, and NULL propagates instead of zeroing. Per row, `CAST(...cache_read...) + CAST(...cache_write...)` is NULL for every OpenAI row, so `sum()` skips them and that provider's whole cache-read total silently reads 0 (measured: 25,581,312 -> 0). Per aggregate, `sum()` over all-NULL returns NULL, so a Codex-scoped `t_in + t_cr + t_cw` total is NULL. COALESCE each term inside an addition, and each sum. Usage rides exactly one row per response (the last assistant part; non-carrier parts are null), so a plain `SUM` over assistant rows is correct with no dedup (the one-carrier rule, LLP 0035). If you prefer a defensive dedup, `max(...) GROUP BY session_id, message_id` returns the same number: key on `session_id` (`conversation_id` is null for Claude, and only separates threads within a Codex session). Claude transcript enrichment adds `provider_uuid`, `parent_uuid`, `request_id`, `entrypoint`, `client_version`, `user_type`, `permission_mode`, and `hook_event` when the local Claude Code JSONL transcript can be matched. diff --git a/llp/0035-token-usage-normalization.decision.md b/llp/0035-token-usage-normalization.decision.md index 48115102..0f391eb8 100644 --- a/llp/0035-token-usage-normalization.decision.md +++ b/llp/0035-token-usage-normalization.decision.md @@ -124,21 +124,37 @@ row, so a plain sum is correct: ```sql SELECT - SUM(CAST(JSON_EXTRACT(attributes, '$.usage.input_tokens') AS BIGINT)) AS input, - SUM(CAST(JSON_EXTRACT(attributes, '$.usage.output_tokens') AS BIGINT)) AS output, - SUM(CAST(JSON_EXTRACT(attributes, '$.usage.cache_read_tokens') AS BIGINT)) AS cache_read, - SUM(CAST(JSON_EXTRACT(attributes, '$.usage.cache_write_tokens')AS BIGINT)) AS cache_write, - SUM(CAST(JSON_EXTRACT(attributes, '$.usage.reasoning_tokens') AS BIGINT)) AS reasoning + COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.input_tokens') AS BIGINT)), 0) AS input, + COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.output_tokens') AS BIGINT)), 0) AS output, + COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.cache_read_tokens') AS BIGINT)), 0) AS cache_read, + COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.cache_write_tokens')AS BIGINT)), 0) AS cache_write, + COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.reasoning_tokens') AS BIGINT)), 0) AS reasoning FROM ai_gateway_messages WHERE role = 'assistant' AND JSON_EXTRACT(attributes, '$.usage') IS NOT NULL ``` A defensive `max()`-per-`COALESCE(raw_frame.message_id, message_id)` rollup also remains correct (the non-carrier blocks are null and ignored), so it's safe to -keep in queries written before this decision. Field union across providers: -Codex carries `reasoning_tokens` and no `cache_write_tokens`; Claude is the -reverse; `input_tokens` is net for both (#net-input). `COALESCE(..., 0)` the -union. +keep in queries written before this decision. + +**The field union is a null trap, and the nulls are +silent.** Codex carries `reasoning_tokens` and no `cache_write_tokens`; Claude +is the reverse; `input_tokens` is net for both (#net-input). A field the +provider never emits reads NULL, and NULL propagates rather than zeroing, in +two distinct places: + +- *Inside a row's arithmetic.* `CAST(...cache_read...) + CAST(...cache_write...)` + is NULL on every OpenAI row, so `SUM` skips the row entirely and that + provider's whole cache-read total collapses to 0. Measured on a real install: + 25,581,312 cache-read tokens became 0, with no error. +- *At the aggregate.* `SUM` over all-NULL returns NULL, not 0, so an + OpenAI-scoped slice yields `cache_write: null` and any + `input + cache_read + cache_write` total built from it is NULL. + +So: `COALESCE(..., 0)` **every** token sum, and **every term** of every token +addition. The rule is the same shape as the net/gross normalization this +document exists for - a cross-provider query must not silently mean different +things per provider - one layer down, in null handling. ### Consequences diff --git a/llp/0135-install-experience-overhaul.design.md b/llp/0135-install-experience-overhaul.design.md index 3097f5c8..6f0a8d1e 100644 --- a/llp/0135-install-experience-overhaul.design.md +++ b/llp/0135-install-experience-overhaul.design.md @@ -31,12 +31,15 @@ ``` src/core/cli/wizard/ - index.js // runInitWizard(opts): fork -> join -> pick -> configure -> privacy -> finale + index.js // runInitWizard(opts): fork -> join -> pick -> configure -> finale -> first look -> privacy + first_look.js // runWizardFirstLook(opts): when the closing overview runs, and that it never fails setup fork.js // runWizardFork(opts), the returning-gate split (scoped vs full re-entry) join.js // runWizardJoin(opts): wraps runRemoteLogin, bounded org-config wait, locked-row set pick.js // runWizardPick(opts): the picker prompt + composePickerConfig (descriptor-driven, was walkthrough.js) configure.js // runConfigurePhase(picked, ctx): needs_setup loop, drop-on-failure, --print-commands passthrough provenance.js // classifyClientProvenance(name, layered): shared by pick.js, status.js, and the export seam +src/core/query/overview.js // the shared block: probe, chooseOverviewWindow, buildOverviewSql, collectOverview, renderOverview +src/core/commands/query.js // `hyp query overview [--json] [--sql] [--days ]`: the same block on demand src/core/cli/detect.js // detectPickerSources(catalog, env): replaces the hardcoded DETECTABLE_CLIENT_SOURCES table src/core/plugin_catalog.js // buildPluginCatalog gains pickerDescriptors alongside clientDescriptors src/core/cache/storage.js // readRowsSince gains an optional sourceWithholdResolver alongside usagePolicyResolver @@ -186,6 +189,7 @@ export async function runInitWizard(opts) { const configured = await runConfigurePhase(picked, opts) // @ref LLP 0131 const finale = await runWizardFinale({ picked, configured, joinedAlready: pathway === 'team', opts }) + await runWizardFirstLook(opts) // @ref LLP 0135#first-look, attended runs only await narratePrivacyIfTeamPath(opts, pathway) // @ref LLP 0134#login-lane, unchanged mechanism return finale } @@ -459,13 +463,329 @@ requires no new code beyond `claude-desktop`'s manifest declaring (per [LLP 0128 Design sketch](./0128-install-experience-overhaul.rfc.md#design-sketch), unchanged): an enrolled machine backfills under [LLP 0037](./0037-backfill-on-join.decision.md) default-on doctrine. +## First look {#first-look} + +Setup used to end on a hint: `next: hyp query sql 'select count(*) from +logs'`. That command fails on most installs, because `logs` only exists +when `@hypaware/otel` is configured, and it asks the user to do the work of +finding out whether anything was captured. The hint is removed; +`writeWalkthroughRunSummary` now prints only what the finale did. + +In its place, the shared overview (`src/core/query/overview.js`) runs fixed +read-only queries over `ai_gateway_messages` and renders them as aligned +tables with proportional bars, in four sections: **models** (input/cached/ +output tokens per provider and model), **daily** (the same three per day +alongside a session count), **repos** (which checkouts the sessions ran +in), and **tools** (which tools the models call). + +Both callers render all four. The first cut gave the wizard only +`['models', 'daily']` on the theory that a ~60-line block (against ~35) +would bury the closing privacy narration; that was over-cautious, since the +narration is written *after* the block and stays the last thing on screen +either way (#privacy). `collectOverview` still takes a section list and runs +only those queries, so the seam for a shorter variant remains if setup +output ever needs trimming. Two shapes the data forced: + +- **A ranked table sorts by exactly what its bar charts.** The models table + originally ordered by `output_tokens` while the bar charted + `input + output`, so a prompt-heavy row (`gpt-5.5`: 2.3M input, 172k + output) drew a longer bar than the row above it. Both ranked token tables + now `order by input_tokens + output_tokens desc`, and tools orders by + `calls`, the metric it bars. Daily is the deliberate exception: it is + chronological, so its bars are a time series rather than a rank and are + expected to rise and fall. A test pins each table's sort key against its + bar metric. +- **Repos group by repo alone, not repo + branch.** `git_branch` is set on + 15 of 431 sessions on the authoring machine (~3%), so a branch column + would be almost entirely blank - and grouping by it splits one repo + across a null-branch row and a named one, showing the same checkout + twice as if it were two places. +- **Tools filter `part_type = 'tool_call'`.** The projector normalizes + every provider's call shape onto one vocabulary (`text` / `reasoning` / + `tool_call` / `tool_result` / `image` / `fallback`), so a provider's own + wire name (`tool_use`) matches nothing and yields a silently empty + section. + +Sessions with no repo are folded into a count line rather than dropped or +drawn as a nameless row: they are 177 of 431 here, so hiding them would +misstate the split. The line reads "no repo recorded", **not** "outside a +repo", because the column cannot distinguish those and one of them would +be a false claim: `repo_root` is populated on 27,342 Claude rows and on +**zero** Codex rows, even though Codex rows do carry `cwd` (1,248) and +sometimes `git_branch` (464). Every Codex session therefore lands in that +count regardless of where it actually ran. The projector asymmetry is a +`@hypaware/codex` gap worth closing on its own; until it is, the overview +must not narrate absence of a field as absence of a repo. The block is the wizard's proof +of life: the run ends on the user's own numbers. + +**Tokens, not rows.** A row is one *part* of a message +(`part_id = #`, [LLP 0026](./0026-claude-native-granularity.decision.md)), so a `count(*)` +headline names a unit nobody outside the schema recognizes, and inflates +wherever a model answers in several content blocks. Tokens are the unit +users already think in, and they sum honestly: +[LLP 0035 #one-carrier](./0035-token-usage-normalization.decision.md#one-carrier) puts response-level usage on exactly one row, +so a plain `SUM` needs no dedup and no `role` filter (non-carrier rows are +null) - which also keeps `sessions` counting every session, not only those +with an assistant reply. + +**Bars chart input + output, split by shade, and cache is excluded.** On +real data cached is 99.0-99.9% of every day's total, so a total-token bar +is a cache-read chart: one long conversation re-reading a large prompt +outranks a day with 37 sessions, and every other bar flattens against it. +The bar therefore compares +`input + output` - the tokens that were actually new - and splits them by +shade (`▒` input, `█` output) so the mix is readable at a glance. The split +is encoded twice, shade *and* colour, so it survives a monochrome +terminal, a pipe, and colour-blind readers; a component that exists is +never rounded away to nothing. + +Each table captions its own bar (`by input+output`, `by sessions`, +`by calls`) because the sections legitimately chart different things, and +an unlabelled bar two columns from what it measures gets read as the +column beside it - which is exactly how the daily bars were first taken +for session counts. In the token tables the caption's words carry their own +shades' colours, so the header *is* the key and no legend lookup is needed. + +That forces one rendering rule: header cells are painted individually +rather than as one dim run, because a colour sequence inside a dim run ends +it early and leaves the remainder of the row undimmed. A test asserts no +colour start ever follows a dim start without an intervening reset. + +**Cache is excluded from the bar but never from the columns.** An earlier +draft of this section justified the exclusion by calling cache "the +cheapest token there is" - true per token (reads price at 0.1x input), and +wrong as a claim about significance. Measured against this repo's own +history with the published multipliers (read 0.1x, write 1.25x, output +5x), the split is **cache read 55.8% of cost, output 22.0%, cache write +20.7%, fresh input 1.5%** - cached is **76.5% of spend** off 98.9% of +tokens. Cache writes especially: 2.9% of tokens, a fifth of the cost, at +12.5x a read's price. + +So the exclusion is a *charting* decision, not a significance one. A +cache-dominated bar stops discriminating (every row saturates) and tracks +context size x turn count rather than work done. Every token table carries +input, cached and output as columns - including the repos table, which +would otherwise report ~1.5% of what a repo actually costs. + +**Three columns: input, cached, output.** Input is stored net of cache +([#net-input](./0035-token-usage-normalization.decision.md#net-input)), so each column sums exactly the field it is named +after and `input + cached` is the whole prompt, with nothing +double-counted. Folding cache into input would hide where the volume +actually goes - on this repo's own history, cached runs ~500x net input - +and would leave the "input" header meaning something narrower elsewhere in +the schema. `cached` covers reads and writes together; the read/write +split is a cost question for the usage-report skills, not a first look. + +Each cache term carries its own `coalesce`, which is load-bearing rather +than defensive: `cache_write_tokens` is Claude-only, so an unguarded +`cache_read_tokens + cache_write_tokens` evaluates to null on every OpenAI +row and silently drops that provider's cache reads from the sum (25.5M -> +0 on the authoring machine). A test pins it. + +**Zero-token rows: two kinds, two treatments.** A `0 0 0` line reads +like a bug, so the table shows only measured rows - but whether their +absence deserves a word depends on what they are. + +A group with no model label is not a model: it is the rows no model +answered (prompts, tool results), which cannot carry usage, because a +response's tokens are stamped on the response ([#one-carrier](./0035-token-usage-normalization.decision.md#one-carrier)) and the +prompt's cost is therefore already inside the answering model's `input` +and `cached`. These are omitted silently - nothing is missing to report. +The first cut got this wrong and counted them, so 14.5k of the reader's +own prompts were announced as "+ 2 models whose traffic was recorded +without token counts": correct arithmetic under a sentence that described +the reader's messages as something they are not. + +A *labelled* model with zero tokens is the other kind: a real model whose +provider reported no usage. That is a genuine gap in what was recorded, so +it is still counted out loud. And an unlabelled group that ever does carry +tokens renders as `(model not recorded)` rather than being filtered, so +the omission rule can never silently drop a measured token. + +The SQL sits behind `--sql`, not inline: the token statements are four +lines of `json_extract`/`cast` each, and printing both above the tables +would bury the numbers. A one-line footer names the flag, so the queries +stay one keystroke away - they are exactly the incantation a user cannot +guess. Printed with newlines preserved inside `hyp query sql "..."`, which +a shell pastes back verbatim. + +**One block, two callers.** `runWizardFirstLook` (`wizard/first_look.js`) +owns only the wizard's half of the contract (when the step runs, and that +it never fails a finished install); `hyp query overview` +(`commands/query.js`, plus `--json` for scripting and `--sql` for the +statements) prints the same block on demand, and the wizard's closing line +names it. A view a user sees once +during setup and can never summon again is a worse deal than a command +they learn - so the render, the SQL, and the empty state live in one +module rather than being reproduced per surface. The two callers differ +only in heading, and in what an unregistered dataset means: the wizard +skips silently (nothing was picked that records gateway traffic), while +the command exits 1 with the fix on stderr (the user asked for AI traffic +and there is no source recording it). + +Constraints that make it safe to run automatically: + +- **Attended and non-dry-run only.** `--yes`, presets and `--from-file` + produce no extra output (scripted callers keep a clean stdout), and a + dry run has no writes to look at. +- **After the finale, before the privacy narration.** Backfill has already + landed by then, so a first install with imported history shows real rows; + and the privacy narration stays the wizard's last words (#privacy). +- **Never a gate.** In the wizard, no dataset (no gateway source picked) + skips silently and a failure is recorded on the span and skipped. Setup + already succeeded by the time this runs, so nothing here may fail it - + and the guarantee is structural, not merely behavioral: `runInitWizard` + **discards the step's result**, so it cannot reach the exit code, and the + step's `try` wraps the *whole* body rather than just the queries. An + earlier version caught only `collectOverview`, leaving rendering and + `stdout.write` outside it - an unforeseen row shape, or an `EPIPE` from a + closed pipe (`hyp init | head`), would have surfaced as `hyp: ` + and a non-zero exit from an install that had already fully succeeded. + Two tests pin it, one throwing from the writer and one from a row. +- **Never a hang, and never a blank.** The section queries are full + aggregations whose cost is the cache's size (~3s over 48k rows / 158MB, + and the step runs right after a backfill that may have imported months + into a much bigger one). Rather than run them and hope, the block plans + its own scope, described below. +- **No new visibility surface.** The runner routes through the `query sql` + verb operation with the caller's cwd, so the LLP 0105 filter applies + exactly as it would to the user typing the query + (`@ref LLP 0105 [constrained-by]`). + +Zero rows is a first-class state, not a blank: it names what has to happen +(start a session in an attached client) and repeats the command to run +afterwards. + +### The window is planned, and always stated {#window} + +A block whose cost grows without bound has two bad failure modes: it hangs +on a big cache, or it silently disappears. Instead the block **chooses a +period it can afford, and says which period that is.** + +`collectOverview` first runs a deliberately narrow probe - +`select date, count(*) from ai_gateway_messages group by 1`, one column and +no JSON extraction, ~0.27s against the ~0.50s of a single token section. +Partitions are keyed by `source`, not `date`, so per-day counts cannot come +from Iceberg metadata; asking the data is the cheap option. From that +histogram `chooseOverviewWindow` walks days newest-first and takes the +widest span that satisfies **two** caps. Every section then carries the +same `date >= since` bound, so the block is one claim about one period +rather than four differently scoped ones. + +**Time, measured rather than assumed.** The probe just read every row, so +how long *it* took is a fresh per-row rate for this machine, this disk and +this moment's load. Sections cost ~1.9x the probe per row (0.27s against +0.50s at 48k rows) - a ratio that is a property of the queries, where the +rate is a property of the hardware - so the affordable row count is +`remaining / (perRowMs x 1.9 x sections)`. `remaining` is the 5s budget +*minus what the probe already spent*: the probe is part of the user's wait, +and a plan that ignored it would let a slow probe consume the budget before +planning noticed it was spending anything (on a 20x-slow machine the probe +alone outlasts it). A floor keeps the newest day payable even then. A row-count target *alone* would bake in the +author's laptop: the same 150k rows on a machine 10x slower is the same +plan and ten times the wait, which is precisely the "huge logs, long wait" +case the window exists to prevent. Verified against this repo's real +per-day counts (31 active days, 48k rows): at the measured ~270ms probe the +plan takes all 31 days; simulating 3x, 10x and 50x slower machines narrows +it to 27, 6 and 2 days respectively, with no change to the data. + +**Rows, as a memory backstop** (`OVERVIEW_ROW_TARGET`, 150k). Time says +nothing about heap, and a fast machine could otherwise pick a window +approaching the LLP 0056 execution ceiling that LLP 0057 measured at the +~200k-row scale. The tighter of the two caps wins, and `boundBy` records +which one did. + +Three rules make this honest rather than merely fast: + +- **The newest day is always included, even alone, even when it exceeds the + target.** A block covering one busy day is a real answer; no block is + not. Narrowing replaces skipping. +- **The window is always printed**, directly under the title. A total whose + period is unstated is not an answer, and a smaller number must never be + mistaken for less work. A narrowed window reports the scope and the lever + - `showing 3 of 31 active days (2,918 of 48,405 rows); widen with --days + 31` - and never the reason. One wording whatever caused it: the row + budget, a slow machine and an explicit `--days` are the tool's business, + and a window the user asked for must not arrive with an apology attached. + The count is "active days" because the bounds are calendar dates while + the count is dates that recorded something. +- **An explicit `--days ` outranks the budget.** The user asking is + worth more than the tool's guess about their patience. + +### Same plan, different overrun behavior {#overrun} + +Both callers plan identically - one budget constant, one probe, one window +- because "how much history can this machine summarize quickly?" has one +answer regardless of who asked. They diverge only when the plan turns out +wrong: + +- **`hyp init`** wraps the step in a deadline of `budget + 3s` + (`FIRST_LOOK_BUDGET_MS`, derived from the shared constant rather than + chosen independently). The gap is deliberate: it fires only when + measurement was wrong, never in ordinary operation, and if it starts + firing routinely the fix is the planner's calibration rather than a + longer deadline. Setup is where a stall does real damage - the last step + of an install, after every durable action already succeeded, so a freeze + reads as "the install broke" when nothing did. + + **Expiry keeps what finished.** `collectOverview` writes each section into + the caller's object as it lands, so an expired deadline renders the + completed sections instead of discarding them - three done and a fourth + in flight is a shorter block, not a blank one. The unfinished sections are + then named as *unfinished* ("the repos and tools sections did not + finish"), never silently omitted: "no repos" and "the repos section did + not finish" are different claims and only one is true. Only when nothing + usable landed - the probe itself outlasting the budget - does the step + fall back to skipping entirely (`skip_reason: 'slow'`) with a pointer to + `hyp query overview`. +- **`hyp query overview`** has no deadline. The user asked and is watching; + no answer is worse than a slow one, and they hold `--days` either way. At + the extreme the LLP 0056 heap ceiling still refuses - and the command + catches that refusal to name the lever the kernel's generic advice + cannot ("add a WHERE/date filter, a LIMIT" is useless to a block that + already has a date filter and takes no LIMIT). + +The abandoned queries are in-process CPU work and cannot be cancelled; the +CLI's closing `process.exit` drops them. Threading the deadline into +`executeQuerySql`'s existing `signal` (LLP 0054 #signal-threading) would +make the abandonment a real cancellation, and is the obvious next step. + +### What the block omits, it says {#disclosure} + +The overview runs through the same `executeQuerySql` every other surface +uses, so it inherits that seam's two out-of-band reports: LLP 0105's +withheld-row count, and the freshness line for a dataset with unflushed +writes. Neither belongs in the table - both are about the table. + +Withheld rows are disclosed on **both** callers, to stderr. A block that +quietly drops rows and reads as a complete picture is precisely the failure +LLP 0105 exists to prevent, and it would land harder here than on a +hand-written query: the user typed no filter and has no reason to suspect +one. Being mid-install is not an exemption. + +Freshness is disclosed by `hyp query overview` and dropped by the wizard +(`firstLookNoticeSink`). The line is true and actionable for someone asking +a question of their data; to someone finishing an install it names a +condition they did not cause and cannot act on, attached to a block whose +backfilled rows were force-flushed on the way in. + +One further wrinkle: the block issues five statements, so a naive pass- +through would print the same sentence five times. The runner dedups by +line, which is also why the wording is `renderLocalOnlyNotice` from the +query verb rather than a second copy - two surfaces phrasing the same +disclosure differently is how one of them ends up subtly wrong. + ## Telemetry Per CLAUDE.md's log-driven-development conventions, each new phase gets its own span, `component: 'wizard'`: `wizard.fork`, `wizard.join` (with `join_status`, `wait_ms`, `converged: boolean`), `wizard.pick` (superseding `walkthrough.pick`), `wizard.configure` (one span per descriptor: -`descriptor_id`, `status`, `error_kind` on drop), `wizard.finale`. The +`descriptor_id`, `status`, `error_kind` on drop), `wizard.finale`, +`wizard.first_look` (`provider_rows`, `day_rows`, or `status: 'skipped'` +with `skip_reason` when it did not run). `hyp query overview` emits its own +`query.overview` span (`component: 'query'`, `format`, the same row +counts). The existing `walkthrough.start`/`write_config`/`finish` spans rename to their `wizard.*` equivalents in the same change that moves the code, per CLAUDE.md's "update or remove the `@ref` if not" rule for the `@ref LLP 0011#interactive- diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index 28b11dcc..0ecdf03e 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -9,6 +9,7 @@ import { makeGroupCommand } from './group_help.js' import { runStatus } from '../commands/status.js' import { runQueryMaintain, + runQueryOverview, runQueryRefresh, runQuerySchema, runQueryStatus, @@ -103,8 +104,14 @@ function buildCoreCommands(registry) { ' cache (bare --remote uses query.default_remote, else the\n' + " shipped default; manage targets with 'hyp remote').\n" + "See 'hyp query --help' for which flags a subcommand supports\n" + - '(status/schema/refresh/maintain are local-only and ignore --remote).', + '(overview/status/schema/refresh/maintain are local-only and ignore --remote).', }), + { + name: 'query overview', + summary: 'Show recorded AI traffic: tokens per model, activity per day, repos, and tools', + usage: 'hyp query overview [--json] [--sql] [--days ]', + run: runQueryOverview, + }, { name: 'query schema', summary: 'Print the schema for a dataset', diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index a3cbfc1e..3b267de5 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -596,8 +596,13 @@ export async function runPickerWalkthrough(opts) { /** * Print the closing run summary: the written config path plus one line * per finale action that ran (daemon target, attaches, skills/agents - * counts) and the first-query hint. Shared by `runPickerWalkthrough` and - * the wizard orchestrator so both entry points end a run identically. + * counts). Shared by `runPickerWalkthrough` and the wizard orchestrator so + * both entry points end a run identically. + * + * No "next: hyp query sql ..." hint: it named the `logs` dataset, which + * only exists when `@hypaware/otel` is configured, so most installs ended + * on a command that failed. The wizard now runs real queries instead + * (LLP 0135 #first-look). * * @param {{ * stdout: NodeJS.WritableStream | { write(chunk: string): unknown }, @@ -635,7 +640,6 @@ export function writeWalkthroughRunSummary({ stdout, configPath, finaleSummary } const tag = finaleSummary.agentsInstalled[0].dryRun ? '(dry-run) ' : '' stdout.write(`${tag}agents: ${finaleSummary.agentsInstalled.length} copied\n`) } - stdout.write(`next: hyp query sql 'select count(*) from logs'\n`) } /** diff --git a/src/core/cli/wizard/first_look.js b/src/core/cli/wizard/first_look.js new file mode 100644 index 00000000..3eac4d60 --- /dev/null +++ b/src/core/cli/wizard/first_look.js @@ -0,0 +1,209 @@ +// @ts-check + +/** + * The wizard's closing "first look": the shared gateway overview + * (`query/overview.js`, the same block `hyp query overview` prints), + * placed at the end of an attended setup so the run ends on the user's own + * rows rather than on a command they still have to type. + * + * This module owns only the wizard's half of the contract: when the step + * runs, and that it can never fail a finished install. + * + * @import { FirstLookResult } from '../../../../src/core/cli/wizard/types.js' + * @import { OverviewNotice, OverviewQueryRunner, OverviewRows } from '../../../../src/core/query/types.js' + */ + +import { Attr, withSpan } from '../../observability/index.js' +import { + OVERVIEW_DATASET, + OVERVIEW_TIME_BUDGET_MS, + collectOverview, + emptyOverview, + hasRenderableOverview, + missingSections, + renderOverview, +} from '../../query/overview.js' + +export { overviewRunnerFromCtx as firstLookRunnerFromCtx } from '../../query/overview.js' + +/** + * The notice sink to hand `firstLookRunnerFromCtx` during setup. + * + * Withheld rows are disclosed, always: a block that quietly drops rows and + * reads as a complete picture is the one failure LLP 0105 exists to + * prevent, and being mid-install does not excuse it. + * + * Freshness is dropped, though, and only here. The line says live capture + * may lag by up to the flush debounce - true, actionable, and worth + * printing to someone who just asked a question of their data. To someone + * finishing an install it is noise about a condition they did not cause and + * cannot act on, attached to a block whose backfilled rows were + * force-flushed anyway. `hyp query overview` prints both. + * + * @ref LLP 0105 [implements]: withholding is disclosed on every surface, setup included + * @param {{ write(chunk: string): unknown }} stderr + * @returns {(notice: OverviewNotice) => void} + */ +export function firstLookNoticeSink(stderr) { + return (notice) => { + if (notice.kind === 'local-only') stderr.write(notice.line) + } +} + +/** The wizard's heading for the shared block: this is a setup milestone. */ +const FIRST_LOOK_TITLE = 'First look at what HypAware has recorded' + +/** + * How long the closing look may take before setup gives up on it. + * + * Not an independent number: it is the shared plan's budget + * (`OVERVIEW_TIME_BUDGET_MS`, which both callers aim at and which already + * charges itself for the probe) plus headroom. The gap is what makes this + * a backstop rather than the mechanism - it should only fire when the + * measured plan was *wrong* (a pathological day, a disk that stalls after + * the probe), never in ordinary operation. If it starts firing routinely, + * the fix is the planner's calibration, not a longer deadline. + * + * Setup is where a stall does real damage: it is the last step of an + * install, after every durable action has already succeeded, so a freeze + * reads as "the install broke" when nothing did. `hyp query overview` runs + * the same plan with no deadline, because there the user asked and is + * watching, and no answer is worse than a slow one. + * + * The abandoned queries are not cancellable - they are in-process CPU work + * - but the CLI ends on `process.exit`, which drops them. + */ +const FIRST_LOOK_BUDGET_MS = OVERVIEW_TIME_BUDGET_MS + 3000 + +/** + * Resolve to `null` if `promise` outlives `ms`. The loser keeps running; + * the caller's contract is only that setup stops waiting on it. + * + * @template T + * @param {Promise} promise + * @param {number} ms + * @returns {Promise} + */ +async function withDeadline(promise, ms) { + /** @type {NodeJS.Timeout | undefined} */ + let timer + try { + return await Promise.race([ + promise, + new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms) }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +/** + * Run the first look and write it to stdout. Never throws: a query + * failure (an unreadable cache, a dataset registered but not yet + * materialized) degrades to a skipped step, because setup itself already + * succeeded by the time this runs. + * + * @ref LLP 0135#first-look [implements]: setup ends on the user's own rows, and never fails on them + * + * @param {{ + * runner?: OverviewQueryRunner | undefined, + * stdout: NodeJS.WritableStream | { write(chunk: string): unknown }, + * color?: boolean, + * budgetMs?: number, + * }} args + * @returns {Promise} + */ +export async function runWizardFirstLook({ runner, stdout, color = false, budgetMs = FIRST_LOOK_BUDGET_MS }) { + return withSpan( + 'wizard.first_look', + { + [Attr.COMPONENT]: 'wizard', + [Attr.OPERATION]: 'wizard.first_look', + status: 'ok', + }, + async (span) => { + if (!runner || !runner.hasDataset(OVERVIEW_DATASET)) { + span.setAttribute('status', 'skipped') + span.setAttribute('skip_reason', 'no-dataset') + return { shown: false, reason: /** @type {const} */ ('no-dataset') } + } + + // Sections land in `partial` as they complete, so an expired deadline + // keeps finished work rather than discarding it: three sections done + // and a fourth in flight is a shorter block, not a blank one. + const partial = emptyOverview() + // The whole step is inside one try, not just the queries: rendering + // and writing can fail too (an unforeseen row shape, or EPIPE when + // stdout is a closed pipe), and an escape from *any* of it would + // surface as `hyp: ` and a non-zero exit from an install that + // had already fully succeeded. Nothing here may fail setup. + try { + // Every section, the same block `hyp query overview` prints. The + // run is longer for it (~60 lines against ~35), which the privacy + // narration survives because it is written after this and stays the + // last thing on screen (`@ref LLP 0135#privacy`). + const overview = await withDeadline(collectOverview(runner, { into: partial }), budgetMs) + + const expired = overview === null + const rows = overview ?? partial + if (expired && !hasRenderableOverview(rows)) { + // Nothing usable landed - the probe itself outlasted the budget. + // Say what happened and what to run instead: a silent skip after a + // visible pause reads as something having gone wrong. + span.setAttribute('status', 'skipped') + span.setAttribute('skip_reason', 'slow') + span.setAttribute('budget_ms', budgetMs) + stdout.write( + '\nSkipped the first look: summarizing this much history would hold up setup.\n' + + 'Run `hyp query overview` to see it.\n' + ) + return { shown: false, reason: /** @type {const} */ ('slow') } + } + + span.setAttribute('provider_rows', rows.providerRows.length) + span.setAttribute('day_rows', rows.dailyRows.length) + if (expired) { + span.setAttribute('partial', true) + span.setAttribute('budget_ms', budgetMs) + span.setAttribute('missing_sections', missingSections(rows).join(',')) + } + // `footer: false` because the closing line below is this run's single + // pointer: setup should teach one command, not two dim lines naming + // the same one. + stdout.write(renderOverview({ ...rows, title: FIRST_LOOK_TITLE, color, footer: false })) + if (expired) { + // Name the missing sections as *unfinished*, not as empty. "no + // repos" and "the repos section did not finish" are different + // claims, and only one of them is true - the same never-silent rule + // the block follows for the rows it omits. + const missing = missingSections(rows) + const which = missing.length > 1 + ? `${missing.slice(0, -1).join(', ')} and ${missing[missing.length - 1]} sections` + : `${missing[0]} section` + stdout.write( + missing.length > 0 + ? `\nStopped here to keep setup moving - the ${which} did not finish.\n` + : '\nStopped here to keep setup moving.\n' + ) + } + // The block is re-runnable: name the command that reprints it, so the + // setup teaches one durable entry point instead of a one-off view. + stdout.write(`\nSee this again anytime: hyp query overview (--sql shows the queries)\n`) + return { + shown: true, + providerRows: rows.providerRows.length, + dayRows: rows.dailyRows.length, + ...(expired ? { partial: true } : {}), + } + } catch (err) { + // The block is a diagnostic, not a gate: record the failure kind on + // the span and end quietly rather than printing a stack over a + // successful install. + span.setAttribute('status', 'error') + span.setAttribute(Attr.ERROR_KIND, err instanceof Error ? err.name : 'unknown') + return { shown: false, reason: /** @type {const} */ ('error') } + } + }, + { component: 'wizard' } + ) +} diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index e037a6fb..35f72826 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -20,14 +20,17 @@ import { collectHypAwareStatus } from '../../daemon/status.js' import { formatFirstSyncDeadline, readFirstSyncDeadline } from '../../usage-policy/first_sync_hold.js' import { runPickerFinale, writeWalkthroughRunSummary } from '../walkthrough.js' import { LOGIN_ORG_SELECTION_MESSAGE } from '../remote_commands.js' +import { isTty } from '../stdio.js' import { evaluateReturningGate, runWizardFork } from './fork.js' +import { firstLookNoticeSink, firstLookRunnerFromCtx, runWizardFirstLook } from './first_look.js' import { computeCentralLockedSources, runWizardJoin } from './join.js' import { runWizardPick } from './pick.js' import { runConfigurePhase } from './configure.js' /** * The `hyp init` wizard orchestrator: the fork -> join -> pick -> - * configure -> finale -> privacy state machine (LLP 0135 #orchestration). + * configure -> finale -> first look -> privacy state machine (LLP 0135 + * #orchestration). * * Interactive runs front the phases with the returning gate (LLP 0129 * #returning-gate): a configured solo machine's `Reconfigure` re-enters @@ -165,6 +168,18 @@ export async function runInitWizard(opts) { } writeWalkthroughRunSummary({ stdout: opts.stdout, configPath: picked.configPath, finaleSummary }) + // End an attended setup on the user's own rows, not on a command they + // still have to type. Attended and non-dry-run only: a scripted `--yes` + // install gets no extra output, and a dry run has no writes to look at. + // @ref LLP 0135#first-look [implements]: placed after the finale (backfill has landed) and before the privacy narration, which stays the last words + if (interactive && !cancelled && opts.finale?.dryRun !== true) { + await runWizardFirstLook({ + runner: opts.firstLook ?? firstLookRunnerFromCtx(opts.ctx, firstLookNoticeSink(opts.stderr)), + stdout: opts.stdout, + color: isTty(opts.stdout), + }) + } + // The wizard's last words on the team pathway: when the first upload // happens and that nothing has shipped yet (LLP 0100/0101, narration // only - the hold itself was written by the join lane's login). diff --git a/src/core/cli/wizard/types.d.ts b/src/core/cli/wizard/types.d.ts index c38562ca..dbc4ee67 100644 --- a/src/core/cli/wizard/types.d.ts +++ b/src/core/cli/wizard/types.d.ts @@ -1,5 +1,6 @@ import type { CapabilityRegistry, CommandRunContext, HypAwareV2Config } from '../../../../hypaware-plugin-kernel-types.d.ts' import type { CollectStatusOptions, HypAwareStatusReport } from '../../daemon/types.d.ts' +import type { OverviewQueryRunner } from '../../query/types.d.ts' import type { PickerDescriptor, PluginCatalog } from '../../types.d.ts' import type { AsyncBackfillConsentPrompt, @@ -261,6 +262,16 @@ export interface RunWizardPickOptions { confirmOverwrite?: (targetPath: string) => Promise } +/** + * What the first look did. `shown: false` is a normal outcome, not a + * failure: `no-dataset` when no gateway source was picked, `error` when the + * query itself failed, `slow` when summarizing the cache would have + * outlasted the step's budget (setup had already succeeded in every case). + */ +export type FirstLookResult = + | { shown: true; providerRows: number; dayRows: number; partial?: true } + | { shown: false; reason: 'no-dataset' | 'error' | 'slow' } + /** * Options for `runInitWizard`, the fork -> join -> pick -> configure -> * privacy -> finale orchestrator (LLP 0135 #orchestration). Non-interactive @@ -304,6 +315,11 @@ export interface RunInitWizardOptions { runStatus?: () => Promise /** Pre-built catalog (tests); defaults to the bundled-plugin catalog. */ catalog?: PluginCatalog + /** + * Override the first look's query seam (tests). Defaults to a runner + * built from `ctx`; the step is skipped when neither is available. + */ + firstLook?: OverviewQueryRunner /** Phase overrides (tests). */ gate?: (opts: EvaluateReturningGateOptions) => Promise fork?: (opts: RunWizardForkOptions) => Promise diff --git a/src/core/commands/query.js b/src/core/commands/query.js index 24ab2ac8..26388352 100644 --- a/src/core/commands/query.js +++ b/src/core/commands/query.js @@ -4,9 +4,11 @@ import { Attr, withSpan } from '../observability/index.js' import { migrateLegacyPartitions } from '../cache/migrate.js' import { renderSchema, schemaForDataset } from '../query/schema.js' import { parseCommandArgv } from '../cli/verb_codec.js' +import { isTty } from '../cli/stdio.js' /** * @import { CommandRunContext, VerbInputSchema } from '../../../hypaware-plugin-kernel-types.js' + * @import { OverviewRows } from '../../../src/core/query/types.js' */ // `measureCacheRoot` / `walkCacheRoot` / `loadRetentionDays` moved into @@ -78,6 +80,118 @@ export async function runQueryStatus(_argv, ctx) { return 0 } +/** + * `hyp query overview [--json] [--sql] [--days ]` + * + * Prints the gateway overview: token volume per provider and model, then + * sessions and tokens per day, repos, and tools. The same block the + * `hyp init` wizard ends on, so "see that again" is one command rather than + * four remembered SQL statements. `--sql` prints those statements above + * each table: they are worth learning and hard to guess (token usage lives + * inside the `attributes` JSON), but too long to show unasked. + * + * The window is chosen to fit a row budget and always stated in the + * output; `--days ` pins it instead, whatever it costs, because an + * explicit request outranks the budget. + * + * Local-only, deliberately. The runner is a seam, so pointing it at a + * remote `query_sql` tool is a small change - but against a fleet-sized + * server even a bounded window exceeds the gateway timeout (measured: 504 + * at 60s unbounded, 58s bounded to 7 days, ~10s bounded to 1 day). + * `--remote` waits on a server-side summary rather than shipping a flag + * that times out. + * + * Exits 1 when `ai_gateway_messages` is not registered: the user asked for + * their AI traffic and there is no source that records it, which is a + * result worth a non-zero code (with the fix on stderr), not a silent + * empty table. + * + * @ref LLP 0135#first-look [implements]: the wizard's closing block, re-runnable on demand + * + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runQueryOverview(argv, ctx) { + const { OVERVIEW_DATASET, collectOverview, overviewRunnerFromCtx, renderOverview } = + await import('../query/overview.js') + const json = argv.includes('--json') + const showSql = argv.includes('--sql') + const daysFlag = argv.indexOf('--days') + /** @type {number | undefined} */ + let days + if (daysFlag !== -1) { + days = Number(argv[daysFlag + 1]) + if (!Number.isInteger(days) || days < 1) { + ctx.stderr.write('hyp query overview: --days takes a whole number of days (1 or more)\n') + return 2 + } + } + // Same reporting as `hyp query sql`: a withheld-row count is required + // disclosure (LLP 0105), a freshness line is advisory. Both to stderr + // so stdout stays the block (and stays valid JSON under --json). + const runner = overviewRunnerFromCtx(ctx, (notice) => ctx.stderr.write(notice.line)) + if (!runner || !runner.hasDataset(OVERVIEW_DATASET)) { + // Says what is true, then what to do. The absent thing is a dataset + // name (`ai_gateway_messages`) that means nothing to the person who + // typed the command - naming it explains the tool's internals instead + // of their situation. It also reads as breakage when it is really just + // "no AI client is set up yet". + ctx.stderr.write( + 'hyp query overview: nothing has been recorded yet - no AI client is connected.\n' + + ' Run `hyp init` to start capturing Claude or Codex sessions.\n' + ) + return 1 + } + + return withSpan( + 'query.overview', + { + [Attr.COMPONENT]: 'query', + [Attr.OPERATION]: 'query.overview', + [Attr.DATASET]: OVERVIEW_DATASET, + format: json ? 'json' : 'text', + show_sql: showSql, + ...(days !== undefined ? { days_requested: days } : {}), + status: 'ok', + }, + async (span) => { + /** @type {OverviewRows} */ + let overview + try { + overview = await collectOverview(runner, { ...(days !== undefined ? { days } : {}) }) + } catch (err) { + span.setAttribute('status', 'error') + span.setAttribute(Attr.ERROR_KIND, err instanceof Error ? err.name : 'unknown') + ctx.stderr.write(`hyp query overview: ${err instanceof Error ? err.message : String(err)}\n`) + // The kernel's budget refusal advises a WHERE/date filter or a LIMIT + // - right for hand-written SQL, useless here: this block already has + // a date filter and takes no LIMIT. Name the lever it does have. + // @ref LLP 0056 [constrained-by]: the refusal is the kernel's; the actionable next step is this command's to name + if (err instanceof Error && err.name === 'QueryExecutionBudgetError') { + const shorter = days !== undefined ? Math.max(1, Math.floor(days / 2)) : 7 + ctx.stderr.write(` this block's lever is a shorter window: hyp query overview --days ${shorter}\n`) + } + return 1 + } + span.setAttribute('provider_rows', overview.providerRows.length) + span.setAttribute('day_rows', overview.dailyRows.length) + if (overview.window) { + span.setAttribute('window_days', overview.window.days) + span.setAttribute('window_rows', overview.window.rows) + span.setAttribute('window_narrowed', overview.window.narrowed) + } + if (json) { + ctx.stdout.write(JSON.stringify(overview, null, 2) + '\n') + return 0 + } + ctx.stdout.write(renderOverview({ ...overview, color: isTty(ctx.stdout), showSql })) + return 0 + }, + { component: 'query' } + ) +} + /** * @param {string[]} argv * @param {CommandRunContext} ctx diff --git a/src/core/query/overview.js b/src/core/query/overview.js new file mode 100644 index 00000000..2b1b71ba --- /dev/null +++ b/src/core/query/overview.js @@ -0,0 +1,994 @@ +// @ts-check + +/** + * The gateway overview: a probe that plans an affordable window, then four + * aggregations over `ai_gateway_messages` inside it, rendered as aligned + * tables with proportional bars. One block, two callers - the wizard's + * closing first look (`cli/wizard/first_look.js`) and `hyp query overview` + * (`commands/query.js`) - so what setup shows is exactly what the command + * reproduces later. + * + * `renderOverview` and `chooseOverviewWindow` are pure: rows in, string or + * plan out, no I/O. + * + * @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js' + * @import { OverviewNotice, OverviewRows, OverviewQueryRunner, OverviewWindow } from '../../../src/core/query/types.js' + */ + +import { executeQuerySql } from './sql.js' +import { renderLocalOnlyNotice } from './verb.js' + +/** The dataset both overview queries read. Absent, there is nothing to show. */ +export const OVERVIEW_DATASET = 'ai_gateway_messages' + +/** + * Tokens, the unit the block reports. Not rows: a row is one *part* of a + * message (`part_id = #`, LLP 0026), so a + * `count(*)` headline names a unit nobody outside the schema recognizes, + * and inflates wherever a model answers in several content blocks. Tokens + * are the unit users already think in, and they sum honestly: + * response-level usage rides exactly one carrier row, so a plain `SUM` + * over rows needs no dedup (LLP 0035 #one-carrier). Non-carrier rows hold + * null, hence no `role` filter is needed - and leaving it off keeps + * `sessions` counting every session, not only those with an assistant + * reply. + * + * Cache gets its own column rather than being folded into input. Every + * `input_tokens` is net of cache (LLP 0035 #net-input), so each column + * here sums exactly the field it is named after, and the prompt total is + * `input + cached`. Folding them would both hide where the volume goes + * (on this repo's own history, cache is ~500x net input) and force the + * "input tokens" header to mean something narrower elsewhere in the + * schema. + * + * Each term carries its own `coalesce`: `cache_write_tokens` is Claude-only, + * so an unguarded `read + write` addition is null for every OpenAI row and + * silently drops that provider's cache reads from the sum. + * + * @ref LLP 0035#one-carrier [constrained-by]: a plain SUM over rows is the correct total, no dedup + * @ref LLP 0035#net-input [constrained-by]: input is net of cache, so input + cached is the whole prompt and neither column double-counts + * @ref LLP 0035#null-union [implements]: coalesce every sum and every term, since a provider-absent field nulls the arithmetic instead of zeroing it + */ +const SUM_INPUT = + "coalesce(sum(cast(json_extract(attributes,'$.usage.input_tokens') as bigint)), 0) input_tokens" + +const SUM_CACHED = + "coalesce(sum(coalesce(cast(json_extract(attributes,'$.usage.cache_read_tokens') as bigint), 0)\n" + + " + coalesce(cast(json_extract(attributes,'$.usage.cache_write_tokens') as bigint), 0)), 0) cached_tokens" + +const SUM_OUTPUT = + "coalesce(sum(cast(json_extract(attributes,'$.usage.output_tokens') as bigint)), 0) output_tokens" + +/** + * The window planner's probe: how many rows sit on each day. + * + * Deliberately narrow - one column, no JSON extraction - so planning costs + * a fraction of what it saves. Measured against 48k rows / 158MB: ~0.27s, + * against ~0.50s for a single token section. Partitions are keyed by + * `source`, not `date`, so this cannot be answered from Iceberg metadata; + * asking the data is the cheap option, not the expensive one. + */ +export const OVERVIEW_PROBE_SQL = + 'select date, count(*) n from ai_gateway_messages group by 1 order by 1 desc' + +/** + * Build the four section statements for one window. + * + * Every section carries the same `date >= since` bound, so the block's + * numbers are all one claim about one period rather than four differently + * scoped ones. + * + * @param {string} since inclusive `YYYY-MM-DD` lower bound + * @returns {{ models: string, daily: string, repos: string, tools: string }} + */ +export function buildOverviewSql(since) { + const window = `where date >= '${since}'` + return { + // Which providers and models this machine actually uses, by token volume. + models: + `select provider, model,\n ${SUM_INPUT},\n ${SUM_CACHED},\n ${SUM_OUTPUT}\n` + + `from ai_gateway_messages ${window}\ngroup by 1, 2 order by input_tokens + output_tokens desc`, + + // Sessions and tokens per day, most recent first. + daily: + `select date, count(distinct session_id) sessions,\n ${SUM_INPUT},\n ${SUM_CACHED},\n ${SUM_OUTPUT}\n` + + `from ai_gateway_messages ${window}\ngroup by 1 order by 1 desc limit 14`, + + // Where the work happens. Grouped by repo alone, not repo + branch: + // `git_branch` is set on 15 of 431 sessions on the authoring machine + // (~3%), so a branch column would be almost entirely blank, and grouping + // by it splits one repo across a "(no branch)" row and a named one - the + // same repo, twice, looking like two places. Sessions with no repo are + // folded into a count line by the renderer rather than filtered here, so + // the total stays reconcilable. + repos: + `select repo_root, count(distinct session_id) sessions,\n ${SUM_INPUT},\n ${SUM_CACHED},\n ${SUM_OUTPUT}\n` + + `from ai_gateway_messages ${window}\ngroup by 1 order by input_tokens + output_tokens desc limit 20`, + + // Which tools the models actually reach for. The part type is + // `tool_call`, not `tool_use`: the projector normalizes every provider's + // call shape onto one vocabulary (`text`/`reasoning`/`tool_call`/ + // `tool_result`/`image`/`fallback`), so the provider's own wire name for + // a call matches nothing here and returns a silent empty result. + tools: + 'select tool_name, count(*) calls, count(distinct session_id) sessions\n' + + `from ai_gateway_messages ${window}\n and part_type = 'tool_call' and tool_name is not null\n` + + 'group by 1 order by calls desc limit 10', + } +} + +/** Input's shade in a token bar; distinct from output without colour. */ +const BAR_INPUT_CELL = '▒' + +/** Output's shade: solid, since it is the scarce half worth reading first. */ +const BAR_OUTPUT_CELL = '█' + +/** + * What the columns mean. Each header now names the field it sums, so the + * legend only has to explain the split itself: which half of the prompt + * went through cache. + */ +const UNIT_LEGEND = + 'Input is prompt sent fresh; cached is prompt served from (or written to) the cache.\n' + + 'Output is what the model generated. Input + cached is the whole prompt.' + +/** + * The sections, in display order. Both callers render all four; + * `collectOverview` still takes a subset so a shorter variant stays one + * argument away. + * + * @type {readonly ('models'|'daily'|'repos'|'tools')[]} + */ +export const OVERVIEW_SECTIONS = /** @type {const} */ (['models', 'daily', 'repos', 'tools']) + +/** + * How many rows the four sections may scan. + * + * Calibrated from measurement, not taste: 48k rows costs ~2.0s of query + * work across the four sections, so 150k lands near 6s - inside the + * wizard's 8s budget with room for the probe, and short of the ~200k-row + * scale where LLP 0057 measured queries approaching the LLP 0056 heap + * ceiling. Beyond it the window narrows rather than the block disappearing. + * + * @ref LLP 0056 [constrained-by]: stay well inside the per-query heap budget rather than relying on its refusal + * @ref LLP 0135#window [implements]: the block picks a period it can afford instead of scanning without bound + */ +export const OVERVIEW_ROW_TARGET = 150_000 + +/** + * How long the whole block should take: probe plus sections. + * + * One number for both callers. The wizard and `hyp query overview` plan + * identically - same budget, same measurement, same window - because the + * question "how much history can this machine summarize quickly?" has one + * answer regardless of who asked. They differ only in what happens when + * the plan turns out wrong: setup abandons (the block is a bonus at the end + * of an install), the command runs on (you asked for it, and no answer is + * worse than a slow one). + * + * The budget covers the probe because the probe is part of the wait. A + * budget that only counted the sections would let a slow probe eat the + * user's patience before planning had noticed it was spending anything. + */ +export const OVERVIEW_TIME_BUDGET_MS = 5000 + +/** + * What the planner keeps for the sections even when the probe overran the + * whole budget. Small on purpose: it buys the newest day or two rather + * than nothing, which is the same "always show something" rule the day + * walk follows. + */ +const MIN_SECTION_BUDGET_MS = 400 + +/** + * What one section's scan costs relative to the probe's, per row. + * + * The probe reads one column; a section reads `attributes` and runs + * `json_extract`/`cast` over it. Measured at 48k rows: ~0.27s against + * ~0.50s, so a section is ~1.9x the probe. Deliberately a ratio rather + * than an absolute rate - the ratio is a property of the queries, while + * the rate is a property of the machine, and the machine's half is + * measured fresh on every run. + */ +const SECTION_COST_VS_PROBE = 1.9 + +/** + * How many rows the sections can scan inside the time budget, inferred + * from what the probe just cost on this machine. + * + * Without a probe timing there is nothing to infer from, so the caller + * falls back to the row cap alone (`Infinity` here defers to it). + * + * @param {{ budgetMs?: number, probeMs?: number, totalRows: number }} args + * @returns {number} + */ +function rowsAffordable({ budgetMs = OVERVIEW_TIME_BUDGET_MS, probeMs, totalRows }) { + if (probeMs === undefined || totalRows <= 0) return Infinity + // A probe too fast to time is not evidence of infinite speed; floor it at + // 1ms so the estimate stays finite and the row cap keeps its say. + const perRowMs = Math.max(probeMs, 1) / totalRows + const sectionCount = OVERVIEW_SECTIONS.length + return Math.floor(budgetMs / (perRowMs * SECTION_COST_VS_PROBE * sectionCount)) +} + +/** Rows shown per section before the remainder is folded into a count line. */ +const MAX_PROVIDER_ROWS = 8 + +/** Repos shown before the tail is folded into a count line. */ +const MAX_REPO_ROWS = 8 + +/** Bar column width, in cells. */ +const BAR_WIDTH = 18 + +/** Model names longer than this are ellipsized so the columns stay aligned. */ +const MAX_MODEL_WIDTH = 30 + +const ANSI = { + bold: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', + reset: '\x1b[0m', +} + + +/** + * @param {string} text + * @param {string} sgr + * @param {boolean} on + */ +function paint(text, sgr, on) { + return on ? `${sgr}${text}${ANSI.reset}` : text +} + +/** + * Build the query runner from a command context. Runs the same executor, + * refresh mode and caller cwd `hyp query sql` uses, so the overview shows + * exactly what the user would see typing either query themselves - and + * reports the same two things `hyp query sql` reports alongside the rows. + * + * `onNotice` receives those reports, tagged so each caller can decide: + * + * - `local-only`: rows were withheld because their directory's usage + * class outranks the caller's (LLP 0105). This one is a rule, not a + * nicety - withholding may never be silent, or a total silently means + * something narrower than it claims. Rendered by the verb's own + * `renderLocalOnlyNotice` so the two surfaces cannot word it + * differently. + * - `freshness`: a partition had unflushed rows and the flush debounce + * suppressed the flush, so the answer trails live capture by under two + * minutes. + * + * Each distinct line is emitted once per runner, not once per section: + * five statements over the same partitions would otherwise repeat the same + * sentence five times. + * + * @ref LLP 0105 [implements]: the overview inherits both halves - the filter and the disclosure that it filtered + * @ref LLP 0135#disclosure [implements]: which report each caller passes on, and why they differ + * + * @param {CommandRunContext} ctx + * @param {(notice: OverviewNotice) => void} [onNotice] + * @returns {OverviewQueryRunner | undefined} + */ +export function overviewRunnerFromCtx(ctx, onNotice) { + const registry = /** @type {any} */ (ctx)?.query + if (!registry || typeof registry.getDataset !== 'function') return undefined + /** @type {Set} */ + const said = new Set() + /** @param {OverviewNotice} notice */ + const say = (notice) => { + if (!onNotice || said.has(notice.line)) return + said.add(notice.line) + onNotice(notice) + } + return { + hasDataset(name) { + try { + return Boolean(registry.getDataset(name)) + } catch { + return false + } + }, + async run(sql) { + const result = await executeQuerySql({ + query: sql, + registry, + storage: /** @type {any} */ (ctx.storage), + refresh: 'auto', + config: ctx.config, + callerCwd: typeof ctx.cwd === 'string' && ctx.cwd.length > 0 ? ctx.cwd : null, + }) + for (const line of result.freshnessMessages ?? []) { + say({ kind: 'freshness', line: `${line}\n` }) + } + const withheld = renderLocalOnlyNotice(result.localOnly) + if (withheld) say({ kind: 'local-only', line: withheld }) + return { columns: result.columns ?? [], rows: result.rows ?? [] } + }, + } +} + +/** + * Choose the widest window this machine can summarize in time, walking days + * newest-first. + * + * Two caps, for two different failure modes: + * + * - **Time**, measured rather than assumed. The probe just scanned every + * row; how long *that* took on *this* machine, right now, is the only + * honest basis for predicting the sections. A row-count target alone + * bakes in the author's laptop - a slower disk, a weaker CPU or a + * machine under load would get the same window and take proportionally + * longer, which is exactly the "huge logs, long wait" case the window + * exists to prevent. `probeMs` turns the plan into an observation, and + * is also deducted from the budget, since the probe was part of the + * wait. + * - **Rows**, as a memory backstop. Time says nothing about heap, and a + * fast machine could otherwise pick a window big enough to approach the + * LLP 0056 execution ceiling. The tighter of the two wins. + * + * The newest day is always included, even alone, even when it exceeds both: + * a block covering one busy day is a real answer, where no block at all is + * not. That is the whole point of narrowing rather than skipping - the + * reader always gets numbers, and always gets told which period they + * describe. + * + * @param {Record[]} probeRows `{ date, n }`, any order + * @param {{ + * targetRows?: number, + * days?: number, + * budgetMs?: number, + * probeMs?: number, + * }} [opts] `days` pins an explicit window (the user asked for it) and + * skips both caps; `probeMs` is how long the probe took over every row, + * which calibrates the time cap to this machine + * @returns {OverviewWindow | null} null when nothing has been recorded + * @ref LLP 0135#window [implements]: the affordable-window plan, measured on the machine it runs on + */ +export function chooseOverviewWindow(probeRows, opts = {}) { + const days = [...probeRows] + .map((r) => ({ date: cell(r.date), rows: toNumber(r.n) })) + .filter((d) => d.date !== '(none)') + .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)) + if (days.length === 0) return null + + const totalRows = days.reduce((n, d) => n + d.rows, 0) + const rowCap = opts.targetRows ?? OVERVIEW_ROW_TARGET + // The probe has already been paid for out of the same budget, so plan the + // sections against what is left. On a slow machine this is what turns a + // blown deadline into a shorter window: the probe reporting that it cost + // 4 of the 5 seconds leaves the planner one second to spend, and it picks + // accordingly instead of budgeting as if the clock had not started. + const budgetMs = opts.budgetMs ?? OVERVIEW_TIME_BUDGET_MS + const sectionBudgetMs = Math.max(MIN_SECTION_BUDGET_MS, budgetMs - (opts.probeMs ?? 0)) + const timeCap = rowsAffordable({ + budgetMs: sectionBudgetMs, + ...(opts.probeMs !== undefined ? { probeMs: opts.probeMs } : {}), + totalRows, + }) + const cap = Math.min(rowCap, timeCap) + /** @type {OverviewWindow['boundBy']} */ + const boundBy = opts.days !== undefined ? 'requested' : timeCap < rowCap ? 'time' : 'rows' + + let included = 0 + let rows = 0 + for (const day of days) { + // `opts.days` is an explicit request: honor it whatever it costs. + if (opts.days !== undefined) { + if (included >= opts.days) break + } else if (included > 0 && rows + day.rows > cap) { + break + } + rows += day.rows + included += 1 + } + + return { + since: days[included - 1].date, + until: days[0].date, + days: included, + rows, + boundBy, + totalDays: days.length, + totalRows, + narrowed: included < days.length, + } +} + +/** + * An empty result, for a caller that wants to watch one fill in. + * + * @returns {OverviewRows} + */ +export function emptyOverview() { + return { providerRows: [], dailyRows: [], repoRows: [], toolRows: [] } +} + +/** + * Whether a (possibly partial) result has enough to render: a window to + * state, and at least the headline section behind it. + * + * @param {OverviewRows} rows + * @returns {boolean} + */ +export function hasRenderableOverview(rows) { + return rows.window !== undefined && rows.providerRows.length > 0 +} + +/** + * Which of the requested sections have landed. Lets a caller that stopped + * early say what is missing rather than presenting a short block as whole. + * + * @param {OverviewRows} rows + * @returns {('models'|'daily'|'repos'|'tools')[]} + */ +export function missingSections(rows) { + /** @type {Record[]>} */ + const byName = { + models: rows.providerRows, + daily: rows.dailyRows, + repos: rows.repoRows, + tools: rows.toolRows, + } + return OVERVIEW_SECTIONS.filter((s) => byName[s].length === 0) +} + +/** + * Probe the cache, choose a window that fits, then run the requested + * sections bounded by it. Only the asked-for sections are executed. + * + * The probe is what lets the block always render *something*: rather than + * running four unbounded aggregations and hoping, it learns the per-day + * row counts first (cheaply) and scopes the real work to what a reader can + * wait for. The chosen window rides back on the result so the renderer can + * state it - a number whose period is unstated is not an answer. + * + * `into` lets a caller watch the work accumulate: each section is written + * as it lands, so a caller that stops waiting (the wizard's deadline) can + * still render what completed instead of discarding it. Without it the + * partial work of an abandoned run would be thrown away - three finished + * sections and a fourth in flight would show as nothing. + * + * @param {OverviewQueryRunner} runner + * @param {{ + * sections?: readonly ('models'|'daily'|'repos'|'tools')[], + * targetRows?: number, + * days?: number, + * budgetMs?: number, + * clock?: () => number, + * into?: OverviewRows, + * }} [opts] + * @returns {Promise} + */ +export async function collectOverview(runner, opts = {}) { + const sections = opts.sections ?? OVERVIEW_SECTIONS + const clock = opts.clock ?? Date.now + const out = opts.into ?? emptyOverview() + + // Timing the probe is what makes the plan an observation of this machine + // rather than an assumption about it: the probe reads every row, so its + // elapsed time is a fresh per-row rate for the hardware, disk and load + // the sections are about to meet. + const probeStart = clock() + const probe = await runner.run(OVERVIEW_PROBE_SQL) + const probeMs = Math.max(0, clock() - probeStart) + const window = chooseOverviewWindow(probe.rows, { + probeMs, + ...(opts.targetRows !== undefined ? { targetRows: opts.targetRows } : {}), + ...(opts.budgetMs !== undefined ? { budgetMs: opts.budgetMs } : {}), + ...(opts.days !== undefined ? { days: opts.days } : {}), + }) + // Nothing recorded: no window to state, and no section worth running. + if (!window) return out + out.window = window + + const sql = buildOverviewSql(window.since) + out.sql = sql + for (const section of OVERVIEW_SECTIONS) { + if (!sections.includes(section)) continue + const { rows } = await runner.run(sql[section]) + if (section === 'models') out.providerRows = rows + else if (section === 'daily') out.dailyRows = rows + else if (section === 'repos') out.repoRows = rows + else out.toolRows = rows + } + return out +} + +/** + * Render the whole block, including its leading blank line. With no rows + * at all, renders the empty state: what has to happen before there is + * anything to show. + * + * `showSql` prints the statement behind each section. It is off by default + * and pointed at by a one-line footer: the token queries are four lines of + * `json_extract`/`cast` apiece, so printing them always would bury the + * numbers the block exists to show - but they are also exactly the + * incantation a user cannot guess, so they stay one flag away. + * + * `footer` is opt-out so a caller that closes with its own pointer line + * (the wizard names `hyp query overview` itself) does not print two dim + * lines naming the same command back to back. + * + * The window is always stated, directly under the title: every number in + * the block is "per this period", and a total whose period is unstated is + * not an answer. When the window was narrowed to stay affordable, the same + * line says so and how to widen it, so a smaller number is never mistaken + * for less work. + * + * @param {{ + * providerRows: Record[], + * dailyRows: Record[], + * repoRows?: Record[], + * toolRows?: Record[], + * window?: OverviewWindow | undefined, + * sql?: { models: string, daily: string, repos: string, tools: string } | undefined, + * title?: string, + * color?: boolean, + * showSql?: boolean, + * footer?: boolean, + * }} args + * @returns {string} + */ +export function renderOverview({ + providerRows, + dailyRows, + repoRows = [], + toolRows = [], + window: win, + sql, + title = 'What HypAware has recorded', + color = false, + showSql = false, + footer = true, +}) { + const statements = sql ?? buildOverviewSql(win?.since ?? '') + let out = `\n${paint(title, ANSI.bold, color)}\n` + if (win) out += paint(describeWindow(win), ANSI.dim, color) + '\n' + out += `${paint('─'.repeat(40), ANSI.dim, color)}\n` + + if (providerRows.length === 0) { + out += '\nNothing recorded yet. Start a session in a client you attached,\n' + out += 'then run `hyp query overview` again.\n' + return out + } + + // Painted per line: one SGR pair spanning a newline leaves the dim + // attribute set across the break on some terminals. + out += UNIT_LEGEND.split('\n').map((line) => paint(line, ANSI.dim, color)).join('\n') + '\n' + out += barKeyLine(color) + '\n' + out += '\n' + renderProviderMix(providerRows, color, showSql, statements.models) + if (dailyRows.length > 0) out += '\n' + renderDailyActivity(dailyRows, color, showSql, statements.daily) + if (repoRows.length > 0) out += '\n' + renderRepoMix(repoRows, color, showSql, statements.repos) + if (toolRows.length > 0) out += '\n' + renderToolMix(toolRows, color, showSql, statements.tools) + if (!showSql && footer) out += paint('\nThe SQL behind these: hyp query overview --sql\n', ANSI.dim, color) + return out +} + +/** + * The window line: the period every number below describes, plus - when + * the window was capped - what was left out and how to ask for it. + * + * @param {OverviewWindow} win + * @returns {string} + */ +export function describeWindow(win) { + const span = `${win.since} to ${win.until}` + // "active days", not "days": the bounds are calendar dates but the count + // is dates that recorded something, and a quiet weekend inside the range + // would otherwise make the two look contradictory. + const unit = `active day${win.days === 1 ? '' : 's'}` + if (!win.narrowed) return `${span} (${win.days} ${unit}, ${formatCount(win.rows)} rows)` + // Narrowed means totalDays > days >= 1, so the count it agrees with is + // always plural - "1 of 30 active day" reads as a typo. + // States what is shown and how to see more - not why it is short. The + // reason (a row budget, a slow machine, an explicit --days) is the tool's + // business; the reader only needs the scope and the lever. One wording + // for every reason, so a window the user asked for does not arrive with + // an apology attached. + return ( + `${span} - showing ${win.days} of ${win.totalDays} active days ` + + `(${formatCount(win.rows)} of ${formatCount(win.totalRows)} rows); widen with --days ${win.totalDays}` + ) +} + +/** + * Providers and models by token volume, largest first. + * + * @param {Record[]} rows + * @param {boolean} color + * @param {boolean} [showSql] + * @param {string} [sql] the statement this section ran, for `--sql` + * @returns {string} + */ +export function renderProviderMix(rows, color, showSql = false, sql = '') { + // A zero-token row would render as "0 0 0", which reads like a bug, so + // the table shows only measured rows. What the omitted ones are decides + // whether their absence is worth a word: + // + // - No model label: prompts, tool results, and every other row a model + // never answered. These CANNOT carry usage - a response's tokens are + // stamped on the response (LLP 0035 #one-carrier), so the prompt's + // cost is already in the answering model's `input`/`cached`. Counting + // them as "models without token counts" (14.5k prompts read as "2 + // models") described the reader's own messages as something else. + // Omitted silently: nothing is missing to report. + // - Labelled, but zero: a real model whose provider reported no usage. + // That IS a gap in what was recorded, so it is counted out loud. + const counted = rows.filter((r) => tokenTotal(r) > 0) + const untokened = rows.filter((r) => tokenTotal(r) === 0 && hasModelLabel(r)).length + const shown = counted.slice(0, MAX_PROVIDER_ROWS) + const max = Math.max(...shown.map((r) => toNumber(r.input_tokens) + toNumber(r.output_tokens))) + const body = shown.map((r) => [ + cell(r.provider), + // An unlabelled row only reaches the table if it carried tokens, which + // today it never does. If that changes, name it for what it is rather + // than dropping measured tokens on the floor. + truncate(hasModelLabel(r) ? String(r.model).trim() : '(model not recorded)', MAX_MODEL_WIDTH), + formatCount(r.input_tokens), + formatCount(r.cached_tokens), + formatCount(r.output_tokens), + tokenBar(toNumber(r.input_tokens), toNumber(r.output_tokens), max, color), + ]) + + let out = renderHeading('Which providers and models you use, by volume', sql, color, showSql) + if (shown.length === 0) { + // Traffic exists but no provider reported usage: say that, rather than + // printing an empty table under a "by volume" heading. + if (untokened === 0) return out + ' No token counts were recorded.\n' + return out + ` ${untokened} model${untokened === 1 ? ' was' : 's were'} recorded, none with token counts.\n` + } + out += renderTable( + ['provider', 'model', 'input', 'cached', 'output', 'by input+output'], + body, + ['left', 'left', 'right', 'right', 'right', 'left'], + color, + tokenBarCaption(color) + ) + const hidden = counted.length - shown.length + if (hidden > 0) out += paint(` + ${hidden} more model${hidden === 1 ? '' : 's'}\n`, ANSI.dim, color) + if (untokened > 0) { + out += paint( + ` + ${untokened} model${untokened === 1 ? '' : 's'} whose traffic was recorded without token counts\n`, + ANSI.dim, + color + ) + } + return out +} + +/** + * Sessions and tokens per day, most recent first. + * + * @param {Record[]} rows + * @param {boolean} color + * @param {boolean} [showSql] + * @returns {string} + */ +export function renderDailyActivity(rows, color, showSql = false, sql = '') { + const max = Math.max(...rows.map((r) => toNumber(r.input_tokens) + toNumber(r.output_tokens))) + const body = rows.map((r) => [ + cell(r.date), + formatCount(r.sessions), + formatCount(r.input_tokens), + formatCount(r.cached_tokens), + formatCount(r.output_tokens), + tokenBar(toNumber(r.input_tokens), toNumber(r.output_tokens), max, color), + ]) + + const out = renderHeading('Sessions and tokens per day', sql, color, showSql) + return out + renderTable( + ['day', 'sessions', 'input', 'cached', 'output', 'by input+output'], + body, + ['left', 'right', 'right', 'right', 'right', 'left'], + color, + tokenBarCaption(color) + ) +} + +/** + * Which repos the sessions ran in, busiest first. + * + * Sessions with no repo are folded into a count line rather than dropped + * or rendered as a blank-named row: on the authoring machine that is 177 + * of 431 sessions, so hiding them silently would misrepresent the split, + * and a nameless row would just look broken. + * + * The line says "no repo recorded", not "outside any repo", because those + * are not the same claim and this column cannot tell them apart: Codex + * rows carry `cwd` (and sometimes `git_branch`) but never `repo_root`, so + * every Codex session lands here regardless of where it actually ran. + * Saying they were outside a repo would state something false about them. + * + * @param {Record[]} rows + * @param {boolean} color + * @param {boolean} [showSql] + * @returns {string} + */ +export function renderRepoMix(rows, color, showSql = false, sql = '') { + const named = rows.filter((r) => typeof r.repo_root === 'string' && r.repo_root.trim().length > 0) + const loose = rows.filter((r) => !named.includes(r)).reduce((n, r) => n + toNumber(r.sessions), 0) + const shown = named.slice(0, MAX_REPO_ROWS) + + let out = renderHeading('Which repos the work happens in', sql, color, showSql) + if (shown.length === 0) { + return out + ` No repo was recorded on any session (${formatCount(loose)} of them).\n` + } + + // Scaled on the same metric the bar charts, so a row's width means the + // same thing here as in the models and daily tables. + const max = Math.max(...shown.map((r) => toNumber(r.input_tokens) + toNumber(r.output_tokens))) + const body = shown.map((r) => [ + shortRepo(String(r.repo_root)), + formatCount(r.sessions), + formatCount(r.input_tokens), + formatCount(r.cached_tokens), + formatCount(r.output_tokens), + tokenBar(toNumber(r.input_tokens), toNumber(r.output_tokens), max, color), + ]) + out += renderTable( + ['repo', 'sessions', 'input', 'cached', 'output', 'by input+output'], + body, + ['left', 'right', 'right', 'right', 'right', 'left'], + color, + tokenBarCaption(color) + ) + + const hidden = named.length - shown.length + if (hidden > 0) out += paint(` + ${hidden} more repo${hidden === 1 ? '' : 's'}\n`, ANSI.dim, color) + if (loose > 0) { + out += paint(` + ${formatCount(loose)} session${loose === 1 ? '' : 's'} with no repo recorded\n`, ANSI.dim, color) + } + return out +} + +/** + * Which tools the models reach for, most-called first. + * + * @param {Record[]} rows + * @param {boolean} color + * @param {boolean} [showSql] + * @returns {string} + */ +export function renderToolMix(rows, color, showSql = false, sql = '') { + const max = Math.max(...rows.map((r) => toNumber(r.calls))) + const body = rows.map((r) => [ + truncate(cell(r.tool_name), MAX_MODEL_WIDTH), + formatCount(r.calls), + formatCount(r.sessions), + paint(bar(toNumber(r.calls), max), ANSI.cyan, color), + ]) + + const out = renderHeading('Which tools get called', sql, color, showSql) + return out + renderTable(['tool', 'calls', 'sessions', 'by calls'], body, ['left', 'right', 'right', 'left'], color) +} + +/** + * The bar key: each glyph and its word share a colour, so the mapping is + * legible without reading the sentence. Assembled per segment rather than + * dimmed as one run - a colour sequence inside a dim run ends it early. + * + * @param {boolean} color + * @returns {string} + */ +function barKeyLine(color) { + const dim = (/** @type {string} */ t) => paint(t, ANSI.dim, color) + return ( + dim('Token bars split ') + + paint(`${BAR_INPUT_CELL} input`, ANSI.magenta, color) + + dim(' from ') + + paint(`${BAR_OUTPUT_CELL} output`, ANSI.cyan, color) + + dim('; cache is excluded.') + ) +} + +/** + * "by input+output" with each word in its bar shade, so the header itself + * is the key: no legend lookup to learn which half of a bar is which. + * + * @param {boolean} color + * @returns {string} + */ +function tokenBarCaption(color) { + return ( + paint('by ', ANSI.dim, color) + + paint('input', ANSI.magenta, color) + + paint('+', ANSI.dim, color) + + paint('output', ANSI.cyan, color) + ) +} + +/** + * A repo path shortened to its last two segments. Absolute paths are wide + * enough to force the numeric columns off screen, and the tail is what + * distinguishes them anyway (`hypaware-2/hypaware` from + * `hypaware-3/hypaware`), which a basename alone would collapse. + * + * @param {string} repoRoot + * @returns {string} + */ +function shortRepo(repoRoot) { + const parts = repoRoot.split('/').filter(Boolean) + return parts.length <= 2 ? repoRoot : parts.slice(-2).join('/') +} + +/** + * A section title, optionally over the exact command that produced it. + * The statement is indented as one runnable `hyp query sql "..."`: a + * shell keeps embedded newlines inside the quotes, so the printed form + * pastes and runs unchanged. + * + * @param {string} title + * @param {string} sql + * @param {boolean} color + * @param {boolean} showSql + * @returns {string} + */ +function renderHeading(title, sql, color, showSql) { + if (!showSql) return `${title}\n\n` + const indented = sql.split('\n').map((line, i) => (i === 0 ? line : ` ${line}`)).join('\n') + return `${title}\n${paint(` hyp query sql "${indented}"`, ANSI.dim, color)}\n\n` +} + +/** + * Pad `body` into aligned columns under a dim header row. The last column + * is never padded, so a trailing bar column adds no trailing spaces. + * + * `caption` replaces the last header cell with a pre-painted string, so a + * token table can colour the words "input" and "output" to match the shades + * in the bars underneath. Header cells are painted one at a time rather + * than as one dim run: a colour sequence inside a dim run ends it early, + * leaving the rest of the row undimmed. + * + * @param {string[]} headers + * @param {string[][]} body + * @param {('left'|'right')[]} aligns + * @param {boolean} color + * @param {string} [caption] pre-painted replacement for the last header + * @returns {string} + */ +function renderTable(headers, body, aligns, color, caption) { + const widths = headers.map((h, i) => Math.max(h.length, ...body.map((r) => (r[i] ?? '').length))) + const last = headers.length - 1 + const headerLine = headers + .map((h, i) => (i === last + ? caption ?? paint(h, ANSI.dim, color) + : paint(pad(h, widths[i], aligns[i]), ANSI.dim, color))) + .join(' ') + let out = ` ${headerLine}\n` + // Bars arrive already painted: a token bar carries two colours, so the + // table cannot assume one. + for (const row of body) { + const padded = row + .map((c, i) => (i === row.length - 1 ? c : pad(c ?? '', widths[i], aligns[i]))) + .join(' ') + .trimEnd() + out += ` ${padded}\n` + } + return out +} + +/** + * @param {string} text + * @param {number} width + * @param {'left'|'right'} align + */ +function pad(text, width, align) { + const fill = ' '.repeat(Math.max(0, width - text.length)) + return align === 'right' ? fill + text : text + fill +} + +/** + * A two-tone bar: input tokens then output tokens, scaled against the + * largest `input + output` in the table. + * + * Cache is excluded on purpose. Cached runs 99.0-99.9% of every day's total + * on real data, so a total-token bar is a cache-read chart: one long + * conversation re-reading a big prompt outranks a day with 37 sessions, + * and every other bar flattens against it. Cache is also the cheapest + * token there is, so a total would weight the least significant volume + * most heavily. Input + output is the tokens that were actually new. + * + * The split is encoded twice - shade *and* colour - so it survives a + * monochrome terminal, a pipe (`color: false`), and colour-blind readers. + * + * @param {number} input + * @param {number} output + * @param {number} max largest `input + output` in the same table + * @param {boolean} color + * @returns {string} + */ +function tokenBar(input, output, max, color) { + const total = Math.max(0, input) + Math.max(0, output) + if (!(total > 0) || !(max > 0)) return '' + const width = Math.min(BAR_WIDTH, Math.max(1, Math.round((total / max) * BAR_WIDTH))) + let inputCells = Math.round((input / total) * width) + // Never let a present component vanish, and never let it eat the whole + // bar: a row that is 99% output should still show that it had input. + if (input > 0 && inputCells === 0) inputCells = 1 + if (output > 0 && inputCells === width) inputCells = width - 1 + return ( + paint(BAR_INPUT_CELL.repeat(inputCells), ANSI.magenta, color) + + paint(BAR_OUTPUT_CELL.repeat(width - inputCells), ANSI.cyan, color) + ) +} + +/** + * @param {number} value + * @param {number} max + * @returns {string} + */ +function bar(value, max) { + if (!(value > 0) || !(max > 0)) return '' + return '█'.repeat(Math.min(BAR_WIDTH, Math.max(1, Math.round((value / max) * BAR_WIDTH)))) +} + +/** + * Group-by results carry empty strings for parts a provider did not label + * (an unnamed model, an undated row), so render the absence explicitly + * rather than emitting a blank column the reader has to interpret. + * + * @param {unknown} value + * @returns {string} + */ +function cell(value) { + if (value === null || value === undefined) return '(none)' + const text = String(value).trim() + return text.length === 0 ? '(none)' : text +} + +/** + * @param {string} text + * @param {number} width + */ +function truncate(text, width) { + return text.length <= width ? text : `${text.slice(0, width - 1)}…` +} + +/** + * True when the group names a model. A group without one is not a model + * at all: it is the rows no model answered (prompts, tool results), which + * carry no usage by construction. + * + * @param {Record} row + * @returns {boolean} + */ +function hasModelLabel(row) { + return row.model !== null && row.model !== undefined && String(row.model).trim().length > 0 +} + +/** + * Every token a row accounts for. Used only to tell a measured row from + * one whose provider reported no usage at all. + * + * @param {Record} row + * @returns {number} + */ +function tokenTotal(row) { + return toNumber(row.input_tokens) + toNumber(row.cached_tokens) + toNumber(row.output_tokens) +} + +/** + * @param {unknown} value + * @returns {number} + */ +function toNumber(value) { + const n = Number(value) + return Number.isFinite(n) ? n : 0 +} + +/** + * Thousands separators without `toLocaleString`, whose grouping depends on + * the host locale and would make the rendered block non-deterministic. + * + * @param {unknown} value + * @returns {string} + */ +export function formatCount(value) { + // `Number(null)` is 0, so an absent count would otherwise read as a real + // zero; route every empty value through the same `(none)` rendering. + if (value === null || value === undefined || value === '') return cell(value) + const n = Number(value) + if (!Number.isFinite(n)) return cell(value) + return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} diff --git a/src/core/query/types.d.ts b/src/core/query/types.d.ts index 403c24ed..6c4bcabe 100644 --- a/src/core/query/types.d.ts +++ b/src/core/query/types.d.ts @@ -98,3 +98,65 @@ export interface ExecuteSqlResult { freshnessMessages: string[] localOnly: LocalOnlyVisibilityReport } + +/** + * The read seam the gateway overview runs through (LLP 0135 #first-look). + * Production wiring is `overviewRunnerFromCtx`, which routes to + * `executeQuerySql`; tests inject fixed rows. + */ +export interface OverviewQueryRunner { + /** False when no plugin registered the dataset, so there is nothing to show. */ + hasDataset(name: string): boolean + run(sql: string): Promise<{ columns: string[]; rows: Record[] }> +} + +/** + * The period the overview's numbers describe, chosen by walking days + * newest-first until the scan would exceed the row target. Always stated + * in the rendered block: a total whose period is unstated is not an answer. + */ +export interface OverviewWindow { + /** Inclusive `YYYY-MM-DD` bounds of the window actually queried. */ + since: string + until: string + /** Days included, and rows they hold. */ + days: number + rows: number + /** Days and rows available in the cache, whether or not included. */ + totalDays: number + totalRows: number + /** True when the cache holds more than the window covers. */ + narrowed: boolean + /** + * Which cap decided the window: `time` (the measured per-row rate from + * the probe), `rows` (the memory backstop), or `requested` (`--days`). + * Telemetry only - the rendered line reports the scope and the lever, + * never the reason, because the reason is the tool's business. + */ + boundBy: 'time' | 'rows' | 'requested' +} + +/** + * The overview's result sets, in display order. A section not requested + * from `collectOverview` comes back empty, which renders as absent rather + * than as an empty table. `window` and `sql` are absent only when nothing + * has been recorded at all. + */ +export interface OverviewRows { + providerRows: Record[] + dailyRows: Record[] + repoRows: Record[] + toolRows: Record[] + window?: OverviewWindow + sql?: { models: string; daily: string; repos: string; tools: string } +} + +/** + * Something the overview must say alongside its numbers, tagged by kind so + * each caller can route it. `local-only` is required disclosure (LLP 0105); + * `freshness` is advisory. `line` is preformatted and newline-terminated. + */ +export interface OverviewNotice { + kind: 'local-only' | 'freshness' + line: string +} diff --git a/test/core/cli/wizard/first-look.test.js b/test/core/cli/wizard/first-look.test.js new file mode 100644 index 00000000..795fa30b --- /dev/null +++ b/test/core/cli/wizard/first-look.test.js @@ -0,0 +1,228 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { firstLookNoticeSink, runWizardFirstLook } from '../../../../src/core/cli/wizard/first_look.js' +import { OVERVIEW_PROBE_SQL, buildOverviewSql } from '../../../../src/core/query/overview.js' + +// The wizard's half of the first look (LLP 0135 #first-look): when the step +// runs, and that it can never fail a finished install. The block's layout +// is covered by test/core/query-overview.test.js. +// @ref LLP 0135#first-look [tests]: + +function makeBuf() { + let value = '' + return { + /** @param {string} chunk */ + write(chunk) { value += String(chunk); return true }, + text() { return value }, + } +} + +const PROVIDER_ROWS = [ + { provider: 'anthropic', model: 'claude-opus-5', input_tokens: 200, cached_tokens: 4000, output_tokens: 42 }, +] +const DAILY_ROWS = [ + { date: '2026-07-24', sessions: 3, input_tokens: 200, cached_tokens: 4000, output_tokens: 42 }, +] +const REPO_ROWS = [{ repo_root: '/w/acme/api', sessions: 3, input_tokens: 200, cached_tokens: 4000, output_tokens: 42 }] +const TOOL_ROWS = [{ tool_name: 'Bash', calls: 9, sessions: 3 }] +const PROBE_ROWS = [{ date: '2026-07-24', n: 120 }] + +/** + * Rows for the probe and for whichever section statement follows it. The + * window the planner picks from PROBE_ROWS decides the statements, so the + * fixtures key off that same window. + */ +const SECTION_SQL = buildOverviewSql('2026-07-24') +const ROWS_BY_SQL = { + [OVERVIEW_PROBE_SQL]: PROBE_ROWS, + [SECTION_SQL.models]: PROVIDER_ROWS, + [SECTION_SQL.daily]: DAILY_ROWS, + [SECTION_SQL.repos]: REPO_ROWS, + [SECTION_SQL.tools]: TOOL_ROWS, +} + +test('runWizardFirstLook: writes every section, names the repeat command, reports row counts', async () => { + const stdout = makeBuf() + /** @type {string[]} */ + const seen = [] + const result = await runWizardFirstLook({ + stdout, + runner: { + hasDataset: () => true, + async run(sql) { + seen.push(sql) + return { columns: [], rows: ROWS_BY_SQL[sql] ?? [] } + }, + }, + }) + // Probe first, then the same four sections `hyp query overview` prints. + assert.deepEqual( + seen, + [OVERVIEW_PROBE_SQL, SECTION_SQL.models, SECTION_SQL.daily, SECTION_SQL.repos, SECTION_SQL.tools] + ) + assert.deepEqual(result, { shown: true, providerRows: 1, dayRows: 1 }) + const text = stdout.text() + assert.match(text, /First look at what HypAware has recorded/) + assert.match(text, /claude-opus-5/) + assert.match(text, /acme\/api\s+3\s+200\s+4,000\s+42/) + assert.match(text, /Bash\s+9\s+3/) + // One pointer line, not the render's footer plus this one. + assert.match(text, /See this again anytime: hyp query overview \(--sql shows the queries\)/) + assert.ok(!text.includes('The SQL behind these')) +}) + +test('runWizardFirstLook: an expired deadline keeps the sections that finished', async () => { + const stdout = makeBuf() + const result = await runWizardFirstLook({ + stdout, + budgetMs: 120, + runner: { + hasDataset: () => true, + /** @param {string} sql */ + async run(sql) { + // Probe and the first two sections land fast; `repos` stalls past + // the budget, so `tools` never starts. + if (sql === SECTION_SQL.repos) { + return new Promise((resolve) => { setTimeout(() => resolve({ columns: [], rows: [] }), 5000).unref() }) + } + return { columns: [], rows: ROWS_BY_SQL[sql] ?? [] } + }, + }, + }) + const text = stdout.text() + // What completed is shown rather than thrown away. + assert.equal(result.shown, true) + assert.equal(result.partial, true) + assert.match(text, /First look at what HypAware has recorded/) + assert.match(text, /claude-opus-5/) + assert.match(text, /2026-07-24/) + // The unfinished sections are named as unfinished, not as empty. + assert.match(text, /Stopped here to keep setup moving - the repos and tools sections did not finish\./) + assert.ok(!text.includes('acme/api')) +}) + +test('runWizardFirstLook: a slow cache skips within budget and says what to run', async () => { + const stdout = makeBuf() + const started = Date.now() + const result = await runWizardFirstLook({ + stdout, + budgetMs: 40, + runner: { + hasDataset: () => true, + // Far longer than the budget: stands in for a cache big enough that + // summarizing it would hold up the install. `unref` so the abandoned + // query does not keep the test runner alive - production drops it at + // `process.exit` instead. + run: () => new Promise((resolve) => { + setTimeout(() => resolve({ columns: [], rows: [] }), 5000).unref() + }), + }, + }) + assert.deepEqual(result, { shown: false, reason: 'slow' }) + // Setup moved on rather than waiting out the query. + assert.ok(Date.now() - started < 2000) + assert.match(stdout.text(), /Skipped the first look/) + assert.match(stdout.text(), /Run `hyp query overview` to see it/) +}) + +test('runWizardFirstLook: a cache inside the budget still renders', async () => { + const stdout = makeBuf() + const result = await runWizardFirstLook({ + stdout, + budgetMs: 5000, + runner: { + hasDataset: () => true, + async run(sql) { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { columns: [], rows: ROWS_BY_SQL[sql] ?? [] } + }, + }, + }) + assert.equal(result.shown, true) + assert.match(stdout.text(), /claude-opus-5/) + assert.ok(!stdout.text().includes('Skipped the first look')) +}) + +test('runWizardFirstLook: an unregistered dataset skips silently', async () => { + const stdout = makeBuf() + const result = await runWizardFirstLook({ + stdout, + runner: { hasDataset: () => false, async run() { throw new Error('must not run') } }, + }) + assert.deepEqual(result, { shown: false, reason: 'no-dataset' }) + assert.equal(stdout.text(), '') +}) + +test('runWizardFirstLook: a query failure degrades to a skipped step, not a throw', async () => { + const stdout = makeBuf() + const result = await runWizardFirstLook({ + stdout, + runner: { hasDataset: () => true, async run() { throw new Error('cache unreadable') } }, + }) + assert.deepEqual(result, { shown: false, reason: 'error' }) + assert.equal(stdout.text(), '') +}) + +test('runWizardFirstLook: a write failure cannot escape and fail a finished install', async () => { + // stdout that throws the way a closed pipe does (`hyp init | head`). + // Rendering and writing sit after the queries, so a throw here used to + // escape into the wizard and surface as `hyp: EPIPE` with a non-zero exit + // from an install that had already succeeded. + const exploding = { + write() { + const err = /** @type {Error & { code?: string }} */ (new Error('write EPIPE')) + err.code = 'EPIPE' + throw err + }, + } + const result = await runWizardFirstLook({ + stdout: exploding, + runner: { + hasDataset: () => true, + async run(sql) { + return { columns: [], rows: ROWS_BY_SQL[sql] ?? [] } + }, + }, + }) + assert.deepEqual(result, { shown: false, reason: 'error' }) +}) + +test('runWizardFirstLook: a render failure is contained too', async () => { + const stdout = makeBuf() + const result = await runWizardFirstLook({ + stdout, + runner: { + hasDataset: () => true, + async run(sql) { + // A row shape the renderer never expects: `repo_root` claims to be a + // string but throws when read. + if (sql === SECTION_SQL.repos) { + return { columns: [], rows: [new Proxy({}, { get() { throw new Error('bad row') } })] } + } + return { columns: [], rows: ROWS_BY_SQL[sql] ?? [] } + }, + }, + }) + assert.deepEqual(result, { shown: false, reason: 'error' }) +}) + +test('firstLookNoticeSink: discloses withheld rows, drops the freshness line', async () => { + const stderr = makeBuf() + const sink = firstLookNoticeSink(stderr) + // Setup is not an excuse to omit rows quietly (LLP 0105)... + sink({ kind: 'local-only', line: 'local-only: withheld 3 row(s) not visible from this full caller\n' }) + // ...but a sub-two-minute lag on live capture is not something the person + // finishing an install can act on. `hyp query overview` prints it. + sink({ kind: 'freshness', line: 'note: capture may lag by up to 2 minutes\n' }) + assert.equal(stderr.text(), 'local-only: withheld 3 row(s) not visible from this full caller\n') +}) + +test('runWizardFirstLook: no runner (no query registry) skips', async () => { + const stdout = makeBuf() + const result = await runWizardFirstLook({ stdout }) + assert.deepEqual(result, { shown: false, reason: 'no-dataset' }) + assert.equal(stdout.text(), '') +}) diff --git a/test/core/cli/wizard/index.test.js b/test/core/cli/wizard/index.test.js index 4e71b316..cecec5bd 100644 --- a/test/core/cli/wizard/index.test.js +++ b/test/core/cli/wizard/index.test.js @@ -8,6 +8,7 @@ import path from 'node:path' import { runInitWizard } from '../../../../src/core/cli/wizard/index.js' import { writeFirstSyncHoldMarker } from '../../../../src/core/usage-policy/first_sync_hold.js' +import { OVERVIEW_PROBE_SQL } from '../../../../src/core/query/overview.js' // The wizard orchestrator (LLP 0135 #orchestration): gate short-circuits, // the fork/join loop, phase threading (locked/managed/scoped), the @@ -278,7 +279,77 @@ test('runInitWizard: prints the run summary with the written config path', async const { opts, stdout } = wizardOpts(await tmpHome()) await runInitWizard(opts) assert.match(stdout.text(), /✓ Wrote \/tmp\/x\/config\.json/) - assert.match(stdout.text(), /next: hyp query sql/) + // The old `next: hyp query sql 'select count(*) from logs'` hint named a + // dataset most installs do not register (LLP 0135 #first-look). + assert.ok(!stdout.text().includes('next: hyp query sql')) +}) + +// --- first look --- + +/** + * A first-look runner: the probe answers with one day of history, then one + * query per section. Only the two sections this file asserts on are + * scripted; the rest come back empty (which renders as absent). + */ +function firstLookStub(providerRows, dailyRows) { + /** @type {string[]} */ + const seen = [] + return { + seen, + runner: { + hasDataset: () => true, + /** @param {string} sql */ + async run(sql) { + seen.push(sql) + if (sql === OVERVIEW_PROBE_SQL) return { columns: [], rows: [{ date: '2026-07-24', n: 40 }] } + if (sql.includes('group by 1, 2')) return { columns: [], rows: providerRows } + if (sql.includes('count(distinct session_id) sessions,')) return { columns: [], rows: dailyRows } + return { columns: [], rows: [] } + }, + }, + } +} + +test('runInitWizard: an attended run ends on the first look, before the privacy narration', async () => { + const home = await tmpHome() + await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') }) + const stub = firstLookStub( + [{ provider: 'anthropic', model: 'claude-opus-5', input_tokens: 400, cached_tokens: 4000, output_tokens: 40 }], + [{ date: '2026-07-24', sessions: 3, input_tokens: 400, cached_tokens: 4000, output_tokens: 40 }] + ) + const { opts, stdout } = wizardOpts(home, { fork: async () => 'team', firstLook: stub.runner }) + await runInitWizard(opts) + const text = stdout.text() + // The window probe, then one query per section: setup shows the same + // block as `hyp query overview`. + assert.equal(stub.seen[0], OVERVIEW_PROBE_SQL) + assert.equal(stub.seen.length, 5) + // Every number in the block is scoped to the window the probe chose. + assert.match(text, /2026-07-24 to 2026-07-24 \(1 active day, 40 rows\)/) + assert.match(text, /First look at what HypAware has recorded/) + assert.match(text, /anthropic\s+claude-opus-5\s+400\s+4,000\s+40/) + assert.match(text, /2026-07-24/) + // The privacy narration stays the wizard's last words (LLP 0135 #privacy). + assert.ok(text.indexOf('First look') < text.indexOf('Nothing has been uploaded yet')) +}) + +test('runInitWizard: a non-interactive or dry run skips the first look', async () => { + const stub = firstLookStub([{ provider: 'anthropic', model: 'm', input_tokens: 1, cached_tokens: 10, output_tokens: 1 }], []) + const { opts, stdout } = wizardOpts(await tmpHome(), { + picks: { sources: ['claude'], exportChoice: 'local-parquet', retentionDays: 30 }, + firstLook: stub.runner, + }) + await runInitWizard(opts) + assert.equal(stub.seen.length, 0) + assert.ok(!stdout.text().includes('First look')) + + const dry = firstLookStub([{ provider: 'anthropic', model: 'm', input_tokens: 1, cached_tokens: 10, output_tokens: 1 }], []) + const { opts: dryOpts } = wizardOpts(await tmpHome(), { + finale: { dryRun: true }, + firstLook: dry.runner, + }) + await runInitWizard(dryOpts) + assert.equal(dry.seen.length, 0) }) test('runInitWizard: team pathway with a live first-sync hold narrates the deadline', async () => { diff --git a/test/core/command-dispatch.test.js b/test/core/command-dispatch.test.js index 7d183019..f9d251d5 100644 --- a/test/core/command-dispatch.test.js +++ b/test/core/command-dispatch.test.js @@ -569,7 +569,7 @@ test('bare group command with an unknown subcommand reports the registry childre assert.equal(code, 2) assert.match(stderr.text(), /hyp query: unknown subcommand 'bogus'/) - assert.match(stderr.text(), /expected one of: maintain, refresh, schema, sql, status/) + assert.match(stderr.text(), /expected one of: maintain, overview, refresh, schema, sql, status/) }) test('a token that is neither a command nor a group prefix still errors', async () => { diff --git a/test/core/query-overview.test.js b/test/core/query-overview.test.js new file mode 100644 index 00000000..16abc8ba --- /dev/null +++ b/test/core/query-overview.test.js @@ -0,0 +1,783 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { asyncRow } from 'squirreling' + +import { + OVERVIEW_DATASET, + OVERVIEW_PROBE_SQL, + buildOverviewSql, + chooseOverviewWindow, + collectOverview, + describeWindow, + formatCount, + overviewRunnerFromCtx, + renderOverview, +} from '../../src/core/query/overview.js' +import { runQueryOverview } from '../../src/core/commands/query.js' + +// The shared gateway overview (LLP 0135 #first-look): the rendered block is +// a pure function of the rows, the runner is the ordinary query seam, and +// `hyp query overview` reprints exactly what the wizard ended on. +// @ref LLP 0135#first-look [tests]: + +function makeBuf() { + let value = '' + return { + /** @param {string} chunk */ + write(chunk) { value += String(chunk); return true }, + text() { return value }, + } +} + +const PROVIDER_ROWS = [ + { provider: 'anthropic', model: 'claude-opus-5', input_tokens: 15842, cached_tokens: 15842000, output_tokens: 158420 }, + { provider: 'anthropic', model: '', input_tokens: 1200, cached_tokens: 1200000, output_tokens: 12000 }, + { provider: 'openai', model: 'gpt-5.5', input_tokens: 512, cached_tokens: 64, output_tokens: 6 }, +] + +/** + * The window a rendered block describes; real runs always have one. + * + * @type {import('../../src/core/query/types.js').OverviewWindow} + */ +const WINDOW = { + since: '2026-07-23', until: '2026-07-24', days: 2, rows: 5150, + totalDays: 2, totalRows: 5150, narrowed: false, boundBy: 'rows', +} + +const DAILY_ROWS = [ + { date: '2026-07-24', sessions: 9, input_tokens: 648, cached_tokens: 64800, output_tokens: 648 }, + { date: '2026-07-23', sessions: 37, input_tokens: 4502, cached_tokens: 450200, output_tokens: 4502 }, +] + +/** + * Raw rows shaped like ai_gateway_messages. Usage rides one carrier row + * per response (LLP 0035 #one-carrier), so most rows carry no usage at all + * - the fixtures mirror that rather than stamping every row. The OpenAI + * row omits `cache_write_tokens` entirely, as that provider does. + */ +const RAW_ROWS = [ + { + provider: 'anthropic', + model: 'claude-opus-5', + date: '2026-07-24', + session_id: 's1', + repo_root: '/w/acme/api', + part_type: 'text', + tool_name: null, + attributes: null, + }, + { + provider: 'anthropic', + model: 'claude-opus-5', + date: '2026-07-24', + session_id: 's1', + repo_root: '/w/acme/api', + part_type: 'tool_call', + tool_name: 'Bash', + attributes: { usage: { input_tokens: 100, cache_read_tokens: 900, cache_write_tokens: 40, output_tokens: 50 } }, + }, + { + provider: 'anthropic', + model: 'claude-opus-5', + date: '2026-07-23', + session_id: 's2', + repo_root: '/w/acme/api', + part_type: 'tool_call', + tool_name: 'Bash', + attributes: { usage: { input_tokens: 200, cache_read_tokens: 0, cache_write_tokens: 0, output_tokens: 25 } }, + }, + { + provider: 'openai', + model: 'gpt-5.5', + date: '2026-07-23', + session_id: 's3', + repo_root: null, + part_type: 'tool_call', + tool_name: 'exec_command', + attributes: { usage: { input_tokens: 10, cache_read_tokens: 5, output_tokens: 7 } }, + }, +] + +/** + * A command context whose query registry serves rows as the gateway + * dataset, so the aggregates come from the same executor `hyp query sql` + * uses rather than from a stub. + * + * @param {{ rows?: Record[], dataset?: string }} [opts] + */ +function ctxWithRows(opts = {}) { + const rows = opts.rows ?? RAW_ROWS + // Off `rows[0]`, not `RAW_ROWS[0]`: a case that adds a column (`cwd`, which + // is what arms the LLP 0105 filter) has to see it in the schema. + const columns = Object.keys(rows[0] ?? RAW_ROWS[0]) + const dataset = { + discoverPartitions: async () => [], + createDataSource: async () => ({ + columns, + numRows: rows.length, + scan: () => ({ + async *rows() { + for (const row of rows) yield asyncRow(row, columns) + }, + }), + }), + } + const stdout = makeBuf() + const stderr = makeBuf() + const name = opts.dataset ?? OVERVIEW_DATASET + return { + stdout, + stderr, + ctx: /** @type {any} */ ({ + stdout, + stderr, + query: { + getDataset: (/** @type {string} */ n) => (n === name ? dataset : null), + listDatasets: () => [], + }, + storage: {}, + config: { version: 2 }, + env: {}, + cwd: '/w/project', + }), + } +} + +test('renderOverview: aligns columns, groups thousands, and bars the largest row widest', () => { + const out = renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: DAILY_ROWS }) + const lines = out.split('\n') + + assert.match(out, /What HypAware has recorded/) + // Cache is its own column, and the legend says what the split means. + assert.match(out, /provider\s+model\s+input\s+cached\s+output/) + assert.match(out, /Input is prompt sent fresh; cached is prompt served from \(or written to\) the cache/) + assert.match(out, /Input \+ cached is the whole prompt/) + + const opus = lines.find((l) => l.includes('claude-opus-5')) ?? '' + const gpt = lines.find((l) => l.includes('gpt-5.5')) ?? '' + // The bar is two-tone: input's shade then output's, scaled on input + + // output (cache excluded - it would swamp both). + assert.match(opus, /anthropic\s+claude-opus-5\s+15,842\s+15,842,000\s+158,420\s+▒+█+/) + // Column ends line up across rows of different content widths. Anchored + // from the right: the bar column holds no digits, so the last digit run + // on a line is always the output value. + assert.equal(opus.lastIndexOf('158,420') + '158,420'.length, gpt.lastIndexOf('6') + 1) + // Bars are scaled on input + output: the top row saturates, the smallest + // gets the minimum. + const cells = (/** @type {string} */ line) => (line.match(/[▒█]/g) ?? []).length + assert.ok(cells(opus) > cells(gpt)) + assert.equal(cells(gpt), 1) + // Each table says what its own bar charts, since they differ by section. + assert.match(out, /output\s+by input\+output/) + assert.match(out, /Token bars split ▒ input from █ output; cache is excluded/) + + // An unlabelled group renders explicitly rather than as a blank column. + assert.match(lines.find((l) => l.includes('1,200,000')) ?? '', /anthropic\s+\(model not recorded\)\s+1,200\s+1,200,000/) + + const busiest = lines.find((l) => l.includes('2026-07-23')) ?? '' + // Equal input and output split the bar down the middle. + assert.match(busiest, /2026-07-23\s+37\s+4,502\s+450,200\s+4,502\s+▒+█+/) +}) + +test('buildOverviewSql: ranked tables sort by exactly what their bar charts', () => { + const sql = buildOverviewSql('2026-07-01') + // A table ranked by one metric and barred by another renders bars that do + // not descend - the models table did this, sorting on output while the bar + // charted input + output. + assert.ok(sql.models.includes('order by input_tokens + output_tokens desc'), sql.models) + assert.ok(sql.repos.includes('order by input_tokens + output_tokens desc'), sql.repos) + assert.ok(sql.tools.includes('order by calls desc'), sql.tools) + // Daily is chronological on purpose: its bars are a time series, not a rank. + assert.ok(sql.daily.includes('order by 1 desc'), sql.daily) +}) + +test('renderOverview: bars descend with the row order in ranked tables', () => { + const rows = [ + { provider: 'a', model: 'big', input_tokens: 1000, cached_tokens: 9_000_000, output_tokens: 9000 }, + { provider: 'a', model: 'mid', input_tokens: 500, cached_tokens: 1000, output_tokens: 4500 }, + { provider: 'a', model: 'small', input_tokens: 10, cached_tokens: 500_000_000, output_tokens: 90 }, + ] + const lines = renderOverview({ providerRows: rows, dailyRows: [] }).split('\n') + const width = (/** @type {string} */ name) => + ((lines.find((l) => l.includes(name)) ?? '').match(/[▒█]/g) ?? []).length + assert.ok(width('big') > width('mid'), `${width('big')} should exceed ${width('mid')}`) + assert.ok(width('mid') > width('small'), `${width('mid')} should exceed ${width('small')}`) +}) + +test('renderOverview: token bars split input from output and ignore cache', () => { + const rows = [ + // Same input+output as the next row, but 1000x the cache. A bar that + // counted cache would dwarf everything else here; these two must match. + { provider: 'a', model: 'cache-heavy', input_tokens: 500, cached_tokens: 90_000_000, output_tokens: 500 }, + { provider: 'a', model: 'cache-light', input_tokens: 500, cached_tokens: 90_000, output_tokens: 500 }, + // All output, no input: no input shade at all. + { provider: 'a', model: 'output-only', input_tokens: 0, cached_tokens: 0, output_tokens: 1000 }, + ] + const lines = renderOverview({ providerRows: rows, dailyRows: [] }).split('\n') + const barOf = (/** @type {string} */ name) => (lines.find((l) => l.includes(name)) ?? '').replace(/[^▒█]/g, '') + + assert.equal(barOf('cache-heavy'), barOf('cache-light')) + // Equal input and output halve the bar. + assert.equal(barOf('cache-heavy'), '▒'.repeat(9) + '█'.repeat(9)) + assert.equal(barOf('output-only'), '█'.repeat(18)) +}) + +test('renderOverview: a component that exists never vanishes from its bar', () => { + const rows = [ + { provider: 'a', model: 'big', input_tokens: 1000, cached_tokens: 0, output_tokens: 1000 }, + // Input is 0.05% of this row - too small to round to a cell, but real. + { provider: 'a', model: 'sliver', input_tokens: 1, cached_tokens: 0, output_tokens: 1999 }, + ] + const out = renderOverview({ providerRows: rows, dailyRows: [] }) + const sliver = (out.split('\n').find((l) => l.includes('sliver')) ?? '').replace(/[^▒█]/g, '') + assert.ok(sliver.startsWith('▒'), `expected a visible input shade, got ${sliver}`) + assert.ok(sliver.includes('█')) +}) + +test('renderOverview: SQL is hidden behind --sql, and pointed at when hidden', () => { + const plain = renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: DAILY_ROWS }) + assert.ok(!plain.includes('json_extract')) + assert.match(plain, /The SQL behind these: hyp query overview --sql/) + + const sql = buildOverviewSql('2026-07-23') + const withSql = renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: DAILY_ROWS, sql, showSql: true }) + // Printed as one runnable command: a shell keeps the newlines inside the + // quotes, so the block pastes back unchanged. + assert.ok(withSql.includes('hyp query sql "select provider, model,')) + assert.ok(withSql.includes(sql.daily.split('\n')[0])) + // The printed statements carry the same window the numbers came from. + assert.ok(withSql.includes("where date >= '2026-07-23'")) + assert.ok(!withSql.includes('hyp query overview --sql')) +}) + +test('renderOverview: a labelled model with no usage is counted out loud', () => { + const rows = [ + ...PROVIDER_ROWS, + { provider: 'anthropic', model: 'claude-legacy', input_tokens: 0, cached_tokens: 0, output_tokens: 0 }, + ] + const out = renderOverview({ providerRows: rows, dailyRows: [] }) + assert.match(out, /\+ 1 model whose traffic was recorded without token counts/) + // Not shown as a "0 0 0" row masquerading as a measurement. + assert.ok(!out.includes('claude-legacy ')) +}) + +test('renderOverview: unlabelled groups are omitted silently, never called models', () => { + // These are the reader's own prompts and tool results: no model answered + // them, so they carry no usage by construction (LLP 0035 #one-carrier). + const rows = [ + ...PROVIDER_ROWS, + { provider: 'anthropic', model: null, input_tokens: 0, cached_tokens: 0, output_tokens: 0 }, + { provider: 'openai', model: '', input_tokens: 0, cached_tokens: 0, output_tokens: 0 }, + ] + const out = renderOverview({ providerRows: rows, dailyRows: [] }) + assert.ok(!out.includes('without token counts')) + assert.ok(!out.includes('(none)')) +}) + +test('renderOverview: an unlabelled group that DID carry tokens is named, not dropped', () => { + const out = renderOverview({ + providerRows: [{ provider: 'anthropic', model: null, input_tokens: 10, cached_tokens: 0, output_tokens: 5 }], + dailyRows: [], + }) + assert.match(out, /anthropic\s+\(model not recorded\)\s+10\s+0\s+5/) +}) + +test('renderOverview: traffic with no token counts at all says so instead of an empty table', () => { + const out = renderOverview({ + providerRows: [{ provider: 'anthropic', model: 'claude-opus-5', input_tokens: 0, cached_tokens: 0, output_tokens: 0 }], + dailyRows: [], + }) + assert.match(out, /1 model was recorded, none with token counts\./) + // No table header over an empty table. + assert.ok(!/provider\s+model\s+input\s+cached/.test(out)) +}) + +test('renderOverview: nothing but unlabelled groups reports no counts, not "0 models"', () => { + const out = renderOverview({ + providerRows: [{ provider: 'anthropic', model: null, input_tokens: 0, cached_tokens: 0, output_tokens: 0 }], + dailyRows: [], + }) + assert.match(out, /No token counts were recorded\./) + assert.ok(!out.includes('0 models')) +}) + +test('renderOverview: a cache-only model counts as measured, not as missing usage', () => { + const out = renderOverview({ + providerRows: [{ provider: 'anthropic', model: 'cache-only', input_tokens: 0, cached_tokens: 900, output_tokens: 0 }], + dailyRows: [], + }) + assert.match(out, /anthropic\s+cache-only\s+0\s+900\s+0/) + assert.ok(!out.includes('without token counts')) +}) + +test('renderOverview: the caller names the heading (setup milestone vs standing command)', () => { + const out = renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: [], title: 'First look' }) + assert.match(out, /First look/) + assert.ok(!out.includes('What HypAware has recorded')) +}) + +test('renderOverview: no rows renders the empty state with what to do next', () => { + const out = renderOverview({ providerRows: [], dailyRows: [] }) + assert.match(out, /Nothing recorded yet/) + assert.match(out, /hyp query overview/) + assert.ok(!out.includes('provider ')) +}) + +test('renderOverview: folds the tail of a long provider list into a count line', () => { + const rows = Array.from({ length: 11 }, (_, i) => ( + { provider: 'p', model: `m${i}`, input_tokens: 10, cached_tokens: 1000 - i, output_tokens: 100 - i } + )) + const out = renderOverview({ providerRows: rows, dailyRows: [] }) + assert.match(out, /\+ 3 more models/) + assert.ok(!out.includes('m8')) +}) + +test('renderOverview: the caption and key wear the shades they describe', () => { + const MAGENTA = '\x1b[35m' + const CYAN = '\x1b[36m' + const RESET = '\x1b[0m' + const out = renderOverview({ + providerRows: [{ provider: 'a', model: 'm', input_tokens: 500, cached_tokens: 9_000_000, output_tokens: 500 }], + dailyRows: [], + color: true, + }) + // The header names each half in its own colour, so the table is its own + // key - matching the shades in the bar directly beneath it. + assert.ok(out.includes(`${MAGENTA}input${RESET}`)) + assert.ok(out.includes(`${CYAN}output${RESET}`)) + // Legend glyph and word share a colour. + assert.ok(out.includes(`${MAGENTA}▒ input${RESET}`)) + assert.ok(out.includes(`${CYAN}█ output${RESET}`)) + // The bar's own segments carry the same two colours. + assert.ok(out.includes(`${MAGENTA}▒`)) + assert.ok(out.includes(`${CYAN}█`)) + + // A colour run must never sit inside a dim run: the reset that ends the + // colour would end the dim too, leaving the rest of the row undimmed. + const header = out.split('\n').find((l) => l.includes('by ')) ?? '' + assert.ok(!/\x1b\[2m[^\x1b]*\x1b\[3[56]m/.test(header), `dim run swallows a colour: ${JSON.stringify(header)}`) +}) + +test('renderOverview: color=true wraps in ANSI, the default emits none', () => { + assert.ok(!renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: DAILY_ROWS }).includes('\x1b[')) + assert.ok(renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: DAILY_ROWS, color: true }).includes('\x1b[')) +}) + +test('renderRepoMix: shortens paths, ranks by token volume, and counts repo-less sessions', () => { + const out = renderOverview({ + providerRows: PROVIDER_ROWS, + dailyRows: [], + repoRows: [ + { repo_root: null, sessions: 172, input_tokens: 0, cached_tokens: 0, output_tokens: 0 }, + { repo_root: '/Users/x/Development/hyperparam-work/hypaware-3/hypaware', sessions: 73, input_tokens: 255403, cached_tokens: 488328729, output_tokens: 1993041 }, + { repo_root: '/Users/x/Development/hyperparam-work/hypaware-2/hypaware', sessions: 26, input_tokens: 235507, cached_tokens: 173399157, output_tokens: 1243332 }, + ], + }) + // Last two segments: a basename alone would collapse hypaware-2 and -3. + // Full token vocabulary: cache is ~76% of spend, so a repo table without a + // cached column would show a repo's cost as its 1.5% sliver. + assert.match(out, /hypaware-3\/hypaware\s+73\s+255,403\s+488,328,729\s+1,993,041\s+▒+█+/) + assert.match(out, /hypaware-2\/hypaware\s+26\s+235,507\s+173,399,157\s+1,243,332\s+▒+█+/) + // Same two-tone bar and caption as the other token tables. + assert.match(out, /output\s+by input\+output/) + assert.ok(!out.includes('/Users/x/')) + // Stated, not silently dropped - and "no repo recorded" rather than + // "outside a repo", which would be false for Codex rows (they never + // carry repo_root even when they ran inside a checkout). + assert.match(out, /\+ 172 sessions with no repo recorded/) +}) + +test('renderRepoMix: no repo at all says so rather than printing an empty table', () => { + const out = renderOverview({ + providerRows: PROVIDER_ROWS, + dailyRows: [], + repoRows: [{ repo_root: '', sessions: 9, input_tokens: 0, cached_tokens: 0, output_tokens: 0 }], + }) + assert.match(out, /No repo was recorded on any session \(9 of them\)\./) +}) + +test('renderToolMix: ranks by calls and shows the session spread', () => { + const out = renderOverview({ + providerRows: PROVIDER_ROWS, + dailyRows: [], + toolRows: [ + { tool_name: 'Bash', calls: 8043, sessions: 384 }, + { tool_name: 'Read', calls: 1633, sessions: 237 }, + ], + }) + assert.match(out, /Which tools get called/) + assert.match(out, /tool\s+calls\s+sessions/) + assert.match(out, /Bash\s+8,043\s+384\s+█+/) + assert.match(out, /Read\s+1,633\s+237\s+█+/) +}) + +test('buildOverviewSql: every section carries the same window, and tools filters tool_call', () => { + const sql = buildOverviewSql('2026-07-01') + // One claim about one period: a section scoped differently would make the + // block's numbers incomparable with each other. + for (const statement of Object.values(sql)) { + assert.ok(statement.includes("where date >= '2026-07-01'"), statement) + } + // `tool_use` is a provider wire name and matches no row, returning a + // silent empty section. + assert.ok(sql.tools.includes("part_type = 'tool_call'")) + assert.ok(!sql.tools.includes('tool_use')) +}) + +test('chooseOverviewWindow: takes the widest span that fits the row budget', () => { + const probe = [ + { date: '2026-07-24', n: 40 }, + { date: '2026-07-23', n: 40 }, + { date: '2026-07-22', n: 40 }, + { date: '2026-07-21', n: 40 }, + ] + const win = chooseOverviewWindow(probe, { targetRows: 100 }) + assert.deepEqual(win, { + since: '2026-07-23', until: '2026-07-24', days: 2, rows: 80, + totalDays: 4, totalRows: 160, narrowed: true, boundBy: 'rows', + }) +}) + +test('chooseOverviewWindow: everything fits when the cache is small', () => { + const probe = [{ date: '2026-07-24', n: 10 }, { date: '2026-07-23', n: 10 }] + const win = chooseOverviewWindow(probe, { targetRows: 100 }) + assert.equal(win?.narrowed, false) + assert.equal(win?.days, 2) + assert.equal(win?.since, '2026-07-23') +}) + +test('chooseOverviewWindow: one oversized day is still shown, never nothing', () => { + // The whole point of narrowing rather than skipping: a busy single day is + // a real answer where an empty block is not. + const win = chooseOverviewWindow( + [{ date: '2026-07-24', n: 5_000_000 }, { date: '2026-07-23', n: 10 }], + { targetRows: 100 } + ) + assert.equal(win?.days, 1) + assert.equal(win?.rows, 5_000_000) + assert.equal(win?.narrowed, true) +}) + +test('chooseOverviewWindow: the same data yields a smaller window on a slower machine', () => { + // 10 days x 10k rows. The row cap alone would take all of it; what + // differs between these two runs is only how long the probe took, i.e. + // how fast the machine is. + const probe = Array.from({ length: 10 }, (_, i) => ({ date: `2026-07-${27 - i}`, n: 10_000 })) + + // Fast: the probe read 100k rows in 100ms, so the sections are affordable. + const fast = chooseOverviewWindow(probe, { targetRows: 1_000_000, budgetMs: 5000, probeMs: 100 }) + assert.equal(fast?.days, 10) + assert.equal(fast?.narrowed, false) + + // Slow: the same 100k rows took 10s to probe. Predicting the sections + // from that rate, only a fraction of the history fits the same budget. + const slow = chooseOverviewWindow(probe, { targetRows: 1_000_000, budgetMs: 5000, probeMs: 10_000 }) + assert.ok(slow && slow.days < 10, `expected a narrower window, got ${slow?.days} days`) + assert.equal(slow.narrowed, true) + assert.equal(slow.boundBy, 'time') +}) + +test('chooseOverviewWindow: the probe is charged to the budget it shares', () => { + const probe = Array.from({ length: 10 }, (_, i) => ({ date: `2026-07-${27 - i}`, n: 10_000 })) + // Identical machine speed (1ms per 1000 rows); the only difference is how + // much of the budget the probe already spent. Planning as if the clock + // had not started is what lets a slow probe blow a deadline no matter how + // well the window was chosen. + const fresh = chooseOverviewWindow(probe, { targetRows: 1e9, budgetMs: 5000, probeMs: 100 }) + const late = chooseOverviewWindow(probe, { targetRows: 1e9, budgetMs: 5000, probeMs: 4800 }) + assert.ok(fresh && late && late.days < fresh.days, `${late?.days} should be under ${fresh?.days}`) +}) + +test('chooseOverviewWindow: a probe that overran the budget still yields a window', () => { + const probe = Array.from({ length: 10 }, (_, i) => ({ date: `2026-07-${27 - i}`, n: 10_000 })) + // The probe alone cost double the budget. There is nothing left to spend, + // but the block still shows the newest day rather than nothing. + const win = chooseOverviewWindow(probe, { targetRows: 1e9, budgetMs: 5000, probeMs: 10_000 }) + assert.ok(win) + assert.ok(win.days >= 1) + assert.equal(win.narrowed, true) +}) + +test('chooseOverviewWindow: the row cap still binds a fast machine', () => { + const probe = Array.from({ length: 10 }, (_, i) => ({ date: `2026-07-${27 - i}`, n: 10_000 })) + // Instant probe: time says everything fits, but memory says otherwise. + const win = chooseOverviewWindow(probe, { targetRows: 25_000, budgetMs: 5000, probeMs: 0 }) + assert.equal(win?.days, 2) + assert.equal(win?.boundBy, 'rows') +}) + +test('chooseOverviewWindow: with no probe timing, the row cap decides alone', () => { + const probe = Array.from({ length: 4 }, (_, i) => ({ date: `2026-07-${27 - i}`, n: 40 })) + const win = chooseOverviewWindow(probe, { targetRows: 100 }) + assert.equal(win?.days, 2) + assert.equal(win?.boundBy, 'rows') +}) + +test('chooseOverviewWindow: an explicit --days request outranks both caps', () => { + const probe = Array.from({ length: 10 }, (_, i) => ({ date: `2026-07-${20 - i}`, n: 1_000_000 })) + // Row cap tiny, machine measured as glacial: the user asked anyway. + const win = chooseOverviewWindow(probe, { targetRows: 100, budgetMs: 10, probeMs: 60_000, days: 5 }) + assert.equal(win?.days, 5) + assert.equal(win?.rows, 5_000_000) + assert.equal(win?.boundBy, 'requested') +}) + +test('chooseOverviewWindow: unordered probe rows and an empty cache', () => { + const win = chooseOverviewWindow([ + { date: '2026-07-22', n: 1 }, + { date: '2026-07-24', n: 1 }, + { date: '2026-07-23', n: 1 }, + ], { targetRows: 100 }) + assert.equal(win?.until, '2026-07-24') + assert.equal(win?.since, '2026-07-22') + assert.equal(chooseOverviewWindow([], {}), null) +}) + +test('describeWindow: states the period, and the lever - never the reason', () => { + assert.equal( + describeWindow({ since: '2026-07-23', until: '2026-07-24', days: 2, rows: 80, totalDays: 2, totalRows: 80, narrowed: false, boundBy: /** @type {const} */ ('rows') }), + // "active days", since the bounds are calendar dates but the count is + // dates that recorded something - a quiet day inside the span is not a + // contradiction. + '2026-07-23 to 2026-07-24 (2 active days, 80 rows)' + ) + const capped = describeWindow({ + since: '2026-07-24', until: '2026-07-24', days: 1, rows: 90, totalDays: 30, totalRows: 900_000, narrowed: true, boundBy: /** @type {const} */ ('time'), + }) + assert.match(capped, /showing 1 of 30 active days \(90 of 900,000 rows\); widen with --days 30/) + assert.ok(!capped.includes('stay fast')) + + // One wording whatever narrowed it: a window the user asked for must not + // arrive with an apology, and the tool's reason is not the reader's + // business either way. + const asked = describeWindow({ + since: '2026-07-24', until: '2026-07-24', days: 1, rows: 90, totalDays: 30, totalRows: 900_000, narrowed: true, boundBy: /** @type {const} */ ('requested'), + }) + assert.equal(asked, capped) +}) + +test('renderOverview: the window is stated under the title, always', () => { + const out = renderOverview({ providerRows: PROVIDER_ROWS, dailyRows: DAILY_ROWS, window: WINDOW }) + const lines = out.split('\n').filter(Boolean) + // Directly under the title, before the rule: every number below is "per + // this period". + assert.match(lines[0], /What HypAware has recorded/) + assert.match(lines[1], /2026-07-23 to 2026-07-24 \(2 active days, 5,150 rows\)/) +}) + +/** A runner that answers the probe with `probe` and every section empty. */ +function probingRunner(probe) { + /** @type {string[]} */ + const seen = [] + return { + seen, + runner: { + hasDataset: () => true, + /** @param {string} sql */ + async run(sql) { + seen.push(sql) + return { columns: [], rows: sql === OVERVIEW_PROBE_SQL ? probe : [] } + }, + }, + } +} + +test('collectOverview: probes first, then runs only the requested sections', async () => { + const { seen, runner } = probingRunner([{ date: '2026-07-24', n: 10 }]) + const rows = await collectOverview(runner, { sections: ['models', 'daily'] }) + const sql = buildOverviewSql('2026-07-24') + // The probe pays for itself by scoping the expensive statements. + assert.deepEqual(seen, [OVERVIEW_PROBE_SQL, sql.models, sql.daily]) + assert.deepEqual(rows.repoRows, []) + assert.deepEqual(rows.toolRows, []) +}) + +test('collectOverview: runs all four sections by default, in display order', async () => { + const { seen, runner } = probingRunner([{ date: '2026-07-24', n: 10 }]) + const rows = await collectOverview(runner) + const sql = buildOverviewSql('2026-07-24') + assert.deepEqual(seen, [OVERVIEW_PROBE_SQL, sql.models, sql.daily, sql.repos, sql.tools]) + assert.equal(rows.window?.days, 1) +}) + +test('collectOverview: an empty cache runs no section at all', async () => { + const { seen, runner } = probingRunner([]) + const rows = await collectOverview(runner) + // Nothing recorded: no window to state, and nothing worth scanning for. + assert.deepEqual(seen, [OVERVIEW_PROBE_SQL]) + assert.equal(rows.window, undefined) + assert.deepEqual(rows.providerRows, []) +}) + +test('overviewRunnerFromCtx: aggregates through the real query seam', async () => { + const runner = overviewRunnerFromCtx(ctxWithRows().ctx) + assert.ok(runner) + assert.equal(runner.hasDataset(OVERVIEW_DATASET), true) + assert.equal(runner.hasDataset('logs'), false) + + const { providerRows, dailyRows } = await collectOverview(runner) + // cached is read + write; the usage-free row contributes nothing, and no + // row is double-counted. The OpenAI row carries no cache_write_tokens at + // all: an unguarded `read + write` would go null and drop its 5 cached + // tokens, so this pins the per-term coalesce. + assert.deepEqual(providerRows, [ + { provider: 'anthropic', model: 'claude-opus-5', input_tokens: 300, cached_tokens: 940, output_tokens: 75 }, + { provider: 'openai', model: 'gpt-5.5', input_tokens: 10, cached_tokens: 5, output_tokens: 7 }, + ]) + // sessions counts every session, including one whose only row carries no + // usage - which is why the queries take no `role = 'assistant'` filter. + assert.deepEqual(dailyRows, [ + { date: '2026-07-24', sessions: 1, input_tokens: 100, cached_tokens: 940, output_tokens: 50 }, + { date: '2026-07-23', sessions: 2, input_tokens: 210, cached_tokens: 5, output_tokens: 32 }, + ]) +}) + +test('overviewRunnerFromCtx: a withheld row is reported, once, not once per section', async () => { + // A directory marked `local-only` holds the rows; the caller sits outside + // it. The rows are recorded and locally readable, so `hyp query sql` would + // report the withholding rather than perform it silently - and so must + // this block, which is otherwise a place five queries could each swallow + // the same disclosure or repeat it five times. + // @ref LLP 0105 [tests]: withholding is never silent, on this surface too + const root = mkdtempSync(path.join(tmpdir(), 'hyp-overview-vis-')) + const shielded = path.join(root, 'private') + const open = path.join(root, 'open') + mkdirSync(shielded) + mkdirSync(open) + writeFileSync(path.join(shielded, '.hypignore'), 'local-only\n') + + const { ctx } = ctxWithRows({ rows: RAW_ROWS.map((row) => ({ ...row, cwd: shielded })) }) + ctx.cwd = open + /** @type {{ kind: string, line: string }[]} */ + const notices = [] + const runner = overviewRunnerFromCtx(ctx, (notice) => notices.push(notice)) + assert.ok(runner) + const rows = await collectOverview(runner) + + // Withheld, so the tables are empty - and the reason is on the record. + assert.deepEqual(rows.providerRows, []) + assert.deepEqual(notices.map((n) => n.kind), ['local-only']) + assert.match(notices[0].line, /^local-only: withheld \d+ row\(s\) not visible from this full caller/) + assert.match(notices[0].line, /--include-local-only/) + + rmSync(root, { recursive: true, force: true }) +}) + +test('overviewRunnerFromCtx: nothing to withhold says nothing', async () => { + /** @type {unknown[]} */ + const notices = [] + const runner = overviewRunnerFromCtx(ctxWithRows().ctx, (notice) => notices.push(notice)) + assert.ok(runner) + await collectOverview(runner) + assert.deepEqual(notices, []) +}) + +test('overviewRunnerFromCtx: no query registry yields no runner', () => { + assert.equal(overviewRunnerFromCtx(/** @type {any} */ ({})), undefined) + assert.equal(overviewRunnerFromCtx(/** @type {any} */ ({ query: {} })), undefined) +}) + +test('hyp query overview: renders all four sections from real rows', async () => { + const { ctx, stdout, stderr } = ctxWithRows() + const code = await runQueryOverview([], ctx) + assert.equal(code, 0) + assert.equal(stderr.text(), '') + const text = stdout.text() + assert.match(text, /What HypAware has recorded/) + assert.match(text, /anthropic\s+claude-opus-5\s+300\s+940\s+75\s+▒*█+/) + assert.match(text, /2026-07-24\s+1\s+100\s+940\s+50/) + // repos: path shortened, and the repo-less OpenAI session counted below + assert.match(text, /acme\/api\s+2\s+300\s+940\s+75\s+▒+█+/) + // Every token table captions the same bar metric; tools charts its own. + assert.match(text, /output\s+by input\+output/) + assert.match(text, /sessions\s+by calls/) + assert.match(text, /\+ 1 session with no repo recorded/) + // tools: only tool_call rows, most-called first + assert.match(text, /Bash\s+2\s+2\s+█+/) + assert.match(text, /exec_command\s+1\s+1/) + assert.ok(!text.includes('json_extract')) +}) + +test('hyp query overview: a withheld row is disclosed on stderr, off the block', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'hyp-overview-cmd-vis-')) + const shielded = path.join(root, 'private') + const open = path.join(root, 'open') + mkdirSync(shielded) + mkdirSync(open) + writeFileSync(path.join(shielded, '.hypignore'), 'local-only\n') + + const { ctx, stdout, stderr } = ctxWithRows({ rows: RAW_ROWS.map((row) => ({ ...row, cwd: shielded })) }) + ctx.cwd = open + const code = await runQueryOverview([], ctx) + + // Withholding is not a failure: the block still prints, still exits 0, and + // says on stderr what it left out - so stdout stays the block (and stays + // parseable under --json). + assert.equal(code, 0) + assert.match(stderr.text(), /^local-only: withheld \d+ row\(s\)/) + assert.ok(!stdout.text().includes('local-only:')) + assert.match(stdout.text(), /Nothing recorded yet|What HypAware has recorded/) + + rmSync(root, { recursive: true, force: true }) +}) + +test('hyp query overview --sql: prints the statement above each table', async () => { + const { ctx, stdout } = ctxWithRows() + const code = await runQueryOverview(['--sql'], ctx) + assert.equal(code, 0) + const text = stdout.text() + assert.match(text, /hyp query sql "select provider, model,/) + assert.match(text, /json_extract\(attributes,'\$\.usage\.output_tokens'\)/) + assert.ok(!text.includes('The SQL behind these')) +}) + +test('hyp query overview --json: emits both result sets for scripting', async () => { + const { ctx, stdout } = ctxWithRows() + const code = await runQueryOverview(['--json'], ctx) + assert.equal(code, 0) + const parsed = JSON.parse(stdout.text()) + assert.deepEqual(parsed.providerRows[0], { + provider: 'anthropic', + model: 'claude-opus-5', + input_tokens: 300, + cached_tokens: 940, + output_tokens: 75, + }) + assert.equal(parsed.dailyRows.length, 2) + assert.ok(!stdout.text().includes('█')) +}) + +test('hyp query overview: no capture configured reports that, not a schema name', async () => { + const { ctx, stdout, stderr } = ctxWithRows({ dataset: 'something-else' }) + const code = await runQueryOverview([], ctx) + assert.equal(code, 1) + assert.equal(stdout.text(), '') + assert.match(stderr.text(), /nothing has been recorded yet - no AI client is connected/) + assert.match(stderr.text(), /Run `hyp init` to start capturing Claude or Codex sessions/) + // The dataset name is the tool's vocabulary, not the reader's. + assert.ok(!stderr.text().includes('ai_gateway_messages')) + assert.ok(!stderr.text().includes('dataset')) +}) + +test('hyp query overview: an empty dataset renders the empty state, not an error', async () => { + const { ctx, stdout } = ctxWithRows({ rows: [] }) + const code = await runQueryOverview([], ctx) + assert.equal(code, 0) + assert.match(stdout.text(), /Nothing recorded yet/) +}) + +test('formatCount: groups thousands and passes non-numbers through', () => { + assert.equal(formatCount(0), '0') + assert.equal(formatCount(999), '999') + assert.equal(formatCount(1000), '1,000') + assert.equal(formatCount(15842), '15,842') + assert.equal(formatCount(1234567), '1,234,567') + assert.equal(formatCount(null), '(none)') +}) From 256dfc3759fe643e05afd2f0a9c8f146694b903e Mon Sep 17 00:00:00 2001 From: Brendan McMullen Date: Mon, 27 Jul 2026 15:08:17 -0700 Subject: [PATCH 2/3] Fix what the overview under-reports (review of #407) Five places where a block whose whole purpose is an honest picture of the user's own data quietly said something narrower than the truth. **Fold counts were computed against a LIMIT, not the data.** `repos` carried `limit 20` while the renderer shows 8, so "+ N more repos" saturated at 12: with 30 repos the user was told 12 were hidden when 22 were. Worse, the repo-less group sorts by token volume like any other row, so past 20 repos it fell off the end and took its own "+ N sessions with no repo recorded" line with it - exactly the disclosure #398 makes load- bearing. Both LIMITs dropped; the grouping was computed in full either way, the LIMIT only decided how much of it the renderer got to see. **The daily table truncated silently.** `limit 14` under a header that states a 30-day window, with no fold line, so anyone summing the column got half the period they had just been told they were reading. Now folds in the renderer with a stated count, like every other section. **"Nothing recorded yet" was false when every row was withheld.** The LLP 0105 filter taking everything left an empty probe and that sentence on stdout, contradicted by the withheld-row count on stderr. The runner now reports whether it withheld, and the renderer picks the sentence that is true. **`--days=7` was silently ignored.** Hand-rolled `indexOf('--days')` missed the `--flag=value` form and accepted unknown flags, so a user who pinned a window got the auto-planned one with no indication. Routed through `parseCommandArgv`, which the rest of this file already uses. **`NO_COLOR` was ignored on a TTY.** Both call sites gated on `isTty` alone; added `useColor` next to it in stdio.js and used it in both. Also: the wizard's notice sink now closes when the step returns, so a query abandoned at the deadline cannot print a disclosure after the privacy narration; `buildOverviewSql` asserts `since` is a plain date rather than resting on a projector invariant maintained in another package; the EPIPE claim in the first-look comment and test was corrected to state only what it pins (synchronous write failures - async stream errors bypass every try/catch and are filed as #409); an inline `import('...')` type and six em dashes were removed per CLAUDE.md. Deferred with issues rather than decided unilaterally: #409 (CLI-wide async EPIPE handling) and #410 (the block renders 132 columns; getting under 80 means compact counts like `1.2B`, which changes what the block is). Co-Authored-By: Claude Opus 5 (1M context) --- .../claude/skills/hypaware-query/SKILL.md | 6 +- .../codex/skills/hypaware-query/SKILL.md | 6 +- ...0135-install-experience-overhaul.design.md | 29 +++++ src/core/cli/stdio.js | 18 +++ src/core/cli/wizard/first_look.js | 43 +++++-- src/core/cli/wizard/index.js | 12 +- src/core/commands/query.js | 50 ++++++-- src/core/query/overview.js | 71 +++++++++-- src/core/query/types.d.ts | 7 ++ test/core/cli/wizard/first-look.test.js | 25 +++- test/core/query-overview.test.js | 117 +++++++++++++++++- 11 files changed, 337 insertions(+), 47 deletions(-) diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md index 2845fc52..694d7df5 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md @@ -28,9 +28,9 @@ hyp query sql "" --format jsonl --output # full result, lossless hyp query refresh ``` -**`hyp query overview` totals are windowed, not all-time.** It probes the cache, times that probe to measure this machine, and picks the widest recent window it can summarize quickly — so on a large cache it silently covers a subset. The line under the title always states the period (`2026-07-24 to 2026-07-27 - showing 3 of 31 active days …`); read it before quoting any number, and pass `--days ` to widen (that overrides the budget, whatever it costs). Never report its totals as the full history without checking that line. +**`hyp query overview` totals are windowed, not all-time.** It probes the cache, times that probe to measure this machine, and picks the widest recent window it can summarize quickly, so on a large cache it silently covers a subset. The line under the title always states the period (`2026-07-24 to 2026-07-27 - showing 3 of 31 active days …`); read it before quoting any number, and pass `--days ` to widen (that overrides the budget, whatever it costs). Never report its totals as the full history without checking that line. -These are the only subcommands in the installed CLI (`hyp query`: overview, schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. +These are the only subcommands in the installed CLI (`hyp query`: overview, schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands; answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. ## Remote queries (other HypAware hosts) @@ -79,7 +79,7 @@ Recorded AI-gateway traffic is exposed through one dataset: `ai_gateway_messages Key columns: - `session_id`, `conversation_id`, `message_id`, `message_index`, `part_id`, `part_index` — stable identity. `session_id` is the always-present session key (group/scope on it); `conversation_id` is a nullable thread within a session (a Codex thread; null for Claude). -- `provider`, `model`, `role`, `part_type`, `content_text` — normalized provider/message content fields. `part_type` is HypAware's own vocabulary, NOT the provider's wire name: `text`, `reasoning`, `tool_call`, `tool_result`, `image`, `fallback`. Tool calls are `part_type='tool_call'` — Anthropic's `tool_use` matches no row and returns a silently empty result. `role` is `user` / `assistant` / `tool` / `system` / `developer`. +- `provider`, `model`, `role`, `part_type`, `content_text`: normalized provider/message content fields. `part_type` is HypAware's own vocabulary, NOT the provider's wire name: `text`, `reasoning`, `tool_call`, `tool_result`, `image`, `fallback`. Tool calls are `part_type='tool_call'`: Anthropic's `tool_use` matches no row and returns a silently empty result. `role` is `user` / `assistant` / `tool` / `system` / `developer`. - `tool_name`, `tool_call_id`, `tool_args`, `status` — tool-call/result joins and sparse status such as `finish_reason`. - `attributes` (JSON) — request settings, usage, propagated `dev_run_id`, and gateway diagnostics under `attributes.gateway`. diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md index 14aeda82..92d6f598 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md @@ -28,9 +28,9 @@ hyp query sql "" --format jsonl --output # full result, lossless hyp query refresh ``` -**`hyp query overview` totals are windowed, not all-time.** It probes the cache, times that probe to measure this machine, and picks the widest recent window it can summarize quickly — so on a large cache it silently covers a subset. The line under the title always states the period (`2026-07-24 to 2026-07-27 - showing 3 of 31 active days …`); read it before quoting any number, and pass `--days ` to widen (that overrides the budget, whatever it costs). Never report its totals as the full history without checking that line. +**`hyp query overview` totals are windowed, not all-time.** It probes the cache, times that probe to measure this machine, and picks the widest recent window it can summarize quickly, so on a large cache it silently covers a subset. The line under the title always states the period (`2026-07-24 to 2026-07-27 - showing 3 of 31 active days …`); read it before quoting any number, and pass `--days ` to widen (that overrides the budget, whatever it costs). Never report its totals as the full history without checking that line. -These are the only subcommands in the installed CLI (`hyp query`: overview, schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. +These are the only subcommands in the installed CLI (`hyp query`: overview, schema, status, sql, refresh, maintain). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands; answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. ## Remote queries (other HypAware hosts) @@ -79,7 +79,7 @@ Recorded AI-gateway traffic is exposed through one dataset: `ai_gateway_messages Key columns: - `session_id`, `conversation_id`, `message_id`, `message_index`, `part_id`, `part_index` — stable identity. `session_id` is the always-present session key (group/scope on it); `conversation_id` is a nullable thread within a session (a Codex thread; null for Claude). -- `provider`, `model`, `role`, `part_type`, `content_text` — normalized provider/message content fields. `part_type` is HypAware's own vocabulary, NOT the provider's wire name: `text`, `reasoning`, `tool_call`, `tool_result`, `image`, `fallback`. Tool calls are `part_type='tool_call'` — Anthropic's `tool_use` matches no row and returns a silently empty result. `role` is `user` / `assistant` / `tool` / `system` / `developer`. +- `provider`, `model`, `role`, `part_type`, `content_text`: normalized provider/message content fields. `part_type` is HypAware's own vocabulary, NOT the provider's wire name: `text`, `reasoning`, `tool_call`, `tool_result`, `image`, `fallback`. Tool calls are `part_type='tool_call'`: Anthropic's `tool_use` matches no row and returns a silently empty result. `role` is `user` / `assistant` / `tool` / `system` / `developer`. - `tool_name`, `tool_call_id`, `tool_args`, `status` — tool-call/result joins and sparse status such as `finish_reason`. - `attributes` (JSON) — request settings, usage, propagated `dev_run_id`, and gateway diagnostics under `attributes.gateway`. diff --git a/llp/0135-install-experience-overhaul.design.md b/llp/0135-install-experience-overhaul.design.md index 6f0a8d1e..0c502808 100644 --- a/llp/0135-install-experience-overhaul.design.md +++ b/llp/0135-install-experience-overhaul.design.md @@ -750,6 +750,35 @@ CLI's closing `process.exit` drops them. Threading the deadline into `executeQuerySql`'s existing `signal` (LLP 0054 #signal-threading) would make the abandonment a real cancellation, and is the obvious next step. +### Every truncation is counted, and counted honestly {#folds} + +Three of the four sections show a head and fold the tail into a count line. +The count must be computed over the *whole* grouping, which is why the +`repos` and `daily` statements carry no `LIMIT`: a SQL limit silently +becomes the renderer's idea of the entire result, so "+ N more repos" would +report the tail of the limit rather than the tail of the truth (a 20-row +limit reports 12 hidden when 22 are). Worse for `repos`, where the +repo-less group sorts by token volume like any other row: past 20 repos it +falls off the end and takes its own disclosure line with it, so a machine +with many repos would be told nothing about the Codex sessions that have no +`repo_root` at all. The grouping is computed in full either way; the +`LIMIT` only decided how much of it the renderer got to see. + +`daily` has the same shape with a sharper edge, because the block states +its window in the header. A 30-day window rendered as 14 rows under a +header that says 30 is a table that does not answer the question above it, +and anyone summing the column gets half the period without being told. The +renderer shows `MAX_DAY_ROWS` and states how many days it folded. + +`tools` keeps its `LIMIT 10` and has no fold line: "the ten most-used +tools" is the whole question there, not a truncation of a larger one. + +An empty result gets the same treatment. "Nothing recorded yet" is a claim +about the cache, and when the LLP 0105 filter took every row it is false - +the sessions are recorded, just not visible from here. The runner reports +whether it withheld anything, and the renderer picks the sentence that is +true. + ### What the block omits, it says {#disclosure} The overview runs through the same `executeQuerySql` every other surface diff --git a/src/core/cli/stdio.js b/src/core/cli/stdio.js index 76e09d8f..170d5925 100644 --- a/src/core/cli/stdio.js +++ b/src/core/cli/stdio.js @@ -5,6 +5,24 @@ export function isTty(stream) { return !!stream && typeof stream === 'object' && /** @type {{ isTTY?: boolean }} */ (stream).isTTY === true } +/** + * Whether to emit ANSI on this stream: a TTY, and not vetoed by `NO_COLOR`. + * + * `isTty` alone is the wrong test. `NO_COLOR` (no-color.org) is set by + * people whose terminal is a TTY and who still want plain text - low-vision + * users on high-contrast themes, anyone piping through a pager that renders + * escapes literally. The TUI already honours it; commands that reach for + * `isTty` directly did not, so the same run could be half-coloured. + * + * @param {unknown} stream + * @param {Record} [env] + * @returns {boolean} + */ +export function useColor(stream, env = process.env) { + if (env?.NO_COLOR) return false + return isTty(stream) +} + /** * @param {unknown} stdin * @returns {Promise} diff --git a/src/core/cli/wizard/first_look.js b/src/core/cli/wizard/first_look.js index 3eac4d60..6e984974 100644 --- a/src/core/cli/wizard/first_look.js +++ b/src/core/cli/wizard/first_look.js @@ -40,14 +40,24 @@ export { overviewRunnerFromCtx as firstLookRunnerFromCtx } from '../../query/ove * cannot act on, attached to a block whose backfilled rows were * force-flushed anyway. `hyp query overview` prints both. * + * The sink closes. An expired deadline abandons queries that keep running, + * and one of them resolving late must not print a disclosure after the + * privacy narration that setup documents as its last words. + * * @ref LLP 0105 [implements]: withholding is disclosed on every surface, setup included * @param {{ write(chunk: string): unknown }} stderr - * @returns {(notice: OverviewNotice) => void} + * @returns {((notice: OverviewNotice) => void) & { close(): void }} */ export function firstLookNoticeSink(stderr) { - return (notice) => { - if (notice.kind === 'local-only') stderr.write(notice.line) - } + let open = true + const sink = /** @type {((notice: OverviewNotice) => void) & { close(): void }} */ ( + /** @param {OverviewNotice} notice */ + (notice) => { + if (open && notice.kind === 'local-only') stderr.write(notice.line) + } + ) + sink.close = () => { open = false } + return sink } /** The wizard's heading for the shared block: this is a setup milestone. */ @@ -133,10 +143,19 @@ export async function runWizardFirstLook({ runner, stdout, color = false, budget // and a fourth in flight is a shorter block, not a blank one. const partial = emptyOverview() // The whole step is inside one try, not just the queries: rendering - // and writing can fail too (an unforeseen row shape, or EPIPE when - // stdout is a closed pipe), and an escape from *any* of it would - // surface as `hyp: ` and a non-zero exit from an install that - // had already fully succeeded. Nothing here may fail setup. + // and writing can fail too (an unforeseen row shape, or a stream that + // throws on write), and an escape from *any* of it would surface as + // `hyp: ` and a non-zero exit from an install that had already + // fully succeeded. Nothing here may fail setup. + // + // Scope, stated precisely: this contains *synchronous* failures. An + // asynchronous stream error - EPIPE on a real pipe arrives as an + // 'error' event on the socket, not as a throw - bypasses every + // try/catch in the process and is a CLI-wide concern rather than this + // step's (see #409). Unreachable here in practice on two counts: the + // wizard runs only under `isTty`, and the block is ~4 KB, well inside + // a 64 KB pipe buffer, so the write completes before a reader's exit + // could matter. try { // Every section, the same block `hyp query overview` prints. The // run is longer for it (~60 lines against ~35), which the privacy @@ -170,7 +189,13 @@ export async function runWizardFirstLook({ runner, stdout, color = false, budget // `footer: false` because the closing line below is this run's single // pointer: setup should teach one command, not two dim lines naming // the same one. - stdout.write(renderOverview({ ...rows, title: FIRST_LOOK_TITLE, color, footer: false })) + stdout.write(renderOverview({ + ...rows, + title: FIRST_LOOK_TITLE, + color, + footer: false, + withheld: runner.sawWithholding?.() ?? false, + })) if (expired) { // Name the missing sections as *unfinished*, not as empty. "no // repos" and "the repos section did not finish" are different diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index 35f72826..a32af460 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -20,7 +20,7 @@ import { collectHypAwareStatus } from '../../daemon/status.js' import { formatFirstSyncDeadline, readFirstSyncDeadline } from '../../usage-policy/first_sync_hold.js' import { runPickerFinale, writeWalkthroughRunSummary } from '../walkthrough.js' import { LOGIN_ORG_SELECTION_MESSAGE } from '../remote_commands.js' -import { isTty } from '../stdio.js' +import { useColor } from '../stdio.js' import { evaluateReturningGate, runWizardFork } from './fork.js' import { firstLookNoticeSink, firstLookRunnerFromCtx, runWizardFirstLook } from './first_look.js' import { computeCentralLockedSources, runWizardJoin } from './join.js' @@ -173,11 +173,17 @@ export async function runInitWizard(opts) { // install gets no extra output, and a dry run has no writes to look at. // @ref LLP 0135#first-look [implements]: placed after the finale (backfill has landed) and before the privacy narration, which stays the last words if (interactive && !cancelled && opts.finale?.dryRun !== true) { + const notices = firstLookNoticeSink(opts.stderr) await runWizardFirstLook({ - runner: opts.firstLook ?? firstLookRunnerFromCtx(opts.ctx, firstLookNoticeSink(opts.stderr)), + runner: opts.firstLook ?? firstLookRunnerFromCtx(opts.ctx, notices), stdout: opts.stdout, - color: isTty(opts.stdout), + color: useColor(opts.stdout, opts.env), }) + // The abandoned queries from an expired deadline keep running and can + // still resolve with a withheld-row report. Close the sink so that + // report cannot land after the privacy narration below, which is + // documented to be the last thing on screen. + notices.close() } // The wizard's last words on the team pathway: when the first upload diff --git a/src/core/commands/query.js b/src/core/commands/query.js index 26388352..534cc16a 100644 --- a/src/core/commands/query.js +++ b/src/core/commands/query.js @@ -4,7 +4,7 @@ import { Attr, withSpan } from '../observability/index.js' import { migrateLegacyPartitions } from '../cache/migrate.js' import { renderSchema, schemaForDataset } from '../query/schema.js' import { parseCommandArgv } from '../cli/verb_codec.js' -import { isTty } from '../cli/stdio.js' +import { useColor } from '../cli/stdio.js' /** * @import { CommandRunContext, VerbInputSchema } from '../../../hypaware-plugin-kernel-types.js' @@ -80,6 +80,18 @@ export async function runQueryStatus(_argv, ctx) { return 0 } +const QUERY_OVERVIEW_USAGE = 'usage: hyp query overview [--json] [--sql] [--days ]' + +/** @type {VerbInputSchema} */ +const QUERY_OVERVIEW_SCHEMA = { + type: 'object', + properties: { + json: { type: 'boolean', default: false }, + sql: { type: 'boolean', default: false }, + days: { type: 'integer', minimum: 1 }, + }, +} + /** * `hyp query overview [--json] [--sql] [--days ]` * @@ -115,18 +127,25 @@ export async function runQueryStatus(_argv, ctx) { export async function runQueryOverview(argv, ctx) { const { OVERVIEW_DATASET, collectOverview, overviewRunnerFromCtx, renderOverview } = await import('../query/overview.js') - const json = argv.includes('--json') - const showSql = argv.includes('--sql') - const daysFlag = argv.indexOf('--days') - /** @type {number | undefined} */ - let days - if (daysFlag !== -1) { - days = Number(argv[daysFlag + 1]) - if (!Number.isInteger(days) || days < 1) { - ctx.stderr.write('hyp query overview: --days takes a whole number of days (1 or more)\n') - return 2 - } + // Through the shared codec, not a hand-rolled `indexOf('--days')`: that + // form silently ignores `--days=7` and accepts unknown flags, so a user + // who pinned a window would get the auto-planned one and no indication + // that anything was dropped. + const parsed = parseCommandArgv(argv, QUERY_OVERVIEW_SCHEMA) + if ('help' in parsed) { + ctx.stdout.write(`${QUERY_OVERVIEW_USAGE}\n`) + return 0 + } + if (!parsed.ok) { + ctx.stderr.write(`hyp query overview: ${parsed.error}\n${QUERY_OVERVIEW_USAGE}\n`) + return 2 } + const p = /** @type {{ json: boolean, sql: boolean, days?: number }} */ (parsed.params) + const json = p.json + const showSql = p.sql + // `minimum: 1` on the schema does the range check, with the codec's + // standard wording ("--days expects a positive integer (got 0)"). + const days = p.days // Same reporting as `hyp query sql`: a withheld-row count is required // disclosure (LLP 0105), a freshness line is advisory. Both to stderr // so stdout stays the block (and stays valid JSON under --json). @@ -185,7 +204,12 @@ export async function runQueryOverview(argv, ctx) { ctx.stdout.write(JSON.stringify(overview, null, 2) + '\n') return 0 } - ctx.stdout.write(renderOverview({ ...overview, color: isTty(ctx.stdout), showSql })) + ctx.stdout.write(renderOverview({ + ...overview, + color: useColor(ctx.stdout, ctx.env), + showSql, + withheld: runner.sawWithholding?.() ?? false, + })) return 0 }, { component: 'query' } diff --git a/src/core/query/overview.js b/src/core/query/overview.js index 2b1b71ba..a7cb7e8d 100644 --- a/src/core/query/overview.js +++ b/src/core/query/overview.js @@ -82,6 +82,15 @@ export const OVERVIEW_PROBE_SQL = * @returns {{ models: string, daily: string, repos: string, tools: string }} */ export function buildOverviewSql(since) { + // `since` is interpolated, not bound - the executor takes no parameters. + // Today it can only be a projector-issued `date` (always + // `toISOString().slice(0, 10)`) or the empty string, so nothing hostile + // reaches here. But the value round-trips out of the cache, and "some + // other package maintains an invariant" is the wrong thing for a string + // concatenated into SQL to rest on. Assert the shape at the seam. + if (since !== '' && !/^\d{4}-\d{2}-\d{2}$/.test(since)) { + throw new Error(`buildOverviewSql: since must be YYYY-MM-DD (got ${JSON.stringify(since)})`) + } const window = `where date >= '${since}'` return { // Which providers and models this machine actually uses, by token volume. @@ -89,10 +98,14 @@ export function buildOverviewSql(since) { `select provider, model,\n ${SUM_INPUT},\n ${SUM_CACHED},\n ${SUM_OUTPUT}\n` + `from ai_gateway_messages ${window}\ngroup by 1, 2 order by input_tokens + output_tokens desc`, - // Sessions and tokens per day, most recent first. + // Sessions and tokens per day, most recent first. No LIMIT: the + // renderer shows the newest `MAX_DAY_ROWS` and states how many days it + // folded, which it can only count from the full result. A LIMIT here + // would truncate a 30-day window to 14 rows under a header that says + // 30, and the reader summing the column would silently get half. daily: `select date, count(distinct session_id) sessions,\n ${SUM_INPUT},\n ${SUM_CACHED},\n ${SUM_OUTPUT}\n` + - `from ai_gateway_messages ${window}\ngroup by 1 order by 1 desc limit 14`, + `from ai_gateway_messages ${window}\ngroup by 1 order by 1 desc`, // Where the work happens. Grouped by repo alone, not repo + branch: // `git_branch` is set on 15 of 431 sessions on the authoring machine @@ -101,9 +114,17 @@ export function buildOverviewSql(since) { // same repo, twice, looking like two places. Sessions with no repo are // folded into a count line by the renderer rather than filtered here, so // the total stays reconcilable. + // + // No LIMIT, for that same reason. The renderer's "+ N more repos" is + // computed from what this returns, so a LIMIT would cap N rather than + // the truth (a 20-row limit reports 12 hidden when 22 are), and the + // repo-less group - which sorts by token volume like any other - could + // be evicted off the end, taking its disclosure line with it. The + // grouping is computed in full either way; the LIMIT only decided how + // much of the answer the renderer got to see. repos: `select repo_root, count(distinct session_id) sessions,\n ${SUM_INPUT},\n ${SUM_CACHED},\n ${SUM_OUTPUT}\n` + - `from ai_gateway_messages ${window}\ngroup by 1 order by input_tokens + output_tokens desc limit 20`, + `from ai_gateway_messages ${window}\ngroup by 1 order by input_tokens + output_tokens desc`, // Which tools the models actually reach for. The part type is // `tool_call`, not `tool_use`: the projector normalizes every provider's @@ -217,6 +238,13 @@ const MAX_PROVIDER_ROWS = 8 /** Repos shown before the tail is folded into a count line. */ const MAX_REPO_ROWS = 8 +/** + * Days shown before the older ones are folded into a count line. Two weeks + * is enough to read a rhythm off; a 31-day window printed in full would + * make the daily table longer than the rest of the block combined. + */ +const MAX_DAY_ROWS = 14 + /** Bar column width, in cells. */ const BAR_WIDTH = 18 @@ -275,13 +303,19 @@ export function overviewRunnerFromCtx(ctx, onNotice) { if (!registry || typeof registry.getDataset !== 'function') return undefined /** @type {Set} */ const said = new Set() + // Recorded even when no `onNotice` was supplied: whether rows were + // withheld decides which empty state the block renders, which is a + // separate question from who wanted to be told about it. + let withheld = false /** @param {OverviewNotice} notice */ const say = (notice) => { + if (notice.kind === 'local-only') withheld = true if (!onNotice || said.has(notice.line)) return said.add(notice.line) onNotice(notice) } return { + sawWithholding: () => withheld, hasDataset(name) { try { return Boolean(registry.getDataset(name)) @@ -527,6 +561,7 @@ export async function collectOverview(runner, opts = {}) { * color?: boolean, * showSql?: boolean, * footer?: boolean, + * withheld?: boolean, * }} args * @returns {string} */ @@ -541,6 +576,7 @@ export function renderOverview({ color = false, showSql = false, footer = true, + withheld = false, }) { const statements = sql ?? buildOverviewSql(win?.since ?? '') let out = `\n${paint(title, ANSI.bold, color)}\n` @@ -548,8 +584,16 @@ export function renderOverview({ out += `${paint('─'.repeat(40), ANSI.dim, color)}\n` if (providerRows.length === 0) { - out += '\nNothing recorded yet. Start a session in a client you attached,\n' - out += 'then run `hyp query overview` again.\n' + // "Nothing recorded yet" is a claim about the cache; when the LLP 0105 + // filter took every row it is a false one, and the withheld-row count + // on stderr would be the only sign. Two different situations, two + // different sentences, neither of them "start a session" to someone + // whose sessions are all sitting there recorded. + out += withheld + ? '\nEvery recorded session in this window is marked local-only and not visible\n' + + 'from here. Re-run inside one of those directories, or with --include-local-only.\n' + : '\nNothing recorded yet. Start a session in a client you attached,\n' + + 'then run `hyp query overview` again.\n' return out } @@ -666,8 +710,9 @@ export function renderProviderMix(rows, color, showSql = false, sql = '') { * @returns {string} */ export function renderDailyActivity(rows, color, showSql = false, sql = '') { - const max = Math.max(...rows.map((r) => toNumber(r.input_tokens) + toNumber(r.output_tokens))) - const body = rows.map((r) => [ + const shown = rows.slice(0, MAX_DAY_ROWS) + const max = Math.max(...shown.map((r) => toNumber(r.input_tokens) + toNumber(r.output_tokens))) + const body = shown.map((r) => [ cell(r.date), formatCount(r.sessions), formatCount(r.input_tokens), @@ -676,14 +721,22 @@ export function renderDailyActivity(rows, color, showSql = false, sql = '') { tokenBar(toNumber(r.input_tokens), toNumber(r.output_tokens), max, color), ]) - const out = renderHeading('Sessions and tokens per day', sql, color, showSql) - return out + renderTable( + let out = renderHeading('Sessions and tokens per day', sql, color, showSql) + out += renderTable( ['day', 'sessions', 'input', 'cached', 'output', 'by input+output'], body, ['left', 'right', 'right', 'right', 'right', 'left'], color, tokenBarCaption(color) ) + // The header states the window; this table may be shorter than it. Say + // so, or someone summing the column gets a fraction of the period they + // were just told they were looking at. + const older = rows.length - shown.length + if (older > 0) { + out += paint(` + ${older} earlier day${older === 1 ? '' : 's'} in this window\n`, ANSI.dim, color) + } + return out } /** diff --git a/src/core/query/types.d.ts b/src/core/query/types.d.ts index 6c4bcabe..68b9869c 100644 --- a/src/core/query/types.d.ts +++ b/src/core/query/types.d.ts @@ -108,6 +108,13 @@ export interface OverviewQueryRunner { /** False when no plugin registered the dataset, so there is nothing to show. */ hasDataset(name: string): boolean run(sql: string): Promise<{ columns: string[]; rows: Record[] }> + /** + * True once the LLP 0105 filter withheld a row from any statement this + * runner issued. An empty result then means "withheld", not "nothing + * recorded" - two different sentences for the reader. Optional so test + * runners need not implement it. + */ + sawWithholding?(): boolean } /** diff --git a/test/core/cli/wizard/first-look.test.js b/test/core/cli/wizard/first-look.test.js index 795fa30b..29a40340 100644 --- a/test/core/cli/wizard/first-look.test.js +++ b/test/core/cli/wizard/first-look.test.js @@ -166,11 +166,15 @@ test('runWizardFirstLook: a query failure degrades to a skipped step, not a thro assert.equal(stdout.text(), '') }) -test('runWizardFirstLook: a write failure cannot escape and fail a finished install', async () => { - // stdout that throws the way a closed pipe does (`hyp init | head`). - // Rendering and writing sit after the queries, so a throw here used to - // escape into the wizard and surface as `hyp: EPIPE` with a non-zero exit - // from an install that had already succeeded. +test('runWizardFirstLook: a synchronous write failure cannot escape and fail a finished install', async () => { + // A stdout whose `write` throws. Rendering and writing sit after the + // queries, so a throw here used to escape into the wizard and surface as + // `hyp: ` with a non-zero exit from an install that had already + // succeeded. + // + // What this does NOT pin: an async EPIPE from a real pipe, which arrives + // as an 'error' event and no try/catch can contain (#409). Asserting that + // would need a real pipe, and it is a CLI-wide concern, not this step's. const exploding = { write() { const err = /** @type {Error & { code?: string }} */ (new Error('write EPIPE')) @@ -220,6 +224,17 @@ test('firstLookNoticeSink: discloses withheld rows, drops the freshness line', a assert.equal(stderr.text(), 'local-only: withheld 3 row(s) not visible from this full caller\n') }) +test('firstLookNoticeSink: a closed sink drops a late disclosure', async () => { + // An expired deadline abandons queries that keep running. One resolving + // after the step returned must not print over the privacy narration, + // which setup documents as its last words. + const stderr = makeBuf() + const sink = firstLookNoticeSink(stderr) + sink.close() + sink({ kind: 'local-only', line: 'local-only: withheld 3 row(s)\n' }) + assert.equal(stderr.text(), '') +}) + test('runWizardFirstLook: no runner (no query registry) skips', async () => { const stdout = makeBuf() const result = await runWizardFirstLook({ stdout }) diff --git a/test/core/query-overview.test.js b/test/core/query-overview.test.js index 16abc8ba..6e82cfb4 100644 --- a/test/core/query-overview.test.js +++ b/test/core/query-overview.test.js @@ -17,10 +17,14 @@ import { describeWindow, formatCount, overviewRunnerFromCtx, + renderDailyActivity, renderOverview, + renderRepoMix, } from '../../src/core/query/overview.js' import { runQueryOverview } from '../../src/core/commands/query.js' +/** @import { OverviewWindow } from '../../src/core/query/types.js' */ + // The shared gateway overview (LLP 0135 #first-look): the rendered block is // a pure function of the rows, the runner is the ordinary query seam, and // `hyp query overview` reprints exactly what the wizard ended on. @@ -44,7 +48,7 @@ const PROVIDER_ROWS = [ /** * The window a rendered block describes; real runs always have one. * - * @type {import('../../src/core/query/types.js').OverviewWindow} + * @type {OverviewWindow} */ const WINDOW = { since: '2026-07-23', until: '2026-07-24', days: 2, rows: 5150, @@ -723,7 +727,12 @@ test('hyp query overview: a withheld row is disclosed on stderr, off the block', assert.equal(code, 0) assert.match(stderr.text(), /^local-only: withheld \d+ row\(s\)/) assert.ok(!stdout.text().includes('local-only:')) - assert.match(stdout.text(), /Nothing recorded yet|What HypAware has recorded/) + // And the empty state names the real reason. "Nothing recorded yet" is a + // claim about the cache, and here it is simply false - every one of those + // sessions is recorded, just not visible from this directory. + assert.match(stdout.text(), /Every recorded session in this window is marked local-only/) + assert.match(stdout.text(), /--include-local-only/) + assert.ok(!stdout.text().includes('Nothing recorded yet')) rmSync(root, { recursive: true, force: true }) }) @@ -781,3 +790,107 @@ test('formatCount: groups thousands and passes non-numbers through', () => { assert.equal(formatCount(1234567), '1,234,567') assert.equal(formatCount(null), '(none)') }) + +// --- what the block folds, it counts correctly --- + +test('renderRepoMix: the fold count is the real tail, and the repo-less line survives it', () => { + // 30 named repos plus a repo-less group. The SQL carries no LIMIT for + // exactly this reason: a 20-row cap would report "+ 12 more" (the tail of + // what SQL returned) rather than "+ 22 more" (the truth), and the + // repo-less group - which sorts by token volume like any other row - + // could be pushed off the end, taking its disclosure line with it. + /** @type {Record[]} */ + const rows = Array.from({ length: 30 }, (_, i) => ({ + repo_root: `/w/r${i}`, sessions: 2, input_tokens: 1000 - i, cached_tokens: 0, output_tokens: 10, + })) + rows.push({ repo_root: null, sessions: 41, input_tokens: 1, cached_tokens: 0, output_tokens: 1 }) + const out = renderRepoMix(rows, false) + assert.match(out, /\+ 22 more repos/) + assert.match(out, /\+ 41 sessions with no repo recorded/) + assert.ok(!out.includes('+ 12 more')) + // Only MAX_REPO_ROWS are tabled; the rest are the count line. + assert.equal((out.match(/^\s+\/w\/r\d+\s/gm) ?? []).length, 8) +}) + +test('buildOverviewSql: no LIMIT on the sections whose tails are counted', () => { + const sql = buildOverviewSql('2026-07-01') + // A LIMIT here would silently become the renderer's idea of the whole + // result, and every fold count would be computed against it. + assert.ok(!/limit/i.test(sql.repos)) + assert.ok(!/limit/i.test(sql.daily)) + assert.ok(!/limit/i.test(sql.models)) + // tools is the exception: it has no fold line, and "top 10 tools" is the + // whole question rather than a truncation of it. + assert.match(sql.tools, /limit 10/) +}) + +test('renderDailyActivity: a window longer than the table says how many days it folded', () => { + const rows = Array.from({ length: 30 }, (_, i) => ({ + date: `2026-07-${String(30 - i).padStart(2, '0')}`, + sessions: 2, input_tokens: 100, cached_tokens: 10, output_tokens: 5, + })) + const out = renderDailyActivity(rows, false) + // The header states a 30-day window; the table shows 14. Summing this + // column without the fold line silently yields half the period. + assert.equal((out.match(/^\s+2026-07-\d\d\s/gm) ?? []).length, 14) + assert.match(out, /\+ 16 earlier days in this window/) +}) + +test('renderDailyActivity: a window that fits says nothing', () => { + const rows = Array.from({ length: 3 }, (_, i) => ({ + date: `2026-07-0${3 - i}`, sessions: 1, input_tokens: 10, cached_tokens: 1, output_tokens: 1, + })) + assert.ok(!renderDailyActivity(rows, false).includes('earlier day')) +}) + +test('buildOverviewSql: rejects a since that is not a plain date', () => { + // `since` is concatenated into five statements and the executor takes no + // bind parameters, so the shape is asserted at the seam rather than left + // to an invariant maintained in another package. + assert.throws(() => buildOverviewSql("2026-07-01' or '1'='1"), /must be YYYY-MM-DD/) + assert.throws(() => buildOverviewSql('yesterday'), /must be YYYY-MM-DD/) + // The empty string is the no-window case the renderer passes when there + // is nothing to scope. + assert.doesNotThrow(() => buildOverviewSql('')) + assert.doesNotThrow(() => buildOverviewSql('2026-07-01')) +}) + +// --- flag parsing goes through the shared codec --- + +test('hyp query overview: --days=7 is honored, not silently ignored', async () => { + const { ctx, stdout } = ctxWithRows() + assert.equal(await runQueryOverview(['--days=1'], ctx), 0) + // A pinned window overrides the planner, so the stated period is the one + // asked for rather than the one measured. + assert.match(stdout.text(), /2026-07-24 to 2026-07-24/) +}) + +test('hyp query overview: an unknown flag is refused, not ignored', async () => { + const { ctx, stdout, stderr } = ctxWithRows() + assert.equal(await runQueryOverview(['--bogus'], ctx), 2) + assert.match(stderr.text(), /unknown flag --bogus/) + assert.match(stderr.text(), /usage: hyp query overview/) + assert.equal(stdout.text(), '') +}) + +test('hyp query overview: --days rejects non-positive and non-integer values', async () => { + for (const bad of ['0', '-1', 'abc', '3.5']) { + const { ctx, stderr } = ctxWithRows() + assert.equal(await runQueryOverview(['--days', bad], ctx), 2, `--days ${bad}`) + assert.match(stderr.text(), /--days expects a positive integer/) + } +}) + +test('hyp query overview: --help prints usage and exits 0', async () => { + const { ctx, stdout } = ctxWithRows() + assert.equal(await runQueryOverview(['--help'], ctx), 0) + assert.match(stdout.text(), /^usage: hyp query overview/) +}) + +test('hyp query overview: NO_COLOR suppresses ANSI even on a TTY', async () => { + const { ctx, stdout } = ctxWithRows() + ctx.stdout.isTTY = true + ctx.env = { NO_COLOR: '1' } + assert.equal(await runQueryOverview([], ctx), 0) + assert.ok(!/\x1b\[/.test(stdout.text())) +}) From 64adc0dac5d45ab253f509f27aa5a5ef1a4cea50 Mon Sep 17 00:00:00 2001 From: Brendan McMullen Date: Mon, 27 Jul 2026 16:23:54 -0700 Subject: [PATCH 3/3] Make the local-only override real, and survive a broken pipe (round 2 of #407) **Blocker: the block advised a flag it refused.** The withheld-rows notice names `--include-local-only` as the remedy, on stderr and again in the new empty state - and round 1's move to `parseCommandArgv` turned that flag from a silent no-op into `exit 2, unknown flag`. A user whose sessions are all local-only was told twice to run something that refuses. Fixed by supporting the flag rather than softening the advice: the disclosure is right that this is the way to see those rows. Declared on `QUERY_OVERVIEW_SCHEMA` with the same consent wording as the `query sql` verb, threaded through `overviewRunnerFromCtx` to `executeQuerySql`. The wizard never passes it - nothing in a setup step should quietly widen what a captured transcript can carry, and whoever reads the notice can run the command themselves. **The EPIPE finding was right after all, and this PR is what reaches it.** I reported no live reproduction; that was measured on payloads under the ~64 KiB pipe buffer, where the write completes before the reader's exit can matter. Dropping the SQL limits made `--json` unbounded (the counts behind the fold lines have to be exact), and past the buffer an async 'error' event is fatal: `exit 1` and a stack trace from a command that had already done its work. `bin/hypaware.js` now installs a stdout/stderr 'error' listener before anything writes (`cli/stream_errors.js`). EPIPE is swallowed - a reader closing the pipe is `| head` saying enough, not a failure to report - and the run finishes its normal path, observability shutdown included, exiting with the code the command chose. Pinned against a real pipe, both directions: 400 KB into a closed reader exits 7 with an empty stderr with the handler, exits 1 with a stack without it. That is the test round 1 correctly said a synchronously-throwing stub could not stand in for. The first-look comment no longer claims async EPIPE is unreachable; it says where the handling lives and why it cannot be a command's to catch. Co-Authored-By: Claude Opus 5 (1M context) --- bin/hypaware.js | 9 ++ ...0135-install-experience-overhaul.design.md | 9 ++ src/core/cli/core_commands.js | 2 +- src/core/cli/stream_errors.js | 54 +++++++++++ src/core/cli/wizard/first_look.js | 9 +- src/core/commands/query.js | 25 ++++- src/core/query/overview.js | 12 ++- test/core/cli/stream-errors.test.js | 95 +++++++++++++++++++ test/core/cli/wizard/first-look.test.js | 5 +- test/core/query-overview.test.js | 41 ++++++++ 10 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 src/core/cli/stream_errors.js create mode 100644 test/core/cli/stream-errors.test.js diff --git a/bin/hypaware.js b/bin/hypaware.js index 9c58f522..104046a9 100755 --- a/bin/hypaware.js +++ b/bin/hypaware.js @@ -38,6 +38,15 @@ if (argv[0] === '__smoke_internal') { const { dispatch } = await import('../src/core/cli/dispatch.js') const { installObservability } = await import('../src/core/observability/index.js') const { flushStream } = await import('../src/core/cli/flush-streams.js') +const { installStreamErrorHandlers } = await import('../src/core/cli/stream_errors.js') + +// Before anything writes: an asynchronous stdout/stderr failure (EPIPE when +// a reader like `head` walks away mid-write) is delivered as an 'error' +// event, which bypasses the try/catch below and every one inside the +// commands. Unlistened, it crashes a run that had already succeeded. +installStreamErrorHandlers([process.stdout, process.stderr], (message) => { + try { process.stderr.write(message) } catch { /* the stream is what failed */ } +}) const obs = installObservability() let exitCode = 1 diff --git a/llp/0135-install-experience-overhaul.design.md b/llp/0135-install-experience-overhaul.design.md index 0c502808..c6ee7caa 100644 --- a/llp/0135-install-experience-overhaul.design.md +++ b/llp/0135-install-experience-overhaul.design.md @@ -804,6 +804,15 @@ line, which is also why the wording is `renderLocalOnlyNotice` from the query verb rather than a second copy - two surfaces phrasing the same disclosure differently is how one of them ends up subtly wrong. +The override the disclosure names has to exist. `hyp query overview` takes +`--include-local-only` for exactly one reason: the withheld-row notice +names that flag as the remedy, and so does the withheld empty state. A +block that tells the user to run a flag and then exits 2 on it is worse +than one that never mentioned it - it turns a disclosure into a dead end. +The wizard never passes it: nothing in a setup step should quietly widen +what a captured transcript can carry, and the person reading the notice can +run the command themselves. + ## Telemetry Per CLAUDE.md's log-driven-development conventions, each new phase gets its diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index 0ecdf03e..3ca32de7 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -109,7 +109,7 @@ function buildCoreCommands(registry) { { name: 'query overview', summary: 'Show recorded AI traffic: tokens per model, activity per day, repos, and tools', - usage: 'hyp query overview [--json] [--sql] [--days ]', + usage: 'hyp query overview [--json] [--sql] [--days ] [--include-local-only]', run: runQueryOverview, }, { diff --git a/src/core/cli/stream_errors.js b/src/core/cli/stream_errors.js new file mode 100644 index 00000000..c5613887 --- /dev/null +++ b/src/core/cli/stream_errors.js @@ -0,0 +1,54 @@ +// @ts-check + +/** + * @import { Writable } from 'node:stream' + */ + +/** + * Survive an asynchronous write failure on stdout/stderr. + * + * A `try`/`catch` around a write catches a *synchronous* throw. On a pipe, + * `process.stdout` is a socket and a failed write arrives later as an + * `'error'` event, which no `try`/`catch` in the process can contain. With + * no listener, Node treats that as an unhandled `'error'`: stack trace, + * exit 1, from a command that had already done its work and simply had its + * reader walk away. + * + * The common case is benign and deserves no output at all: `hyp ... | head` + * closes the pipe once it has its lines, and the writer sees EPIPE. That is + * the reader saying "enough", not a failure of the command. + * + * Why this is needed at all when small outputs already survive: a write + * that fits the ~64 KiB pipe buffer completes before the reader's exit can + * matter, so most commands never hit it. Anything larger does - and + * `hyp query overview --json` is unbounded by design, since the row counts + * behind the block's fold lines have to be exact. + * + * Errors are swallowed rather than exited on, so the run finishes its + * normal path (observability shutdown included) and exits with the code the + * command chose. Once a stream is broken, every later write to it fails the + * same way; each is handled and ignored. + * + * @param {Writable[]} streams + * @param {(message: string) => void} [onUnexpected] called once for a + * non-EPIPE stream error, which is worth a word even though nothing can + * be done about it + * @returns {() => void} detaches the listeners + */ +export function installStreamErrorHandlers(streams, onUnexpected) { + let reported = false + /** @type {Array<() => void>} */ + const detach = [] + for (const stream of streams) { + /** @param {NodeJS.ErrnoException} err */ + const handler = (err) => { + if (err?.code === 'EPIPE') return + if (reported) return + reported = true + onUnexpected?.(`hyp: output stream failed (${err?.code ?? err?.message ?? 'unknown'})\n`) + } + stream.on('error', handler) + detach.push(() => stream.off('error', handler)) + } + return () => { for (const off of detach) off() } +} diff --git a/src/core/cli/wizard/first_look.js b/src/core/cli/wizard/first_look.js index 6e984974..dccd49ad 100644 --- a/src/core/cli/wizard/first_look.js +++ b/src/core/cli/wizard/first_look.js @@ -151,11 +151,10 @@ export async function runWizardFirstLook({ runner, stdout, color = false, budget // Scope, stated precisely: this contains *synchronous* failures. An // asynchronous stream error - EPIPE on a real pipe arrives as an // 'error' event on the socket, not as a throw - bypasses every - // try/catch in the process and is a CLI-wide concern rather than this - // step's (see #409). Unreachable here in practice on two counts: the - // wizard runs only under `isTty`, and the block is ~4 KB, well inside - // a 64 KB pipe buffer, so the write completes before a reader's exit - // could matter. + // try/catch in the process, so it is not this block's to catch and + // never could be. `bin/hypaware.js` installs a listener for it + // (`cli/stream_errors.js`), which is where it belongs: it is a + // property of the process's streams, not of any one command. try { // Every section, the same block `hyp query overview` prints. The // run is longer for it (~60 lines against ~35), which the privacy diff --git a/src/core/commands/query.js b/src/core/commands/query.js index 534cc16a..6188ee04 100644 --- a/src/core/commands/query.js +++ b/src/core/commands/query.js @@ -80,7 +80,8 @@ export async function runQueryStatus(_argv, ctx) { return 0 } -const QUERY_OVERVIEW_USAGE = 'usage: hyp query overview [--json] [--sql] [--days ]' +const QUERY_OVERVIEW_USAGE = + 'usage: hyp query overview [--json] [--sql] [--days ] [--include-local-only]' /** @type {VerbInputSchema} */ const QUERY_OVERVIEW_SCHEMA = { @@ -89,11 +90,23 @@ const QUERY_OVERVIEW_SCHEMA = { json: { type: 'boolean', default: false }, sql: { type: 'boolean', default: false }, days: { type: 'integer', minimum: 1 }, + // The block's own withheld-rows disclosure names this flag as the + // remedy, on stderr and again in the empty state. Declaring it is what + // makes that advice true: without it the codec refuses the flag, so the + // one action the output told the user to take exits 2. + // @ref LLP 0105#override [implements]: the informed-consent override, offered wherever the withholding is disclosed + 'include-local-only': { + type: 'boolean', + default: false, + description: + 'Include local-only rows even when this context is synced. If this session ' + + 'is itself captured, their content enters the transcript and can be forwarded.', + }, }, } /** - * `hyp query overview [--json] [--sql] [--days ]` + * `hyp query overview [--json] [--sql] [--days ] [--include-local-only]` * * Prints the gateway overview: token volume per provider and model, then * sessions and tokens per day, repos, and tools. The same block the @@ -140,7 +153,7 @@ export async function runQueryOverview(argv, ctx) { ctx.stderr.write(`hyp query overview: ${parsed.error}\n${QUERY_OVERVIEW_USAGE}\n`) return 2 } - const p = /** @type {{ json: boolean, sql: boolean, days?: number }} */ (parsed.params) + const p = /** @type {{ json: boolean, sql: boolean, days?: number, 'include-local-only': boolean }} */ (parsed.params) const json = p.json const showSql = p.sql // `minimum: 1` on the schema does the range check, with the codec's @@ -149,7 +162,11 @@ export async function runQueryOverview(argv, ctx) { // Same reporting as `hyp query sql`: a withheld-row count is required // disclosure (LLP 0105), a freshness line is advisory. Both to stderr // so stdout stays the block (and stays valid JSON under --json). - const runner = overviewRunnerFromCtx(ctx, (notice) => ctx.stderr.write(notice.line)) + const runner = overviewRunnerFromCtx( + ctx, + (notice) => ctx.stderr.write(notice.line), + { includeLocalOnly: p['include-local-only'] === true } + ) if (!runner || !runner.hasDataset(OVERVIEW_DATASET)) { // Says what is true, then what to do. The absent thing is a dataset // name (`ai_gateway_messages`) that means nothing to the person who diff --git a/src/core/query/overview.js b/src/core/query/overview.js index a7cb7e8d..6fcd0bcd 100644 --- a/src/core/query/overview.js +++ b/src/core/query/overview.js @@ -294,11 +294,20 @@ function paint(text, sgr, on) { * @ref LLP 0105 [implements]: the overview inherits both halves - the filter and the disclosure that it filtered * @ref LLP 0135#disclosure [implements]: which report each caller passes on, and why they differ * + * `includeLocalOnly` is LLP 0105's informed-consent override, and it is the + * caller's to pass, never this module's to assume. `hyp query overview` + * exposes it because the disclosure it prints names it as the remedy - a + * block that says "rerun with --include-local-only" and then refuses the + * flag is worse than one that never mentioned it. The wizard never passes + * it: nothing during an unattended-feeling setup step should quietly widen + * what a captured transcript can carry. + * * @param {CommandRunContext} ctx * @param {(notice: OverviewNotice) => void} [onNotice] + * @param {{ includeLocalOnly?: boolean }} [opts] * @returns {OverviewQueryRunner | undefined} */ -export function overviewRunnerFromCtx(ctx, onNotice) { +export function overviewRunnerFromCtx(ctx, onNotice, opts = {}) { const registry = /** @type {any} */ (ctx)?.query if (!registry || typeof registry.getDataset !== 'function') return undefined /** @type {Set} */ @@ -331,6 +340,7 @@ export function overviewRunnerFromCtx(ctx, onNotice) { refresh: 'auto', config: ctx.config, callerCwd: typeof ctx.cwd === 'string' && ctx.cwd.length > 0 ? ctx.cwd : null, + includeLocalOnly: opts.includeLocalOnly === true, }) for (const line of result.freshnessMessages ?? []) { say({ kind: 'freshness', line: `${line}\n` }) diff --git a/test/core/cli/stream-errors.test.js b/test/core/cli/stream-errors.test.js new file mode 100644 index 00000000..4915038c --- /dev/null +++ b/test/core/cli/stream-errors.test.js @@ -0,0 +1,95 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { installStreamErrorHandlers } from '../../../src/core/cli/stream_errors.js' + +// Asynchronous stdout/stderr failures bypass every try/catch in the +// process. Unlistened they crash a command that had already done its work. +// @ref LLP 0135#first-look [tests]: nothing in the block may fail a run that succeeded + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') +const HANDLER = path.join(REPO_ROOT, 'src/core/cli/stream_errors.js') + +test('installStreamErrorHandlers: EPIPE is swallowed without a word', () => { + const stream = /** @type {any} */ (new EventEmitter()) + /** @type {string[]} */ + const said = [] + installStreamErrorHandlers([stream], (m) => said.push(m)) + // A reader that closed the pipe is the normal end of `| head`, not a + // failure of the command, and an unlistened 'error' would throw here. + stream.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) + assert.deepEqual(said, []) +}) + +test('installStreamErrorHandlers: any other failure is reported once', () => { + const out = /** @type {any} */ (new EventEmitter()) + const err = /** @type {any} */ (new EventEmitter()) + /** @type {string[]} */ + const said = [] + installStreamErrorHandlers([out, err], (m) => said.push(m)) + out.emit('error', Object.assign(new Error('nope'), { code: 'ENOSPC' })) + // Once a stream is broken every later write fails the same way; saying so + // repeatedly would bury whatever the command was actually reporting. + out.emit('error', Object.assign(new Error('nope'), { code: 'ENOSPC' })) + err.emit('error', Object.assign(new Error('nope'), { code: 'ENOSPC' })) + assert.equal(said.length, 1) + assert.match(said[0], /output stream failed \(ENOSPC\)/) +}) + +test('installStreamErrorHandlers: the returned detach removes the listeners', () => { + const stream = /** @type {any} */ (new EventEmitter()) + const detach = installStreamErrorHandlers([stream]) + detach() + assert.equal(stream.listenerCount('error'), 0) +}) + +/** + * Run a child that writes `bytes` to stdout, with the handler installed or + * not, against a reader that walks away after the first chunk. + * + * A real pipe, not a stub whose `write` throws: EPIPE is delivered as an + * asynchronous 'error' event, and a synchronous stub proves nothing about + * whether that path is survivable. + * + * @param {{ install: boolean, bytes: number }} opts + */ +function writeIntoAClosedPipe({ install, bytes }) { + const body = `process.stdout.write('x'.repeat(${bytes}));setTimeout(()=>process.exit(7),200)` + const script = install + ? `import(${JSON.stringify(HANDLER)}).then(m=>{m.installStreamErrorHandlers([process.stdout,process.stderr]);${body}})` + : body + return new Promise((resolve) => { + const child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stderr = '' + child.stderr.on('data', (d) => { stderr += String(d) }) + child.stdout.once('data', () => child.stdout.destroy()) + child.on('exit', (code) => resolve({ code, stderr })) + }) +} + +test('a write past the pipe buffer survives a reader that walked away', async () => { + // 400 KB, well past the ~64 KiB pipe buffer. Under that, the write + // completes before the reader's exit can matter, which is why small + // outputs never showed this. `hyp query overview --json` is unbounded by + // design - the counts behind the fold lines have to be exact - so it can + // cross it on a large cache. + const withHandler = await writeIntoAClosedPipe({ install: true, bytes: 400_000 }) + // The command's own exit code, and nothing on stderr: the reader leaving + // is not this command's failure to report. + assert.equal(withHandler.code, 7) + assert.equal(withHandler.stderr, '') +}) + +test('the same write without the handler is what we are protecting against', async () => { + // Pins that the test above is testing something: unlistened, the async + // 'error' is fatal and the command's exit code is lost. + const bare = await writeIntoAClosedPipe({ install: false, bytes: 400_000 }) + assert.equal(bare.code, 1) + assert.match(bare.stderr, /EPIPE|Unhandled 'error' event/) +}) diff --git a/test/core/cli/wizard/first-look.test.js b/test/core/cli/wizard/first-look.test.js index 29a40340..ebf68933 100644 --- a/test/core/cli/wizard/first-look.test.js +++ b/test/core/cli/wizard/first-look.test.js @@ -173,8 +173,9 @@ test('runWizardFirstLook: a synchronous write failure cannot escape and fail a f // succeeded. // // What this does NOT pin: an async EPIPE from a real pipe, which arrives - // as an 'error' event and no try/catch can contain (#409). Asserting that - // would need a real pipe, and it is a CLI-wide concern, not this step's. + // as an 'error' event and no try/catch can contain. That is handled at + // the process's streams and pinned against a real pipe in + // test/core/cli/stream-errors.test.js. const exploding = { write() { const err = /** @type {Error & { code?: string }} */ (new Error('write EPIPE')) diff --git a/test/core/query-overview.test.js b/test/core/query-overview.test.js index 6e82cfb4..2a3172ad 100644 --- a/test/core/query-overview.test.js +++ b/test/core/query-overview.test.js @@ -894,3 +894,44 @@ test('hyp query overview: NO_COLOR suppresses ANSI even on a TTY', async () => { assert.equal(await runQueryOverview([], ctx), 0) assert.ok(!/\x1b\[/.test(stdout.text())) }) + +test('hyp query overview: --include-local-only is accepted, and does what the disclosure promises', async () => { + // The block's own withheld-rows notice names this flag as the remedy, in + // two places. Declaring it on the schema is what makes that advice true: + // the codec refuses undeclared flags, so advising one it does not declare + // would exit 2 on the single action the output told the user to take. + const root = mkdtempSync(path.join(tmpdir(), 'hyp-overview-override-')) + const shielded = path.join(root, 'private') + const open = path.join(root, 'open') + mkdirSync(shielded) + mkdirSync(open) + writeFileSync(path.join(shielded, '.hypignore'), 'local-only\n') + const rows = RAW_ROWS.map((row) => ({ ...row, cwd: shielded })) + + // Without it: withheld, disclosed, and the empty state names the flag. + const withoutFlag = ctxWithRows({ rows }) + withoutFlag.ctx.cwd = open + assert.equal(await runQueryOverview([], withoutFlag.ctx), 0) + assert.match(withoutFlag.stdout.text(), /--include-local-only/) + + // With it: the rows the disclosure was about actually appear. + const withFlag = ctxWithRows({ rows }) + withFlag.ctx.cwd = open + assert.equal(await runQueryOverview(['--include-local-only'], withFlag.ctx), 0) + assert.match(withFlag.stdout.text(), /claude-opus-5/) + assert.ok(!withFlag.stdout.text().includes('marked local-only')) + // Nothing was withheld, so nothing is disclosed. + assert.equal(withFlag.stderr.text(), '') + + rmSync(root, { recursive: true, force: true }) +}) + +test('hyp query overview: the usage line lists every flag the codec accepts', async () => { + const { ctx, stdout } = ctxWithRows() + await runQueryOverview(['--help'], ctx) + // A flag missing here is a flag users cannot discover; a flag listed but + // undeclared is one that exits 2 when they try it. + for (const flag of ['--json', '--sql', '--days', '--include-local-only']) { + assert.match(stdout.text(), new RegExp(flag.replace(/-/g, '\\-')), flag) + } +})