@plune-ai/cli 0.26.0 → 0.28.0

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/dist/index.d.ts CHANGED
@@ -15,6 +15,24 @@ interface Summary {
15
15
  skipped?: number;
16
16
  cost_usd: number;
17
17
  duration_ms: number;
18
+ /** plune-ai/plune#1696. Where the answers came from. Optional and additive; absent in older runs. */
19
+ cache?: CacheSummary;
20
+ }
21
+ /**
22
+ * `from_cache` rows were answered by the store, `from_model` by a model call, `missed` had no entry
23
+ * under `--cache-strict` and were not answered. The `saved` totals are the sum of the rows' `cache_saved`.
24
+ */
25
+ interface CacheSummary {
26
+ from_cache: number;
27
+ from_model: number;
28
+ missed: number;
29
+ /** Rows the provider failed on: answered by neither, so the four counts add up to `total` (dry-run rows aside). */
30
+ errored: number;
31
+ saved: {
32
+ input_tokens: number;
33
+ output_tokens: number;
34
+ cost_usd: number;
35
+ };
18
36
  }
19
37
  interface AssertionResultRecord {
20
38
  type: string;
@@ -87,6 +105,12 @@ interface RowResult {
87
105
  latency_ms?: number;
88
106
  error?: RunError;
89
107
  assertions: AssertionResultRecord[];
108
+ /** plune-ai/plune#1696. On a cache hit: what the call would have cost. Optional and additive. */
109
+ cache_saved?: {
110
+ input_tokens: number;
111
+ output_tokens: number;
112
+ cost_usd: number;
113
+ };
90
114
  /**
91
115
  * ADR 0045. Say it when the test was never judged, rather than letting `failed` stand for both
92
116
  * "the environment broke" and "the code is wrong".
@@ -231,6 +255,11 @@ interface CompletionResponse {
231
255
  * estimated downstream (ADR-PRC02). Additive + optional: existing providers omit it.
232
256
  */
233
257
  cost_usd?: number;
258
+ /**
259
+ * Why the model stopped, normalised: `length` (hit the token limit), `error` or `content_filter`
260
+ * mark an answer the cache must not keep. Absent when the provider does not say. Optional and additive.
261
+ */
262
+ finish_reason?: string;
234
263
  }
235
264
  interface CostEstimate {
236
265
  cost_usd: number;
@@ -260,6 +289,10 @@ interface Cache {
260
289
  maxAgeMs?: number;
261
290
  }): CompletionResponse | undefined;
262
291
  set(key: string, value: CompletionResponse): void;
292
+ /** Remove one entry (a cached answer that turned out unusable). Best-effort. */
293
+ delete(key: string): void;
294
+ /** Entries left by an older policy version, found when the store was opened (0 when none or unknown). */
295
+ readonly staleEntries?: number;
263
296
  clear(): void;
264
297
  close(): void;
265
298
  }
@@ -280,6 +313,9 @@ interface RunOptions {
280
313
  concurrency?: number;
281
314
  noCache?: boolean;
282
315
  bail?: boolean;
316
+ cacheStrict?: boolean;
317
+ /** Milliseconds; parsed from `--cache-max-age` by the caller. */
318
+ cacheMaxAgeMs?: number;
283
319
  }
284
320
  declare function handleRun(options: RunOptions, depsFactory?: (config: Config, baseDir: string) => RunDeps): Promise<RunResult>;
285
321
 
@@ -449,6 +485,17 @@ declare const runResultSchema: z.ZodObject<{
449
485
  skipped: z.ZodOptional<z.ZodNumber>;
450
486
  cost_usd: z.ZodNumber;
451
487
  duration_ms: z.ZodNumber;
488
+ cache: z.ZodOptional<z.ZodObject<{
489
+ from_cache: z.ZodNumber;
490
+ from_model: z.ZodNumber;
491
+ missed: z.ZodNumber;
492
+ errored: z.ZodNumber;
493
+ saved: z.ZodObject<{
494
+ input_tokens: z.ZodNumber;
495
+ output_tokens: z.ZodNumber;
496
+ cost_usd: z.ZodNumber;
497
+ }, z.core.$strip>;
498
+ }, z.core.$strip>>;
452
499
  }, z.core.$strip>;
453
500
  evals: z.ZodArray<z.ZodObject<{
454
501
  id: z.ZodString;
@@ -477,6 +524,11 @@ declare const runResultSchema: z.ZodObject<{
477
524
  passed: z.ZodBoolean;
478
525
  }, z.core.$strip>>>;
479
526
  }, z.core.$strip>>;
527
+ cache_saved: z.ZodOptional<z.ZodObject<{
528
+ input_tokens: z.ZodNumber;
529
+ output_tokens: z.ZodNumber;
530
+ cost_usd: z.ZodNumber;
531
+ }, z.core.$strip>>;
480
532
  inconclusive: z.ZodOptional<z.ZodEnum<{
481
533
  blocked: "blocked";
482
534
  skipped: "skipped";
package/dist/index.js CHANGED
@@ -452,7 +452,8 @@ function makeAnthropicProvider(config, env, pricing) {
452
452
  usage: {
453
453
  input_tokens: res.usage.input_tokens,
454
454
  output_tokens: res.usage.output_tokens
455
- }
455
+ },
456
+ ...res.stop_reason === "max_tokens" ? { finish_reason: "length" } : res.stop_reason !== null && res.stop_reason !== void 0 ? { finish_reason: res.stop_reason } : {}
456
457
  };
457
458
  } catch (err) {
458
459
  throw normalizeProviderError(err, apiKey, ENV_VAR);
@@ -499,8 +500,11 @@ function makeOpenAiCompatibleProvider(opts) {
499
500
  { max_retries: maxRetries }
500
501
  );
501
502
  const reportedCost = reportsCost ? res.usage?.cost : void 0;
503
+ const choice = res.choices[0];
504
+ const finish = choice?.error !== void 0 ? "error" : choice?.finish_reason;
502
505
  return {
503
- output: res.choices[0]?.message?.content ?? "",
506
+ output: choice?.message?.content ?? "",
507
+ ...typeof finish === "string" ? { finish_reason: finish } : {},
504
508
  usage: {
505
509
  input_tokens: res.usage?.prompt_tokens ?? 0,
506
510
  output_tokens: res.usage?.completion_tokens ?? 0
@@ -635,12 +639,94 @@ function getDefaultEmbedder() {
635
639
  }
636
640
 
637
641
  // src/cache/cache.ts
638
- import Database from "better-sqlite3";
642
+ import { createRequire } from "module";
643
+
644
+ // src/util/hash.ts
645
+ import { createHash } from "crypto";
646
+
647
+ // src/util/canonical-json.ts
648
+ function normalizeValue(value) {
649
+ if (value === null) return null;
650
+ if (typeof value === "boolean") return value;
651
+ if (typeof value === "number") {
652
+ if (!isFinite(value)) {
653
+ throw new Error(
654
+ `canonicalJson: non-finite number rejected: ${String(value)}`
655
+ );
656
+ }
657
+ return value === 0 ? 0 : value;
658
+ }
659
+ if (typeof value === "string") {
660
+ return value.normalize("NFC");
661
+ }
662
+ if (Array.isArray(value)) {
663
+ return value.map(normalizeValue);
664
+ }
665
+ if (typeof value === "object") {
666
+ const proto = Object.getPrototypeOf(value);
667
+ if (proto !== null && proto !== Object.prototype) {
668
+ const name = value.constructor?.name ?? "unknown";
669
+ throw new Error(
670
+ `canonicalJson: unsupported non-plain object (${name}) \u2014 only plain objects, arrays, and primitives are serializable`
671
+ );
672
+ }
673
+ const obj = value;
674
+ const sorted = {};
675
+ for (const key of Object.keys(obj).sort()) {
676
+ sorted[key] = normalizeValue(obj[key]);
677
+ }
678
+ return sorted;
679
+ }
680
+ throw new Error(
681
+ `canonicalJson: unsupported value type: ${typeof value}`
682
+ );
683
+ }
684
+ function canonicalJson(value) {
685
+ return JSON.stringify(normalizeValue(value));
686
+ }
687
+
688
+ // src/util/hash.ts
689
+ function sha256(input) {
690
+ return createHash("sha256").update(input, "utf8").digest("hex");
691
+ }
692
+ var CACHE_POLICY_VERSION = 1;
693
+ function cacheKey(inputs, policyVersion = CACHE_POLICY_VERSION) {
694
+ const { provider, model, temperature, max_tokens, prompt_resolved } = inputs;
695
+ return sha256(
696
+ canonicalJson({ provider, model, temperature, max_tokens, prompt_resolved, policy: policyVersion })
697
+ );
698
+ }
699
+ function configHash(config) {
700
+ return sha256(canonicalJson(config));
701
+ }
702
+
703
+ // src/cache/cache.ts
639
704
  var SCHEMA_VERSION = 1;
640
705
  var META_DDL = "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);";
641
706
  var COMPLETIONS_DDL = "CREATE TABLE IF NOT EXISTS completions (cache_key TEXT PRIMARY KEY, output TEXT NOT NULL, input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL, created_at INTEGER NOT NULL);";
707
+ var UNCACHEABLE_FINISH = /* @__PURE__ */ new Set(["length", "max_tokens", "error", "content_filter", "pause_turn"]);
708
+ function isCacheable(res) {
709
+ if (res.output.trim() === "") return false;
710
+ return res.finish_reason === void 0 || !UNCACHEABLE_FINISH.has(res.finish_reason);
711
+ }
712
+ var missingNoted = false;
713
+ function loadDatabase() {
714
+ try {
715
+ return createRequire(import.meta.url)("better-sqlite3");
716
+ } catch {
717
+ if (!missingNoted) {
718
+ missingNoted = true;
719
+ console.error(
720
+ "plune: completion cache disabled (better-sqlite3 is not installed or failed to load). Reinstall with a C++ toolchain and Python available, or ignore: runs work without the cache."
721
+ );
722
+ }
723
+ return void 0;
724
+ }
725
+ }
642
726
  function openCache(file, opts = {}) {
643
727
  const now = opts.now ?? Date.now;
728
+ const Database = loadDatabase();
729
+ if (Database === void 0) return new NoOpCache();
644
730
  try {
645
731
  const db = new Database(file);
646
732
  try {
@@ -649,7 +735,14 @@ function openCache(file, opts = {}) {
649
735
  db.close();
650
736
  throw err;
651
737
  }
652
- return new SqliteCache(db, now);
738
+ let stale;
739
+ try {
740
+ stale = markPolicy(db, opts.policyVersion ?? CACHE_POLICY_VERSION);
741
+ } catch (err) {
742
+ db.close();
743
+ throw err;
744
+ }
745
+ return new SqliteCache(db, now, stale);
653
746
  } catch {
654
747
  return new NoOpCache();
655
748
  }
@@ -664,17 +757,33 @@ function ensureSchema(db) {
664
757
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("schema_version", current);
665
758
  }
666
759
  }
760
+ function markPolicy(db, version) {
761
+ const current = String(version);
762
+ const stored = db.prepare("SELECT value FROM meta WHERE key = 'policy_version'").get()?.value;
763
+ if (stored === current) return 0;
764
+ return db.transaction(() => {
765
+ const { n } = db.prepare("SELECT COUNT(*) AS n FROM completions").get();
766
+ db.exec("DELETE FROM completions");
767
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("policy_version", current);
768
+ return n;
769
+ })();
770
+ }
667
771
  var SqliteCache = class {
668
- constructor(db, now) {
772
+ constructor(db, now, staleEntries) {
669
773
  this.db = db;
670
774
  this.now = now;
775
+ this.staleEntries = staleEntries;
671
776
  }
672
777
  db;
673
778
  now;
779
+ staleEntries;
674
780
  get(key, opts = {}) {
675
781
  try {
676
782
  const row = this.db.prepare("SELECT output, input_tokens, output_tokens, created_at FROM completions WHERE cache_key = ?").get(key);
677
783
  if (row === void 0) return void 0;
784
+ if (typeof row.output !== "string" || !Number.isFinite(row.input_tokens) || !Number.isFinite(row.output_tokens) || !Number.isFinite(row.created_at)) {
785
+ return void 0;
786
+ }
678
787
  if (opts.maxAgeMs !== void 0 && this.now() - row.created_at > opts.maxAgeMs) {
679
788
  return void 0;
680
789
  }
@@ -700,6 +809,12 @@ var SqliteCache = class {
700
809
  } catch {
701
810
  }
702
811
  }
812
+ delete(key) {
813
+ try {
814
+ this.db.prepare("DELETE FROM completions WHERE cache_key = ?").run(key);
815
+ } catch {
816
+ }
817
+ }
703
818
  clear() {
704
819
  try {
705
820
  this.db.exec("DELETE FROM completions");
@@ -716,6 +831,8 @@ var NoOpCache = class {
716
831
  }
717
832
  set() {
718
833
  }
834
+ delete() {
835
+ }
719
836
  clear() {
720
837
  }
721
838
  close() {
@@ -726,60 +843,72 @@ var NoOpCache = class {
726
843
  import * as fs3 from "fs";
727
844
  import * as path2 from "path";
728
845
 
729
- // src/util/hash.ts
730
- import { createHash } from "crypto";
731
-
732
- // src/util/canonical-json.ts
733
- function normalizeValue(value) {
734
- if (value === null) return null;
735
- if (typeof value === "boolean") return value;
736
- if (typeof value === "number") {
737
- if (!isFinite(value)) {
738
- throw new Error(
739
- `canonicalJson: non-finite number rejected: ${String(value)}`
740
- );
741
- }
742
- return value === 0 ? 0 : value;
743
- }
744
- if (typeof value === "string") {
745
- return value.normalize("NFC");
846
+ // src/assertions/json-extract.ts
847
+ function extractJson(output, mode) {
848
+ if (mode === "strict") {
849
+ return tryParse(output.trim());
746
850
  }
747
- if (Array.isArray(value)) {
748
- return value.map(normalizeValue);
851
+ const fenced = extractFencedBlock(output);
852
+ if (fenced !== null) {
853
+ const r = tryParse(fenced);
854
+ if (r.ok) return r;
749
855
  }
750
- if (typeof value === "object") {
751
- const proto = Object.getPrototypeOf(value);
752
- if (proto !== null && proto !== Object.prototype) {
753
- const name = value.constructor?.name ?? "unknown";
754
- throw new Error(
755
- `canonicalJson: unsupported non-plain object (${name}) \u2014 only plain objects, arrays, and primitives are serializable`
756
- );
757
- }
758
- const obj = value;
759
- const sorted = {};
760
- for (const key of Object.keys(obj).sort()) {
761
- sorted[key] = normalizeValue(obj[key]);
762
- }
763
- return sorted;
856
+ for (const run of balancedRuns(output)) {
857
+ const r = tryParse(run);
858
+ if (r.ok) return r;
764
859
  }
765
- throw new Error(
766
- `canonicalJson: unsupported value type: ${typeof value}`
767
- );
860
+ return { ok: false };
768
861
  }
769
- function canonicalJson(value) {
770
- return JSON.stringify(normalizeValue(value));
862
+ function tryParse(s) {
863
+ try {
864
+ return { ok: true, value: JSON.parse(s) };
865
+ } catch {
866
+ return { ok: false };
867
+ }
771
868
  }
772
-
773
- // src/util/hash.ts
774
- function sha256(input) {
775
- return createHash("sha256").update(input, "utf8").digest("hex");
869
+ function extractFencedBlock(s) {
870
+ const m = s.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
871
+ return m?.[1]?.trim() ?? null;
776
872
  }
777
- function cacheKey(inputs) {
778
- const { provider, model, temperature, max_tokens, prompt_resolved } = inputs;
779
- return sha256(canonicalJson({ provider, model, temperature, max_tokens, prompt_resolved }));
873
+ function* balancedRuns(s) {
874
+ let i = 0;
875
+ while (i < s.length) {
876
+ const rel = s.slice(i).search(/[{[]/);
877
+ if (rel === -1) return;
878
+ const start = i + rel;
879
+ const run = balancedFrom(s, start);
880
+ if (run === null) {
881
+ i = start + 1;
882
+ } else {
883
+ yield run;
884
+ i = start + run.length;
885
+ }
886
+ }
780
887
  }
781
- function configHash(config) {
782
- return sha256(canonicalJson(config));
888
+ function balancedFrom(s, start) {
889
+ const open = s[start];
890
+ const close = open === "{" ? "}" : "]";
891
+ let depth = 0;
892
+ let inString = false;
893
+ let escaped = false;
894
+ for (let i = start; i < s.length; i++) {
895
+ const ch = s[i];
896
+ if (inString) {
897
+ if (escaped) escaped = false;
898
+ else if (ch === "\\") escaped = true;
899
+ else if (ch === '"') inString = false;
900
+ continue;
901
+ }
902
+ if (ch === '"') {
903
+ inString = true;
904
+ } else if (ch === open) {
905
+ depth++;
906
+ } else if (ch === close) {
907
+ depth--;
908
+ if (depth === 0) return s.slice(start, i + 1);
909
+ }
910
+ }
911
+ return null;
783
912
  }
784
913
 
785
914
  // src/assertions/interpolate.ts
@@ -855,76 +984,6 @@ var containsAll = {
855
984
 
856
985
  // src/assertions/json-schema.ts
857
986
  import { Ajv } from "ajv";
858
-
859
- // src/assertions/json-extract.ts
860
- function extractJson(output, mode) {
861
- if (mode === "strict") {
862
- return tryParse(output.trim());
863
- }
864
- const fenced = extractFencedBlock(output);
865
- if (fenced !== null) {
866
- const r = tryParse(fenced);
867
- if (r.ok) return r;
868
- }
869
- for (const run of balancedRuns(output)) {
870
- const r = tryParse(run);
871
- if (r.ok) return r;
872
- }
873
- return { ok: false };
874
- }
875
- function tryParse(s) {
876
- try {
877
- return { ok: true, value: JSON.parse(s) };
878
- } catch {
879
- return { ok: false };
880
- }
881
- }
882
- function extractFencedBlock(s) {
883
- const m = s.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
884
- return m?.[1]?.trim() ?? null;
885
- }
886
- function* balancedRuns(s) {
887
- let i = 0;
888
- while (i < s.length) {
889
- const rel = s.slice(i).search(/[{[]/);
890
- if (rel === -1) return;
891
- const start = i + rel;
892
- const run = balancedFrom(s, start);
893
- if (run === null) {
894
- i = start + 1;
895
- } else {
896
- yield run;
897
- i = start + run.length;
898
- }
899
- }
900
- }
901
- function balancedFrom(s, start) {
902
- const open = s[start];
903
- const close = open === "{" ? "}" : "]";
904
- let depth = 0;
905
- let inString = false;
906
- let escaped = false;
907
- for (let i = start; i < s.length; i++) {
908
- const ch = s[i];
909
- if (inString) {
910
- if (escaped) escaped = false;
911
- else if (ch === "\\") escaped = true;
912
- else if (ch === '"') inString = false;
913
- continue;
914
- }
915
- if (ch === '"') {
916
- inString = true;
917
- } else if (ch === open) {
918
- depth++;
919
- } else if (ch === close) {
920
- depth--;
921
- if (depth === 0) return s.slice(start, i + 1);
922
- }
923
- }
924
- return null;
925
- }
926
-
927
- // src/assertions/json-schema.ts
928
987
  var jsonSchema = {
929
988
  run(ctx) {
930
989
  const mode = ctx.params.extract ?? "auto";
@@ -1284,6 +1343,12 @@ var RunConfigError = class extends Error {
1284
1343
  this.name = "RunConfigError";
1285
1344
  }
1286
1345
  };
1346
+ var CACHE_MISS_CODE = "CACHE_MISS";
1347
+ function answerParses(output, assertions) {
1348
+ return assertions.every(
1349
+ (a) => a.type !== "json-schema" || extractJson(output, a.extract ?? "auto").ok
1350
+ );
1351
+ }
1287
1352
  function resolvePrompt(template, vars) {
1288
1353
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
1289
1354
  if (!(key in vars)) {
@@ -1311,17 +1376,35 @@ async function runRow(p) {
1311
1376
  const usage2 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1312
1377
  return { vars: p.row.vars, output: null, cached: false, usage: usage2, assertions: [] };
1313
1378
  }
1314
- const key = cacheKey({
1315
- provider: p.providerConfig.type,
1316
- model: p.providerConfig.model,
1317
- temperature: temperature ?? 0,
1318
- max_tokens: maxTokens,
1319
- prompt_resolved: prompt5
1320
- });
1379
+ const key = cacheKey(
1380
+ {
1381
+ provider: p.providerConfig.type,
1382
+ model: p.providerConfig.model,
1383
+ temperature: temperature ?? 0,
1384
+ max_tokens: maxTokens,
1385
+ prompt_resolved: prompt5
1386
+ },
1387
+ p.policyVersion
1388
+ );
1321
1389
  let output;
1322
1390
  let usage;
1323
1391
  let cached = false;
1324
- const hit = p.noCache ? void 0 : p.cache.get(key);
1392
+ let cacheSaved;
1393
+ let hit = p.noCache ? void 0 : p.cache.get(key, p.cacheMaxAgeMs !== void 0 ? { maxAgeMs: p.cacheMaxAgeMs } : {});
1394
+ if (hit !== void 0 && !answerParses(hit.output, p.assertions)) {
1395
+ if (p.cacheStrict !== true) p.cache.delete(key);
1396
+ hit = void 0;
1397
+ }
1398
+ if (hit === void 0 && p.cacheStrict === true) {
1399
+ return {
1400
+ vars: p.row.vars,
1401
+ output: null,
1402
+ cached: false,
1403
+ latency_ms: p.now() - t0,
1404
+ error: { code: CACHE_MISS_CODE, message: "no usable cache entry (--cache-strict)" },
1405
+ assertions: []
1406
+ };
1407
+ }
1325
1408
  if (hit !== void 0) {
1326
1409
  output = hit.output;
1327
1410
  cached = true;
@@ -1330,6 +1413,11 @@ async function runRow(p) {
1330
1413
  output_tokens: hit.usage.output_tokens,
1331
1414
  cost_usd: 0
1332
1415
  };
1416
+ cacheSaved = {
1417
+ input_tokens: hit.usage.input_tokens,
1418
+ output_tokens: hit.usage.output_tokens,
1419
+ cost_usd: p.provider.estimateCost(hit.usage).cost_usd
1420
+ };
1333
1421
  } else {
1334
1422
  try {
1335
1423
  const res = await p.provider.complete({
@@ -1347,7 +1435,9 @@ async function runRow(p) {
1347
1435
  // lets a config pricing override win, else falls back to the table estimate (ADR-PRC01).
1348
1436
  cost_usd: p.provider.estimateCost(res.usage, res.cost_usd).cost_usd
1349
1437
  };
1350
- if (!p.noCache) p.cache.set(key, res);
1438
+ if (!p.noCache && isCacheable(res) && answerParses(res.output, p.assertions)) {
1439
+ p.cache.set(key, res);
1440
+ }
1351
1441
  } catch (err) {
1352
1442
  return {
1353
1443
  vars: p.row.vars,
@@ -1393,6 +1483,7 @@ async function runRow(p) {
1393
1483
  cached,
1394
1484
  usage: finalUsage,
1395
1485
  latency_ms: p.now() - t0,
1486
+ ...cacheSaved !== void 0 ? { cache_saved: cacheSaved } : {},
1396
1487
  ...assertionError !== void 0 ? { error: assertionError } : {},
1397
1488
  assertions: records
1398
1489
  };
@@ -1427,19 +1518,41 @@ function summarize(evals, durationMs) {
1427
1518
  cost_usd: 0,
1428
1519
  duration_ms: durationMs
1429
1520
  };
1521
+ const cache = {
1522
+ from_cache: 0,
1523
+ from_model: 0,
1524
+ missed: 0,
1525
+ errored: 0,
1526
+ saved: { input_tokens: 0, output_tokens: 0, cost_usd: 0 }
1527
+ };
1430
1528
  for (const ev of evals) {
1431
1529
  for (const row of ev.rows) {
1432
1530
  summary.total += 1;
1433
1531
  summary[rowStatus(row)] += 1;
1434
1532
  summary.cost_usd += row.usage?.cost_usd ?? 0;
1533
+ if (row.cached) {
1534
+ cache.from_cache += 1;
1535
+ cache.saved.input_tokens += row.cache_saved?.input_tokens ?? 0;
1536
+ cache.saved.output_tokens += row.cache_saved?.output_tokens ?? 0;
1537
+ cache.saved.cost_usd += row.cache_saved?.cost_usd ?? 0;
1538
+ } else if (row.error?.code === CACHE_MISS_CODE) cache.missed += 1;
1539
+ else if (row.output !== null) cache.from_model += 1;
1540
+ else if (row.error !== void 0) cache.errored += 1;
1435
1541
  }
1436
1542
  }
1437
- return summary;
1543
+ return { ...summary, cache };
1438
1544
  }
1439
1545
  async function runOrchestration(config, options, deps) {
1546
+ const noCache = options.noCache ?? false;
1547
+ const cacheStrict = options.cacheStrict ?? false;
1548
+ if (noCache && cacheStrict) {
1549
+ throw new RunConfigError("--no-cache and --cache-strict cannot be used together");
1550
+ }
1551
+ if (noCache && options.cacheMaxAgeMs !== void 0) {
1552
+ throw new RunConfigError("--no-cache and --cache-max-age cannot be used together");
1553
+ }
1440
1554
  const started = deps.now();
1441
1555
  const dryRun = options.dryRun ?? false;
1442
- const noCache = options.noCache ?? false;
1443
1556
  const evalResults = [];
1444
1557
  const prepared = selectEvals(config.evals, options.only).map((ev) => {
1445
1558
  const template = resolveTemplate(ev, deps.baseDir);
@@ -1466,7 +1579,9 @@ async function runOrchestration(config, options, deps) {
1466
1579
  cache: deps.cache,
1467
1580
  now: deps.now,
1468
1581
  dryRun,
1469
- noCache
1582
+ noCache,
1583
+ cacheStrict,
1584
+ ...options.cacheMaxAgeMs !== void 0 ? { cacheMaxAgeMs: options.cacheMaxAgeMs } : {}
1470
1585
  })
1471
1586
  );
1472
1587
  const passed = rowResults.every((r) => rowStatus(r) === "passed");
@@ -1499,6 +1614,7 @@ function loadDataset(ref, baseDir) {
1499
1614
 
1500
1615
  // src/cli/commands/run.ts
1501
1616
  var NOOP_CACHE = { get: () => void 0, set: () => {
1617
+ }, delete: () => {
1502
1618
  }, clear: () => {
1503
1619
  }, close: () => {
1504
1620
  } };
@@ -1535,6 +1651,11 @@ async function handleRun(options, depsFactory) {
1535
1651
  const factory = depsFactory ?? ((c, d) => buildRealDeps(c, d, options.dryRun));
1536
1652
  const deps = factory(config, baseDir);
1537
1653
  try {
1654
+ const stale = deps.cache.staleEntries ?? 0;
1655
+ if (stale > 0) {
1656
+ process.stderr.write(`cache policy changed, ${stale} entries are stale (dropped)
1657
+ `);
1658
+ }
1538
1659
  const result = await runOrchestration(
1539
1660
  config,
1540
1661
  {
@@ -1542,7 +1663,9 @@ async function handleRun(options, depsFactory) {
1542
1663
  ...options.only !== void 0 ? { only: options.only } : {},
1543
1664
  ...options.concurrency !== void 0 ? { concurrency: options.concurrency } : {},
1544
1665
  ...options.noCache !== void 0 ? { noCache: options.noCache } : {},
1545
- ...options.bail !== void 0 ? { bail: options.bail } : {}
1666
+ ...options.bail !== void 0 ? { bail: options.bail } : {},
1667
+ ...options.cacheStrict === true ? { cacheStrict: true } : {},
1668
+ ...options.cacheMaxAgeMs !== void 0 ? { cacheMaxAgeMs: options.cacheMaxAgeMs } : {}
1546
1669
  },
1547
1670
  deps
1548
1671
  );
@@ -1589,6 +1712,7 @@ var rowResultSchema = z3.object({
1589
1712
  latency_ms: z3.number().optional(),
1590
1713
  error: runErrorSchema.optional(),
1591
1714
  assertions: z3.array(assertionResultRecordSchema2),
1715
+ cache_saved: z3.object({ input_tokens: z3.number(), output_tokens: z3.number(), cost_usd: z3.number() }).optional(),
1592
1716
  /** ADR 0045. Optional and additive — the same enum the platform validates on `POST /v1/runs`. */
1593
1717
  inconclusive: z3.enum(ROW_INCONCLUSIVE).optional()
1594
1718
  });
@@ -1601,7 +1725,14 @@ var summarySchema = z3.object({
1601
1725
  blocked: z3.number().optional(),
1602
1726
  skipped: z3.number().optional(),
1603
1727
  cost_usd: z3.number(),
1604
- duration_ms: z3.number()
1728
+ duration_ms: z3.number(),
1729
+ cache: z3.object({
1730
+ from_cache: z3.number(),
1731
+ from_model: z3.number(),
1732
+ missed: z3.number(),
1733
+ errored: z3.number(),
1734
+ saved: z3.object({ input_tokens: z3.number(), output_tokens: z3.number(), cost_usd: z3.number() })
1735
+ }).optional()
1605
1736
  });
1606
1737
  var evalResultSchema = z3.object({
1607
1738
  id: z3.string(),
@@ -316,6 +316,7 @@ function shardOf(env) {
316
316
  }
317
317
  var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
318
318
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
319
+ var IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
319
320
  function value(env, name) {
320
321
  const raw = env[name]?.trim();
321
322
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -353,6 +354,7 @@ function readEnv(env = process.env) {
353
354
  const warnings = [];
354
355
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
355
356
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
357
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
356
358
  const title = value(env, "PLUNE_RUN_TITLE");
357
359
  const environment = value(env, "PLUNE_ENV");
358
360
  const labels = list(env, "PLUNE_LABELS");
@@ -384,6 +386,7 @@ function readEnv(env = process.env) {
384
386
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
385
387
  ...shard !== void 0 ? { shard } : {},
386
388
  ...shardError !== void 0 ? { shardError } : {},
389
+ ...importTimeout !== void 0 ? { importTimeout } : {},
387
390
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
388
391
  warnings
389
392
  };