@jeffreyjyz/reqshape 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Jeffrey JYZ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # reqshape
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Measure the shape of your requests from opencode's own history, then price that shape against any model.
4
+
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/JeffreyJYZ/cmdcode-tools/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/JeffreyJYZ/cmdcode-tools/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/@jeffreyjyz/reqshape?style=flat-square)](https://www.npmjs.com/package/@jeffreyjyz/reqshape) [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
6
+
7
+ ## What it is
8
+
9
+ A **req** is one model call — the same unit the opencode sidebar counts: a single `assistant` row in opencode's store. An agentic ask is many reqs, because the model is called again after every tool result.
10
+
11
+ `reqshape` reads that history, drops the requests that ask for nothing, averages the rest, and hands you the answer as a token vector: *one of my requests is 8.2K input, 309 output, 139 reasoning, 248K cache read*. Then it prices that vector on every model OpenCode Go and CommandCode sell, and divides each plan's allowance and window caps by the result.
12
+
13
+ `mpc` answers the same question with a fixed assumption (800 in / 50K cache / 200 out). This answers it with your actual traffic — and usually disagrees, because context is re-read on every call and yours is deep.
14
+
15
+ ## Install
16
+
17
+ Install the published CLI globally:
18
+
19
+ ```sh
20
+ bun add -g @jeffreyjyz/reqshape
21
+ ```
22
+
23
+ Or run it from a checkout — Bun runs the TypeScript entry directly, or link the binary:
24
+
25
+ ```sh
26
+ bun install
27
+ bun run src/index.ts --help
28
+ bun link
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```sh
34
+ reqshape
35
+ ```
36
+
37
+ ```text
38
+ reqshape 7.3K reqs · every req weighted equally
39
+ measured from the opencode v2 store · priced by mpc
40
+
41
+ MEASURED 1,084 asks · 38 sessions · 12 projects · 2026-07-10 → 2026-09-29
42
+ DROPPED synthetic prompt 260 · trivial prompt 138 · system prompt 130 · empty prompt 14 · compaction prompt 9 · shell prompt 7
43
+
44
+ PER REQ input 8.2K · output 309 · reasoning 139 · cache read 248.0K · cache write 8.1
45
+ p10/p90 input 67/9.2K · output 46/701 · cache read 1.9K/648.7K
46
+ one vote per conversation instead: cache read 64.0K · input 10.4K · output 216
47
+
48
+ PER SIDE each side priced on its own traffic
49
+ OpenCode Go input 7.2K output 296 cache read 134.8K 3,684 reqs
50
+ CommandCode input 3.7K output 337 cache read 397.0K 3,360 reqs
51
+
52
+ POSITION how far into its session the req sat — every call re-reads the context
53
+ pos 1 cache read 579 input 8.5K output 75 28 reqs
54
+ pos 2-5 cache read 9.9K input 4.9K output 180 107 reqs
55
+ pos 6-20 cache read 21.5K input 8.2K output 279 356 reqs
56
+ pos 21-100 cache read 62.2K input 10.9K output 388 1,043 reqs
57
+ pos 101+ cache read 301.5K input 7.8K output 301 5,737 reqs
58
+
59
+ MODELS deepseek-v4.1-flash 2.8K · minimax-m3 1.7K · glm-5.2 1.4K · best-coding 447 · hy3-free 391 · big-pickle 140 …2 more
60
+
61
+ PROJECTED what the plan's allowance buys at the measured shape
62
+
63
+ MODEL PLAN $/req req/mo req/5h req/wk
64
+ ────────────────────────── ─────────── ──────────── ────────── ────────── ──────────
65
+ Jev CC GOAT $0.00034461 58.0K 11.6K 29.0K
66
+ Muse Spark 1.3 Contributor OC Go $0.00140623 42.7K 8.5K 21.3K
67
+ MiMo V2.6 Flash OC Go $0.00197864 30.3K 6.1K 15.2K
68
+ DeepSeek V4.1 Flash OC Go $0.0022531 26.6K 5.3K 13.3K
69
+ ...
70
+ Grok 4.6 OC Go $0.1431 105 21 52
71
+
72
+ CC account 1,898 reqs this period · GOAT · ends 2026-10-27
73
+ ```
74
+
75
+ ## Why "cache read" is the whole story
76
+
77
+ Every request re-sends the conversation, and providers bill the re-sent part as cached tokens. So cache read is not a property of how you prompt; it is a function of **how far into the conversation you are**:
78
+
79
+ | position in its session | reqs | cache read / req |
80
+ | --- | --- | --- |
81
+ | 1st | 28 | 579 |
82
+ | 2–5 | 107 | 9.9K |
83
+ | 6–20 | 356 | 21.5K |
84
+ | 21–100 | 1,043 | 62.2K |
85
+ | 101+ | 5,737 | 301.5K |
86
+
87
+ The first request of a conversation is essentially free to re-send; the hundredth re-sends 300K tokens. On this history **78% of all requests sit at 101+**, which is why the per-request average (248K) is four times the per-conversation average (64K) — one enormous session otherwise sets the profile.
88
+
89
+ `--weight turn` (the default) treats every req as one data point, because that is the unit you are billed in. `--weight session` gives each conversation one vote and prices that instead. Both are printed either way, so you can see the gap.
90
+
91
+ A model with no published cache-read rate is billed for that context at its **input** rate, and flagged with `*`. This is the single biggest swing in the table: a model with no caching support is not slightly more expensive for this traffic, it is unusable.
92
+
93
+ ## Noise filtering
94
+
95
+ "hi", "thanks", "ok", "continue" are real requests but say nothing about the work you do. `reqshape` drops an ask — the prompt *and* every req it drove — when:
96
+
97
+ - its prompt normalises to a keyword (`hi`, `hey there`, `thanks`, `great`, `lgtm`, `continue please`, …) — edit the list with `--keywords`
98
+ - its prompt is nothing but emoji or punctuation (`--min-chars` adds a length floor)
99
+ - it produced almost no output (`--min-output N`)
100
+
101
+ Structural noise is always dropped: auto-generated `synthetic` prompts, the `compaction` summary turn, `shell` commands, and `system` rows are not requests you made. `--keep-trivial` turns off the prompt filters and keeps everything else the same, and `--explain` prints every reason with its count — nothing is silently discarded.
102
+
103
+ ## Options
104
+
105
+ | flag | default | meaning |
106
+ | --- | --- | --- |
107
+ | `--weight <mode>` | `turn` | `turn` (every req equal) or `session` (one vote per conversation) |
108
+ | `--sessions <mode>` | `all` | `all` includes subagent sessions; `user` drops them |
109
+ | `--keywords <list>` | built-in list | comma-separated prompts to treat as noise (replaces the list) |
110
+ | `--min-chars <n>` | `0` | also drop prompts shorter than this |
111
+ | `--min-output <n>` | `0` | also drop asks that produced fewer output tokens |
112
+ | `--keep-trivial` | off | keep every prompt; only structural noise is dropped |
113
+ | `--since <date>` | all time | only asks on or after this date |
114
+ | `--project <dir>` | all | only sessions whose directory contains this |
115
+ | `--model <text>` | all | only model rows whose name contains this |
116
+ | `--limit <n>` | all | cap the number of model rows |
117
+ | `--sort <key>` | `reqmo` | `reqmo` (most requests first), `cost`, `name` |
118
+ | `--explain` | off | list every drop reason rather than the top six |
119
+ | `--format <mode>` | `text` | `text` or `json` |
120
+ | `--db <path>` | opencode's store | where the history lives (`OPENCODE_DB`) |
121
+ | `--mpc <bin>` | `mpc` | catalogue source (`MPC_BIN`) |
122
+ | `--cmduse <bin>` | `cmduse` | account line (`CMDUSE_BIN`) |
123
+ | `--no-account` | off | skip the `cmduse` account line |
124
+ | `--no-color` | off | plain output (also honours `NO_COLOR`) |
125
+
126
+ ### A model nobody sells
127
+
128
+ ```sh
129
+ reqshape --in 0.15 --out 0.6 --cache-read 0.003 --budget 60 --five-hour 12 --weekly 30
130
+ ```
131
+
132
+ `--budget` is the monthly allowance; `--five-hour` and `--weekly` are the plan's caps, and without them those two columns show `—` rather than inventing a window. Omit `--cache-read` and the context is billed at the input rate, flagged `*`.
133
+
134
+ ## Where the numbers come from
135
+
136
+ - **Your traffic** — opencode's own store, read-only (`~/.local/share/opencode/opencode.db`). v2's `session_message` is read when present, the pre-v2 `message` table otherwise, and session metadata is joined across `session` and `session_v2`.
137
+ - **Model rates, allowances and window caps** — `mpc --json`. The 5-hour and weekly caps are read back from mpc's own figures, so the provider's window rule is not restated here where it could drift.
138
+ - **The account line** — `cmduse -1 --json`, purely as context for how much of your usage this profile covers. If it cannot answer, the line is simply absent.
139
+
140
+ ### Per side, and back into `mpc`
141
+
142
+ `--format json` carries `sides.oc` and `sides.cc`: the same measured profile split by whose traffic it is (requests to `opencode*` vs the `CC_PREFIXES` ids), with each side's own request count. `mpc --shape measured` reads exactly that payload and prices each plan on its side's shape, so its estimated `req/mo` is "how many of *my* requests fit" instead of a fixed 800-in / 50K-cache / 200-out assumption.
143
+
144
+ Runs take about 20 seconds: both `mpc` and `cmduse` go to the network.
145
+
146
+ ## Links
147
+
148
+ - [Source (GitHub)](https://github.com/JeffreyJYZ/cmdcode-tools/tree/main/reqshape)
149
+ - [npm: @jeffreyjyz/reqshape](https://www.npmjs.com/package/@jeffreyjyz/reqshape)
150
+ - [MIT license](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
- "name": "@jeffreyjyz/reqshape",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@jeffreyjyz/reqshape",
3
+ "version": "0.1.0",
4
+ "description": "Measure the shape of your requests from opencode's own history, then price it against any model",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "reqshape": "src/index.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "LICENSE"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "start": "bun run src/index.ts",
19
+ "test": "bun test",
20
+ "fmt": "biome format --write .",
21
+ "check": "biome check --write .",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "devDependencies": {
25
+ "@biomejs/biome": "^2.5.13",
26
+ "@types/bun": "latest",
27
+ "typescript": "^7"
28
+ },
29
+ "dependencies": {
30
+ "cac": "^7.0.0"
31
+ }
32
+ }
@@ -0,0 +1,108 @@
1
+ import type { CustomRates } from "~/market/rates.ts";
2
+
3
+ /**
4
+ * cac prints --help and --version itself and only sets `run = false`, which is
5
+ * already false for us, so the tool would otherwise carry on and print a report
6
+ * after the help text. This reports that the question is already answered.
7
+ */
8
+ export function answeredByCac(flags: Record<string, unknown>): boolean {
9
+ return flags.help === true || flags.version === true;
10
+ }
11
+
12
+ /**
13
+ * mri coerces a numeric-looking value to a number, `""` to `0` and a valueless
14
+ * flag to `true`, so a string option is read through here rather than trusted
15
+ * to arrive as a string.
16
+ */
17
+ export function str(value: unknown): string | undefined {
18
+ if (value === undefined || value === null || typeof value === "boolean") {
19
+ return undefined;
20
+ }
21
+ return String(value);
22
+ }
23
+
24
+ export function number(value: unknown, flag: string): number {
25
+ if (typeof value === "boolean") {
26
+ throw new Error(`--${flag} expects a number, got a bare flag`);
27
+ }
28
+ if (value === undefined || value === null) return 0;
29
+ const parsed = Number(value);
30
+ if (!Number.isFinite(parsed) || parsed < 0) {
31
+ throw new Error(
32
+ `--${flag} expects a number, got ${JSON.stringify(value)}`,
33
+ );
34
+ }
35
+ return parsed;
36
+ }
37
+
38
+ export function optionalNumber(value: unknown, flag: string): number | null {
39
+ if (value === undefined || value === null || value === "") return null;
40
+ return number(value, flag);
41
+ }
42
+
43
+ export function oneOf<T extends string>(
44
+ value: unknown,
45
+ allowed: readonly T[],
46
+ flag: string,
47
+ ): T | undefined {
48
+ if (value === undefined || value === null) return undefined;
49
+ const text = String(value);
50
+ if (!(allowed as readonly string[]).includes(text)) {
51
+ throw new Error(
52
+ `--${flag} expects ${allowed.join(" | ")}, got "${text}"`,
53
+ );
54
+ }
55
+ return text as T;
56
+ }
57
+
58
+ export function sinceOf(value: unknown): number {
59
+ if (value === undefined || value === null || value === "") return 0;
60
+ const parsed = Date.parse(String(value));
61
+ if (Number.isNaN(parsed)) {
62
+ throw new Error(`--since expects a date, got ${JSON.stringify(value)}`);
63
+ }
64
+ return parsed;
65
+ }
66
+
67
+ /** `--keywords ""` and a bare `--keywords` both mean "no keyword list at all". */
68
+ export function keywordList(value: unknown): string[] | undefined {
69
+ if (value === undefined || value === null) return undefined;
70
+ if (value === true || value === 0 || value === "") return [];
71
+ return String(value)
72
+ .split(",")
73
+ .map((word) => word.trim().toLowerCase())
74
+ .filter(Boolean);
75
+ }
76
+
77
+ /** Custom rates only make sense with something to price them against. */
78
+ export function customOf(flags: Record<string, unknown>): CustomRates | null {
79
+ // Any custom-model flag counts as "this run is about a custom model". The
80
+ // window caps and label were previously ignored on their own (`customOf`
81
+ // returned null and they vanished); now they demand the rates + budget too.
82
+ const touched =
83
+ flags.in !== undefined ||
84
+ flags.out !== undefined ||
85
+ flags.cacheRead !== undefined ||
86
+ flags.cacheWrite !== undefined ||
87
+ flags.budget !== undefined ||
88
+ flags.fiveHour !== undefined ||
89
+ flags.weekly !== undefined ||
90
+ flags.label !== undefined;
91
+ if (!touched) return null;
92
+ const budget = number(flags.budget, "budget");
93
+ if (budget <= 0) {
94
+ throw new Error("--budget <usd> is required alongside custom rates");
95
+ }
96
+ return {
97
+ label: typeof flags.label === "string" ? flags.label : "custom model",
98
+ input: number(flags.in, "in"),
99
+ output: number(flags.out, "out"),
100
+ cacheRead: optionalNumber(flags.cacheRead, "cache-read"),
101
+ cacheWrite: optionalNumber(flags.cacheWrite, "cache-write"),
102
+ budget,
103
+ // cac camelCases `--five-hour` to `fiveHour`; a name whose dash is
104
+ // followed by a digit (`--cap-5h`) would stay hyphenated and be unreadable.
105
+ cap5h: optionalNumber(flags.fiveHour, "five-hour"),
106
+ capWeek: optionalNumber(flags.weekly, "weekly"),
107
+ };
108
+ }
@@ -0,0 +1,55 @@
1
+ import { DEFAULT_KEYWORDS } from "~/constants/keywords.ts";
2
+ import type { CustomRates } from "~/market/rates.ts";
3
+ import { defaultStorePath } from "~/measure/store.ts";
4
+
5
+ export interface Options {
6
+ /** `user` keeps only prompts you typed; `all` includes subagent sessions. */
7
+ sessions: "user" | "all";
8
+ /** Which mean the projection prices: the per-req mean, or one vote per conversation. */
9
+ weight: "turn" | "session";
10
+ keepTrivial: boolean;
11
+ keywords: string[];
12
+ minChars: number;
13
+ minOutput: number;
14
+ /** Epoch ms floor; 0 keeps everything. */
15
+ since: number;
16
+ project: string;
17
+ model: string;
18
+ limit: number;
19
+ sort: "reqmo" | "cost" | "name";
20
+ format: "text" | "json";
21
+ explain: boolean;
22
+ account: boolean;
23
+ /** cac already printed --help or --version; there is nothing left to do. */
24
+ handled: boolean;
25
+ color: "auto" | "always" | "never";
26
+ db: string;
27
+ mpc: string;
28
+ cmduse: string;
29
+ custom: CustomRates | null;
30
+ }
31
+
32
+ export function defaultOptions(): Options {
33
+ return {
34
+ sessions: "all",
35
+ weight: "turn",
36
+ keepTrivial: false,
37
+ keywords: [...DEFAULT_KEYWORDS],
38
+ minChars: 0,
39
+ minOutput: 0,
40
+ since: 0,
41
+ project: "",
42
+ model: "",
43
+ limit: 0,
44
+ sort: "reqmo",
45
+ format: "text",
46
+ explain: false,
47
+ account: true,
48
+ handled: false,
49
+ color: "auto",
50
+ db: defaultStorePath(),
51
+ mpc: process.env.MPC_BIN ?? "mpc",
52
+ cmduse: process.env.CMDUSE_BIN ?? "cmduse",
53
+ custom: null,
54
+ };
55
+ }
@@ -0,0 +1,101 @@
1
+ import { cac } from "cac";
2
+ import pkg from "../../package.json" with { type: "json" };
3
+ import {
4
+ answeredByCac,
5
+ customOf,
6
+ keywordList,
7
+ number,
8
+ oneOf,
9
+ sinceOf,
10
+ str,
11
+ } from "./flags.ts";
12
+ import { defaultOptions, type Options } from "./options.ts";
13
+
14
+ export function parseArgs(argv: string[]): Options {
15
+ const cli = cac("reqshape");
16
+ cli.option("--sessions <mode>", "all | user — user drops subagent sessions")
17
+ .option(
18
+ "--weight <mode>",
19
+ "turn | session — which mean the projection prices",
20
+ )
21
+ .option("--keep-trivial", "profile every prompt, noise included")
22
+ .option(
23
+ "--keywords <list>",
24
+ "comma-separated prompts counted as noise (replaces the default list)",
25
+ )
26
+ .option(
27
+ "--min-chars <n>",
28
+ "also drop prompts shorter than this (0 = off)",
29
+ )
30
+ .option(
31
+ "--min-output <n>",
32
+ "also drop asks producing fewer output tokens (0 = off)",
33
+ )
34
+ .option("--since <date>", "only asks on or after this date")
35
+ .option("--project <dir>", "only sessions under this directory")
36
+ .option("--model <text>", "only model rows whose name contains this")
37
+ .option("--limit <n>", "cap the number of model rows")
38
+ .option("--sort <key>", "reqmo | cost | name")
39
+ .option("--in <rate>", "custom model: input $/M tokens")
40
+ .option("--out <rate>", "custom model: output $/M tokens")
41
+ .option("--cache-read <rate>", "custom model: cache-read $/M tokens")
42
+ .option("--cache-write <rate>", "custom model: cache-write $/M tokens")
43
+ .option("--budget <usd>", "custom model: monthly allowance")
44
+ .option("--five-hour <usd>", "custom model: five-hour cap")
45
+ .option("--weekly <usd>", "custom model: weekly cap")
46
+ .option("--label <name>", "custom model: display name")
47
+ .option(
48
+ "--db <path>",
49
+ "opencode store (default ~/.local/share/opencode/opencode.db)",
50
+ )
51
+ .option("--mpc <bin>", "mpc binary (else MPC_BIN)")
52
+ .option("--cmduse <bin>", "cmduse binary (else CMDUSE_BIN)")
53
+ .option("--no-account", "skip the cmduse account line")
54
+ .option("--color <mode>", "auto | always | never")
55
+ .option("--explain", "list every drop reason rather than the top few")
56
+ .option("--format <mode>", "text | json");
57
+ cli.help();
58
+ cli.version(pkg.version);
59
+ cli.example("reqshape");
60
+ cli.example("reqshape --weight session --sort cost");
61
+ cli.example("reqshape --in 0.15 --out 0.6 --cache-read 0.003 --budget 60");
62
+
63
+ const { options: flags } = cli.parse(["node", "reqshape", ...argv], {
64
+ run: false,
65
+ });
66
+ const raw = (flags ?? {}) as Record<string, unknown>;
67
+ const options = defaultOptions();
68
+
69
+ options.sessions =
70
+ oneOf(raw.sessions, ["all", "user"], "sessions") ?? options.sessions;
71
+ options.weight =
72
+ oneOf(raw.weight, ["turn", "session"], "weight") ?? options.weight;
73
+ options.sort =
74
+ oneOf(raw.sort, ["reqmo", "cost", "name"], "sort") ?? options.sort;
75
+ options.format =
76
+ oneOf(raw.format, ["text", "json"], "format") ?? options.format;
77
+ // cac negates a declared flag itself, so --no-color arrives as `false`.
78
+ options.color =
79
+ raw.color === false
80
+ ? "never"
81
+ : (oneOf(raw.color, ["auto", "always", "never"], "color") ??
82
+ options.color);
83
+
84
+ options.keepTrivial = raw.keepTrivial === true;
85
+ options.keywords = keywordList(raw.keywords) ?? options.keywords;
86
+ options.minChars = number(raw.minChars, "min-chars");
87
+ options.minOutput = number(raw.minOutput, "min-output");
88
+ options.limit = number(raw.limit, "limit");
89
+ options.since = sinceOf(raw.since);
90
+ options.project = str(raw.project) ?? "";
91
+ options.model = str(raw.model) ?? "";
92
+ options.explain = raw.explain === true;
93
+ options.account = raw.account !== false;
94
+ options.handled = answeredByCac(raw);
95
+ options.db = str(raw.db) ?? options.db;
96
+ options.mpc = str(raw.mpc) ?? options.mpc;
97
+ options.cmduse = str(raw.cmduse) ?? options.cmduse;
98
+ options.custom = customOf(raw);
99
+
100
+ return options;
101
+ }
package/src/cli/run.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { entriesOf } from "~/market/entries.ts";
2
+ import { type Projection, projectAll } from "~/market/project.ts";
3
+ import { customEntry, type RateEntry } from "~/market/rates.ts";
4
+ import { loadAccount, loadMpc } from "~/market/sources.ts";
5
+ import { buildAsks } from "~/measure/asks.ts";
6
+ import { filterAsks } from "~/measure/filter.ts";
7
+ import { buildShape } from "~/measure/profile.ts";
8
+ import { readStore } from "~/measure/store.ts";
9
+ import type { Profile } from "~/types.ts";
10
+ import { setColorMode } from "~/view/format.ts";
11
+ import { renderJson } from "~/view/json.ts";
12
+ import { renderText } from "~/view/render.ts";
13
+ import type { Options } from "./options.ts";
14
+ import { parseArgs } from "./parse.ts";
15
+
16
+ function colorEnabled(options: Options): boolean {
17
+ if (options.color === "always") return true;
18
+ if (options.color === "never") return false;
19
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
20
+ }
21
+
22
+ /** Most requests per month first. Free models tie rather than compare as NaN. */
23
+ function byRequestsPerMonth(a: Projection, b: Projection): number {
24
+ if (a.requestsPerMonth === b.requestsPerMonth) return 0;
25
+ return a.requestsPerMonth > b.requestsPerMonth ? -1 : 1;
26
+ }
27
+
28
+ function sortProjections(
29
+ projections: Projection[],
30
+ sort: Options["sort"],
31
+ ): Projection[] {
32
+ const sorted = [...projections];
33
+ if (sort === "cost") {
34
+ return sorted.sort((a, b) => a.costPerReq - b.costPerReq);
35
+ }
36
+ if (sort === "name") {
37
+ return sorted.sort((a, b) =>
38
+ a.entry.model.localeCompare(b.entry.model),
39
+ );
40
+ }
41
+ return sorted.sort(byRequestsPerMonth);
42
+ }
43
+
44
+ /**
45
+ * The catalogue comes from mpc, which already knows both plans' pricing,
46
+ * allowances and window rules. A custom model is priced alongside it, and
47
+ * carries the run when mpc is unavailable.
48
+ */
49
+ function ratesOf(options: Options): RateEntry[] {
50
+ let entries: RateEntry[] = [];
51
+ try {
52
+ entries = entriesOf(loadMpc(options.mpc));
53
+ } catch (error) {
54
+ const detail = error instanceof Error ? error.message : String(error);
55
+ if (!options.custom) {
56
+ throw new Error(
57
+ `${detail}\nPass --in/--out/--budget to price a custom model instead.`,
58
+ );
59
+ }
60
+ process.stderr.write(
61
+ `reqshape: ${detail}\n pricing the custom model only\n`,
62
+ );
63
+ }
64
+ if (options.custom) entries.push(customEntry(options.custom));
65
+ return entries;
66
+ }
67
+
68
+ export async function run(argv: string[]): Promise<number> {
69
+ const options = parseArgs(argv);
70
+ // cac has already written the help or version text.
71
+ if (options.handled) return 0;
72
+ setColorMode(colorEnabled(options));
73
+
74
+ const store = readStore(options.db);
75
+ const asks = buildAsks(store.messages, store.sessions);
76
+ const { kept, drops } = filterAsks(asks, {
77
+ sessions: options.sessions,
78
+ keywords: new Set(options.keywords),
79
+ minChars: options.minChars,
80
+ minOutput: options.minOutput,
81
+ keepTrivial: options.keepTrivial,
82
+ since: options.since,
83
+ project: options.project,
84
+ });
85
+ const shape = buildShape(kept);
86
+
87
+ if (shape.reqs === 0) {
88
+ const why =
89
+ drops.map((drop) => `${drop.reason} ${drop.asks}`).join(" · ") ||
90
+ "no requests found";
91
+ process.stderr.write(
92
+ `reqshape: nothing to price — no requests survived the filters (${why})\n`,
93
+ );
94
+ return 1;
95
+ }
96
+
97
+ const profile: Profile =
98
+ options.weight === "session" ? shape.perSession : shape.perReq;
99
+
100
+ let entries = ratesOf(options);
101
+ const needle = options.model.trim().toLowerCase();
102
+ if (needle) {
103
+ entries = entries.filter((entry) =>
104
+ entry.model.toLowerCase().includes(needle),
105
+ );
106
+ }
107
+ const total = entries.length;
108
+ let projections = sortProjections(
109
+ projectAll(entries, profile),
110
+ options.sort,
111
+ );
112
+ if (options.limit > 0) projections = projections.slice(0, options.limit);
113
+ const account = options.account ? loadAccount(options.cmduse) : null;
114
+
115
+ if (options.format === "json") {
116
+ process.stdout.write(
117
+ renderJson({
118
+ shape,
119
+ projections,
120
+ drops,
121
+ weight: options.weight,
122
+ layout: store.layout,
123
+ profile: { ...profile },
124
+ }),
125
+ );
126
+ return 0;
127
+ }
128
+
129
+ process.stdout.write(
130
+ renderText({
131
+ shape,
132
+ projections,
133
+ drops,
134
+ weight: options.weight,
135
+ account,
136
+ layout: store.layout,
137
+ total,
138
+ explain: options.explain,
139
+ }),
140
+ );
141
+ return 0;
142
+ }
@@ -0,0 +1,4 @@
1
+ // Biome forbids control characters in regex literals, and a literal escape here
2
+ // would be one: build the pattern from a codepoint instead.
3
+ export const ESC = String.fromCharCode(27);
4
+ export const ANSI = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
@@ -0,0 +1,14 @@
1
+ import type { AskKind } from "~/types.ts";
2
+
3
+ /**
4
+ * Row kinds that *open* a new ask. Only a prompt the user typed does.
5
+ *
6
+ * `synthetic` / `system` / `compaction` / `shell` rows are interjections: a
7
+ * system-reminder lands after the prompt and before its answer, so treating one
8
+ * as a boundary filed the answer's reqs under the interjection, which the
9
+ * filter then discarded along with the whole response. They now continue the
10
+ * ask they interrupted.
11
+ */
12
+ export const BOUNDARY: Record<string, AskKind> = {
13
+ user: "user",
14
+ };