@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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Prompts that ask for nothing worth modelling: acknowledgements, greetings,
3
+ * and the one-word nudges an agentic loop hands back. Matched after punctuation
4
+ * is stripped, so `Hi!` and `thanks.` land here too.
5
+ */
6
+ export const DEFAULT_KEYWORDS = [
7
+ "hi",
8
+ "hi there",
9
+ "hey",
10
+ "hello",
11
+ "hello there",
12
+ "yo",
13
+ "sup",
14
+ "hiya",
15
+ "howdy",
16
+ "thanks",
17
+ "thank you",
18
+ "ty",
19
+ "thx",
20
+ "cheers",
21
+ "nice",
22
+ "great",
23
+ "awesome",
24
+ "perfect",
25
+ "cool",
26
+ "sweet",
27
+ "lovely",
28
+ "well done",
29
+ "good job",
30
+ "nice work",
31
+ "ok",
32
+ "okay",
33
+ "k",
34
+ "sure",
35
+ "yes",
36
+ "yeah",
37
+ "yep",
38
+ "y",
39
+ "no",
40
+ "nope",
41
+ "nah",
42
+ "n",
43
+ "continue",
44
+ "go",
45
+ "go on",
46
+ "go ahead",
47
+ "proceed",
48
+ "next",
49
+ "carry on",
50
+ "keep going",
51
+ "resume",
52
+ "done",
53
+ "stop",
54
+ "wait",
55
+ "hold on",
56
+ "hmm",
57
+ "hm",
58
+ "oh",
59
+ "ah",
60
+ "i see",
61
+ "got it",
62
+ "makes sense",
63
+ "make sense",
64
+ "sounds good",
65
+ "looks good",
66
+ "lgtm",
67
+ "agreed",
68
+ "right",
69
+ "correct",
70
+ "exactly",
71
+ "indeed",
72
+ ];
73
+
74
+ /**
75
+ * Filler that rides along with an acknowledgement — `hey there`, `continue
76
+ * please`, `thanks bro`. Only the all-words rule consults it, and it still
77
+ * needs a real keyword in the prompt, so a filler-heavy *ask* survives.
78
+ */
79
+ export const FILLER = new Set([
80
+ "there",
81
+ "then",
82
+ "again",
83
+ "please",
84
+ "pls",
85
+ "bro",
86
+ "man",
87
+ "dude",
88
+ "guys",
89
+ "all",
90
+ "everyone",
91
+ ]);
@@ -0,0 +1,6 @@
1
+ /** Column widths and the section-label gutter, shared by the text renderers. */
2
+ export const LABEL = 10;
3
+ export const NAME = 26;
4
+ export const PLAN = 11;
5
+ export const MONEY = 12;
6
+ export const CELL = 10;
@@ -0,0 +1,2 @@
1
+ /** Provider ids that mean CommandCode: the CLI, and the plugin's API variants. */
2
+ export const CC_PREFIXES = ["command-code", "commandcode"];
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ import { run } from "./cli/run.ts";
3
+
4
+ try {
5
+ process.exitCode = await run(process.argv.slice(2));
6
+ } catch (error) {
7
+ console.error(
8
+ `reqshape: ${error instanceof Error ? error.message : String(error)}`,
9
+ );
10
+ process.exitCode = 1;
11
+ }
@@ -0,0 +1,89 @@
1
+ import type { MpcJson, MpcSide, ProviderId, RateEntry } from "./rates.ts";
2
+ import { numberOr } from "./sources.ts";
3
+
4
+ /**
5
+ * The plan's window caps as a share of its monthly allowance. Taken from the
6
+ * plan block when published, otherwise read back off a priced row — mpc has
7
+ * already applied the provider's own rule there (20%/50% on OpenCode Go, 20%/50%
8
+ * on GOAT and Pro, 30%/60% on Max and Go), so stating the rule again here is how
9
+ * the two would drift apart.
10
+ */
11
+ function ratiosOf(
12
+ json: MpcJson,
13
+ ): Map<string, { fiveHour: number; weekly: number }> {
14
+ const ratios = new Map<string, { fiveHour: number; weekly: number }>();
15
+ for (const [id, plan] of Object.entries(json.plans ?? {})) {
16
+ const credits = numberOr(plan.credits);
17
+ const fiveHour = numberOr(plan.fiveHour);
18
+ const weekly = numberOr(plan.weekly);
19
+ if (credits && credits > 0 && fiveHour !== null && weekly !== null) {
20
+ ratios.set(id, {
21
+ fiveHour: fiveHour / credits,
22
+ weekly: weekly / credits,
23
+ });
24
+ }
25
+ }
26
+ for (const row of json.rows ?? []) {
27
+ for (const side of [row.oc, row.cc]) {
28
+ if (!side?.provider || ratios.has(side.provider)) continue;
29
+ const month = numberOr(side.requestsPerMonth);
30
+ const fiveHour = numberOr(side.requestsPerFiveHour);
31
+ const weekly = numberOr(side.requestsPerWeek);
32
+ if (!month || month <= 0 || fiveHour === null || weekly === null) {
33
+ continue;
34
+ }
35
+ ratios.set(side.provider, {
36
+ fiveHour: fiveHour / month,
37
+ weekly: weekly / month,
38
+ });
39
+ }
40
+ }
41
+ return ratios;
42
+ }
43
+
44
+ function entryOf(
45
+ key: string,
46
+ model: string,
47
+ provider: ProviderId,
48
+ side: MpcSide,
49
+ ratios: Map<string, { fiveHour: number; weekly: number }>,
50
+ ): RateEntry | null {
51
+ const pricing = side.pricing;
52
+ const allowance = numberOr(side.allowance);
53
+ // A model with no pricing is not a zero-cost model; it is unpriced.
54
+ if (!pricing || allowance === null) return null;
55
+ const ratio = ratios.get(provider);
56
+ return {
57
+ key,
58
+ model,
59
+ provider,
60
+ plan: side.plan ?? "",
61
+ input: numberOr(pricing.input) ?? 0,
62
+ output: numberOr(pricing.output) ?? 0,
63
+ cacheRead: numberOr(pricing.cacheRead),
64
+ cacheWrite: numberOr(pricing.cacheWrite),
65
+ allowance,
66
+ ratio5h: ratio?.fiveHour ?? null,
67
+ ratioWeek: ratio?.weekly ?? null,
68
+ free: Boolean(side.free),
69
+ };
70
+ }
71
+
72
+ /** Every model the two plans sell, as (model, provider) entries. */
73
+ export function entriesOf(json: MpcJson): RateEntry[] {
74
+ const ratios = ratiosOf(json);
75
+ const entries: RateEntry[] = [];
76
+ for (const row of json.rows ?? []) {
77
+ const key = row.key ?? "";
78
+ const model = row.name ?? key;
79
+ for (const [provider, side] of [
80
+ ["oc-go", row.oc],
81
+ ["cc", row.cc],
82
+ ] as const) {
83
+ if (!side) continue;
84
+ const entry = entryOf(key, model, provider, side, ratios);
85
+ if (entry) entries.push(entry);
86
+ }
87
+ }
88
+ return entries;
89
+ }
@@ -0,0 +1,61 @@
1
+ import type { Profile } from "~/types.ts";
2
+ import type { RateEntry } from "./rates.ts";
3
+
4
+ /** One model's answer to "how many requests does my allowance buy me?". */
5
+ export interface Projection {
6
+ entry: RateEntry;
7
+ costPerReq: number;
8
+ requestsPerMonth: number;
9
+ requestsPerFiveHour: number | null;
10
+ requestsPerWeek: number | null;
11
+ /** Cache reads priced at the input rate because no cache rate is published. */
12
+ cacheAtInput: boolean;
13
+ /** Same, for cache writes. */
14
+ writeAtInput: boolean;
15
+ }
16
+
17
+ /**
18
+ * Price one request of the measured shape on one model.
19
+ *
20
+ * Reasoning tokens bill as output on every provider, so they join the output
21
+ * term. A rate the model does not publish (cache write is null on most of the
22
+ * catalogue) falls back to the input rate rather than to zero: not knowing a
23
+ * price is no reason to assume it is free.
24
+ */
25
+ export function project(entry: RateEntry, profile: Profile): Projection {
26
+ const output = profile.output + profile.reasoning;
27
+ const cacheRate = entry.cacheRead ?? entry.input;
28
+ const writeRate = entry.cacheWrite ?? entry.input;
29
+ const costPerReq =
30
+ (profile.input * entry.input +
31
+ output * entry.output +
32
+ profile.cacheRead * cacheRate +
33
+ profile.cacheWrite * writeRate) /
34
+ 1e6;
35
+
36
+ const requestsPerMonth =
37
+ costPerReq > 0
38
+ ? entry.allowance / costPerReq
39
+ : Number.POSITIVE_INFINITY;
40
+
41
+ return {
42
+ entry,
43
+ costPerReq,
44
+ requestsPerMonth,
45
+ requestsPerFiveHour:
46
+ entry.ratio5h === null ? null : requestsPerMonth * entry.ratio5h,
47
+ requestsPerWeek:
48
+ entry.ratioWeek === null
49
+ ? null
50
+ : requestsPerMonth * entry.ratioWeek,
51
+ cacheAtInput: entry.cacheRead === null && profile.cacheRead > 0,
52
+ writeAtInput: entry.cacheWrite === null && profile.cacheWrite > 0,
53
+ };
54
+ }
55
+
56
+ export function projectAll(
57
+ entries: RateEntry[],
58
+ profile: Profile,
59
+ ): Projection[] {
60
+ return entries.map((entry) => project(entry, profile));
61
+ }
@@ -0,0 +1,103 @@
1
+ export type ProviderId = "oc-go" | "cc" | "custom";
2
+
3
+ export interface RateEntry {
4
+ key: string;
5
+ model: string;
6
+ provider: ProviderId;
7
+ /** Plan label as the provider writes it: Go, GOAT, Pro, Max 20x — or `custom`. */
8
+ plan: string;
9
+ input: number;
10
+ output: number;
11
+ /** Null when the model publishes no cache-read rate; bill it as input. */
12
+ cacheRead: number | null;
13
+ cacheWrite: number | null;
14
+ /** Dollars of list-rate spend the plan grants this model per month. */
15
+ allowance: number;
16
+ /** Share of the allowance the plan opens per five hours, and per week. */
17
+ ratio5h: number | null;
18
+ ratioWeek: number | null;
19
+ free: boolean;
20
+ }
21
+
22
+ export interface MpcSide {
23
+ provider?: string;
24
+ plan?: string;
25
+ pricing?: {
26
+ input?: number;
27
+ output?: number;
28
+ cacheRead?: number | null;
29
+ cacheWrite?: number | null;
30
+ } | null;
31
+ allowance?: number | null;
32
+ requestsPerMonth?: number;
33
+ requestsPerFiveHour?: number;
34
+ requestsPerWeek?: number;
35
+ free?: boolean;
36
+ }
37
+
38
+ export interface MpcJson {
39
+ plans?: Record<
40
+ string,
41
+ {
42
+ label?: string;
43
+ credits?: number;
44
+ fiveHour?: number | null;
45
+ weekly?: number | null;
46
+ }
47
+ >;
48
+ rows?: Array<{
49
+ key?: string;
50
+ name?: string;
51
+ oc?: MpcSide | null;
52
+ cc?: MpcSide | null;
53
+ }>;
54
+ }
55
+
56
+ export interface CustomRates {
57
+ label: string;
58
+ input: number;
59
+ output: number;
60
+ cacheRead: number | null;
61
+ cacheWrite: number | null;
62
+ budget: number;
63
+ cap5h: number | null;
64
+ capWeek: number | null;
65
+ }
66
+
67
+ /** Full provider name, for the JSON payload's consumers. */
68
+ export function providerName(provider: ProviderId): string {
69
+ if (provider === "oc-go") return "OpenCode";
70
+ return provider === "cc" ? "CommandCode" : "custom";
71
+ }
72
+
73
+ /** Column-width provider tag. */
74
+ export function shortProvider(provider: ProviderId): string {
75
+ if (provider === "oc-go") return "OC";
76
+ return provider === "cc" ? "CC" : "custom";
77
+ }
78
+
79
+ /** e.g. "OC Go", "CC GOAT". */
80
+ export function planLabel(entry: RateEntry): string {
81
+ return entry.provider === "custom"
82
+ ? "custom"
83
+ : `${shortProvider(entry.provider)} ${entry.plan}`.trim();
84
+ }
85
+
86
+ export function customEntry(custom: CustomRates): RateEntry {
87
+ const share = (cap: number | null): number | null =>
88
+ cap !== null && custom.budget > 0 ? cap / custom.budget : null;
89
+ return {
90
+ key: "custom",
91
+ model: custom.label,
92
+ provider: "custom",
93
+ plan: "custom",
94
+ input: custom.input,
95
+ output: custom.output,
96
+ cacheRead: custom.cacheRead,
97
+ cacheWrite: custom.cacheWrite,
98
+ allowance: custom.budget,
99
+ ratio5h: share(custom.cap5h),
100
+ ratioWeek: share(custom.capWeek),
101
+ free: false,
102
+ };
103
+ }
@@ -0,0 +1,62 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { MpcJson } from "./rates.ts";
3
+
4
+ export interface AccountSummary {
5
+ plan: string;
6
+ requests: number;
7
+ periodEnd: string;
8
+ }
9
+
10
+ export function numberOr(value: unknown): number | null {
11
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
12
+ }
13
+
14
+ /** Run a sibling CLI and parse its JSON, with the failure mode worth reading. */
15
+ function runJson<T>(bin: string, args: string[]): T {
16
+ const result = spawnSync(bin, args, {
17
+ encoding: "utf8",
18
+ maxBuffer: 64 * 1024 * 1024,
19
+ });
20
+ const detail =
21
+ (result.stderr || "").trim() ||
22
+ result.error?.message ||
23
+ "no output (is it installed and on PATH?)";
24
+ if (result.error || result.status !== 0 || !result.stdout) {
25
+ throw new Error(
26
+ `${bin} ${args.join(" ")} failed: ${detail.slice(0, 240)}`,
27
+ );
28
+ }
29
+ return JSON.parse(result.stdout) as T;
30
+ }
31
+
32
+ /** mpc knows both plans' pricing, allowances and window rules. */
33
+ export function loadMpc(bin: string): MpcJson {
34
+ // `--shape off`: reqshape only needs mpc's pricing and allowances, and it
35
+ // has no workload of its own to price here. mpc's default `--shape auto`
36
+ // shells out to reqshape, so without the flag this call would recurse
37
+ // (mpc -> reqshape -> mpc -> ...).
38
+ return runJson<MpcJson>(bin, ["--json", "--shape", "off"]);
39
+ }
40
+
41
+ /**
42
+ * The account's own count of requests this period. Optional context, never
43
+ * fatal: an unauthenticated or offline cmduse just means no line is printed.
44
+ */
45
+ export function loadAccount(bin: string): AccountSummary | null {
46
+ try {
47
+ const json = runJson<{
48
+ plan?: string;
49
+ periodEnd?: string;
50
+ summary?: { requests?: number };
51
+ }>(bin, ["-1", "--json"]);
52
+ const requests = numberOr(json.summary?.requests);
53
+ if (requests === null) return null;
54
+ return {
55
+ plan: json.plan ?? "",
56
+ requests,
57
+ periodEnd: json.periodEnd ?? "",
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,83 @@
1
+ import { BOUNDARY } from "~/constants/asks.ts";
2
+ import type { Ask, AskKind, Req } from "~/types.ts";
3
+ import type { RawMessage } from "./rows.ts";
4
+ import type { SessionRow } from "./store.ts";
5
+
6
+ function numberOr(value: unknown): number {
7
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
8
+ }
9
+
10
+ function toReq(message: RawMessage, position: number): Req {
11
+ const tokens = message.tokens;
12
+ return {
13
+ model: message.model,
14
+ provider: message.provider,
15
+ tokensIn: numberOr(tokens?.input),
16
+ output: numberOr(tokens?.output),
17
+ reasoning: numberOr(tokens?.reasoning),
18
+ cacheRead: numberOr(tokens?.cache?.read),
19
+ cacheWrite: numberOr(tokens?.cache?.write),
20
+ cost: numberOr(message.cost),
21
+ position,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Group a session's rows into asks. Messages arrive grouped by session and
27
+ * ordered by the store's own sequence, so an ask is simply the run of assistant
28
+ * rows between one boundary and the next. Position counts across the whole
29
+ * session rather than the ask, because that is the axis a re-read context grows
30
+ * along: the first request of a conversation is cheap, the hundredth is not.
31
+ */
32
+ export function buildAsks(
33
+ messages: RawMessage[],
34
+ sessions: Map<string, SessionRow>,
35
+ ): Ask[] {
36
+ const asks: Ask[] = [];
37
+ let current: Ask | null = null;
38
+ let sessionID = "";
39
+ let position = 0;
40
+
41
+ const flush = () => {
42
+ if (current) asks.push(current);
43
+ current = null;
44
+ };
45
+ const open = (message: RawMessage, kind: AskKind): Ask => {
46
+ const session = sessions.get(message.sessionID);
47
+ return {
48
+ sessionID: message.sessionID,
49
+ directory: session?.directory ?? "",
50
+ child: session?.child ?? false,
51
+ kind,
52
+ text: message.text,
53
+ time: message.time,
54
+ reqs: [],
55
+ };
56
+ };
57
+
58
+ for (const message of messages) {
59
+ if (message.sessionID !== sessionID) {
60
+ flush();
61
+ sessionID = message.sessionID;
62
+ position = 0;
63
+ }
64
+ const boundary = BOUNDARY[message.kind];
65
+ if (boundary) {
66
+ flush();
67
+ current = open(message, boundary);
68
+ continue;
69
+ }
70
+ // Everything that is not a boundary continues the ask: bookkeeping rows
71
+ // (idle, agent-switched, model-switched) and the interjections above.
72
+ if (message.kind !== "assistant") continue;
73
+ // A turn without tokens is still streaming; it has not been billed yet.
74
+ if (!message.tokens) continue;
75
+ position += 1;
76
+ // Reqs before any boundary belong to nobody; keep them visible rather
77
+ // than silently dropping them.
78
+ current ??= open(message, "orphan");
79
+ current.reqs.push(toReq(message, position));
80
+ }
81
+ flush();
82
+ return asks;
83
+ }
@@ -0,0 +1,116 @@
1
+ import { FILLER } from "~/constants/keywords.ts";
2
+ import type { Ask, DropCount } from "~/types.ts";
3
+
4
+ export interface FilterOptions {
5
+ /** `user` keeps only the prompts you typed, dropping subagent sessions. */
6
+ sessions: "user" | "all";
7
+ keywords: Set<string>;
8
+ minChars: number;
9
+ minOutput: number;
10
+ keepTrivial: boolean;
11
+ /** Epoch ms floor; 0 keeps everything. */
12
+ since: number;
13
+ /** Substring matched against the session's directory; "" keeps everything. */
14
+ project: string;
15
+ }
16
+
17
+ export interface FilterResult {
18
+ kept: Ask[];
19
+ drops: DropCount[];
20
+ }
21
+
22
+ /** Lowercase, punctuation-free, single-spaced — so `Hi!` and `hi` are the same prompt. */
23
+ export function normalizePrompt(text: string): string {
24
+ return text
25
+ .toLowerCase()
26
+ .replace(/[^\p{L}\p{N}\s]+/gu, " ")
27
+ .replace(/\s+/g, " ")
28
+ .trim();
29
+ }
30
+
31
+ /**
32
+ * Why this prompt is noise, or null when it is worth profiling. A prompt made
33
+ * only of emoji normalises away to nothing, which is its own answer.
34
+ */
35
+ export function trivialReason(
36
+ text: string,
37
+ keywords: Set<string>,
38
+ minChars: number,
39
+ ): string | null {
40
+ const normalized = normalizePrompt(text);
41
+ if (!normalized) return "empty prompt";
42
+ if (keywords.has(normalized)) return "trivial prompt";
43
+ const words = normalized.split(" ");
44
+ if (
45
+ words.length > 1 &&
46
+ words.some((word) => keywords.has(word)) &&
47
+ words.every((word) => keywords.has(word) || FILLER.has(word))
48
+ ) {
49
+ return "trivial prompt";
50
+ }
51
+ if (minChars > 0 && normalized.length < minChars) return "short prompt";
52
+ return null;
53
+ }
54
+
55
+ function outputOf(ask: Ask): number {
56
+ let total = 0;
57
+ for (const req of ask.reqs) total += req.output;
58
+ return total;
59
+ }
60
+
61
+ function dropOf(ask: Ask, reason: string, seen: Map<string, DropCount>): void {
62
+ const entry = seen.get(reason) ?? { reason, asks: 0, reqs: 0 };
63
+ entry.asks += 1;
64
+ entry.reqs += ask.reqs.length;
65
+ seen.set(reason, entry);
66
+ }
67
+
68
+ /**
69
+ * Decide which asks describe real work. Every rejection is counted, so the
70
+ * numbers stay auditable rather than quietly cleaned.
71
+ */
72
+ export function filterAsks(asks: Ask[], options: FilterOptions): FilterResult {
73
+ const kept: Ask[] = [];
74
+ const drops = new Map<string, DropCount>();
75
+
76
+ for (const ask of asks) {
77
+ if (options.since > 0 && ask.time > 0 && ask.time < options.since) {
78
+ dropOf(ask, "before --since", drops);
79
+ continue;
80
+ }
81
+ if (options.project && !ask.directory.includes(options.project)) {
82
+ dropOf(ask, "other project", drops);
83
+ continue;
84
+ }
85
+ if (options.sessions === "user" && ask.child) {
86
+ dropOf(ask, "subagent session", drops);
87
+ continue;
88
+ }
89
+ if (ask.kind !== "user") {
90
+ dropOf(ask, `${ask.kind} prompt`, drops);
91
+ continue;
92
+ }
93
+ if (ask.reqs.length === 0) {
94
+ dropOf(ask, "no model call", drops);
95
+ continue;
96
+ }
97
+ if (!options.keepTrivial) {
98
+ const reason = trivialReason(
99
+ ask.text,
100
+ options.keywords,
101
+ options.minChars,
102
+ );
103
+ if (reason) {
104
+ dropOf(ask, reason, drops);
105
+ continue;
106
+ }
107
+ if (options.minOutput > 0 && outputOf(ask) < options.minOutput) {
108
+ dropOf(ask, "under --min-output", drops);
109
+ continue;
110
+ }
111
+ }
112
+ kept.push(ask);
113
+ }
114
+
115
+ return { kept, drops: [...drops.values()].sort((a, b) => b.reqs - a.reqs) };
116
+ }