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/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/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..694d7df5 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..92d6f598 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..c6ee7caa 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,367 @@ 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. + +### 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 +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. + +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 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..3ca32de7 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 ] [--include-local-only]', + run: runQueryOverview, + }, { name: 'query schema', summary: 'Print the schema for a dataset', 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/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/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..dccd49ad --- /dev/null +++ b/src/core/cli/wizard/first_look.js @@ -0,0 +1,233 @@ +// @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. + * + * 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) & { close(): void }} + */ +export function firstLookNoticeSink(stderr) { + 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. */ +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 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, 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 + // 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, + 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 + // 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..a32af460 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 { useColor } 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,24 @@ 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) { + const notices = firstLookNoticeSink(opts.stderr) + await runWizardFirstLook({ + runner: opts.firstLook ?? firstLookRunnerFromCtx(opts.ctx, notices), + stdout: 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 // 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..6188ee04 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 { useColor } 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,159 @@ export async function runQueryStatus(_argv, ctx) { return 0 } +const QUERY_OVERVIEW_USAGE = + 'usage: hyp query overview [--json] [--sql] [--days ] [--include-local-only]' + +/** @type {VerbInputSchema} */ +const QUERY_OVERVIEW_SCHEMA = { + type: 'object', + properties: { + 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 ] [--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 + * `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') + // 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, '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 + // 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). + 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 + // 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: useColor(ctx.stdout, ctx.env), + showSql, + withheld: runner.sawWithholding?.() ?? false, + })) + 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..6fcd0bcd --- /dev/null +++ b/src/core/query/overview.js @@ -0,0 +1,1057 @@ +// @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) { + // `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. + 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. 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`, + + // 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. + // + // 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`, + + // 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 + +/** + * 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 + +/** 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 + * + * `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, opts = {}) { + const registry = /** @type {any} */ (ctx)?.query + 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)) + } 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, + includeLocalOnly: opts.includeLocalOnly === true, + }) + 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, + * withheld?: 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, + withheld = false, +}) { + 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) { + // "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 + } + + // 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 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), + formatCount(r.cached_tokens), + formatCount(r.output_tokens), + tokenBar(toNumber(r.input_tokens), toNumber(r.output_tokens), max, color), + ]) + + 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 +} + +/** + * 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..68b9869c 100644 --- a/src/core/query/types.d.ts +++ b/src/core/query/types.d.ts @@ -98,3 +98,72 @@ 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[] }> + /** + * 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 +} + +/** + * 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/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 new file mode 100644 index 00000000..ebf68933 --- /dev/null +++ b/test/core/cli/wizard/first-look.test.js @@ -0,0 +1,244 @@ +// @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 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. 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')) + 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('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 }) + 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..2a3172ad --- /dev/null +++ b/test/core/query-overview.test.js @@ -0,0 +1,937 @@ +// @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, + 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. +// @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 {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:')) + // 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 }) +}) + +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)') +}) + +// --- 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())) +}) + +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) + } +})