clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / jev.ts
jev.ts 7.4K
1// Jev, TypeSafe's decision model, answers questions about what was pushed. It2// gives a yes probability (noul), a pick from labels (choice) or a score; it3// writes no text, so pages show its answers as labels, flags and order.4//5// Answers are asked for after a push (lib/jev-fill.ts, run by `forge jev`6// before the build) and kept in the repo at <repo>.git/forge/jev/<kind>/<key>.7// The build only reads them, so pages never wait on the API. Jev can answer8// the same request differently, so answers are stored rather than cached:9// once asked, a page keeps its answer across rebuilds. Keys are content10// (patch ids, tree and blob ids), so a rebase that changes nothing asks11// nothing. A kind carries a version ("commit.v1"); changing a kind's12// questions means bumping it, which asks again.13//14// Everything here fails soft: without TYPESAFE_API_KEY, with FORGE_JEV=0,15// past the request budget, on any API error, or for a few minutes after an16// outage, ask() returns null and the page shows nothing.17import fs from 'node:fs';18import path from 'node:path';19import { APIConnectionError, APIError, RateLimitError, TypeSafeClient, type Questions, type SystemOneResult } from '@typesafe-ai/sdk';20import type { DiffFile } from './types.ts';2122export { noul, choice, score } from '@typesafe-ai/sdk';2324export const enabled = (env: NodeJS.ProcessEnv = process.env): boolean => !!env.TYPESAFE_API_KEY?.trim() && env.FORGE_JEV !== '0';2526// A stored answer set: what was asked of which model, and what it said.27export interface Stored<A = Record<string, unknown>> {28 model: string;29 at: number;30 answers: A;31 tokens: number | null;32}3334// Answers for one repo, one file per key: <gitDir>/forge/jev/<kind>/<key>.35export class JevStore {36 dir: string;3738 constructor(gitDir: string) {39 this.dir = path.join(gitDir, 'forge', 'jev');40 }4142 private file(kind: string, key: string): string {43 if (!/^[\w.-]+$/.test(kind) || !/^[\w.-]+$/.test(key)) throw new Error(`jev: bad kind or key ${kind}/${key}`);44 return path.join(this.dir, kind, key);45 }4647 get<A = Record<string, unknown>>(kind: string, key: string): Stored<A> | null {48 try {49 return JSON.parse(fs.readFileSync(this.file(kind, key), 'utf8')) as Stored<A>;50 } catch {51 return null;52 }53 }5455 has(kind: string, key: string): boolean {56 return fs.existsSync(this.file(kind, key));57 }5859 put(kind: string, key: string, value: Stored<unknown>): void {60 const f = this.file(kind, key);61 fs.mkdirSync(path.dirname(f), { recursive: true });62 fs.writeFileSync(`${f}.${process.pid}`, JSON.stringify(value));63 fs.renameSync(`${f}.${process.pid}`, f);64 }65}6667// Requests one process may make; a push that would need more leaves the rest68// for the next push or `forge jev`.69let budget = Number(process.env.FORGE_JEV_BUDGET || 200);70export const spent = { requests: 0, tokens: 0, failed: 0 };7172let client: TypeSafeClient | null = null;7374export interface AskOptions {75 // Per attempt, in milliseconds. The pre-receive check keeps this short.76 timeout?: number;77 retries?: number;78}7980// When the API times out, can't be reached or is overloaded, $FORGE_STATE/81// jev-down records when and why, and for the next FORGE_JEV_COOLDOWN seconds82// (default 300) nothing is asked. An outage then costs one slow push, not83// every push's timeout. The first answer after the cooldown removes the file.84const downFile = (): string | null => {85 const state = process.env.FORGE_STATE || (process.env.FORGE_ROOT ? path.join(process.env.FORGE_ROOT, '.forge') : null);86 return state ? path.join(state, 'jev-down') : null;87};88const cooldown = (): number => Number(process.env.FORGE_JEV_COOLDOWN || 300) * 1000;89const outage = (err: unknown): boolean =>90 err instanceof APIConnectionError || err instanceof RateLimitError || (err instanceof APIError && err.status >= 500);91const clock = (ms: number): string => new Date(ms).toISOString().slice(11, 19);9293// Why the API is being left alone right now, or null to go ahead.94function down(): string | null {95 const f = downFile();96 if (!f) return null;97 try {98 const at = fs.statSync(f).mtimeMs;99 if (Date.now() - at >= cooldown()) return null;100 const why = fs.readFileSync(f, 'utf8').trim();101 return `not asked: the API failed at ${clock(at)} UTC${why ? ` (${why})` : ''}; trying again after ${clock(at + cooldown())} UTC`;102 } catch {103 return null;104 }105}106107// One request, or null when Jev is off, the budget is spent, the API is108// cooling down or the call fails. Failures are reported on stderr as "jev: ...".109export async function ask<Q extends Questions>(state: unknown, questions: Q, opts: AskOptions = {}): Promise<SystemOneResult<Q> | null> {110 if (!enabled() || budget <= 0) return null;111 const skip = down();112 if (skip) {113 process.stderr.write(`jev: ${skip}\n`);114 return null;115 }116 budget--;117 client ??= new TypeSafeClient({ defaultModel: process.env.FORGE_JEV_MODEL || undefined, logLevel: 'off' });118 const f = downFile();119 try {120 const res = await client.systemOne(121 { state: state as never, questions },122 { timeout: opts.timeout ?? 20_000, retry: opts.retries == null ? undefined : { maxRetries: opts.retries } },123 );124 spent.requests++;125 spent.tokens += (res.usage.input_tokens ?? 0) + (res.usage.output_tokens ?? 0);126 if (f) fs.rmSync(f, { force: true });127 return res;128 } catch (err) {129 spent.failed++;130 const msg = err instanceof Error ? err.message : String(err);131 if (f && outage(err)) {132 try {133 fs.writeFileSync(f, msg.replace(/\.$/, '') + '\n');134 } catch {}135 }136 process.stderr.write(`jev: ${msg}\n`);137 return null;138 }139}140141// The stored answers for kind/key, asking once if there are none yet.142export async function remember<Q extends Questions>(store: JevStore, kind: string, key: string, state: unknown, questions: Q, opts?: AskOptions): Promise<Stored<SystemOneResult<Q>['answers']> | null> {143 const had = store.get<SystemOneResult<Q>['answers']>(kind, key);144 if (had) return had;145 const res = await ask(state, questions, opts);146 if (!res) return null;147 const value = { model: res.model, at: Math.floor(Date.now() / 1000), answers: res.answers, tokens: (res.usage.input_tokens ?? 0) + (res.usage.output_tokens ?? 0) || null };148 store.put(kind, key, value);149 return value;150}151152// Run fn over items, n at a time.153export async function pool<T>(items: T[], n: number, fn: (item: T) => Promise<void>): Promise<void> {154 let i = 0;155 const worker = async (): Promise<void> => {156 while (i < items.length) await fn(items[i++]);157 };158 await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));159}160161// A diff as Jev reads it best: per file, its path and status and the added162// and removed lines as lists, not a unified diff. Lines past `maxLines` (over163// all files) are dropped and the file is marked truncated.164export interface FileState {165 path: string;166 status: string;167 added: string[];168 removed: string[];169 truncated?: true;170}171172export function diffState(files: DiffFile[], maxLines = 400): FileState[] {173 let left = maxLines;174 return files.filter((f) => !f.binary).map((f): FileState => {175 const s: FileState = { path: f.path, status: f.status, added: [], removed: [] };176 for (const h of f.hunks) {177 for (const l of h.lines) {178 if (l.t !== '+' && l.t !== '-') continue;179 if (left <= 0) {180 s.truncated = true;181 continue;182 }183 (l.t === '+' ? s.added : s.removed).push(l.text);184 left--;185 }186 }187 if (f.truncated || f.omitted) s.truncated = true;188 return s;189 });190}