@parcha/agentrun-pi 0.1.0-beta.14 → 0.1.0-beta.15

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.
Files changed (42) hide show
  1. package/README.md +40 -2
  2. package/dist/durable/index.d.ts +2 -1
  3. package/dist/durable/index.js +1 -0
  4. package/dist/durable/node.js +27 -10
  5. package/dist/durable/readers.d.ts +4 -1
  6. package/dist/durable/readers.js +44 -5
  7. package/dist/durable/receipts.d.ts +4 -2
  8. package/dist/durable/receipts.js +3 -2
  9. package/dist/durable/store.js +57 -9
  10. package/dist/durable/task.d.ts +6 -2
  11. package/dist/durable/task.js +18 -2
  12. package/dist/durable/workflow-tool.d.ts +32 -0
  13. package/dist/durable/workflow-tool.js +188 -0
  14. package/dist/extension.js +64 -32
  15. package/dist/host-addendum.js +3 -2
  16. package/dist/pi-host.d.ts +7 -0
  17. package/dist/pi-host.js +22 -0
  18. package/dist/signin/index.d.ts +9 -0
  19. package/dist/signin/index.js +7 -0
  20. package/dist/signin/limit.d.ts +10 -0
  21. package/dist/signin/limit.js +23 -0
  22. package/dist/signin/resolve.d.ts +47 -0
  23. package/dist/signin/resolve.js +163 -0
  24. package/dist/signin/signin.d.ts +44 -0
  25. package/dist/signin/signin.js +126 -0
  26. package/dist/signin/spec.d.ts +116 -0
  27. package/dist/signin/spec.js +181 -0
  28. package/dist/signin/store.d.ts +40 -0
  29. package/dist/signin/store.js +206 -0
  30. package/dist/skills/author/SKILL.md +3 -2
  31. package/dist/skills/author/references/language.md +4 -3
  32. package/dist/skills/author/references/sdk-authoring.md +2 -0
  33. package/dist/workflow-inspector.d.ts +1 -1
  34. package/dist/workflow-inspector.js +2 -2
  35. package/dist/workflow-store.d.ts +6 -0
  36. package/dist/workflow-store.js +6 -0
  37. package/dist/workflow-tool.d.ts +81 -0
  38. package/dist/workflow-tool.js +94 -0
  39. package/dist/workflow-view.js +5 -1
  40. package/dist/workflows.d.ts +1 -0
  41. package/dist/workflows.js +3 -0
  42. package/package.json +16 -4
@@ -0,0 +1,163 @@
1
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
2
+ import { USAGE_LIMIT_CODE, UsageLimitError } from "./limit.js";
3
+ import { CHATGPT_PROVIDER, chatgptModels, consoleInteraction, signIn } from "./signin.js";
4
+ import { agentrunHome, fileCredentialStore } from "./store.js";
5
+ /** What Sign in with ChatGPT covers: streaming Responses calls with `store: false`, without these. */
6
+ export const CHATGPT_UNSUPPORTED = Object.freeze(["temperature", "max_output_tokens", "previous_response_id", "hosted tools", "audio input", "video input"]);
7
+ export class ResolveError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "ResolveError";
13
+ }
14
+ }
15
+ const meets = (model, need) => need === "images" ? model.input.includes("image") : need !== "video";
16
+ function resolved(model, apiKey, getApiKey, source, extra = {}) {
17
+ const responsesOnly = source === "chatgpt" || extra.responsesOnly === true;
18
+ const result = { model, source, responsesOnly, unsupported: responsesOnly ? CHATGPT_UNSUPPORTED : [], ...extra };
19
+ Object.defineProperty(result, "apiKey", { value: apiKey, enumerable: false });
20
+ Object.defineProperty(result, "getApiKey", { value: getApiKey, enumerable: false });
21
+ Object.defineProperty(result, "toJSON", {
22
+ enumerable: false,
23
+ value: () => {
24
+ const { model: _model, ...rest } = result;
25
+ return { provider: model.provider, model: model.id, ...rest, ...(rest.eyes ? { eyes: rest.eyes.toJSON() } : {}) };
26
+ },
27
+ });
28
+ return result;
29
+ }
30
+ /** The env key of `provider` set in `env`, and the names it was looked up under. Anthropic takes an API key only. */
31
+ async function providerKey(provider, env, signal) {
32
+ if (provider.id === "anthropic")
33
+ return { key: env.ANTHROPIC_API_KEY || undefined, names: ["ANTHROPIC_API_KEY"] };
34
+ const names = [];
35
+ const result = await provider.auth.apiKey?.resolve({
36
+ ctx: { env: async (name) => { names.push(name); return env[name] || undefined; }, fileExists: async () => false },
37
+ signal,
38
+ });
39
+ return { key: result?.auth.apiKey, names };
40
+ }
41
+ /** The account's models (GET /v1/models), those it lists as visible. */
42
+ async function accountModels(baseUrl, apiKey, signal) {
43
+ const response = await fetch(`${baseUrl}/models`, { headers: { authorization: `Bearer ${apiKey}` }, signal });
44
+ if (!response.ok) {
45
+ const body = await response.text().catch(() => "");
46
+ if (body.includes(USAGE_LIMIT_CODE))
47
+ throw new UsageLimitError();
48
+ throw new ResolveError("MODEL_LIST", `GET ${baseUrl}/models answered ${response.status}`);
49
+ }
50
+ const data = (await response.json()).data ?? [];
51
+ return data
52
+ .filter((entry) => typeof entry?.id === "string" && (entry.visibility === undefined || entry.visibility === "list"))
53
+ .map((entry) => ({ id: entry.id, created: typeof entry.created === "number" ? entry.created : 0 }));
54
+ }
55
+ /** The preferred id when it is listed and capable, else the newest listed model pi-ai knows that meets `needs`. */
56
+ function choose(provider, listed, needs, preferred) {
57
+ const known = new Map(provider.getModels().map((model) => [model.id, model]));
58
+ const capable = listed
59
+ .flatMap((entry) => { const model = known.get(entry.id); return model && needs.every((need) => meets(model, need)) ? [{ model, created: entry.created }] : []; })
60
+ .sort((a, b) => b.created - a.created || a.model.id.localeCompare(b.model.id));
61
+ const pick = capable.find((entry) => entry.model.id === preferred) ?? capable[0];
62
+ if (!pick)
63
+ throw new ResolveError("NO_MODEL", `The account lists ${listed.length} models, and none is a ${provider.name} model pi-ai knows that takes ${needs.join(", ")}.`);
64
+ return pick.model;
65
+ }
66
+ async function flagModel(providerId, modelId, needs, preferred, env, signal, flag) {
67
+ const provider = builtinProviders().find((entry) => entry.id === providerId);
68
+ if (!provider)
69
+ throw new ResolveError("UNKNOWN_PROVIDER", `${flag} ${providerId}: pi-ai has no provider with that id`);
70
+ const { key, names } = await providerKey(provider, env, signal);
71
+ if (!key)
72
+ throw new ResolveError("MISSING_KEY", `${flag} ${providerId} needs ${names.join(" or ") || "its key"} set`);
73
+ // Video cannot be read from pi-ai's catalog: an explicit model is trusted to read it.
74
+ const checked = needs.filter((need) => need !== "video");
75
+ let model;
76
+ if (modelId) {
77
+ model = provider.getModels().find((entry) => entry.id === modelId);
78
+ if (!model)
79
+ throw new ResolveError("UNKNOWN_MODEL", `${provider.name} has no model ${modelId} in pi-ai's catalog`);
80
+ const gap = checked.filter((need) => !meets(model, need));
81
+ if (gap.length)
82
+ throw new ResolveError("NEEDS", `${provider.name} ${modelId} does not take ${gap.join(" or ")} input, which this run needs`);
83
+ }
84
+ else if (providerId === CHATGPT_PROVIDER && provider.baseUrl) {
85
+ model = choose(provider, await accountModels(provider.baseUrl, key, signal), checked, preferred);
86
+ }
87
+ else {
88
+ const fallback = preferred ? provider.getModels().find((entry) => entry.id === preferred) : undefined;
89
+ if (!fallback || !checked.every((need) => meets(fallback, need)))
90
+ throw new ResolveError("FLAGS", `${flag} ${providerId} needs a model id too`);
91
+ model = fallback;
92
+ }
93
+ return { model, key };
94
+ }
95
+ /**
96
+ * The model this run uses and the key to send, in the order above. The eyes model, when the spec has one, resolves the
97
+ * same way: an explicit eyes model with its provider's key, else the lead's model when it meets the eyes' needs, else
98
+ * a `ResolveError` ("EYES") naming the gap. The ChatGPT plan takes no video input.
99
+ */
100
+ export async function resolveModel(spec, options = {}) {
101
+ const env = options.env ?? process.env;
102
+ const signal = options.signal ?? new AbortController().signal;
103
+ const needs = spec.models.lead.needs;
104
+ let lead;
105
+ if (options.provider || options.model) {
106
+ if (!options.provider)
107
+ throw new ResolveError("FLAGS", "--model needs --provider");
108
+ const { model, key } = await flagModel(options.provider, options.model, needs, spec.models.lead.default, env, signal, "--provider");
109
+ lead = resolved(model, key, async () => key, "flag");
110
+ }
111
+ else {
112
+ const home = agentrunHome(options.home, env);
113
+ const store = fileCredentialStore(home);
114
+ if ((await store.read(CHATGPT_PROVIDER, { signal }))?.type !== "oauth") {
115
+ // The run's signal reaches the sign-in through its interaction, so a cancel ends the login and frees its port.
116
+ const interaction = options.interaction ?? consoleInteraction();
117
+ // Its calls are forwarded, not spread: an interaction's notify and prompt may be methods of its class.
118
+ const cancelled = options.signal ? {
119
+ signal: interaction.signal ? AbortSignal.any([interaction.signal, options.signal]) : options.signal,
120
+ notify: (event) => interaction.notify(event),
121
+ prompt: (prompt) => interaction.prompt(prompt),
122
+ } : interaction;
123
+ try {
124
+ await signIn({ ...options, home, env, interaction: cancelled });
125
+ }
126
+ catch (error) {
127
+ if (options.signal?.aborted)
128
+ throw options.signal.reason;
129
+ throw error;
130
+ }
131
+ }
132
+ const models = chatgptModels(store);
133
+ const getApiKey = async () => {
134
+ const key = (await models.getAuth(CHATGPT_PROVIDER, { signal }))?.auth.apiKey;
135
+ if (!key)
136
+ throw new ResolveError("SIGNED_OUT", "The ChatGPT sign-in is gone from auth.json; sign in again");
137
+ return key;
138
+ };
139
+ const key = await getApiKey();
140
+ const provider = models.getProvider(CHATGPT_PROVIDER);
141
+ const model = choose(provider, await accountModels(provider.baseUrl, key, signal), needs, spec.models.lead.default);
142
+ lead = resolved(model, key, getApiKey, "chatgpt", {
143
+ limit: `Model calls count toward your ChatGPT plan's usage limit (five hours on Plus, shared with your other apps); this run expects about ${spec.expectedCalls}.`,
144
+ });
145
+ }
146
+ const eyes = spec.models.eyes;
147
+ if (!eyes)
148
+ return lead;
149
+ if (options.eyes) {
150
+ const { model, key } = await flagModel(options.eyes.provider, options.eyes.model ?? eyes.default, eyes.needs, eyes.default, env, signal, "--eyes-model");
151
+ lead.eyes = resolved(model, key, async () => key, "flag");
152
+ return lead;
153
+ }
154
+ const gap = eyes.needs.filter((need) => !meets(lead.model, need));
155
+ if (gap.length) {
156
+ const reasons = gap.map((need) => need === "images" ? `the lead model ${lead.model.id} takes no image input`
157
+ : lead.source === "chatgpt" ? "the ChatGPT plan takes no video input"
158
+ : `pi-ai's catalog lists no video input for ${lead.model.provider} ${lead.model.id}`);
159
+ throw new ResolveError("EYES", `The run's eyes need ${gap.join(" and ")} input: ${reasons.join("; ")}. Name a model that reads it with --eyes-model and set its provider's key${gap.includes("video") ? ", or list images and let frames be extracted locally" : ""}.`);
160
+ }
161
+ lead.eyes = resolved(lead.model, lead.apiKey, lead.getApiKey, "lead", { responsesOnly: lead.responsesOnly });
162
+ return lead;
163
+ }
@@ -0,0 +1,44 @@
1
+ import type { AuthInteraction, MutableModels } from "@earendil-works/pi-ai";
2
+ import { type FileCredentialStore } from "./store.js";
3
+ export declare const AGENT_NAME = "AgentRun";
4
+ /** pi-ai's provider id for OpenAI, whose OAuth method is Sign in with ChatGPT. */
5
+ export declare const CHATGPT_PROVIDER = "openai";
6
+ export type SignInOptions = {
7
+ home?: string;
8
+ env?: NodeJS.ProcessEnv;
9
+ /** The app name OpenAI shows on the consent screen. Default "AgentRun". */
10
+ agentName?: string;
11
+ /** How the sign-in talks to the user. Default: the terminal (`consoleInteraction`). */
12
+ interaction?: AuthInteraction;
13
+ };
14
+ export type SignedIn = {
15
+ home: string;
16
+ provider: typeof CHATGPT_PROVIDER;
17
+ hostId: string;
18
+ expires: number;
19
+ };
20
+ export declare class SignInError extends Error {
21
+ readonly code: "PORT_IN_USE" | "APP_NAME_IGNORED";
22
+ constructor(code: "PORT_IN_USE" | "APP_NAME_IGNORED", message: string, options?: {
23
+ cause?: unknown;
24
+ });
25
+ }
26
+ /** A pi-ai collection with only OpenAI, whose credentials live in `store`. */
27
+ export declare function chatgptModels(store: FileCredentialStore): MutableModels;
28
+ /**
29
+ * Sign in once per install; the token refreshes on its own afterwards. The sign-in page must name this app: pi-ai
30
+ * before 1.1 ignores `agentName` and names pi, so a page that names anything else is never shown, and the sign-in fails
31
+ * with `SignInError` ("APP_NAME_IGNORED") before the browser opens.
32
+ */
33
+ export declare function signIn(options?: SignInOptions): Promise<SignedIn>;
34
+ export type ConsoleIo = {
35
+ input?: NodeJS.ReadableStream & {
36
+ isTTY?: boolean;
37
+ };
38
+ output?: NodeJS.WritableStream;
39
+ /** Opens the sign-in page; default the platform's opener, skipped when AGENTRUN_NO_BROWSER is set. */
40
+ open?: (url: string) => void;
41
+ };
42
+ /** The terminal: prints the sign-in page's address and opens it, then waits for the browser's callback or, on a
43
+ * terminal, a pasted redirect address. The page address carries no token. */
44
+ export declare function consoleInteraction(io?: ConsoleIo): AuthInteraction;
@@ -0,0 +1,126 @@
1
+ // Sign in with ChatGPT through pi-ai's own flow (`openaiChatGPTOAuth.login`), unchanged. We pass the two options it
2
+ // takes: `agentName`, this app's name rather than pi's, and `getDeviceId`, the host id kept in auth.json. The credential
3
+ // it returns goes straight into the file store through pi-ai's `Models.login`; nothing here sees or prints a token.
4
+ import { spawn } from "node:child_process";
5
+ import { createInterface } from "node:readline";
6
+ import { createModels } from "@earendil-works/pi-ai/models";
7
+ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
8
+ import { agentrunHome, fileCredentialStore } from "./store.js";
9
+ export const AGENT_NAME = "AgentRun";
10
+ /** pi-ai's provider id for OpenAI, whose OAuth method is Sign in with ChatGPT. */
11
+ export const CHATGPT_PROVIDER = "openai";
12
+ export class SignInError extends Error {
13
+ code;
14
+ constructor(code, message, options) {
15
+ super(message, options);
16
+ this.code = code;
17
+ this.name = "SignInError";
18
+ }
19
+ }
20
+ /** A pi-ai collection with only OpenAI, whose credentials live in `store`. */
21
+ export function chatgptModels(store) {
22
+ const models = createModels({ credentials: store });
23
+ models.setProvider(openaiProvider());
24
+ return models;
25
+ }
26
+ /**
27
+ * Sign in once per install; the token refreshes on its own afterwards. The sign-in page must name this app: pi-ai
28
+ * before 1.1 ignores `agentName` and names pi, so a page that names anything else is never shown, and the sign-in fails
29
+ * with `SignInError` ("APP_NAME_IGNORED") before the browser opens.
30
+ */
31
+ export async function signIn(options = {}) {
32
+ const home = agentrunHome(options.home, options.env);
33
+ const agentName = options.agentName ?? AGENT_NAME;
34
+ const store = fileCredentialStore(home);
35
+ const hostId = await store.hostId();
36
+ const inner = options.interaction ?? consoleInteraction();
37
+ const stop = new AbortController();
38
+ let named;
39
+ const interaction = {
40
+ signal: inner.signal ? AbortSignal.any([inner.signal, stop.signal]) : stop.signal,
41
+ notify(event) {
42
+ if (event.type === "auth_url" && (named = new URL(event.url).searchParams.get("agent_name_hint")) !== agentName)
43
+ return stop.abort();
44
+ inner.notify(event);
45
+ },
46
+ // pi-ai's login ends only when the browser's callback comes or this prompt rejects, and its callback server holds
47
+ // port 1455 until then: a cancel rejects the prompt even when the interaction's own prompt does not watch it.
48
+ prompt: (prompt) => {
49
+ if (stop.signal.aborted)
50
+ return Promise.reject(stop.signal.reason);
51
+ const cancel = prompt.signal;
52
+ if (!cancel)
53
+ return inner.prompt(prompt);
54
+ if (cancel.aborted)
55
+ return Promise.reject(cancel.reason);
56
+ return new Promise((resolve, reject) => {
57
+ const onAbort = () => reject(cancel.reason);
58
+ cancel.addEventListener("abort", onAbort, { once: true });
59
+ inner.prompt(prompt).then(resolve, reject).finally(() => cancel.removeEventListener("abort", onAbort));
60
+ });
61
+ },
62
+ };
63
+ // A variable, not a literal: pi-ai before 1.1 has no `agentName` in its `LoginOptions` type, and the page check above
64
+ // refuses those versions at run time.
65
+ const loginOptions = { agentName, getDeviceId: () => hostId };
66
+ let credential;
67
+ try {
68
+ credential = await chatgptModels(store).login(CHATGPT_PROVIDER, "oauth", interaction, loginOptions);
69
+ }
70
+ catch (error) {
71
+ if (stop.signal.aborted) {
72
+ throw new SignInError("APP_NAME_IGNORED", `The installed pi-ai names ${named ? `"${named}"` : "no app"} on the sign-in page instead of "${agentName}": it ignores the app name. Sign in with ChatGPT needs a pi-ai that passes it on (1.1.0 or later).`, { cause: error });
73
+ }
74
+ // pi-ai's callback port is fixed at 1455, and its message for a taken port names the likely holder.
75
+ if (error instanceof Error && /\bPort 1455 is in use\b/.test(error.message)) {
76
+ throw new SignInError("PORT_IN_USE", `${error.message}\nFinish or cancel the other login, then run again.`, { cause: error });
77
+ }
78
+ throw error;
79
+ }
80
+ return { home, provider: CHATGPT_PROVIDER, hostId, expires: credential.type === "oauth" ? credential.expires : 0 };
81
+ }
82
+ /** The terminal: prints the sign-in page's address and opens it, then waits for the browser's callback or, on a
83
+ * terminal, a pasted redirect address. The page address carries no token. */
84
+ export function consoleInteraction(io = {}) {
85
+ const input = io.input ?? process.stdin;
86
+ const output = io.output ?? process.stderr;
87
+ const open = io.open ?? (process.env.AGENTRUN_NO_BROWSER ? undefined : openBrowser);
88
+ return {
89
+ notify(event) {
90
+ if (event.type === "auth_url") {
91
+ output.write(`Continue with ChatGPT: sign in at\n ${event.url}\n${event.instructions ? `${event.instructions}\n` : ""}`);
92
+ open?.(event.url);
93
+ }
94
+ else if (event.type === "progress" || event.type === "info") {
95
+ output.write(`${event.message}\n`);
96
+ }
97
+ },
98
+ prompt(prompt) {
99
+ return new Promise((done, fail) => {
100
+ const signal = prompt.signal;
101
+ if (signal?.aborted)
102
+ return fail(signal.reason);
103
+ // Off a terminal only the browser's callback can finish the sign-in; the prompt waits to be cancelled.
104
+ const lines = input.isTTY ? createInterface({ input, output }) : undefined;
105
+ const onAbort = () => { lines?.close(); fail(signal?.reason); };
106
+ signal?.addEventListener("abort", onAbort, { once: true });
107
+ lines?.question(`${prompt.message} `, (answer) => {
108
+ signal?.removeEventListener("abort", onAbort);
109
+ lines.close();
110
+ done(answer);
111
+ });
112
+ });
113
+ },
114
+ };
115
+ }
116
+ function openBrowser(url) {
117
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
118
+ : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
119
+ : ["xdg-open", [url]];
120
+ try {
121
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
122
+ child.on("error", () => { });
123
+ child.unref();
124
+ }
125
+ catch { /* no opener: the address is printed */ }
126
+ }
@@ -0,0 +1,116 @@
1
+ /** A run mode an item is needed for: `live` (paid calls) or `kill` (the kill on a real box). Without `for`, every run. */
2
+ export type RunMode = "live" | "kill";
3
+ export type Platform = "linux" | "darwin" | "win32";
4
+ export type Key = {
5
+ env: string;
6
+ why: string;
7
+ where: string;
8
+ required: boolean;
9
+ for?: RunMode;
10
+ };
11
+ /** `min` is an x.y.z version, compared with the first x.y.z that `<cmd> --version` prints. */
12
+ export type Program = {
13
+ cmd: string;
14
+ why: string;
15
+ required: boolean;
16
+ platforms?: Platform[];
17
+ min?: string;
18
+ for?: RunMode;
19
+ };
20
+ /** An npm package the demo loads only when used, installed with `--no-save` so it is never a dependency. */
21
+ export type Package = {
22
+ name: string;
23
+ version: string;
24
+ why: string;
25
+ required: boolean;
26
+ for?: RunMode;
27
+ };
28
+ /** One of its members; an array member means all of its items (a key pair, or a key with its package). */
29
+ export type Choice = {
30
+ oneOf: Array<Key | Program | Package | Array<Key | Program | Package>>;
31
+ why: string;
32
+ for?: RunMode;
33
+ };
34
+ export type ModelNeed = "text" | "tools" | "images";
35
+ export type EyesNeed = "images" | "video";
36
+ export type DemoSpec = {
37
+ name: string;
38
+ models: {
39
+ lead: {
40
+ needs: ModelNeed[];
41
+ default?: string;
42
+ };
43
+ /** A second model the run calls itself. */
44
+ eyes?: {
45
+ needs: EyesNeed[];
46
+ default?: string;
47
+ };
48
+ judge?: {
49
+ jev: true;
50
+ };
51
+ };
52
+ keys: Array<Key | Choice>;
53
+ programs: Array<Program | Choice>;
54
+ packages?: Array<Package | Choice>;
55
+ expectedCalls: number;
56
+ /** USD for one `--live` run. */
57
+ expectedCost: number;
58
+ };
59
+ /** Jev in a stranger's run: its own key, or Jev through OpenRouter. Nothing is substituted silently. */
60
+ export declare const JEV_KEYS: Choice;
61
+ export type CheckOptions = {
62
+ env?: NodeJS.ProcessEnv;
63
+ platform?: NodeJS.Platform;
64
+ /** The demo's directory, where its packages resolve from. Default: the current directory. */
65
+ dir?: string;
66
+ live?: boolean;
67
+ kill?: boolean;
68
+ /** An explicit eyes model (`--eyes-model`); its key is checked when the model is resolved. */
69
+ eyes?: {
70
+ provider: string;
71
+ model: string;
72
+ };
73
+ };
74
+ /** One requirement of this run and whether this machine meets it. `fix` says what to do, never a value. */
75
+ export type ReportItem = {
76
+ kind: "key" | "program" | "package" | "choice" | "model";
77
+ name: string;
78
+ why: string;
79
+ required: boolean;
80
+ met: boolean;
81
+ fix?: string;
82
+ };
83
+ export type KeyReport = {
84
+ ok: boolean;
85
+ /** 2 when a required item is missing: nothing may start. */
86
+ exitCode: 0 | 2;
87
+ items: ReportItem[];
88
+ missing: ReportItem[];
89
+ expectedCalls: number;
90
+ expectedCost: number;
91
+ /** The report as printed: every requirement, what is missing and how to fix it. */
92
+ text: string;
93
+ };
94
+ /**
95
+ * What this demo needs for this run, and what is missing. Items whose `for` is not this run's mode are left out, choice
96
+ * members included; `kill` implies `live`. A choice is met by any member, an array member only when all of its items
97
+ * are. A relative `dir` is taken from the current directory. A required program
98
+ * outside its platforms is missing, before anything starts, and so is one below its `min` or whose version cannot be
99
+ * read. It sends no request and prompts for nothing, so a run that checks first sends nothing while a required item is
100
+ * missing (`exitCode` 2).
101
+ */
102
+ export declare function checkKeys(spec: DemoSpec, options?: CheckOptions): KeyReport;
103
+ export type SetupPromptReport = {
104
+ ok: boolean;
105
+ /** Spec names the prompt never mentions: key env names, program commands and package names, choice members included. */
106
+ missing: string[];
107
+ /** Credential env names the prompt mentions that the spec does not list. */
108
+ extra: string[];
109
+ };
110
+ /**
111
+ * Whether a demo's SETUP-PROMPT.md matches its spec. Every key env name, program command and package name in the spec,
112
+ * choice members included, appears in the prompt. "Nothing more" covers credential env names only: an UPPER_SNAKE name
113
+ * that reads as a credential and is not in the spec fails. A prompt's own command words (sudo, an install step) are
114
+ * never read as programs.
115
+ */
116
+ export declare function checkSetupPrompt(spec: DemoSpec, text: string): SetupPromptReport;
@@ -0,0 +1,181 @@
1
+ // What a demo needs, and the key check that runs before anything else. Neither check sends a request or prompts. They
2
+ // read only the environment they are given, the PATH in it and the demo's own node_modules, and the key check runs one
3
+ // process: `<cmd> --version`, for a program with a minimum version. A key's value is never read past "is it set", and
4
+ // never printed.
5
+ import { spawnSync } from "node:child_process";
6
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { delimiter, join, resolve } from "node:path";
9
+ /** Jev in a stranger's run: its own key, or Jev through OpenRouter. Nothing is substituted silently. */
10
+ export const JEV_KEYS = {
11
+ oneOf: [
12
+ { env: "TYPESAFE_API_KEY", why: "Jev's typed judgments", where: "https://console.typesafe.ai/keys", required: true },
13
+ { env: "OPENROUTER_API_KEY", why: "Jev through OpenRouter", where: "https://openrouter.ai/keys", required: true },
14
+ ],
15
+ why: "the judge is Jev",
16
+ for: "live",
17
+ };
18
+ const isChoice = (item) => "oneOf" in item;
19
+ const isKey = (item) => "env" in item;
20
+ const isProgram = (item) => "cmd" in item;
21
+ const nameOf = (item) => isKey(item) ? item.env : isProgram(item) ? item.cmd : `${item.name}@${item.version}`;
22
+ /** Every requirement of `spec`, the implied ones included: Jev's keys when the judge is Jev. */
23
+ function requirements(spec) {
24
+ return [...spec.keys, ...(spec.models.judge?.jev ? [JEV_KEYS] : []), ...spec.programs, ...(spec.packages ?? [])];
25
+ }
26
+ /** The file `cmd` runs from `env`'s PATH, or undefined when it is not there. */
27
+ function onPath(cmd, ctx) {
28
+ const exts = ctx.platform === "win32" ? ["", ...(ctx.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
29
+ for (const dir of (ctx.env.PATH ?? ctx.env.Path ?? "").split(delimiter)) {
30
+ if (!dir)
31
+ continue;
32
+ for (const ext of exts) {
33
+ const file = join(dir, cmd + ext);
34
+ try {
35
+ if (!statSync(file).isFile())
36
+ continue;
37
+ accessSync(file, constants.X_OK);
38
+ return file;
39
+ }
40
+ catch { /* not this one */ }
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+ const XYZ = /(\d+)\.(\d+)\.(\d+)/;
46
+ const versionParts = (version) => XYZ.exec(version)?.slice(1, 4).map(Number);
47
+ /** The first x.y.z that `file --version` prints, stdout before stderr; undefined when it prints none, fails or hangs. */
48
+ function programVersion(file, ctx) {
49
+ const shell = ctx.platform === "win32" && /\.(cmd|bat)$/i.test(file);
50
+ const run = spawnSync(file, ["--version"], { env: ctx.env, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"], shell });
51
+ return run.status === 0 ? XYZ.exec(`${run.stdout}\n${run.stderr}`)?.[0] : undefined;
52
+ }
53
+ /** Whether x.y.z `found` is at least `min`. */
54
+ function atLeast(found, min) {
55
+ const [a, b] = [versionParts(found), versionParts(min)];
56
+ for (let i = 0; i < 3; i += 1)
57
+ if (a[i] !== b[i])
58
+ return a[i] > b[i];
59
+ return true;
60
+ }
61
+ /** The version of `name` that Node would load from `dir`, or undefined when it resolves nowhere. */
62
+ function installedVersion(name, dir) {
63
+ for (const base of createRequire(join(dir, "noop.js")).resolve.paths(name) ?? []) {
64
+ const manifest = join(base, name, "package.json");
65
+ if (!existsSync(manifest))
66
+ continue;
67
+ try {
68
+ return String(JSON.parse(readFileSync(manifest, "utf8")).version);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+ /** Whether `item` is met here, and the fix when it is not. */
77
+ function checkMember(item, ctx) {
78
+ if (isKey(item))
79
+ return ctx.env[item.env] ? { met: true } : { met: false, fix: `set ${item.env} (create it: ${item.where})` };
80
+ if (isProgram(item)) {
81
+ if (item.platforms && !item.platforms.includes(ctx.platform)) {
82
+ return { met: false, fix: `${item.cmd} runs on ${item.platforms.join(" or ")} only; this machine is ${ctx.platform}` };
83
+ }
84
+ const file = onPath(item.cmd, ctx);
85
+ if (!file)
86
+ return { met: false, fix: `install ${item.cmd}${item.min ? ` ${item.min} or later` : ""}; it is not on PATH` };
87
+ if (!item.min)
88
+ return { met: true };
89
+ if (!versionParts(item.min))
90
+ return { met: false, fix: `the spec's minimum for ${item.cmd} (${item.min}) is not an x.y.z version` };
91
+ const found = programVersion(file, ctx);
92
+ if (!found)
93
+ return { met: false, fix: `${item.cmd} --version printed no x.y.z version, so ${item.min} or later cannot be confirmed` };
94
+ return atLeast(found, item.min) ? { met: true } : { met: false, fix: `${item.cmd} is ${found}; this run needs ${item.min} or later` };
95
+ }
96
+ const found = installedVersion(item.name, ctx.dir);
97
+ if (found === item.version)
98
+ return { met: true };
99
+ const install = `npm install --no-save ${item.name}@${item.version}`;
100
+ return { met: false, fix: found === undefined ? install : `${install} (found ${found})` };
101
+ }
102
+ const applies = (item, options) => !item.for || (item.for === "live" ? options.live || options.kill : options.kill) === true;
103
+ /**
104
+ * What this demo needs for this run, and what is missing. Items whose `for` is not this run's mode are left out, choice
105
+ * members included; `kill` implies `live`. A choice is met by any member, an array member only when all of its items
106
+ * are. A relative `dir` is taken from the current directory. A required program
107
+ * outside its platforms is missing, before anything starts, and so is one below its `min` or whose version cannot be
108
+ * read. It sends no request and prompts for nothing, so a run that checks first sends nothing while a required item is
109
+ * missing (`exitCode` 2).
110
+ */
111
+ export function checkKeys(spec, options = {}) {
112
+ const ctx = { env: options.env ?? process.env, platform: options.platform ?? process.platform, dir: resolve(options.dir ?? ".") };
113
+ const items = [];
114
+ for (const item of requirements(spec)) {
115
+ if (!applies(item, options))
116
+ continue;
117
+ if (!isChoice(item)) {
118
+ const kind = isKey(item) ? "key" : isProgram(item) ? "program" : "package";
119
+ items.push({ kind, name: nameOf(item), why: item.why, required: item.required, ...checkMember(item, ctx) });
120
+ continue;
121
+ }
122
+ // A member's own `for` applies too: an item this mode does not need drops out of its member, and a member left with
123
+ // nothing drops out of the choice, which this mode then does not need at all.
124
+ const members = item.oneOf.map((member) => (Array.isArray(member) ? member : [member]).filter((one) => applies(one, options))).filter((all) => all.length);
125
+ if (!members.length)
126
+ continue;
127
+ const options_ = members.map((all) => {
128
+ const checked = all.map((one) => ({ one, ...checkMember(one, ctx) }));
129
+ return { name: all.map(nameOf).join(" + "), met: checked.every((one) => one.met), fixes: checked.filter((one) => !one.met).map((one) => one.fix) };
130
+ });
131
+ const met = options_.some((option) => option.met);
132
+ items.push({
133
+ kind: "choice", name: options_.map((option) => option.name).join(" or "), why: item.why, required: true, met,
134
+ ...(met ? {} : { fix: `one of: ${options_.map((option) => option.fixes.join(", ")).join("; or ")}` }),
135
+ });
136
+ }
137
+ const eyes = spec.models.eyes;
138
+ if (eyes && (options.live || options.kill) && eyes.needs.includes("video") && !options.eyes) {
139
+ items.push({
140
+ kind: "model", name: "eyes", why: "the run's eyes read video", required: true, met: false,
141
+ fix: "the ChatGPT plan takes no video input, and the lead model's catalog lists none; name a model that reads video natively with --eyes-model and set its provider's key, or list images and let frames be extracted locally",
142
+ });
143
+ }
144
+ const missing = items.filter((item) => item.required && !item.met);
145
+ const mode = options.kill ? "--live --kill" : options.live ? "--live" : "on fakes";
146
+ const lines = [`${spec.name} (${mode}) needs:`];
147
+ for (const item of items) {
148
+ const state = item.met ? "ok " : item.required ? "MISSING " : "optional";
149
+ lines.push(` ${state} ${item.kind} ${item.name}: ${item.why}`);
150
+ if (!item.met)
151
+ lines.push(` ${item.fix}`);
152
+ }
153
+ if (options.live || options.kill) {
154
+ lines.push(" model: Sign in with ChatGPT (your plan pays), or --provider and --model with that provider's key");
155
+ lines.push(`Expected: about ${spec.expectedCalls} model calls, about $${spec.expectedCost.toFixed(2)} at list prices.`);
156
+ }
157
+ lines.push(missing.length
158
+ ? `${missing.length} required ${missing.length === 1 ? "item is" : "items are"} missing, so nothing started. SETUP-PROMPT.md beside this demo walks through each one.`
159
+ : "Everything this run needs is here.");
160
+ return { ok: missing.length === 0, exitCode: missing.length ? 2 : 0, items, missing, expectedCalls: spec.expectedCalls, expectedCost: spec.expectedCost, text: lines.join("\n") };
161
+ }
162
+ /** An UPPER_SNAKE name that reads as a credential: `*_API_KEY`, `*_KEY`, `*_TOKEN*`, `*_SECRET*`, `*_PROJECT_ID`. */
163
+ const CREDENTIAL = /_(API_)?KEY$|_TOKEN|_SECRET|_PROJECT_ID$/;
164
+ const ENV_NAME = /(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![A-Za-z0-9_])/g;
165
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+ /** `name` as a whole word: not part of a longer name, a path, or a hyphenated command. */
167
+ const mentions = (text, name) => new RegExp(`(?<![A-Za-z0-9_@/.-])${escape(name)}(?![A-Za-z0-9_/-])`).test(text);
168
+ /**
169
+ * Whether a demo's SETUP-PROMPT.md matches its spec. Every key env name, program command and package name in the spec,
170
+ * choice members included, appears in the prompt. "Nothing more" covers credential env names only: an UPPER_SNAKE name
171
+ * that reads as a credential and is not in the spec fails. A prompt's own command words (sudo, an install step) are
172
+ * never read as programs.
173
+ */
174
+ export function checkSetupPrompt(spec, text) {
175
+ const members = requirements(spec).flatMap((item) => isChoice(item) ? item.oneOf.flat() : [item]);
176
+ const names = members.map((item) => isKey(item) ? item.env : isProgram(item) ? item.cmd : item.name);
177
+ const envs = new Set(members.filter(isKey).map((item) => item.env));
178
+ const missing = [...new Set(names)].filter((name) => !mentions(text, name));
179
+ const extra = [...new Set(text.match(ENV_NAME) ?? [])].filter((name) => CREDENTIAL.test(name) && !envs.has(name));
180
+ return { ok: missing.length === 0 && extra.length === 0, missing, extra };
181
+ }