cortad 0.4.0 → 0.4.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/lib/replay.mjs CHANGED
@@ -152,7 +152,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
152
152
  // What your app spent on its model providers since it started, as the hook saw each call. Null
153
153
  // when no hook is in your app (an app this command did not start): absent, never zero. With a
154
154
  // `turn`, the calls pinned to that turn also say what their model was told.
155
- usage: (turn) => { poll(); return alive ? meter.report(TURN.test(turn ?? "") ? turn : undefined) : null; },
155
+ usage: (turn, totalsOnly = false) => { poll(); return alive ? meter.report(TURN.test(turn ?? "") ? turn : undefined, totalsOnly) : null; },
156
156
  // The app's routes as its framework holds them: the table of the process on `port`, else the
157
157
  // newest any hooked process wrote. Empty when no hook read one.
158
158
  registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
@@ -339,14 +339,36 @@ const toldOf = (call) => {
339
339
  // The model's words behind a call, and the lines of the app that wrote the reply of the request it
340
340
  // was made in, go out with the rows pinned to the turn asked about, as what the model was told does.
341
341
  const MODEL_WORDS = 4000, SITES_KEPT = 500;
342
+ function tally(totals, call, count) {
343
+ const host = call.host.slice(0, 253);
344
+ const model = String(call.model ?? "").slice(0, 160);
345
+ const answered = typeof call.answered === "string" && call.answered ? call.answered.slice(0, 160) : undefined;
346
+ const key = `${host}|${model}`;
347
+ const t = totals.get(key) ?? { host, model, calls: 0, promptTokens: 0, cachedTokens: 0, completionTokens: 0, unmetered: 0, answered, mixed: false };
348
+ t.calls += 1;
349
+ // The model that answered for this one, when every call of it was answered by one other model.
350
+ if (t.answered !== answered) t.mixed = true;
351
+ t.promptTokens += count(call.promptTokens);
352
+ t.cachedTokens += count(call.cachedTokens);
353
+ t.completionTokens += count(call.completionTokens);
354
+ // An answered call the provider sent no counts for: it was spent, and what it cost is not known.
355
+ const status = count(call.status);
356
+ if (call.usage !== true && status > 0 && status < 400) t.unmetered += 1;
357
+ totals.set(key, t);
358
+ }
359
+
342
360
  const meter = (() => {
343
361
  const rows = [];
344
362
  const deps = [];
363
+ // What the app has spent since it started, per host and model, kept as it goes: the rows are
364
+ // trimmed to the newest ROWS, and totals summed from them lost every call before those.
365
+ const totals = new Map();
345
366
  const sitesOfEx = new Map();
346
367
  const count = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
347
368
  return {
348
369
  add(call) {
349
370
  if (!call || typeof call.host !== "string" || !call.host) return;
371
+ tally(totals, call, count);
350
372
  rows.push({
351
373
  at: count(call.at), ms: count(call.ms), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), ...(typeof call.answered === "string" && call.answered ? { answered: call.answered.slice(0, 160) } : {}), status: count(call.status),
352
374
  promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
@@ -373,24 +395,15 @@ const meter = (() => {
373
395
  },
374
396
  // `turn`: the rows pinned to it carry what their model was told. The instructions go out with the
375
397
  // reply they are read with, never with every call of the run at every turn.
376
- report(turn) {
377
- const totals = new Map();
378
- for (const r of rows) {
379
- const key = `${r.host}|${r.model}`;
380
- const t = totals.get(key) ?? { host: r.host, model: r.model, calls: 0, promptTokens: 0, cachedTokens: 0, completionTokens: 0, unmetered: 0, answered: r.answered };
381
- t.calls += 1;
382
- // The model that answered for this one, when every call of it was answered by one other model.
383
- if (t.answered !== r.answered) t.answered = undefined;
384
- t.promptTokens += r.promptTokens;
385
- t.cachedTokens += r.cachedTokens;
386
- t.completionTokens += r.completionTokens;
387
- // An answered call the provider sent no counts for: it was spent, and what it cost is not known.
388
- if (!r.usage && r.status > 0 && r.status < 400) t.unmetered += 1;
389
- totals.set(key, t);
390
- }
398
+ // `totalsOnly`: the spend alone, for the meter read while a run plays and at its end. The rows
399
+ // carry what each call was handed, and on ulaim's run the whole report was 2.9 MB, past what the
400
+ // server takes, so the bill never arrived.
401
+ report(turn, totalsOnly = false) {
402
+ const spent = [...totals.values()].sort((a, b) => b.calls - a.calls).map(({ answered, mixed, ...t }) => (answered && !mixed ? { ...t, answered } : t));
403
+ if (totalsOnly) return { totals: spent };
391
404
  return {
392
- totals: [...totals.values()].sort((a, b) => b.calls - a.calls).map(({ answered, ...t }) => (answered ? { ...t, answered } : t)),
393
- rows: rows.slice(-REPORTED).reverse().map(({ at, ms, host, model, status, turn: pinned, rules, tools, called, passages, caller, ex, told, said }) => ({ at, ms, host, model, status, ...(pinned ? { turn: pinned } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}),
405
+ totals: spent,
406
+ rows: rows.slice(-REPORTED).reverse().map(({ at, ms, host, model, status, turn: pinned, rules, tools, called, passages, caller, ex, told, said }) => ({ at, ms, host, model, status, ...(pinned ? { turn: pinned } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages && turn !== undefined && pinned === turn ? { passages } : {}), ...(caller ? { caller } : {}),
394
407
  ...(turn !== undefined && pinned === turn ? { ...told, ...(said !== undefined ? { said } : {}), ...(sitesOfEx.get(ex)?.length ? { sites: sitesOfEx.get(ex) } : {}) } : {}) })),
395
408
  deps: deps.slice(-REPORTED).reverse(),
396
409
  };
package/lib/start.mjs CHANGED
@@ -125,10 +125,11 @@ const KNOWN = ["package.json", "pyproject.toml", "requirements.txt", "manage.py"
125
125
  // from its environment whatever folder it runs in; a container, a Go or Java binary does not, and is
126
126
  // watched from outside (lib/proxy.mjs). Judged by the command when there is one, else by the folder.
127
127
  const HOOKED = /^(?:python3?(?:\.\d+)?|node|nodemon|tsx|ts-node|bun|bunx|deno|npm|npx|pnpm|yarn|uvicorn|gunicorn|hypercorn|flask|fastapi|poetry|uv|pipenv)$/;
128
- // The program a command runs, past any PORT=3000 in front of it and the quotes or folder around it.
128
+ // The program a command runs, past any PORT=3000 in front of it, an `env` or `exec` that only sets
129
+ // those, and the quotes or folder around it.
129
130
  const runtimeOf = (cmd) => {
130
- const words = cmd.trim().split(/\s+/).filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
131
- return (words[0] ?? "").replace(/^["']|["']$/g, "").split("/").pop() ?? "";
131
+ const words = cmd.trim().split(/\s+/).filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)).map((w) => w.replace(/^["']|["']$/g, "").split("/").pop() ?? "");
132
+ return words.find((w) => w !== "env" && w !== "exec" && !w.startsWith("-")) ?? "";
132
133
  };
133
134
  export const hookable = (dir, cmd = "") => (cmd ? HOOKED.test(runtimeOf(cmd)) : KNOWN.some((f) => existsSync(join(dir, f))));
134
135
 
package/local.mjs CHANGED
@@ -572,7 +572,7 @@ async function verb(job) {
572
572
  // calls of the `turn` asked about also say what their model was told.
573
573
  // `toolsRecorded: false`: the app was started before the read's list of tools, so a tool that ran
574
574
  // was not seen; a run says "not recorded", never that none ran.
575
- case "usage": return capture ? { ...JSON.parse(mask(JSON.stringify(capture.usage(b.turn) ?? {}))), ...(launched && toolsKnown() ? { toolsRecorded: toolsRecorded() } : {}) } : {};
575
+ case "usage": return capture ? { ...JSON.parse(mask(JSON.stringify(capture.usage(b.turn, b.totals === true) ?? {}))), ...(launched && toolsKnown() ? { toolsRecorded: toolsRecorded() } : {}) } : {};
576
576
  // Every route your app holds, read by the hook off the app itself, masked like the meter.
577
577
  case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
578
578
  // A world is ended from this terminal, never from the cloud.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"