@tech-leads-club/harness-toolkit 0.2.4 → 0.3.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/README.md +22 -26
- package/bin/tlc-build.mjs +93 -0
- package/bin/tlc-cli.ts +110 -66
- package/bin/tlc-exec.mjs +16 -13
- package/dist/compact-before.mjs +66 -8
- package/dist/doctor.mjs +178 -41
- package/dist/help-topic.mjs +0 -0
- package/dist/init-project.mjs +2 -7
- package/dist/install-runtime.mjs +100 -17
- package/dist/lessons-cli.mjs +67 -1
- package/dist/obs-cli.mjs +64 -1
- package/dist/price-lookup.mjs +45 -23
- package/dist/prompt-submit.mjs +66 -8
- package/dist/refresh-model-prices.mjs +7190 -46
- package/dist/response-after.mjs +66 -8
- package/dist/run.mjs +66 -8
- package/dist/session-end.mjs +66 -8
- package/dist/session-start.mjs +66 -8
- package/dist/shim.mjs +66 -3
- package/dist/stop.mjs +66 -8
- package/dist/subagent-start.mjs +66 -8
- package/dist/subagent-stop.mjs +66 -8
- package/dist/support.mjs +64 -1
- package/dist/tlc-cli.mjs +193 -87
- package/dist/tool-after.mjs +111 -31
- package/dist/tool-before.mjs +66 -8
- package/dist/tool-failure.mjs +66 -8
- package/dist/uninstall-runtime.mjs +9 -10
- package/docs/log.md +2 -0
- package/docs/measure.md +35 -31
- package/package.json +4 -4
- package/src/core/core.facade.ts +7 -0
- package/src/core/index.ts +1 -0
- package/src/core/pricing/pricing.freshness.ts +118 -0
- package/src/core/skill/skill.link.ts +14 -3
- package/src/entrypoints/shim.ts +8 -2
- package/src/platform/links.ts +73 -0
- package/src/platform/pricing.ts +139 -31
- package/src/providers/cursor/cursor.wiring.ts +11 -8
- package/tools/doctor.ts +78 -8
- package/tools/init-project.ts +7 -7
- package/tools/install-runtime.ts +89 -6
- package/tools/refresh-model-prices.ts +242 -75
- package/tools/uninstall-runtime.ts +23 -19
- package/bin/tlc-build +0 -80
- package/bin/tlc-exec +0 -10
- package/bin/tlc-exec.cmd +0 -4
- package/model-aliases.json +0 -12
- package/model-prices.cursor.json +0 -410
- package/model-prices.json +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How old a price catalogue is, and whether that is old enough to refetch.
|
|
3
|
+
*
|
|
4
|
+
* why: prices belong to the machine, not to a release. Shipping them in the package means a rate published today
|
|
5
|
+
* reaches an operator only when they update the tool, and the catalogue in the repository was 23 days stale while
|
|
6
|
+
* three versions went out ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
7
|
+
*
|
|
8
|
+
* hazard: `refreshedAt` was already written into every catalogue's `_meta` by the refresh command, and read by
|
|
9
|
+
* nothing. No age was reported and no refetch was ever skipped or triggered by it — a metadatum recorded and never
|
|
10
|
+
* consulted, which is the same shape as the guard that read an environment variable nobody set.
|
|
11
|
+
*
|
|
12
|
+
* invariant: no clock of its own. `now` is a parameter, because a function that reads the wall clock cannot be
|
|
13
|
+
* tested against a boundary.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_TTL_DAYS = 7;
|
|
17
|
+
|
|
18
|
+
const MS_PER_DAY = 86_400_000;
|
|
19
|
+
|
|
20
|
+
export type CatalogueMeta = { refreshedAt?: string; source?: string };
|
|
21
|
+
|
|
22
|
+
export type Freshness =
|
|
23
|
+
| { state: "absent" }
|
|
24
|
+
| { state: "undated" }
|
|
25
|
+
| { state: "fresh"; ageDays: number; refreshedAt: string }
|
|
26
|
+
| { state: "stale"; ageDays: number; refreshedAt: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* why: `undated` is its own answer rather than "infinitely old". A catalogue written by a version that did not
|
|
30
|
+
* record the date is present and usable; treating it as stale would refetch on every run, and treating it as fresh
|
|
31
|
+
* would never refetch. Naming it lets the caller decide once, visibly.
|
|
32
|
+
*/
|
|
33
|
+
export function freshness(
|
|
34
|
+
meta: CatalogueMeta | null,
|
|
35
|
+
now: Date,
|
|
36
|
+
ttlDays: number = DEFAULT_TTL_DAYS,
|
|
37
|
+
): Freshness {
|
|
38
|
+
if (meta === null) {
|
|
39
|
+
return { state: "absent" };
|
|
40
|
+
}
|
|
41
|
+
const stamp = meta.refreshedAt;
|
|
42
|
+
if (stamp === undefined || Number.isNaN(Date.parse(stamp))) {
|
|
43
|
+
return { state: "undated" };
|
|
44
|
+
}
|
|
45
|
+
const ageMs = now.getTime() - Date.parse(stamp);
|
|
46
|
+
// invariant: a stamp from the future is age zero, not a negative age. A clock skew must not read as fresh
|
|
47
|
+
// forever nor as stale immediately.
|
|
48
|
+
const ageDays = Math.max(0, ageMs / MS_PER_DAY);
|
|
49
|
+
return ageDays > ttlDays
|
|
50
|
+
? { state: "stale", ageDays, refreshedAt: stamp }
|
|
51
|
+
: { state: "fresh", ageDays, refreshedAt: stamp };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** invariant: `undated` refetches. Once, on the next run that can, after which it has a date like everything else. */
|
|
55
|
+
export function shouldRefetch(state: Freshness): boolean {
|
|
56
|
+
return state.state === "absent" || state.state === "undated" || state.state === "stale";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function freshnessMessage(state: Freshness, catalogue: string): string {
|
|
60
|
+
switch (state.state) {
|
|
61
|
+
case "absent":
|
|
62
|
+
return `${catalogue}: not on this machine — run \`tlc harness prices refresh\``;
|
|
63
|
+
case "undated":
|
|
64
|
+
return `${catalogue}: present but carries no date — it will be refetched`;
|
|
65
|
+
case "fresh":
|
|
66
|
+
return `${catalogue}: ${describeAge(state.ageDays)} old`;
|
|
67
|
+
default:
|
|
68
|
+
return `${catalogue}: ${describeAge(state.ageDays)} old — run \`tlc harness prices refresh\``;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a freshly parsed catalogue may replace the one on disk.
|
|
74
|
+
*
|
|
75
|
+
* hazard: the only guard was "did we parse zero entries". The upstream page grew from one table to three, the
|
|
76
|
+
* parser read the first and stopped, and 43 models became 3 — which is not zero, so it passed and overwrote the
|
|
77
|
+
* good catalogue. A refresh that silently loses nine tenths of its content is worse than a stale one, because the
|
|
78
|
+
* staleness is at least visible in the date ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
79
|
+
*
|
|
80
|
+
* invariant: growing is always allowed, and a first catalogue is always allowed. Only a large drop is refused, and
|
|
81
|
+
* the refusal names both numbers so the operator can see whether upstream really shrank.
|
|
82
|
+
*/
|
|
83
|
+
export const MIN_RETAINED_RATIO = 0.5;
|
|
84
|
+
|
|
85
|
+
export type ReplaceVerdict = { replace: boolean; reason: string };
|
|
86
|
+
|
|
87
|
+
export function mayReplace(
|
|
88
|
+
existingCount: number,
|
|
89
|
+
incomingCount: number,
|
|
90
|
+
minRatio: number = MIN_RETAINED_RATIO,
|
|
91
|
+
): ReplaceVerdict {
|
|
92
|
+
if (incomingCount === 0) {
|
|
93
|
+
return { replace: false, reason: "parsed no entries at all — the upstream format has changed" };
|
|
94
|
+
}
|
|
95
|
+
if (existingCount === 0) {
|
|
96
|
+
return { replace: true, reason: `first catalogue, ${incomingCount} entries` };
|
|
97
|
+
}
|
|
98
|
+
if (incomingCount >= existingCount) {
|
|
99
|
+
return { replace: true, reason: `${existingCount} → ${incomingCount} entries` };
|
|
100
|
+
}
|
|
101
|
+
const retained = incomingCount / existingCount;
|
|
102
|
+
return retained >= minRatio
|
|
103
|
+
? { replace: true, reason: `${existingCount} → ${incomingCount} entries` }
|
|
104
|
+
: {
|
|
105
|
+
replace: false,
|
|
106
|
+
reason: `would drop from ${existingCount} to ${incomingCount} entries, keeping the existing catalogue — the upstream format has probably changed`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** why: an operator reads "3 days", not "3.4179". Hours below a day, because "0 days" reads as no information. */
|
|
111
|
+
export function describeAge(ageDays: number): string {
|
|
112
|
+
if (ageDays < 1) {
|
|
113
|
+
const hours = Math.max(1, Math.round(ageDays * 24));
|
|
114
|
+
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
115
|
+
}
|
|
116
|
+
const days = Math.round(ageDays);
|
|
117
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
118
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where the init skill has to be linked, and what counts as a link that works.
|
|
3
3
|
*
|
|
4
|
-
* hazard:
|
|
4
|
+
* hazard: the installer looped over every provider config directory that exists and linked
|
|
5
5
|
* `<provider>/skills/harness-init`, with the comment "Each provider only reads its own skills directory".
|
|
6
6
|
* `tlc harness update` linked to `<runtime-home>/../skills/harness-init` instead — a directory no provider reads,
|
|
7
7
|
* and which did not exist at all on the machine where this was found. So an update never refreshed the skill
|
|
@@ -48,11 +48,21 @@ export type LinkHealth =
|
|
|
48
48
|
*
|
|
49
49
|
* invariant: a link that resolves outside the runtime home is reported even when it currently exists, because
|
|
50
50
|
* a source that is not the runtime is a source that will move.
|
|
51
|
+
*
|
|
52
|
+
* hazard: BOTH sides have to be resolved. The first version compared the link's realpath against the runtime home
|
|
53
|
+
* as configured, and on a contributor install — where `~/.tlc/harness` is a symlink to a working clone, which
|
|
54
|
+
* `doctor` reports as healthy — the two never share a prefix. It printed two failures on a machine where nothing
|
|
55
|
+
* was wrong, which is the reading AD-034 exists to forbid: a warning that fires on a healthy install is not a
|
|
56
|
+
* warning ([/decisions/ad-095.md](/decisions/ad-095.md)).
|
|
51
57
|
*/
|
|
52
58
|
export function linkHealth(
|
|
53
59
|
target: string,
|
|
54
60
|
runtimeHome: string,
|
|
55
|
-
probe: {
|
|
61
|
+
probe: {
|
|
62
|
+
linkTarget: (path: string) => string | null;
|
|
63
|
+
exists: (path: string) => boolean;
|
|
64
|
+
realpath?: (path: string) => string;
|
|
65
|
+
},
|
|
56
66
|
): LinkHealth {
|
|
57
67
|
const resolved = probe.linkTarget(target);
|
|
58
68
|
if (resolved === null) {
|
|
@@ -61,7 +71,8 @@ export function linkHealth(
|
|
|
61
71
|
if (!probe.exists(resolved)) {
|
|
62
72
|
return { state: "dangling", target, resolved };
|
|
63
73
|
}
|
|
64
|
-
const
|
|
74
|
+
const resolveHome = probe.realpath ?? ((path: string) => path);
|
|
75
|
+
const home = resolveHome(runtimeHome).replace(/\/+$/, "");
|
|
65
76
|
return resolved === home || resolved.startsWith(`${home}/`)
|
|
66
77
|
? { state: "ok", target, resolved }
|
|
67
78
|
: { state: "outside-runtime", target, resolved };
|
package/src/entrypoints/shim.ts
CHANGED
|
@@ -49,7 +49,13 @@ if (!decision.run) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const home = runtimeHome();
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* hazard: this was the extensionless bash wrapper, which Windows cannot execute — so the first branch simply
|
|
54
|
+
* never fired there and the shim fell through to the bundle. The launcher is a `.mjs` run by the interpreter
|
|
55
|
+
* already running this, which behaves the same on every platform
|
|
56
|
+
* ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
57
|
+
*/
|
|
58
|
+
const execBin = join(home, "bin", "tlc-exec.mjs");
|
|
53
59
|
const distHandler = join(home, "dist", `${handler}.mjs`);
|
|
54
60
|
const srcHandler = join(home, "src", "entrypoints", `${handler}.ts`);
|
|
55
61
|
|
|
@@ -67,7 +73,7 @@ function run(command: string, args: string[]): void {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
if (existsSync(execBin)) {
|
|
70
|
-
run(execBin,
|
|
76
|
+
run(process.execPath, [execBin, handler]);
|
|
71
77
|
} else if (existsSync(distHandler)) {
|
|
72
78
|
run(process.execPath, [distHandler]);
|
|
73
79
|
} else if (existsSync(srcHandler)) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two filesystem primitives an install needs: a directory link, and a config seeded once.
|
|
3
|
+
*
|
|
4
|
+
* why there is no platform branch here: the code this replaces shelled out to `ln -sfn` on POSIX and to
|
|
5
|
+
* PowerShell on Windows, and *that* is where the branch came from — not from anything Node cannot do. Node's
|
|
6
|
+
* `symlinkSync` takes a link type that is only meaningful on Windows and is ignored elsewhere, so `"junction"`
|
|
7
|
+
* is correct on all three platforms: a junction on Windows, a plain symlink on Linux and macOS. Measured on
|
|
8
|
+
* Linux, and stated in Node's own API history ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
9
|
+
*
|
|
10
|
+
* why junction rather than a Windows symlink: a directory symlink needs Developer Mode or an elevated shell,
|
|
11
|
+
* which the PowerShell installer demanded of a contributor. A junction needs neither.
|
|
12
|
+
*
|
|
13
|
+
* The launcher on PATH is not here either. `npm i -g` generates the shims for the platform it runs on, and
|
|
14
|
+
* `npm link` does the same from a checkout — a second implementation of that is a second thing to get wrong.
|
|
15
|
+
*/
|
|
16
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** invariant: one link type, chosen because Windows is the only platform that reads it. */
|
|
20
|
+
export const LINK_TYPE = "junction";
|
|
21
|
+
|
|
22
|
+
export type LinkOutcome =
|
|
23
|
+
| { kind: "linked"; target: string; source: string }
|
|
24
|
+
| { kind: "relinked"; target: string; source: string }
|
|
25
|
+
| { kind: "refused"; target: string; reason: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Point `target` at `source`.
|
|
29
|
+
*
|
|
30
|
+
* invariant: an existing *link* is replaced; anything else is refused. A real directory at the target is either
|
|
31
|
+
* somebody's install or somebody's work, and removing it to make room is not a decision a tool makes
|
|
32
|
+
* ([/decisions/ad-046.md](/decisions/ad-046.md)). The bash installer removed it.
|
|
33
|
+
*/
|
|
34
|
+
export function linkDir(source: string, target: string): LinkOutcome {
|
|
35
|
+
let replaced = false;
|
|
36
|
+
if (isLink(target)) {
|
|
37
|
+
rmSync(target, { recursive: true, force: true });
|
|
38
|
+
replaced = true;
|
|
39
|
+
} else if (existsSync(target)) {
|
|
40
|
+
return {
|
|
41
|
+
kind: "refused",
|
|
42
|
+
target,
|
|
43
|
+
reason: `${target} exists and is not a link — move it aside and re-run`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
47
|
+
symlinkSync(source, target, LINK_TYPE);
|
|
48
|
+
return { kind: replaced ? "relinked" : "linked", target, source };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* why lstat: a link whose destination is gone is still a link, and `existsSync` says it is not there — so the
|
|
53
|
+
* check has to come first, or a dangling link reads as free space. Node reports a Windows junction as a symbolic
|
|
54
|
+
* link, so one call covers both kinds.
|
|
55
|
+
*/
|
|
56
|
+
export function isLink(path: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
return lstatSync(path).isSymbolicLink();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** invariant: seeded once, never overwritten. The operator's config is theirs from the moment it exists. */
|
|
65
|
+
export function seedConfig(dest: string): { seeded: boolean; path: string } {
|
|
66
|
+
const path = join(dest, "config.json");
|
|
67
|
+
const example = join(dest, "config.example.json");
|
|
68
|
+
if (existsSync(path) || !existsSync(example)) {
|
|
69
|
+
return { seeded: false, path };
|
|
70
|
+
}
|
|
71
|
+
copyFileSync(example, path);
|
|
72
|
+
return { seeded: true, path };
|
|
73
|
+
}
|
package/src/platform/pricing.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a model's price comes from, and how it is read.
|
|
3
|
+
*
|
|
4
|
+
* There is one catalogue file on the machine. It used to be four — a per-provider table, a ~1 MB fallback table,
|
|
5
|
+
* an overrides table and an alias table — three of them versioned in this repository and two of them holding the
|
|
6
|
+
* same models at different prices. That is not duplication to be collapsed: the same model genuinely has two
|
|
7
|
+
* prices depending on who bills the call, and a provider reselling a vendor's model charges its own rate. So the
|
|
8
|
+
* planes stay, and the file does not: one catalogue, one refresh, one read path, with each plane named by its
|
|
9
|
+
* provenance ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
10
|
+
*
|
|
11
|
+
* invariant: nothing about prices is versioned. A rate published today has to reach an operator without a release,
|
|
12
|
+
* and a rate in the package is stale the moment it is packed.
|
|
13
|
+
*/
|
|
1
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
15
|
import { join } from "node:path";
|
|
3
16
|
import { runtimeHome } from "./paths.ts";
|
|
@@ -33,8 +46,40 @@ export type CostEstimate = {
|
|
|
33
46
|
catalogKey?: string;
|
|
34
47
|
};
|
|
35
48
|
|
|
36
|
-
type PriceTable = Record<string, ModelPriceEntry>;
|
|
37
|
-
|
|
49
|
+
export type PriceTable = Record<string, ModelPriceEntry>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The catalogue on disk. `planes` is keyed by provenance: a provider's id for what that provider bills, and
|
|
53
|
+
* `litellm` for the vendors' own list prices.
|
|
54
|
+
*
|
|
55
|
+
* why: keyed rather than merged. `claude-sonnet-4-5` is sold by its vendor and resold by other providers at a
|
|
56
|
+
* different rate; merging the two rows would pick one at random and report the other's calls at the wrong price.
|
|
57
|
+
*/
|
|
58
|
+
export type PriceCatalogue = {
|
|
59
|
+
_meta?: { refreshedAt?: string; planes?: Record<string, PlaneMeta> };
|
|
60
|
+
planes?: Record<string, PriceTable>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type PlaneMeta = { source?: string; count?: number; refreshedAt?: string };
|
|
64
|
+
|
|
65
|
+
/** The plane that holds the vendors' own list prices, used when the asking provider has no rate of its own. */
|
|
66
|
+
export const FALLBACK_PLANE = "litellm";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Model ids a host reports that are not the catalogue's key for them.
|
|
70
|
+
*
|
|
71
|
+
* why: in code, not in a versioned JSON file. This is a hand-curated mapping of what hosts call things — it changes
|
|
72
|
+
* when a host renames a model, which is a code change with a test, not machine state an operator maintains. The
|
|
73
|
+
* file it replaces held ten entries of which six were `"x": "x"` no-ops and two were already covered by the effort
|
|
74
|
+
* suffix stripped below ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
75
|
+
*
|
|
76
|
+
* invariant: an operator who needs a mapping of their own writes the key straight into their overrides file. There
|
|
77
|
+
* is no second alias file to keep in sync.
|
|
78
|
+
*/
|
|
79
|
+
export const MODEL_ALIASES: Readonly<Record<string, string>> = {
|
|
80
|
+
"cursor-grok-4.5": "grok-4.5",
|
|
81
|
+
auto: "auto-cost",
|
|
82
|
+
};
|
|
38
83
|
|
|
39
84
|
const VENDOR_TO_NEUTRAL_POOL: Record<VendorPool, NeutralPool> = {
|
|
40
85
|
cursor_models: "provider_native",
|
|
@@ -48,17 +93,6 @@ export function mapPoolToNeutral(pool: VendorPool): NeutralPool {
|
|
|
48
93
|
return VENDOR_TO_NEUTRAL_POOL[pool];
|
|
49
94
|
}
|
|
50
95
|
|
|
51
|
-
function readJsonFile<T>(path: string): T | null {
|
|
52
|
-
if (!existsSync(path)) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
96
|
function stripMeta(table: PriceTable | null): PriceTable {
|
|
63
97
|
if (!table) {
|
|
64
98
|
return {};
|
|
@@ -67,17 +101,30 @@ function stripMeta(table: PriceTable | null): PriceTable {
|
|
|
67
101
|
return rest;
|
|
68
102
|
}
|
|
69
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The one way a model name becomes a catalogue key. Both sides use it: the refresh that writes the catalogue and
|
|
106
|
+
* the lookup that reads it.
|
|
107
|
+
*
|
|
108
|
+
* hazard: there were two of these, and they disagreed about parentheses. This one erased them, so a lookup for
|
|
109
|
+
* `Model X (Fast)` asked for `model-x` — the standard model's price. The writer's copy erased them too, so the two
|
|
110
|
+
* rows collapsed onto one key and the second overwrote the first. Measured on the real catalogue: 51 rows on the
|
|
111
|
+
* upstream page became 44 stored keys, and every model with a `(Fast)` variant carried the wrong price — `$3/$15`
|
|
112
|
+
* where the page said `$0.5/$2.5` ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
113
|
+
*
|
|
114
|
+
* invariant: a qualifier is part of the identity. A markdown link keeps its text and loses its URL; everything
|
|
115
|
+
* else that is not alphanumeric is a separator. Two names that differ produce two keys.
|
|
116
|
+
*/
|
|
70
117
|
export function slugifyModelName(name: string): string {
|
|
71
118
|
return name
|
|
72
119
|
.trim()
|
|
73
120
|
.toLowerCase()
|
|
121
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
74
122
|
.replace(/[[\]]/g, "")
|
|
75
|
-
.replace(/\(.*?\)/g, "")
|
|
76
123
|
.replace(/[^a-z0-9.+]+/g, "-")
|
|
77
124
|
.replace(/^-+|-+$/g, "");
|
|
78
125
|
}
|
|
79
126
|
|
|
80
|
-
function candidatesFor(model: string, aliases:
|
|
127
|
+
function candidatesFor(model: string, aliases: Readonly<Record<string, string>>): string[] {
|
|
81
128
|
const trimmed = model.trim();
|
|
82
129
|
const out: string[] = [];
|
|
83
130
|
const push = (v: string | undefined) => {
|
|
@@ -114,20 +161,20 @@ function fuzzyFind(table: PriceTable, needle: string): { key: string; entry: Mod
|
|
|
114
161
|
return undefined;
|
|
115
162
|
}
|
|
116
163
|
|
|
117
|
-
|
|
164
|
+
/** The catalogue the refresh writes. Not versioned, not packaged, per machine. */
|
|
165
|
+
export function cataloguePath(): string {
|
|
118
166
|
return join(runtimeHome(), "model-prices.json");
|
|
119
167
|
}
|
|
120
168
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return join(runtimeHome(), "model-aliases.json");
|
|
169
|
+
/**
|
|
170
|
+
* The operator's own rates, which win over everything fetched.
|
|
171
|
+
*
|
|
172
|
+
* hazard: this filename was in `.gitignore` and read by nothing. The overrides that were actually read lived in
|
|
173
|
+
* `model-prices.json` — the same name the refresh now writes — so an operator's edits sat in a file the next
|
|
174
|
+
* refresh would replace. The refresh moves such a file here rather than overwriting it.
|
|
175
|
+
*/
|
|
176
|
+
export function overridesPath(): string {
|
|
177
|
+
return join(runtimeHome(), "model-prices.local.json");
|
|
131
178
|
}
|
|
132
179
|
|
|
133
180
|
export type PriceResolution = {
|
|
@@ -136,18 +183,79 @@ export type PriceResolution = {
|
|
|
136
183
|
source: "override" | "provider" | "litellm";
|
|
137
184
|
};
|
|
138
185
|
|
|
186
|
+
type CacheSlot = { text: string; value: PriceCatalogue };
|
|
187
|
+
const cache = new Map<string, CacheSlot>();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* why a cache at all: the fallback plane is around a megabyte and a cost estimate happens on every tool result.
|
|
191
|
+
* This used to parse every catalogue file on every single lookup.
|
|
192
|
+
*
|
|
193
|
+
* hazard: the first version keyed on mtime and size, and Windows CI caught it. Two writes of the same length
|
|
194
|
+
* inside one clock tick are indistinguishable that way — the system clock there advances about every 15 ms, so
|
|
195
|
+
* both writes carry the same timestamp however fine the filesystem's resolution is. A refresh mid-session would
|
|
196
|
+
* then serve the previous prices for the rest of the session, silently, because a price was still returned
|
|
197
|
+
* ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
198
|
+
*
|
|
199
|
+
* invariant: the content decides. Reading 1 MB is the cheap part and the parse is what this avoids, so the file is
|
|
200
|
+
* always read and only reparsed when its bytes differ. Nothing has to remember to invalidate anything.
|
|
201
|
+
*/
|
|
202
|
+
function readCatalogue<T>(path: string): T | null {
|
|
203
|
+
if (!existsSync(path)) {
|
|
204
|
+
cache.delete(path);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
let text: string;
|
|
208
|
+
try {
|
|
209
|
+
text = readFileSync(path, "utf8");
|
|
210
|
+
} catch {
|
|
211
|
+
cache.delete(path);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const hit = cache.get(path);
|
|
215
|
+
if (hit && hit.text === text) {
|
|
216
|
+
return hit.value as T;
|
|
217
|
+
}
|
|
218
|
+
let parsed: PriceCatalogue;
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(text) as PriceCatalogue;
|
|
221
|
+
} catch {
|
|
222
|
+
cache.delete(path);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
cache.set(path, { text, value: parsed });
|
|
226
|
+
return parsed as T;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** invariant: an absent or unparseable catalogue is an empty one. A missing price is never a thrown error. */
|
|
230
|
+
export function loadCatalogue(): PriceCatalogue {
|
|
231
|
+
return readCatalogue<PriceCatalogue>(cataloguePath()) ?? {};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function planeMeta(): Record<string, PlaneMeta> {
|
|
235
|
+
return loadCatalogue()._meta?.planes ?? {};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function catalogueMeta(): { refreshedAt?: string } | null {
|
|
239
|
+
const parsed = readCatalogue<PriceCatalogue>(cataloguePath());
|
|
240
|
+
return parsed === null ? null : (parsed._meta ?? {});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function planeFor(catalogue: PriceCatalogue, plane: string): PriceTable {
|
|
244
|
+
return stripMeta(catalogue.planes?.[plane] ?? null);
|
|
245
|
+
}
|
|
246
|
+
|
|
139
247
|
export function resolveModelPrice(provider: string, model: string): PriceResolution | undefined {
|
|
140
248
|
const trimmed = model.trim();
|
|
141
249
|
if (!trimmed) {
|
|
142
250
|
return undefined;
|
|
143
251
|
}
|
|
144
252
|
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
253
|
+
const catalogue = loadCatalogue();
|
|
254
|
+
const overrides = stripMeta(readCatalogue<PriceTable>(overridesPath()));
|
|
255
|
+
const native = provider ? planeFor(catalogue, provider) : {};
|
|
256
|
+
const litellm = planeFor(catalogue, FALLBACK_PLANE);
|
|
149
257
|
|
|
150
|
-
const candidates = candidatesFor(trimmed,
|
|
258
|
+
const candidates = candidatesFor(trimmed, MODEL_ALIASES);
|
|
151
259
|
|
|
152
260
|
for (const id of candidates) {
|
|
153
261
|
const entry = overrides[id];
|
|
@@ -33,15 +33,18 @@ const ENTRY_SPECS: readonly EntrySpec[] = [
|
|
|
33
33
|
{ hookEvent: "afterAgentResponse", handler: "response-after", timeoutSeconds: 5, matcher: "AgentResponse" },
|
|
34
34
|
];
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
/**
|
|
37
|
+
* hazard: this used to write `cmd /c node <launcher>` on Windows and `node <launcher>` elsewhere, from the first
|
|
38
|
+
* commit, with no recorded reason. The other provider's wiring has always written plain `node` on every platform,
|
|
39
|
+
* including Windows — so the branch was the odd one, not the safe one, and it was the branch no contributor here
|
|
40
|
+
* could exercise ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
41
|
+
*
|
|
42
|
+
* invariant: `node` is resolved by the host's own process spawn, which appends the executable extension on the
|
|
43
|
+
* platform that needs one. If a Windows session ever proves otherwise, this is the one line to change.
|
|
44
|
+
*/
|
|
43
45
|
export function cursorWiring(runtime: RuntimePaths): ProviderWiring {
|
|
44
|
-
const
|
|
46
|
+
const command = "node";
|
|
47
|
+
const argsPrefix = [runtime.launcherPath];
|
|
45
48
|
const entries: WiringEntry[] = ENTRY_SPECS.map((spec) => ({
|
|
46
49
|
hookEvent: spec.hookEvent,
|
|
47
50
|
handler: spec.handler,
|
package/tools/doctor.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir, platform as osPlatform } from "node:os";
|
|
4
|
-
import { basename, dirname, join } from "node:path";
|
|
5
|
-
import { runtimePathKind } from "../bin/tlc-cli.ts";
|
|
4
|
+
import { basename, delimiter, dirname, join } from "node:path";
|
|
5
|
+
import { NPM_PACKAGE, runtimePathKind } from "../bin/tlc-cli.ts";
|
|
6
6
|
import { findBunOnPath, writeRuntimeCache } from "../bin/tlc-exec.mjs";
|
|
7
7
|
import { isCursorWired } from "../bin/write-user-hooks.mjs";
|
|
8
8
|
import type { ProviderWiring } from "../src/contracts/index.ts";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
providerConfigDirs,
|
|
15
15
|
runtimeHome,
|
|
16
16
|
} from "../src/platform/paths.ts";
|
|
17
|
+
import { catalogueMeta, planeMeta } from "../src/platform/pricing.ts";
|
|
17
18
|
import { type ColorName, createStyle, PLAIN, type Style, SYMBOLS } from "../src/platform/style.ts";
|
|
18
19
|
import { mergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
|
|
19
20
|
import {
|
|
@@ -158,6 +159,15 @@ export function checkSkillLinks(
|
|
|
158
159
|
}
|
|
159
160
|
},
|
|
160
161
|
exists: existsSync,
|
|
162
|
+
// why: the runtime home is itself a symlink on a contributor install, so it has to be resolved before the
|
|
163
|
+
// comparison. Without this both sides are spelled differently and every healthy link reads as foreign.
|
|
164
|
+
realpath: (path: string) => {
|
|
165
|
+
try {
|
|
166
|
+
return realpathSync(path);
|
|
167
|
+
} catch {
|
|
168
|
+
return path;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
161
171
|
},
|
|
162
172
|
): Check[] {
|
|
163
173
|
return providerDirs
|
|
@@ -172,10 +182,72 @@ export function checkSkillLinks(
|
|
|
172
182
|
});
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
/**
|
|
186
|
+
* How old the price catalogue on this machine is.
|
|
187
|
+
*
|
|
188
|
+
* hazard: nothing reported this. `docs/measure.md` claimed `doctor` "requires at least one provider catalog to be
|
|
189
|
+
* present" and no such check existed, while the catalogue this repository shipped was 23 days stale across three
|
|
190
|
+
* published versions. An absent catalogue is equally invisible: cost estimates simply come back null, which reads
|
|
191
|
+
* the same as a turn that spent nothing ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
192
|
+
*
|
|
193
|
+
* invariant: a fresh catalogue is an `ok` row that states its age and asks for nothing. A warning that fires on a
|
|
194
|
+
* healthy install is not a warning ([/decisions/ad-034.md](/decisions/ad-034.md)).
|
|
195
|
+
*/
|
|
196
|
+
export function checkPrices(
|
|
197
|
+
now: Date = new Date(),
|
|
198
|
+
read = { meta: catalogueMeta, planes: planeMeta },
|
|
199
|
+
): Check[] {
|
|
200
|
+
const state = coreFacade.pricing.freshness(read.meta(), now);
|
|
201
|
+
const planes = read.planes();
|
|
202
|
+
const named = Object.entries(planes)
|
|
203
|
+
.map(([plane, meta]) => `${plane} ${meta.count ?? 0}`)
|
|
204
|
+
.join(", ");
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
level: state.state === "fresh" ? "ok" : "warn",
|
|
208
|
+
name: "prices",
|
|
209
|
+
detail:
|
|
210
|
+
state.state === "fresh"
|
|
211
|
+
? `${coreFacade.pricing.freshnessMessage(state, "catalogue")}${named ? ` (${named})` : ""}`
|
|
212
|
+
: coreFacade.pricing.freshnessMessage(state, "catalogue"),
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Where a shell would find the `tlc` command, if anywhere.
|
|
219
|
+
*
|
|
220
|
+
* hazard: this row used to pass when `~/.local/bin/tlc` existed **or** `<runtime home>/bin/tlc` existed. The
|
|
221
|
+
* second is part of every install, so the check could not fail — and it printed the first path either way, so an
|
|
222
|
+
* operator whose command was not on PATH read a passing row naming a file they did not have
|
|
223
|
+
* ([/decisions/ad-034.md](/decisions/ad-034.md), [/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
224
|
+
*
|
|
225
|
+
* why the four names: an npm global install writes the shims for its platform — bare on POSIX, `.cmd` and `.ps1`
|
|
226
|
+
* on Windows. Trying all four everywhere costs four `existsSync` calls and needs no platform branch.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveOnPath(
|
|
229
|
+
command: string,
|
|
230
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
231
|
+
exists = existsSync,
|
|
232
|
+
): string | null {
|
|
233
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
234
|
+
if (!dir) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
for (const name of [command, `${command}.cmd`, `${command}.exe`, `${command}.ps1`]) {
|
|
238
|
+
const candidate = join(dir, name);
|
|
239
|
+
if (exists(candidate)) {
|
|
240
|
+
return candidate;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
175
247
|
export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Check[] {
|
|
176
248
|
const launcher = join(home, "bin", "tlc-exec.mjs");
|
|
177
249
|
const distSample = join(home, "dist", "stop.mjs");
|
|
178
|
-
const
|
|
250
|
+
const onPath = resolveOnPath("tlc");
|
|
179
251
|
return [
|
|
180
252
|
{ level: "ok", name: "platform", detail: platform },
|
|
181
253
|
{ level: existsSync(launcher) ? "ok" : "fail", name: "global runtime", detail: home },
|
|
@@ -188,12 +260,9 @@ export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Chec
|
|
|
188
260
|
},
|
|
189
261
|
{ level: existsSync(launcher) ? "ok" : "fail", name: "portable launcher", detail: launcher },
|
|
190
262
|
{
|
|
191
|
-
level:
|
|
192
|
-
existsSync(cliLink) || existsSync(join(home, "bin", platform === "win32" ? "tlc.cmd" : "tlc"))
|
|
193
|
-
? "ok"
|
|
194
|
-
: "fail",
|
|
263
|
+
level: onPath === null ? "fail" : "ok",
|
|
195
264
|
name: "CLI on PATH",
|
|
196
|
-
detail:
|
|
265
|
+
detail: onPath ?? `no \`tlc\` on PATH — npm i -g ${NPM_PACKAGE}, or \`npm link\` from a clone`,
|
|
197
266
|
},
|
|
198
267
|
];
|
|
199
268
|
}
|
|
@@ -573,6 +642,7 @@ export function runChecks(ctx: DoctorContext): Check[] {
|
|
|
573
642
|
...checkProviders(ctx.registry, ctx.runtimeHome),
|
|
574
643
|
...checkProjectPolicy(ctx.root),
|
|
575
644
|
...checkCapabilities(ctx.root, ctx.runtimeHome),
|
|
645
|
+
...checkPrices(),
|
|
576
646
|
checkGlobalCommands(ctx.home),
|
|
577
647
|
];
|
|
578
648
|
}
|
package/tools/init-project.ts
CHANGED
|
@@ -55,12 +55,12 @@ export function launcherPath(home = runtimeHome()): string {
|
|
|
55
55
|
return join(home, "bin", "tlc-exec.mjs");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
58
|
+
/**
|
|
59
|
+
* invariant: the same command the provider wiring writes, on every platform — `node`, resolved by the host that
|
|
60
|
+
* spawns it. The Windows `cmd /c` wrapper this replaced existed on one of the two providers only
|
|
61
|
+
* ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
62
|
+
*/
|
|
63
|
+
const SHIM_COMMAND = { command: "node", argsPrefix: [] as string[] };
|
|
64
64
|
|
|
65
65
|
type ShimSpec = {
|
|
66
66
|
hookEvent: string;
|
|
@@ -92,7 +92,7 @@ const CLAUDE_SHIM_SPECS: readonly ShimSpec[] = [
|
|
|
92
92
|
];
|
|
93
93
|
|
|
94
94
|
export function cursorShimEntries(launcher: string): WiringEntry[] {
|
|
95
|
-
const { command, argsPrefix } =
|
|
95
|
+
const { command, argsPrefix } = SHIM_COMMAND;
|
|
96
96
|
return CURSOR_SHIM_SPECS.map((spec) => ({
|
|
97
97
|
hookEvent: spec.hookEvent,
|
|
98
98
|
handler: spec.handler,
|