@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 +18 -0
- package/README.md +149 -2
- package/package.json +31 -5
- package/src/cli/flags.ts +108 -0
- package/src/cli/options.ts +55 -0
- package/src/cli/parse.ts +101 -0
- package/src/cli/run.ts +142 -0
- package/src/constants/ansi.ts +4 -0
- package/src/constants/asks.ts +14 -0
- package/src/constants/keywords.ts +91 -0
- package/src/constants/layout.ts +6 -0
- package/src/constants/providers.ts +2 -0
- package/src/index.ts +11 -0
- package/src/market/entries.ts +89 -0
- package/src/market/project.ts +61 -0
- package/src/market/rates.ts +103 -0
- package/src/market/sources.ts +62 -0
- package/src/measure/asks.ts +83 -0
- package/src/measure/filter.ts +116 -0
- package/src/measure/profile.ts +114 -0
- package/src/measure/rows.ts +125 -0
- package/src/measure/sides.ts +28 -0
- package/src/measure/stats.ts +47 -0
- package/src/measure/store.ts +93 -0
- package/src/types.ts +115 -0
- package/src/view/format.ts +66 -0
- package/src/view/json.ts +51 -0
- package/src/view/render.ts +96 -0
- package/src/view/sections.ts +119 -0
- package/src/view/table.ts +61 -0
|
@@ -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
|
+
]);
|
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
|
+
}
|