amness 0.1.2 → 0.1.4

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 CHANGED
@@ -28,13 +28,13 @@ Give Amness the key once, in whichever way suits you:
28
28
 
29
29
  | How | Command or step | Saved? |
30
30
  |---|---|---|
31
- | **Paste it on first start** | run `amness`, choose a provider, paste the key (hidden). Change it later in the settings or with `/provider`. | Yes, in your system's secure store: the macOS Keychain, the Linux keyring, or Windows DPAPI. The prompt tells you which. |
31
+ | **Paste it on first start** | run `amness`, choose a provider, paste the key (hidden). Add more providers any time with `/connect`, and switch with `/provider`. | Yes, in your system's secure store: the macOS Keychain, the Linux keyring, or Windows DPAPI. The prompt tells you which. |
32
32
  | **Environment variable** | `export ANTHROPIC_API_KEY=sk-ant-...` or `export OPENAI_API_KEY=sk-...` (PowerShell: `$env:OPENAI_API_KEY="sk-..."`) | No. Used for that shell only, and never written to disk. It wins over a saved key. |
33
33
  | **Secret file** (Docker, Kubernetes) | `ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic` (also `OPENAI_API_KEY_FILE`, `TAVILY_API_KEY_FILE`) | No. Read from the file each run. |
34
34
  | **Docker** | `docker run -it --rm -v "$PWD":/work -e ANTHROPIC_API_KEY amness` | No. Inside a container nothing is saved. |
35
35
  | **Library or CI** | pass `apiKey` in code, or set the variable as a CI secret | No. |
36
36
 
37
- `OPENAI_API_KEY` is used for every provider except Anthropic, including OpenRouter. To remove a saved key, clear "API key" in the settings. A Linux server with no keyring falls back to `~/.amness/config.json` (mode 600, **not encrypted**), so use an environment variable or your platform's secret manager there. Details: [Safety](docs/safety.md).
37
+ Each provider keeps its own saved key, so you can connect several and switch with `/provider`; `/connect` shows which are connected. `OPENAI_API_KEY` is used for every provider except Anthropic (OpenRouter prefers `OPENROUTER_API_KEY`). To remove a saved key, clear "API key" in the settings. A Linux server with no keyring falls back to `~/.amness/config.json` (mode 600, **not encrypted**), so use an environment variable or your platform's secret manager there. Details: [Safety](docs/safety.md).
38
38
 
39
39
  More ways to start:
40
40
 
@@ -15,7 +15,7 @@ import { runPlugin } from "./plugincli.js";
15
15
  import { runSchedule } from "./schedulecli.js";
16
16
  import { ACP_HELP, runAcp } from "./acpcli.js";
17
17
  import { runEval, runTune } from "./evalcli.js";
18
- import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, home, loadConfig, mask, ready, saveConfig } from "./config.js";
18
+ import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, home, keyFor, loadConfig, mask, ready, saveConfig } from "./config.js";
19
19
  import { where } from "./keystore.js";
20
20
  import { DEMO_TASK, explain, runDemo } from "./demo.js";
21
21
  import { formatEvent } from "./format.js";
@@ -91,7 +91,7 @@ async function ensureReady() {
91
91
  cfg.model = (await ask("Model", cfg.model)) || cfg.model;
92
92
  }
93
93
  if (PROVIDERS[id].needsKey)
94
- cfg.apiKey = (await ask(`API key (hidden; ${where()})`, "", { secret: true })) || undefined;
94
+ cfg.apiKey = keyFor(id).key ?? ((await ask(`API key (hidden; ${where()})`, "", { secret: true })) || undefined);
95
95
  saveConfig(cfg);
96
96
  if (!ready(cfg))
97
97
  return false;
@@ -432,7 +432,7 @@ async function settings() {
432
432
  if (what === "provider") {
433
433
  const id = await select("Provider", Object.keys(PROVIDERS).map((k) => ({ label: PROVIDERS[k].label, value: k, hint: PROVIDERS[k].baseUrl })));
434
434
  if (id)
435
- Object.assign(cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model });
435
+ Object.assign(cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey: keyFor(id).key });
436
436
  }
437
437
  else if (what === "model") {
438
438
  if (cfg.provider === "anthropic" || cfg.provider === "openai") {
@@ -610,7 +610,7 @@ async function main() {
610
610
  if (quick === "--help" || quick === "-h" || quick === "help")
611
611
  return void console.log(TOP_HELP);
612
612
  if (quick === "eval" && argv.length > 1) {
613
- process.exitCode = await runEval(argv.slice(1), { model: makeModel, modelFor: (id) => createModel({ ...cfg, model: id }), maxTokens: cfg.maxTokens, confirm });
613
+ process.exitCode = await runEval(argv.slice(1), { model: makeModel, modelName: cfg.model, modelFor: (id) => createModel({ ...cfg, model: id }), maxTokens: cfg.maxTokens, confirm });
614
614
  return;
615
615
  }
616
616
  if (quick === "bench") {
@@ -35,7 +35,8 @@ export type Config = {
35
35
  provider: ProviderId;
36
36
  baseUrl: string;
37
37
  model: string;
38
- apiKey?: string;
38
+ apiKey?: string; /** saved keys for the other providers, only where there is no OS store */
39
+ apiKeys?: Partial<Record<ProviderId, string>>;
39
40
  tavilyKey?: string;
40
41
  maxTokens: number;
41
42
  effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; /** OpenAI wire API: auto (default) picks Responses for reasoning models and self-corrects */
@@ -65,6 +66,26 @@ export type Config = {
65
66
  };
66
67
  export declare const home: () => string;
67
68
  export declare const defaults: () => Config;
69
+ /** The variable that holds a provider's key. Anthropic has its own; OpenRouter may have its own; every other provider uses OPENAI_API_KEY. */
70
+ export declare const envNameFor: (p: ProviderId) => "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY" | "OPENAI_API_KEY";
71
+ export declare const envKeyFor: (p: ProviderId) => string | undefined;
72
+ export type KeyState = {
73
+ key?: string;
74
+ source: "env" | "saved" | "none";
75
+ };
76
+ /** The key to use for a provider: an environment variable wins over a saved key. */
77
+ export declare function keyFor(p: ProviderId): KeyState;
78
+ /** Save (or, with no key, remove) a provider's key. Uses the OS store; else config.json (mode 600); in a container, memory only. */
79
+ export declare function storeKey(p: ProviderId, key?: string): void;
80
+ export type Connection = {
81
+ id: ProviderId;
82
+ label: string;
83
+ needsKey: boolean;
84
+ source: KeyState["source"];
85
+ env: string;
86
+ };
87
+ /** Every provider and whether it is ready to use. */
88
+ export declare function connections(): Connection[];
68
89
  export declare function loadConfig(): Config;
69
90
  export declare function saveConfig(c: Config): void;
70
91
  export declare const ready: (c: Config) => boolean;
@@ -12,7 +12,11 @@ export const PROVIDERS = {
12
12
  export const home = () => process.env.AMNESS_HOME ?? join(homedir(), ".amness");
13
13
  const file = () => join(home(), "config.json");
14
14
  export const defaults = () => ({ provider: "openai", baseUrl: PROVIDERS.openai.baseUrl, model: PROVIDERS.openai.model, maxTokens: 300_000 });
15
- const SECRETS = ["apiKey", "tavilyKey"];
15
+ // ---- keys. One saved key per provider (account "apiKey-<provider>" in the OS store), so you can be connected to several at once. `cfg.apiKey` is always the active provider's key.
16
+ const acct = (p) => `apiKey-${p}`;
17
+ let fileKeys = {}, fileKeysLoaded = false; // only where there is no OS store (a Linux server): kept in config.json, mode 600
18
+ const sessionKeys = {}; // in a container: this run only
19
+ const persisted = {}; // what is stored now, so a save does not rewrite an unchanged key
16
20
  /** An environment variable, or the file NAME_FILE points to (Docker and Kubernetes secrets are mounted as files, for example /run/secrets/openai). */
17
21
  const envSecret = (name) => {
18
22
  const v = process.env[name];
@@ -28,7 +32,58 @@ const envSecret = (name) => {
28
32
  return undefined;
29
33
  }
30
34
  };
31
- let fromEnv = {};
35
+ /** The variable that holds a provider's key. Anthropic has its own; OpenRouter may have its own; every other provider uses OPENAI_API_KEY. */
36
+ export const envNameFor = (p) => (p === "anthropic" ? "ANTHROPIC_API_KEY" : p === "openrouter" && envSecret("OPENROUTER_API_KEY") ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY");
37
+ export const envKeyFor = (p) => envSecret(envNameFor(p));
38
+ const loadFileKeys = () => { if (!fileKeysLoaded) {
39
+ fileKeysLoaded = true;
40
+ fileKeys = { ...readFileCfg().apiKeys };
41
+ } };
42
+ const savedKey = (p) => { loadFileKeys(); return getSecret(acct(p)) ?? fileKeys[p] ?? sessionKeys[p]; };
43
+ /** The key to use for a provider: an environment variable wins over a saved key. */
44
+ export function keyFor(p) {
45
+ const e = envKeyFor(p);
46
+ if (e)
47
+ return { key: e, source: "env" };
48
+ const s = savedKey(p);
49
+ if (s) {
50
+ persisted[p] = s;
51
+ return { key: s, source: "saved" };
52
+ }
53
+ return { source: "none" };
54
+ }
55
+ /** Save (or, with no key, remove) a provider's key. Uses the OS store; else config.json (mode 600); in a container, memory only. */
56
+ export function storeKey(p, key) {
57
+ loadFileKeys();
58
+ persisted[p] = key;
59
+ if (!key) {
60
+ deleteSecret(acct(p));
61
+ delete fileKeys[p];
62
+ delete sessionKeys[p];
63
+ writeConfigFile(readFileCfg());
64
+ return;
65
+ }
66
+ if (setSecret(acct(p), key)) {
67
+ delete fileKeys[p];
68
+ delete sessionKeys[p];
69
+ }
70
+ else if (noStore())
71
+ sessionKeys[p] = key;
72
+ else
73
+ fileKeys[p] = key;
74
+ writeConfigFile(readFileCfg());
75
+ }
76
+ /** Every provider and whether it is ready to use. */
77
+ export function connections() {
78
+ return Object.keys(PROVIDERS).map((id) => ({ id, label: PROVIDERS[id].label, needsKey: PROVIDERS[id].needsKey, source: PROVIDERS[id].needsKey ? keyFor(id).source : "saved", env: envNameFor(id) }));
79
+ }
80
+ const readFileCfg = () => { try {
81
+ return { ...defaults(), ...JSON.parse(readFileSync(file(), "utf8")) };
82
+ }
83
+ catch {
84
+ return defaults();
85
+ } };
86
+ let tavilyEnv;
32
87
  export function loadConfig() {
33
88
  let c = defaults(), onDisk = {};
34
89
  try {
@@ -37,33 +92,40 @@ export function loadConfig() {
37
92
  c = { ...c, ...onDisk };
38
93
  }
39
94
  catch { /* corrupt config: start fresh */ }
40
- // keys live in the OS credential store when there is one; a key still in the file (an older version wrote it there) is moved over, and removed from the file only after it reads back
41
- let migrated = false;
42
- for (const k of SECRETS) {
43
- const stored = getSecret(k);
44
- if (stored) {
45
- c[k] = stored;
46
- if (onDisk[k]) {
47
- delete onDisk[k];
48
- migrated = true;
49
- }
50
- }
51
- else if (onDisk[k] && setSecret(k, onDisk[k])) {
52
- delete onDisk[k];
53
- migrated = true;
95
+ fileKeys = { ...onDisk.apiKeys };
96
+ fileKeysLoaded = true;
97
+ delete c.apiKeys;
98
+ let dirty = false;
99
+ // older versions kept ONE key (account "apiKey", or a field in the file) for whichever provider was active: give it to that provider
100
+ const legacy = [getSecret("apiKey"), onDisk.apiKey].find(Boolean);
101
+ if (legacy && !savedKey(c.provider)) {
102
+ fileKeys[c.provider] = legacy;
103
+ dirty = true;
104
+ }
105
+ if (getSecret("apiKey"))
106
+ deleteSecret("apiKey");
107
+ if (onDisk.apiKey) {
108
+ delete onDisk.apiKey;
109
+ dirty = true;
110
+ }
111
+ // keys still in the file move to the OS store, and leave the file only after they read back
112
+ for (const p of Object.keys(fileKeys))
113
+ if (fileKeys[p] && setSecret(acct(p), fileKeys[p])) {
114
+ delete fileKeys[p];
115
+ dirty = true;
54
116
  }
117
+ const tv = getSecret("tavilyKey") ?? onDisk.tavilyKey;
118
+ if (onDisk.tavilyKey && setSecret("tavilyKey", onDisk.tavilyKey)) {
119
+ delete onDisk.tavilyKey;
120
+ dirty = true;
55
121
  }
56
- if (migrated)
57
- writeConfigFile({ ...defaults(), ...onDisk, ...Object.fromEntries(SECRETS.map((k) => [k, undefined])) });
58
- // environment wins, so CI and one-off runs work without touching the saved key. It is never written back (see saveConfig).
122
+ if (dirty)
123
+ writeConfigFile({ ...defaults(), ...onDisk, apiKey: undefined, tavilyKey: onDisk.tavilyKey });
59
124
  const claude = c.provider === "anthropic";
60
- fromEnv = {};
61
- const key = envSecret(claude ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY");
62
- if (key)
63
- c.apiKey = fromEnv.apiKey = key;
64
- const tavily = envSecret("TAVILY_API_KEY");
65
- if (tavily)
66
- c.tavilyKey = fromEnv.tavilyKey = tavily;
125
+ c.apiKey = keyFor(c.provider).key;
126
+ // environment wins, so CI and one-off runs work without touching the saved key. It is never written back (see saveConfig).
127
+ tavilyEnv = envSecret("TAVILY_API_KEY");
128
+ c.tavilyKey = tavilyEnv ?? tv;
67
129
  if (process.env.OPENAI_BASE_URL && !claude)
68
130
  c.baseUrl = process.env.OPENAI_BASE_URL;
69
131
  if (process.env.AMNESS_MODEL)
@@ -72,25 +134,25 @@ export function loadConfig() {
72
134
  }
73
135
  function writeConfigFile(c) {
74
136
  mkdirSync(home(), { recursive: true });
75
- writeFileSync(file(), JSON.stringify(c, null, 2), { mode: 0o600 }); // owner-only from the first byte
137
+ const out = { ...c, apiKeys: Object.keys(fileKeys).length ? fileKeys : undefined };
138
+ writeFileSync(file(), JSON.stringify(out, null, 2), { mode: 0o600 }); // owner-only from the first byte
76
139
  chmodSync(file(), 0o600); // and for a file that already existed
77
140
  }
78
141
  export function saveConfig(c) {
79
142
  const out = { ...c };
80
- for (const k of SECRETS) {
81
- const v = c[k];
82
- if (v && v === fromEnv[k]) {
83
- delete out[k];
84
- continue;
85
- } // this value came from an environment variable: do not store it
86
- if (!v) {
87
- deleteSecret(k);
88
- delete out[k];
89
- continue;
90
- }
91
- if (setSecret(k, v) || noStore())
92
- delete out[k]; // stored in the OS credential store; otherwise it stays in the file (mode 600), except in a container, where it is kept in memory only
143
+ delete out.apiKey;
144
+ delete out.apiKeys;
145
+ if (c.apiKey !== persisted[c.provider] && !(c.apiKey && c.apiKey === envKeyFor(c.provider)))
146
+ storeKey(c.provider, c.apiKey); // a key from an environment variable is never stored
147
+ const tv = c.tavilyKey;
148
+ if (tv && tv === tavilyEnv)
149
+ delete out.tavilyKey;
150
+ else if (!tv) {
151
+ deleteSecret("tavilyKey");
152
+ delete out.tavilyKey;
93
153
  }
154
+ else if (setSecret("tavilyKey", tv) || noStore())
155
+ delete out.tavilyKey;
94
156
  writeConfigFile(out);
95
157
  }
96
158
  export const ready = (c) => !PROVIDERS[c.provider].needsKey || !!c.apiKey;
@@ -1,7 +1,24 @@
1
- import { type Model } from "../src/index.ts";
1
+ import { type Model, type Row } from "../src/index.ts";
2
2
  export declare const EVAL_HELP: string;
3
+ /** Estimated dollars for some rows (prompt, cached and completion tokens priced separately; the rate card is in pricing.ts). Null when the model's price is unknown. */
4
+ export declare function rowsCost(model: string, rows: Row[]): number | null;
5
+ /** What makes a result repeatable: the Amness version and git commit (when run from a clone), and a hash of the tasks (their files, text and checks). */
6
+ export declare function provenance(cases: {
7
+ name: string;
8
+ files: Record<string, string>;
9
+ task: string;
10
+ verify?: string;
11
+ check: unknown;
12
+ solution?: Record<string, string>;
13
+ }[]): {
14
+ version: string | undefined;
15
+ commit: string | undefined;
16
+ suite: string;
17
+ tasks: number;
18
+ };
3
19
  export declare function runEval(argv: string[], deps: {
4
20
  model: () => Model;
21
+ modelName?: string;
5
22
  modelFor?: (id: string) => Model;
6
23
  maxTokens: number;
7
24
  confirm: (q: string) => Promise<boolean>;
@@ -7,10 +7,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  // `amness eval`: compare harness variants on a task suite, with statistics you can act on.
10
+ import { createHash } from "node:crypto";
11
+ import { execFileSync } from "node:child_process";
10
12
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
13
  import { join, resolve } from "node:path";
12
- import { BUILTIN_METRICS, gateCompare, importPlugins, tune, writeProfile, adversarialCases, hardCases, analyze, builtinCases, evaluate, recommend, leakAudit, loadSuite, suiteToCases, formatReport, harnessVariant, loadCases, selfTest, variantNames, VARIANT_HELP } from "../src/index.js";
14
+ import { BUILTIN_METRICS, gateCompare, importPlugins, tune, writeProfile, adversarialCases, hardCases, analyze, builtinCases, evaluate, recommend, leakAudit, loadSuite, suiteToCases, formatReport, calibrate, harnessVariant, loadCases, selfTest, variantNames, VARIANT_HELP } from "../src/index.js";
13
15
  import { bold, dim, green, red } from "./ui.js";
16
+ import { estimateCost, priceOf } from "./tui/pricing.js";
14
17
  import { pathToFileURL } from "node:url";
15
18
  async function casesFromModule(file) {
16
19
  const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + `?v=${Date.now()}`)), list = mod.default ?? mod.cases;
@@ -46,7 +49,32 @@ export const EVAL_HELP = `usage: amness eval [flags]
46
49
  --out <file> where to save raw results (default .amness/evals/<time>.json)
47
50
  --report <file> re-analyse saved results; runs nothing
48
51
  --selftest check that every task is solvable and its held-out check is sound; runs no model
52
+ --max-cost <dollars> stop starting new sessions once the estimated spend reaches this amount (sessions already running finish). Off by default. Needs a model with a known price.
53
+ --calibrate how hard is each task? runs only \`baseline\` (5 trials unless you set --trials) and lists each task's pass rate: keep 30% to 70%, drop tasks that are too easy or too hard
49
54
  --yes skip the cost confirmation`;
55
+ /** Estimated dollars for some rows (prompt, cached and completion tokens priced separately; the rate card is in pricing.ts). Null when the model's price is unknown. */
56
+ export function rowsCost(model, rows) {
57
+ if (!priceOf(model))
58
+ return null;
59
+ return rows.reduce((n, r) => n + (estimateCost(model, r.prompt ?? r.tokens, Math.max(0, r.tokens - (r.prompt ?? r.tokens)), r.cached ?? 0) ?? 0), 0);
60
+ }
61
+ /** What makes a result repeatable: the Amness version and git commit (when run from a clone), and a hash of the tasks (their files, text and checks). */
62
+ export function provenance(cases) {
63
+ let commit;
64
+ try {
65
+ commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: new URL("..", import.meta.url), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
66
+ }
67
+ catch { /* not a git checkout (an installed package) */ }
68
+ let version;
69
+ try {
70
+ version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
71
+ }
72
+ catch { /* no package.json next to us */ }
73
+ const h = createHash("sha256");
74
+ for (const x of [...cases].sort((a, b) => a.name.localeCompare(b.name)))
75
+ h.update(JSON.stringify([x.name, x.files, x.task, x.verify ?? "", typeof x.check === "function" ? x.check.toString() : x.check, x.solution ?? {}]));
76
+ return { version, commit, suite: h.digest("hex").slice(0, 16), tasks: cases.length };
77
+ }
50
78
  export async function runEval(argv, deps) {
51
79
  const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }, has = (f) => argv.includes(f);
52
80
  if (has("--help") || has("-h")) {
@@ -108,20 +136,41 @@ export async function runEval(argv, deps) {
108
136
  console.log(`${r.ok ? green("ok ") : red("bad")} ${r.case}${r.why ? dim(" " + r.why) : ""}`);
109
137
  return res.every((r) => r.ok) ? 0 : 1;
110
138
  }
111
- const names = variantNames(val("--variants") ?? "ab"), trials = Math.max(1, Number(val("--trials") ?? 3)), jobs = Math.max(1, Number(val("--jobs") ?? 1));
139
+ const calib = has("--calibrate"), names = variantNames(val("--variants") ?? (calib ? "baseline" : "ab")), trials = Math.max(1, Number(val("--trials") ?? (calib ? 5 : 3))), jobs = Math.max(1, Number(val("--jobs") ?? 1));
112
140
  const maxTokens = Number(val("--max-tokens") ?? deps.maxTokens), maxSteps = Number(val("--max-steps") ?? 30);
113
141
  const model = deps.model(), cheap = val("--cheap-model") && deps.modelFor ? deps.modelFor(val("--cheap-model")) : undefined, variants = names.map((n) => harnessVariant(model, n, { maxTokens, maxSteps, cheap, plugins, modelFor: deps.modelFor }));
114
142
  const runs = cases.length * variants.length * trials;
115
143
  console.log(`${bold("plan")} ${cases.length} tasks × ${variants.length} variants (${names.join(", ")}) × ${trials} trials = ${bold(String(runs))} agent sessions, up to ${maxTokens.toLocaleString()} tokens each`);
144
+ const mname = deps.modelName ?? "", maxCost = val("--max-cost") === undefined ? undefined : Number(val("--max-cost"));
145
+ if (maxCost !== undefined && !(maxCost > 0))
146
+ throw new Error("--max-cost needs a positive number of dollars, for example --max-cost 0.50");
147
+ if (maxCost !== undefined && !priceOf(mname))
148
+ throw new Error(`--max-cost needs a known price for the model "${mname}" (see cli/tui/pricing.ts); without one Amness cannot estimate the spend`);
149
+ const p = priceOf(mname);
150
+ if (p)
151
+ console.log(dim(`worst case about $${(runs * maxTokens * p[2] / 1e6).toFixed(2)} (every token priced as output; real runs are far cheaper: most of a session is cached input)${maxCost !== undefined ? ` · stops at about $${maxCost.toFixed(2)}` : " · no spending cap (--max-cost sets one)"}`));
116
152
  if (!has("--yes") && !(await deps.confirm(`This calls your model up to ${(runs * maxTokens).toLocaleString()} tokens in the worst case. Continue?`)))
117
153
  return 1;
118
- const { rows, analysis } = await evaluate(cases, variants, {
154
+ const { rows, analysis, stopped, skipped } = await evaluate(cases, variants, {
155
+ stop: maxCost === undefined ? undefined : (rs) => { const spent = rowsCost(mname, rs) ?? 0; return spent >= maxCost ? `reached the cost cap: about $${spent.toFixed(3)} spent of $${maxCost.toFixed(2)}` : undefined; },
119
156
  metrics: Object.keys(useMetrics).length ? useMetrics : undefined, trials, jobs, baseline: val("--baseline"), k: Math.min(3, trials), seed: val("--seed") ? Number(val("--seed")) : undefined,
120
157
  onRow: (r) => console.error(` ${r.pass ? green("PASS") : red("FAIL")} ${r.case.padEnd(22)} ${r.variant.padEnd(18)} ${dim(`${r.steps} steps · ${r.tokens} tok · ${(r.ms / 1000).toFixed(1)}s${r.note ? " · " + r.note : ""}`)}`),
121
158
  });
122
159
  const out = resolve(val("--out") ?? join(".amness", "evals", new Date().toISOString().replace(/[:.]/g, "-") + ".json"));
123
160
  mkdirSync(join(out, ".."), { recursive: true });
124
- writeFileSync(out, JSON.stringify({ at: new Date().toISOString(), baseline: analysis.baseline, k: Math.min(3, trials), variants: names, trials, rows }, null, 1));
161
+ const spent = rowsCost(mname, rows);
162
+ writeFileSync(out, JSON.stringify({ at: new Date().toISOString(), model: mname || undefined, ...provenance(cases), cliArgs: argv.filter((a, i) => !/key|token/i.test(a) && !/key|token/i.test(argv[i - 1] ?? "")), baseline: analysis.baseline, k: Math.min(3, trials), variants: names, trials, estimatedCost: spent ?? undefined, stopped, rows }, null, 1));
163
+ if (stopped)
164
+ console.log(`\n${red("stopped early")}: ${stopped}. ${skipped} planned session${skipped === 1 ? "" : "s"} did not run, so the results below cover only the runs that finished.`);
165
+ if (spent !== null)
166
+ console.log(dim(`estimated spend: about $${spent.toFixed(3)} for ${rows.length} session${rows.length === 1 ? "" : "s"}${rows.length ? ` ($${(spent / rows.length).toFixed(4)} each)` : ""} (an estimate from token counts, not an invoice)`));
167
+ if (calib) {
168
+ console.log("\n" + bold("how hard is each task?") + dim(` (baseline, ${trials} trials; keep 30% to 70%)`));
169
+ for (const x of calibrate(rows, names[0]))
170
+ console.log(` ${x.verdict === "keep" ? green("keep ") : red(x.verdict.padEnd(10))} ${x.case.padEnd(26)} ${x.passes}/${x.runs} passed ${dim((x.rate * 100).toFixed(0) + "%")}`);
171
+ console.log(dim(`raw results: ${out}`));
172
+ return 0;
173
+ }
125
174
  if (val("--profile-out")) {
126
175
  const rec = recommend(analysis, names.includes("full") ? "full" : analysis.baseline), pm = val("--profile-model") ?? "*";
127
176
  console.log("\nguard verdicts:");
@@ -0,0 +1,8 @@
1
+ export type ModelProbe = {
2
+ provider: string;
3
+ baseUrl: string;
4
+ apiKey?: string;
5
+ };
6
+ export declare function fetchModels(p: ModelProbe, fetchImpl?: typeof fetch): Promise<string[]>;
7
+ /** For tests. */
8
+ export declare const clearModelCache: () => void;
@@ -0,0 +1,31 @@
1
+ const cache = new Map();
2
+ const TTL = 10 * 60_000;
3
+ const NOT_CHAT = /embed|whisper|tts|dall-e|moderation|davinci|babbage|transcribe|realtime|audio|image|rerank/i;
4
+ export async function fetchModels(p, fetchImpl = fetch) {
5
+ if (!p.apiKey && p.provider !== "ollama" && p.provider !== "custom")
6
+ return []; // a hosted provider refuses a request with no key: do not even try
7
+ const key = `${p.provider}|${p.baseUrl}|${(p.apiKey ?? "").slice(-6)}`, hit = cache.get(key);
8
+ if (hit && Date.now() - hit.at < TTL)
9
+ return hit.ids;
10
+ const base = p.baseUrl.replace(/\/+$/, "");
11
+ const anthropic = p.provider === "anthropic";
12
+ try {
13
+ const res = await fetchImpl(anthropic ? `${base}/v1/models?limit=100` : `${base}/models`, {
14
+ headers: anthropic ? { "x-api-key": p.apiKey ?? "", "anthropic-version": "2023-06-01" } : p.apiKey ? { authorization: `Bearer ${p.apiKey}` } : {},
15
+ signal: AbortSignal.timeout(6000),
16
+ });
17
+ if (!res.ok)
18
+ return [];
19
+ const body = (await res.json());
20
+ const ids = [...(body.data ?? []).map((m) => m.id), ...(body.models ?? []).map((m) => m.model ?? m.name)] // Ollama's native shape uses `models`
21
+ .filter((x) => typeof x === "string" && !!x && !NOT_CHAT.test(x));
22
+ const uniq = [...new Set(ids)].sort();
23
+ cache.set(key, { at: Date.now(), ids: uniq });
24
+ return uniq;
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ /** For tests. */
31
+ export const clearModelCache = () => cache.clear();
@@ -131,9 +131,11 @@ export declare class App {
131
131
  items: Suggestion[];
132
132
  sel: number;
133
133
  token: Token;
134
- kind: Token["kind"];
134
+ kind: Token["kind"]; /** the user moved into the list with the arrow keys */
135
+ nav?: boolean;
135
136
  } | null;
136
137
  suggOff: string;
138
+ private argPending;
137
139
  private builtin;
138
140
  private customAt;
139
141
  private customCmds;
@@ -15,7 +15,8 @@ import { BlockStore } from "./blocks.js";
15
15
  import { buildCommands } from "./commands.js";
16
16
  import { FullView } from "./full.js";
17
17
  import { loadCustomCommands } from "./custom.js";
18
- import { FileIndex, applySuggestion, rankCommands, rankFiles, tokenAtCursor } from "./complete.js";
18
+ import { argOptions } from "./argcomplete.js";
19
+ import { FileIndex, fuzzyScore, applySuggestion, menuRows, rankCommands, rankFiles, tokenAtCursor } from "./complete.js";
19
20
  import { Editor } from "./editor.js";
20
21
  import { KeyParser, PASTE_OFF, PASTE_ON } from "./keys.js";
21
22
  import { renderMarkdown, takeCompleteBlocks } from "./markdown.js";
@@ -80,6 +81,7 @@ export class App {
80
81
  pager = null;
81
82
  sugg = null;
82
83
  suggOff = "";
84
+ argPending = new Set();
83
85
  builtin;
84
86
  customAt = 0;
85
87
  customCmds = [];
@@ -370,11 +372,16 @@ export class App {
370
372
  setTitle(t) { if (this.io.output.isTTY !== false)
371
373
  this.io.output.write(`\x1b]0;${t}\x07`); }
372
374
  suggLines(w) {
373
- const s = this.sugg, win = 8, top = Math.max(0, Math.min(s.sel - 3, s.items.length - win));
374
- return s.items.slice(top, top + win).map((it, i) => {
375
- const on = top + i === s.sel, label = s.kind === "file" ? it.label : it.label.padEnd(12);
375
+ const s = this.sugg, { rows, above, below } = menuRows(s.items, s.sel, Math.min(12, Math.max(4, this.screen.rows - 8)));
376
+ const lines = rows.map((r) => {
377
+ if ("head" in r)
378
+ return dim(` ── ${r.head} ${"─".repeat(Math.max(2, 20 - r.head.length))}`);
379
+ const it = s.items[r.item], on = r.item === s.sel, label = s.kind === "file" ? it.label : it.label.padEnd(16);
376
380
  return (on ? cyan("❯ ") + bold(cyan(label)) : " " + label) + (it.detail ? " " + (on ? gray(it.detail) : dim(it.detail)) : "");
377
381
  }).map((l) => (cellWidth(l) > w ? l.slice(0, w + 12) : l));
382
+ if (above || below)
383
+ lines.push(dim(` ${s.sel + 1}/${s.items.length} ${above ? "↑" : " "}${below ? "↓" : " "} ↑↓ move · PgUp/PgDn page`));
384
+ return lines;
378
385
  }
379
386
  tick = () => { this.frame++; this.render(); };
380
387
  ensureTimer() { if (!this.timer)
@@ -451,7 +458,7 @@ export class App {
451
458
  if (k.name === "enter" && !k.alt && !k.shift) {
452
459
  if (this.sugg && this.sugg.items.length) {
453
460
  const it = this.sugg.items[this.sugg.sel];
454
- if (!(this.sugg.kind === "slash" && ed.text.trim() === it.label))
461
+ if (!(this.sugg.kind === "slash" && ed.text.trim() === it.label) && !(this.sugg.kind === "arg" && (this.sugg.items.some((x) => x.label === this.sugg.token.query) || (it.open && !this.sugg.nav))))
455
462
  return this.acceptSugg(true);
456
463
  }
457
464
  if (ed.text.slice(0, ed.cursorIndex).endsWith("\\")) {
@@ -461,10 +468,16 @@ export class App {
461
468
  }
462
469
  return void this.submit();
463
470
  }
471
+ if (this.sugg && (k.name === "pageup" || k.name === "pagedown")) {
472
+ const n = this.sugg.items.length;
473
+ this.sugg.sel = Math.max(0, Math.min(n - 1, this.sugg.sel + (k.name === "pageup" ? -8 : 8)));
474
+ return this.render();
475
+ }
464
476
  if (k.name === "up" || k.name === "down") {
465
477
  if (this.sugg) {
466
478
  const n = this.sugg.items.length;
467
479
  this.sugg.sel = (this.sugg.sel + (k.name === "up" ? n - 1 : 1)) % n;
480
+ this.sugg.nav = true;
468
481
  return this.render();
469
482
  }
470
483
  if (!ed.vertical(k.name === "up" ? -1 : 1))
@@ -492,13 +505,25 @@ export class App {
492
505
  let items = [];
493
506
  if (tok.kind === "slash")
494
507
  items = rankCommands(tok.query, this.cmds);
508
+ else if (tok.kind === "arg") {
509
+ const cmd = this.cmds.find((c) => c.name === tok.cmd || c.alias === tok.cmd);
510
+ if (cmd) {
511
+ const r = argOptions(this, cmd.name, tok.words ?? []), pk = `${cmd.name}|${(tok.words ?? []).join(" ")}`;
512
+ if (r.pending && !this.argPending.has(pk)) {
513
+ this.argPending.add(pk);
514
+ void r.pending.catch(() => { }).then(() => { this.argPending.delete(pk); this.updateSugg(); this.render(); });
515
+ }
516
+ items = r.options.map((o) => ({ o, s: fuzzyScore(tok.query, o.value) })).filter((x) => x.s !== null)
517
+ .sort((x, y) => (tok.query ? y.s - x.s : 0)).map(({ o }) => ({ label: o.value, insert: o.value + (o.more ? " " : ""), detail: o.detail, more: o.more, open: r.open }));
518
+ }
519
+ }
495
520
  else {
496
521
  if (!this.files.fresh())
497
522
  void this.files.ensure().then(() => this.updateSugg(), () => { }); // list still loading: fill the popup in once it arrives
498
523
  items = rankFiles(tok.query, this.files.files);
499
524
  }
500
- const keep = this.sugg && this.sugg.kind === tok.kind && this.sugg.token.query === tok.query ? this.sugg.sel : 0;
501
- this.sugg = items.length ? { items, sel: Math.min(keep, items.length - 1), token: tok, kind: tok.kind } : null;
525
+ const same = this.sugg && this.sugg.kind === tok.kind && this.sugg.token.query === tok.query, keep = same ? this.sugg.sel : 0;
526
+ this.sugg = items.length ? { items, sel: Math.min(keep, items.length - 1), token: tok, kind: tok.kind, nav: same ? this.sugg.nav : false } : null;
502
527
  if (!this.sugg)
503
528
  return;
504
529
  this.schedule();
@@ -512,6 +537,8 @@ export class App {
512
537
  this.suggOff = "";
513
538
  if (fromEnter && s.kind === "slash" && !this.cmds.find((c) => "/" + c.name === s.items[s.sel].label)?.args)
514
539
  return void this.submit();
540
+ if (fromEnter && s.kind === "arg" && !s.items[s.sel].more)
541
+ return void this.submit(); // a finished value: run the command
515
542
  this.updateSugg();
516
543
  this.render();
517
544
  }
@@ -0,0 +1,12 @@
1
+ import type { App } from "./app.ts";
2
+ export type ArgOption = {
3
+ value: string;
4
+ detail?: string; /** more words are expected after this one: insert a space and do not submit */
5
+ more?: boolean;
6
+ };
7
+ export type ArgResult = {
8
+ options: ArgOption[];
9
+ pending?: Promise<unknown>; /** any value is allowed (a number, a model id, a command): Enter runs what you typed unless you moved into the list; Tab still completes */
10
+ open?: boolean;
11
+ };
12
+ export declare function argOptions(a: App, cmd: string, words: string[]): ArgResult;
@@ -0,0 +1,55 @@
1
+ // Completion for the words after a slash command: `/mode ` lists the modes, `/model ` the models your provider offers, `/theme ` the themes, and so on.
2
+ // A completer returns its options now and, when it needs the network, a promise that settles when the list has been filled in (the menu refreshes then).
3
+ import { effortsFor, loadPrompts, SANDBOX_MODES, SHELL_CHOICES, STRATEGIES } from "../../src/index.js";
4
+ import { CLAUDE_MODELS, OPENAI_MODELS } from "../config.js";
5
+ import { fetchModels } from "../models.js";
6
+ import { themeNames, THEMES } from "./themes.js";
7
+ const onOff = [{ value: "on" }, { value: "off" }];
8
+ const opts = (...xs) => xs.map(([value, detail]) => ({ value, detail }));
9
+ /** Known models with their hints first, then any other model the provider reports. The provider is asked once per ten minutes; until it answers, only the known models show. */
10
+ const fetched = new Map();
11
+ function modelOptions(a, extra = []) {
12
+ const known = a.cfg.provider === "anthropic" ? CLAUDE_MODELS : a.cfg.provider === "openai" ? OPENAI_MODELS : [];
13
+ const key = `${a.cfg.provider}|${a.cfg.baseUrl}`, have = fetched.get(key), fresh = have && Date.now() - have.at < 10 * 60_000;
14
+ const seen = new Set(known.map((m) => m.id));
15
+ const options = [...extra, ...known.map((m) => ({ value: m.id, detail: m.hint })), ...(have?.ids ?? []).filter((id) => !seen.has(id)).map((id) => ({ value: id, detail: "from your provider" }))];
16
+ if (fresh)
17
+ return { options, open: true };
18
+ return { options, open: true, pending: fetchModels({ provider: a.cfg.provider, baseUrl: a.cfg.baseUrl, apiKey: a.cfg.apiKey }).then((ids) => { fetched.set(key, { at: Date.now(), ids }); }) };
19
+ }
20
+ export function argOptions(a, cmd, words) {
21
+ const first = words.length === 0;
22
+ const ok = (options) => ({ options });
23
+ switch (cmd) {
24
+ case "mode": return first ? ok(opts(["default", "ask before edits and non-trivial commands"], ["acceptEdits", "edits are automatic, commands still ask"], ["plan", "read-only: investigate and propose a plan"], ["yolo", "never ask (dangerous)"])) : ok([]);
25
+ case "api": return first ? ok(opts(["auto", "pick Responses for reasoning models, self-correct"], ["responses", "OpenAI Responses API"], ["chat", "Chat Completions API"])) : ok([]);
26
+ case "effort": return first ? ok([{ value: "default", detail: "let the model decide" }, ...(a.cfg.provider === "anthropic" ? ["low", "medium", "high", "xhigh", "max"] : effortsFor(a.cfg.model)).map((e) => ({ value: e }))]) : ok([]);
27
+ case "model": return first ? modelOptions(a) : ok([]);
28
+ case "models": return first ? ok([...["explore", "compact", "review", "cheap"].map((r) => ({ value: r, detail: `model for ${r} steps`, more: true })), { value: "fallback", detail: "answer from this model when the main one is down", more: true }])
29
+ : words.length === 1 ? modelOptions(a, [{ value: "off", detail: "use the main model" }]) : ok([]);
30
+ case "theme": return first ? ok(themeNames().map((n) => ({ value: n, detail: THEMES[n].label }))) : ok([]);
31
+ case "shell": return first ? ok(SHELL_CHOICES.map((n) => ({ value: n, detail: n === "auto" ? "detect from your system" : undefined }))) : ok([]);
32
+ case "strategy": return first ? ok([...Object.keys(STRATEGIES).map((n) => ({ value: n })), { value: "off", detail: "the default loop" }]) : ok([]);
33
+ case "prompt": return first ? ok([...loadPrompts(a.cwd).map((p) => ({ value: p.id, detail: "from .amness/prompts" })), { value: "list" }, { value: "off", detail: "the built-in prompt" }]) : ok([]);
34
+ case "sandbox": return first ? ok(SANDBOX_MODES.map((m) => ({ value: m, detail: m === "off" ? "no isolation" : m === "workspace" ? "writes only in the project" : "no network, caches read-only" }))) : ok([]);
35
+ case "diffmode": return first ? ok(opts(["auto"], ["stacked"], ["split"])) : ok([]);
36
+ case "ui": return first ? ok(opts(["full", "full-screen"], ["inline", "scrolls with the terminal"])) : ok([]);
37
+ case "mouse":
38
+ case "keepwarm":
39
+ case "lsp":
40
+ case "approver":
41
+ case "reviewer":
42
+ case "router": return first ? ok(onOff) : ok([]);
43
+ case "copy": return first ? ok(opts(["code", "the last code block"], ["all", "the whole conversation"])) : ok([]);
44
+ case "memory": return first ? ok([...["forget", "add"].map((v) => ({ value: v, more: true })), ...["stale", "prune", "consolidate", "graph", "embeddings"].map((v) => ({ value: v }))]) : ok([]);
45
+ case "permissions": return first ? ok([{ value: "list" }, ...["allow", "ask", "deny"].map((v) => ({ value: v, detail: `e.g. /permissions ${v} sh(npm test*)`, more: true }))]) : ok([]);
46
+ case "branch": return first ? ok(a.session.turns.map((t, i) => ({ value: String(i + 1), detail: t.text.replace(/\s+/g, " ").slice(0, 60) }))) : ok([]);
47
+ case "pr": return first ? ok(opts(["draft", "create it as a draft"])) : ok([]);
48
+ case "report": return first ? ok([{ value: "case", detail: "turn the last request into an eval task", more: true }]) : ok([]);
49
+ case "mcp": return first ? ok([{ value: "list" }, { value: "off" }]) : ok([]);
50
+ case "budget": return first ? { options: opts(["100k"], ["300k", "the default"], ["500k"], ["1m"], ["2m"]), open: true } : ok([]);
51
+ case "check":
52
+ case "verify": return first ? { options: [{ value: "off", detail: "switch it off" }], open: true } : ok([]);
53
+ default: return ok([]);
54
+ }
55
+ }
@@ -16,6 +16,8 @@ export type RenderCtx = {
16
16
  diffMode: "auto" | "stacked" | "split";
17
17
  /** The block above is also a tool call: draw no blank line between them, so a run of tool calls reads as one unit. */
18
18
  tight?: boolean;
19
+ /** this is the most recent tool call of the lead agent: its output stays open (a short preview) until the next one starts */
20
+ last?: boolean;
19
21
  };
20
22
  export type Hit = {
21
23
  row: number;