@tech-leads-club/harness-toolkit 0.2.1
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/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { nextDelay, retry } from "./backoff.ts";
|
|
14
|
+
|
|
15
|
+
const RETRYABLE_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
16
|
+
|
|
17
|
+
function errorCode(error: unknown): string | undefined {
|
|
18
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
19
|
+
? (error as NodeJS.ErrnoException).code
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRetryableFsError(error: unknown): boolean {
|
|
24
|
+
const code = errorCode(error);
|
|
25
|
+
return code !== undefined && RETRYABLE_CODES.has(code);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tempPathFor(path: string): string {
|
|
29
|
+
return `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FsAtomicOptions = {
|
|
33
|
+
attempts?: number;
|
|
34
|
+
baseMs?: number;
|
|
35
|
+
capMs?: number;
|
|
36
|
+
random?: () => number;
|
|
37
|
+
sleep?: (ms: number) => Promise<void>;
|
|
38
|
+
rename?: (from: string, to: string) => void;
|
|
39
|
+
writeFile?: (path: string, data: string) => void;
|
|
40
|
+
removeFile?: (path: string) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export async function writeJsonAtomic(
|
|
44
|
+
path: string,
|
|
45
|
+
value: unknown,
|
|
46
|
+
options: FsAtomicOptions = {},
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const {
|
|
49
|
+
attempts = 5,
|
|
50
|
+
baseMs = 20,
|
|
51
|
+
capMs = 500,
|
|
52
|
+
random,
|
|
53
|
+
sleep,
|
|
54
|
+
rename = renameSync,
|
|
55
|
+
writeFile = (p: string, data: string) => writeFileSync(p, data, "utf8"),
|
|
56
|
+
removeFile = (p: string) => {
|
|
57
|
+
try {
|
|
58
|
+
rmSync(p, { force: true });
|
|
59
|
+
} catch {}
|
|
60
|
+
},
|
|
61
|
+
} = options;
|
|
62
|
+
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
64
|
+
const tempPath = tempPathFor(path);
|
|
65
|
+
writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await retry(
|
|
69
|
+
() => {
|
|
70
|
+
rename(tempPath, path);
|
|
71
|
+
},
|
|
72
|
+
{ attempts, baseMs, capMs, random, sleep, shouldRetry: isRetryableFsError },
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
removeFile(tempPath);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readJson<T>(path: string): T | null {
|
|
81
|
+
if (!existsSync(path)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function withFileLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
|
|
92
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
93
|
+
const attempts = 200;
|
|
94
|
+
let acquired = false;
|
|
95
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
96
|
+
try {
|
|
97
|
+
closeSync(openSync(lockPath, "wx"));
|
|
98
|
+
acquired = true;
|
|
99
|
+
break;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (errorCode(error) !== "EEXIST") {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, nextDelay({ attempt, baseMs: 10, capMs: 200 })));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!acquired) {
|
|
108
|
+
throw new Error(`fs-atomic: could not acquire lock at ${lockPath}`);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return await fn();
|
|
112
|
+
} finally {
|
|
113
|
+
try {
|
|
114
|
+
rmSync(lockPath, { force: true });
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type UpdateJsonAtomicOptions = FsAtomicOptions & {
|
|
120
|
+
lockPath: string /**
|
|
121
|
+
* Run inside the write lock, after the file lands. The platform does not care what it does — recording a
|
|
122
|
+
* content hash is core's business, and doing it here is what makes the record and the content one write.
|
|
123
|
+
*/;
|
|
124
|
+
afterWrite?: (path: string) => void;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export async function updateJsonAtomic<T>(
|
|
128
|
+
path: string,
|
|
129
|
+
mutator: (current: T | null) => T,
|
|
130
|
+
options: UpdateJsonAtomicOptions,
|
|
131
|
+
): Promise<T> {
|
|
132
|
+
const { lockPath, afterWrite, ...atomicOptions } = options;
|
|
133
|
+
return withFileLock(lockPath, async () => {
|
|
134
|
+
const current = readJson<T>(path);
|
|
135
|
+
const next = mutator(current);
|
|
136
|
+
await writeJsonAtomic(path, next, atomicOptions);
|
|
137
|
+
// why: inside the lock. A caller that sealed after the lock released would race the next writer, and the pair
|
|
138
|
+
// that lost would leave a record matching neither content ([/decisions/ad-078.md](/decisions/ad-078.md)).
|
|
139
|
+
afterWrite?.(path);
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function appendRecord(path: string, value: unknown): void {
|
|
5
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
6
|
+
appendFileSync(path, `${JSON.stringify(value)}\n`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readTail<T>(path: string, n: number): T[] {
|
|
10
|
+
if (!existsSync(path)) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
const records: T[] = [];
|
|
14
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
15
|
+
const trimmed = line.trim();
|
|
16
|
+
if (trimmed.length === 0) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
records.push(JSON.parse(trimmed) as T);
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
return records.slice(-n);
|
|
24
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runProcess } from "./process.ts";
|
|
4
|
+
import { normalizeSeparators } from "./sanitize.ts";
|
|
5
|
+
|
|
6
|
+
async function gitLines(projectDir: string, args: string[]): Promise<string[]> {
|
|
7
|
+
const result = await runProcess({ command: ["git", ...args], cwd: projectDir });
|
|
8
|
+
if (result.exitCode !== 0) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
return result.stdout
|
|
12
|
+
.split("\n")
|
|
13
|
+
.map((line) => line.trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* why: `base` is the revision the turn started at, not `HEAD`. A turn that commits moves `HEAD` past its own
|
|
19
|
+
* changes, so every gate reading this list saw an empty diff and skipped — measured on a real turn whose task
|
|
20
|
+
* was named "schema v2 + tests + commit" ([/decisions/ad-058.md](/decisions/ad-058.md)).
|
|
21
|
+
*
|
|
22
|
+
* invariant: `HEAD` stays the default, so a caller with no recorded base behaves exactly as before.
|
|
23
|
+
*/
|
|
24
|
+
export async function listChangedRepoFiles(projectDir: string, base = "HEAD"): Promise<string[]> {
|
|
25
|
+
if (!existsSync(join(projectDir, ".git"))) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const batches = await Promise.all([
|
|
30
|
+
gitLines(projectDir, ["diff", "--name-only", base]),
|
|
31
|
+
gitLines(projectDir, ["diff", "--name-only", "--cached"]),
|
|
32
|
+
gitLines(projectDir, ["ls-files", "--others", "--exclude-standard"]),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const paths = new Set<string>();
|
|
36
|
+
for (const batch of batches) {
|
|
37
|
+
for (const path of batch) {
|
|
38
|
+
paths.add(path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...paths];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* File lists for the most recent commits, newest first. One entry per commit, so a caller can replay a rule
|
|
46
|
+
* over history without knowing how git formats anything.
|
|
47
|
+
*/
|
|
48
|
+
export async function listCommitFileSets(projectDir: string, limit: number): Promise<string[][]> {
|
|
49
|
+
if (!existsSync(join(projectDir, ".git")) || limit <= 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
// why: one git call for all commits. A separate call per commit is the obvious shape and is an order of
|
|
53
|
+
// magnitude slower on the history sizes this is used for.
|
|
54
|
+
const lines = await gitLines(projectDir, [
|
|
55
|
+
"log",
|
|
56
|
+
`-${limit}`,
|
|
57
|
+
"--name-only",
|
|
58
|
+
"--no-renames",
|
|
59
|
+
"--format=%x00",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const commits: string[][] = [];
|
|
63
|
+
let current: string[] | null = null;
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (line === "\u0000") {
|
|
66
|
+
current = [];
|
|
67
|
+
commits.push(current);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
current?.push(line);
|
|
71
|
+
}
|
|
72
|
+
return commits.filter((files) => files.length > 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type AddedLine = {
|
|
76
|
+
file: string;
|
|
77
|
+
line: number;
|
|
78
|
+
text: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export async function listAddedLines(
|
|
82
|
+
projectDir: string,
|
|
83
|
+
relativePaths: string[],
|
|
84
|
+
base = "HEAD",
|
|
85
|
+
): Promise<AddedLine[]> {
|
|
86
|
+
if (!existsSync(join(projectDir, ".git")) || relativePaths.length === 0) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const tracked = new Set(await gitLines(projectDir, ["ls-files", "--", ...relativePaths]));
|
|
90
|
+
const out: AddedLine[] = [];
|
|
91
|
+
|
|
92
|
+
for (const file of relativePaths) {
|
|
93
|
+
if (!tracked.has(file)) {
|
|
94
|
+
let raw = "";
|
|
95
|
+
try {
|
|
96
|
+
raw = readFileSync(join(projectDir, file), "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
raw.split(/\r?\n/).forEach((text, index) => {
|
|
101
|
+
out.push({ file, line: index + 1, text });
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const diff = await gitLines(projectDir, ["diff", "--unified=0", base, "--", file]);
|
|
106
|
+
let lineNo = 0;
|
|
107
|
+
for (const row of diff) {
|
|
108
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(row);
|
|
109
|
+
if (hunk) {
|
|
110
|
+
lineNo = Number(hunk[1]);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (row.startsWith("+++")) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (row.startsWith("+")) {
|
|
117
|
+
out.push({ file, line: lineNo, text: row.slice(1) });
|
|
118
|
+
lineNo += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isUnderPrefixes(relativePath: string, prefixes: string[]): boolean {
|
|
126
|
+
const normalized = normalizeSeparators(relativePath);
|
|
127
|
+
return prefixes.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function filterCodeTargets(relativePaths: string[], codePaths: string[]): string[] {
|
|
131
|
+
return relativePaths.filter((path) => {
|
|
132
|
+
if (!isUnderPrefixes(path, codePaths)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return /\.(ts|tsx|js|jsx|json|mjs|cjs|py|go|rs)$/.test(path);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function filterTestTargets(relativePaths: string[]): string[] {
|
|
140
|
+
return relativePaths.filter((path) => /\.(spec|test)\.(ts|tsx|js|jsx)$/.test(path));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type CommandResult = { exitCode: number; output: string; durationMs: number };
|
|
144
|
+
|
|
145
|
+
export async function runCommand(
|
|
146
|
+
projectDir: string,
|
|
147
|
+
command: string[],
|
|
148
|
+
extraArgs: string[] = [],
|
|
149
|
+
options: { env?: NodeJS.ProcessEnv } = {},
|
|
150
|
+
): Promise<CommandResult> {
|
|
151
|
+
if (command.length === 0) {
|
|
152
|
+
return { exitCode: 0, output: "", durationMs: 0 };
|
|
153
|
+
}
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
const result = await runProcess({
|
|
156
|
+
command: [...command, ...extraArgs],
|
|
157
|
+
cwd: projectDir,
|
|
158
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
159
|
+
});
|
|
160
|
+
const combined = (result.stdout + result.stderr).trim();
|
|
161
|
+
const maxChars = 8000;
|
|
162
|
+
const output =
|
|
163
|
+
combined.length === 0
|
|
164
|
+
? "(no output captured)"
|
|
165
|
+
: combined.length <= maxChars
|
|
166
|
+
? combined
|
|
167
|
+
: combined.slice(-maxChars);
|
|
168
|
+
return {
|
|
169
|
+
exitCode: result.exitCode,
|
|
170
|
+
output,
|
|
171
|
+
durationMs: Date.now() - started,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every tracked file, so a duplication scan reads what the project owns and nothing it ignores.
|
|
177
|
+
*
|
|
178
|
+
* why: `git ls-files` already honours `.gitignore`, so `node_modules` and build output cost nothing to exclude
|
|
179
|
+
* and no second ignore list has to be kept in step ([/decisions/ad-071.md](/decisions/ad-071.md)).
|
|
180
|
+
*/
|
|
181
|
+
export async function listTrackedFiles(projectDir: string): Promise<string[]> {
|
|
182
|
+
const result = await runCommand(projectDir, ["git", "ls-files", "-z"]);
|
|
183
|
+
if (result.exitCode !== 0) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
return result.output.split("\0").filter((path) => path !== "");
|
|
187
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function harnessDir(root: string): string {
|
|
5
|
+
return join(root, ".tlc", "harness");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* why: the path hooks name and the one the installer materialises into. It is deliberately not derived from where
|
|
10
|
+
* the code happens to sit, because an npm-installed copy sits under a directory npm replaces
|
|
11
|
+
* ([/decisions/ad-056.md](/decisions/ad-056.md)).
|
|
12
|
+
*/
|
|
13
|
+
export function conventionalRuntimeHome(): string {
|
|
14
|
+
return join(homedir(), ".tlc", "harness");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// why: the env is a parameter so a caller can be tested. The first version read `process.env` unconditionally,
|
|
18
|
+
// which made every destination-resolution test assert against the machine it ran on rather than its input.
|
|
19
|
+
export function runtimeHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
20
|
+
return env.TLC_HOME ?? conventionalRuntimeHome();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* invariant: `tlc-exec` always sets `TLC_HOME` in the child, so the child cannot tell an operator's choice from
|
|
25
|
+
* the launcher's own resolution. This flag is that difference, and only the installer needs it — everything else
|
|
26
|
+
* wants the resolved home either way.
|
|
27
|
+
*/
|
|
28
|
+
export function runtimeHomeWasChosen(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
|
+
return env.TLC_HOME_FROM_ENV === "1";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runtimeStateDir(): string {
|
|
33
|
+
return join(runtimeHome(), "state");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// why: one file for every repository on the machine. Per-repo state stays authoritative; this is the
|
|
37
|
+
// cross-repo view, which cannot exist under any single project's state directory.
|
|
38
|
+
export function runtimeSpoolPath(): string {
|
|
39
|
+
return join(runtimeStateDir(), "obs-spool.jsonl");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function projectConfigPath(root: string): string {
|
|
43
|
+
return join(harnessDir(root), "config.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function projectStateDir(root: string): string {
|
|
47
|
+
return join(harnessDir(root), "state");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function flagsDir(root: string): string {
|
|
51
|
+
return join(projectStateDir(root), "flags");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function presenceDir(root: string): string {
|
|
55
|
+
return join(projectStateDir(root), "presence");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loopsDir(root: string): string {
|
|
59
|
+
return join(projectStateDir(root), "loops");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function bootDir(root: string): string {
|
|
63
|
+
return join(projectStateDir(root), "boot");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// why: inside the state directory on purpose — the baseline that proves the policy was not switched off
|
|
67
|
+
// inherits the same protection as the policy itself.
|
|
68
|
+
export function policyBaselineDir(root: string): string {
|
|
69
|
+
return join(projectStateDir(root), "policy-baseline");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function claudeConfigDir(): string {
|
|
73
|
+
const custom = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
74
|
+
return custom && custom.length > 0 ? custom : join(homedir(), ".claude");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cursorConfigDir(): string {
|
|
78
|
+
const custom = process.env.CURSOR_CONFIG_DIR?.trim();
|
|
79
|
+
return custom && custom.length > 0 ? custom : join(homedir(), ".cursor");
|
|
80
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runtimeHome } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
export type VendorPool = "cursor_models" | "anthropic_models" | "other_models" | "auto" | "unknown";
|
|
6
|
+
export type NeutralPool = "provider_native" | "other" | "auto" | "unknown";
|
|
7
|
+
export type CostSource = "override" | "provider" | "litellm" | "missing";
|
|
8
|
+
|
|
9
|
+
export type ModelPriceEntry = {
|
|
10
|
+
displayName?: string;
|
|
11
|
+
provider?: string;
|
|
12
|
+
promptPer1M?: number;
|
|
13
|
+
completionPer1M?: number;
|
|
14
|
+
cacheWritePer1M?: number;
|
|
15
|
+
cacheReadPer1M?: number;
|
|
16
|
+
pool?: VendorPool;
|
|
17
|
+
billing?: "metered" | "included" | "unknown";
|
|
18
|
+
contextWindow?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TokenUsage = {
|
|
22
|
+
inputTokens?: number;
|
|
23
|
+
outputTokens?: number;
|
|
24
|
+
cacheReadTokens?: number;
|
|
25
|
+
cacheWriteTokens?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CostEstimate = {
|
|
29
|
+
costUsd: number | null;
|
|
30
|
+
billing: "metered" | "included" | "unknown";
|
|
31
|
+
pool: VendorPool;
|
|
32
|
+
source: CostSource;
|
|
33
|
+
catalogKey?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type PriceTable = Record<string, ModelPriceEntry>;
|
|
37
|
+
type AliasTable = Record<string, string>;
|
|
38
|
+
|
|
39
|
+
const VENDOR_TO_NEUTRAL_POOL: Record<VendorPool, NeutralPool> = {
|
|
40
|
+
cursor_models: "provider_native",
|
|
41
|
+
anthropic_models: "provider_native",
|
|
42
|
+
other_models: "other",
|
|
43
|
+
auto: "auto",
|
|
44
|
+
unknown: "unknown",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function mapPoolToNeutral(pool: VendorPool): NeutralPool {
|
|
48
|
+
return VENDOR_TO_NEUTRAL_POOL[pool];
|
|
49
|
+
}
|
|
50
|
+
|
|
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
|
+
function stripMeta(table: PriceTable | null): PriceTable {
|
|
63
|
+
if (!table) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
const { _meta: _ignored, ...rest } = table as PriceTable & { _meta?: unknown };
|
|
67
|
+
return rest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function slugifyModelName(name: string): string {
|
|
71
|
+
return name
|
|
72
|
+
.trim()
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[[\]]/g, "")
|
|
75
|
+
.replace(/\(.*?\)/g, "")
|
|
76
|
+
.replace(/[^a-z0-9.+]+/g, "-")
|
|
77
|
+
.replace(/^-+|-+$/g, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function candidatesFor(model: string, aliases: AliasTable): string[] {
|
|
81
|
+
const trimmed = model.trim();
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
const push = (v: string | undefined) => {
|
|
84
|
+
if (v && !out.includes(v)) {
|
|
85
|
+
out.push(v);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
push(trimmed);
|
|
89
|
+
push(aliases[trimmed]);
|
|
90
|
+
push(slugifyModelName(trimmed));
|
|
91
|
+
push(aliases[slugifyModelName(trimmed)]);
|
|
92
|
+
if (trimmed.includes("/")) {
|
|
93
|
+
push(trimmed.slice(trimmed.lastIndexOf("/") + 1));
|
|
94
|
+
}
|
|
95
|
+
const noEffort = trimmed.replace(/-(high|medium|low|max|fast|thinking)$/i, "");
|
|
96
|
+
if (noEffort !== trimmed) {
|
|
97
|
+
push(noEffort);
|
|
98
|
+
push(aliases[noEffort]);
|
|
99
|
+
push(slugifyModelName(noEffort));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fuzzyFind(table: PriceTable, needle: string): { key: string; entry: ModelPriceEntry } | undefined {
|
|
105
|
+
const direct = table[needle];
|
|
106
|
+
if (direct) {
|
|
107
|
+
return { key: needle, entry: direct };
|
|
108
|
+
}
|
|
109
|
+
for (const [key, entry] of Object.entries(table)) {
|
|
110
|
+
if (needle.startsWith(key) || key.startsWith(needle)) {
|
|
111
|
+
return { key, entry };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function overridesPath(): string {
|
|
118
|
+
return join(runtimeHome(), "model-prices.json");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function providerNativePath(provider: string): string {
|
|
122
|
+
return join(runtimeHome(), `model-prices.${provider}.json`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function litellmPath(): string {
|
|
126
|
+
return join(runtimeHome(), "model-prices.litellm.json");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function aliasesPath(): string {
|
|
130
|
+
return join(runtimeHome(), "model-aliases.json");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type PriceResolution = {
|
|
134
|
+
entry: ModelPriceEntry;
|
|
135
|
+
key: string;
|
|
136
|
+
source: "override" | "provider" | "litellm";
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function resolveModelPrice(provider: string, model: string): PriceResolution | undefined {
|
|
140
|
+
const trimmed = model.trim();
|
|
141
|
+
if (!trimmed) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const overrides = stripMeta(readJsonFile<PriceTable>(overridesPath()));
|
|
146
|
+
const native = stripMeta(readJsonFile<PriceTable>(providerNativePath(provider)));
|
|
147
|
+
const litellm = stripMeta(readJsonFile<PriceTable>(litellmPath()));
|
|
148
|
+
const aliases = readJsonFile<AliasTable>(aliasesPath()) ?? {};
|
|
149
|
+
|
|
150
|
+
const candidates = candidatesFor(trimmed, aliases);
|
|
151
|
+
|
|
152
|
+
for (const id of candidates) {
|
|
153
|
+
const entry = overrides[id];
|
|
154
|
+
if (entry) {
|
|
155
|
+
return { entry, key: id, source: "override" };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const id of candidates) {
|
|
159
|
+
const entry = native[id];
|
|
160
|
+
if (entry) {
|
|
161
|
+
return { entry, key: id, source: "provider" };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const id of candidates) {
|
|
165
|
+
const entry = litellm[id];
|
|
166
|
+
if (entry) {
|
|
167
|
+
return { entry, key: id, source: "litellm" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const slug = slugifyModelName(trimmed);
|
|
172
|
+
const fuzzyOverride = fuzzyFind(overrides, slug);
|
|
173
|
+
if (fuzzyOverride) {
|
|
174
|
+
return { ...fuzzyOverride, source: "override" };
|
|
175
|
+
}
|
|
176
|
+
const fuzzyNative = fuzzyFind(native, slug);
|
|
177
|
+
if (fuzzyNative) {
|
|
178
|
+
return { ...fuzzyNative, source: "provider" };
|
|
179
|
+
}
|
|
180
|
+
const fuzzyLitellm = fuzzyFind(litellm, slug);
|
|
181
|
+
if (fuzzyLitellm) {
|
|
182
|
+
return { ...fuzzyLitellm, source: "litellm" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function estimateCostUsd(
|
|
189
|
+
provider: string,
|
|
190
|
+
model: string | undefined,
|
|
191
|
+
usage: TokenUsage,
|
|
192
|
+
): CostEstimate {
|
|
193
|
+
const inputTokens = usage.inputTokens ?? 0;
|
|
194
|
+
const outputTokens = usage.outputTokens ?? 0;
|
|
195
|
+
const cacheReadTokens = usage.cacheReadTokens ?? 0;
|
|
196
|
+
const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
|
|
197
|
+
|
|
198
|
+
if (!model || (inputTokens <= 0 && outputTokens <= 0 && cacheReadTokens <= 0 && cacheWriteTokens <= 0)) {
|
|
199
|
+
return { costUsd: null, billing: "unknown", pool: "unknown", source: "missing" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const resolved = resolveModelPrice(provider, model);
|
|
203
|
+
if (!resolved) {
|
|
204
|
+
return { costUsd: null, billing: "unknown", pool: "unknown", source: "missing" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { entry, key, source } = resolved;
|
|
208
|
+
const pool = entry.pool ?? "unknown";
|
|
209
|
+
|
|
210
|
+
if (entry.billing === "included") {
|
|
211
|
+
return { costUsd: null, billing: "included", pool, source, catalogKey: key };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (typeof entry.promptPer1M !== "number" || typeof entry.completionPer1M !== "number") {
|
|
215
|
+
return { costUsd: null, billing: entry.billing ?? "unknown", pool, source, catalogKey: key };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let costUsd =
|
|
219
|
+
(inputTokens / 1_000_000) * entry.promptPer1M + (outputTokens / 1_000_000) * entry.completionPer1M;
|
|
220
|
+
|
|
221
|
+
if (typeof entry.cacheReadPer1M === "number" && cacheReadTokens > 0) {
|
|
222
|
+
costUsd += (cacheReadTokens / 1_000_000) * entry.cacheReadPer1M;
|
|
223
|
+
}
|
|
224
|
+
if (typeof entry.cacheWritePer1M === "number" && cacheWriteTokens > 0) {
|
|
225
|
+
costUsd += (cacheWriteTokens / 1_000_000) * entry.cacheWritePer1M;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { costUsd, billing: "metered", pool, source, catalogKey: key };
|
|
229
|
+
}
|