@theaiteam/promptdiff 1.0.0-rc.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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/SPEC.md +314 -0
- package/package.json +54 -0
- package/promptdiff +4 -0
- package/src/args.ts +83 -0
- package/src/cli.ts +685 -0
- package/src/engine/cache.ts +150 -0
- package/src/engine/compare.ts +563 -0
- package/src/engine/config.ts +502 -0
- package/src/engine/grader.ts +149 -0
- package/src/engine/json-assert.ts +277 -0
- package/src/engine/judge.ts +388 -0
- package/src/engine/receipt.ts +150 -0
- package/src/engine/render.ts +59 -0
- package/src/engine/report.ts +49 -0
- package/src/engine/sandbox.ts +97 -0
- package/src/engine/skill-install.ts +112 -0
- package/src/engine/stats.ts +41 -0
- package/src/prompt.ts +16 -0
- package/src/runner/claude-p.ts +156 -0
- package/src/runner/index.ts +31 -0
- package/src/runner/openai-compat.ts +228 -0
- package/src/types.ts +65 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash, type Hash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ArmSummary } from "./compare";
|
|
5
|
+
import type { ArmConfig } from "./config";
|
|
6
|
+
import type { GraderSpec } from "./grader";
|
|
7
|
+
import type { Delivery } from "./skill-install";
|
|
8
|
+
import type { RunMode } from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Everything that could change a baseline arm's outcome for one case. The key
|
|
12
|
+
* hashes CONTENT (rendered prompts, fixture bytes), not paths — moving a
|
|
13
|
+
* scenario file must not fake a miss, and editing a fixture must not fake a hit.
|
|
14
|
+
*/
|
|
15
|
+
export interface CacheKeyInput {
|
|
16
|
+
/** Rendered baseline system prompt — covers agent text, inlined skill text, and render vars. */
|
|
17
|
+
systemPrompt: string;
|
|
18
|
+
/** Rendered case prompt. */
|
|
19
|
+
casePrompt: string;
|
|
20
|
+
arm: ArmConfig;
|
|
21
|
+
/** Effective run count for the case (case runs ?? config runs). */
|
|
22
|
+
runs: number;
|
|
23
|
+
/** Effective tools string for the case. */
|
|
24
|
+
tools: string;
|
|
25
|
+
/** Effective run mode for the case. */
|
|
26
|
+
mode: RunMode;
|
|
27
|
+
delivery: Delivery;
|
|
28
|
+
grader: GraderSpec;
|
|
29
|
+
/** Per-case image file paths; their CONTENTS are hashed into the key. */
|
|
30
|
+
images: string[];
|
|
31
|
+
/** Effective sandbox seed directory (case seed ?? config seed), if any. */
|
|
32
|
+
seedDir?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Baseline skill paths. Install delivery copies these verbatim instead of
|
|
35
|
+
* inlining them, so the system prompt does NOT cover their text — their
|
|
36
|
+
* tree hashes enter the key only when delivery is "install".
|
|
37
|
+
*/
|
|
38
|
+
baselineSkills: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CacheRecord {
|
|
42
|
+
key: string;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
armSummary: ArmSummary;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildCacheKey(input: CacheKeyInput): string {
|
|
48
|
+
const material = stableStringify({
|
|
49
|
+
version: 1,
|
|
50
|
+
systemPrompt: input.systemPrompt,
|
|
51
|
+
casePrompt: input.casePrompt,
|
|
52
|
+
model: input.arm.model,
|
|
53
|
+
runner: input.arm.runner,
|
|
54
|
+
baseUrl: input.arm.baseUrl ?? "none",
|
|
55
|
+
runs: input.runs,
|
|
56
|
+
tools: input.tools,
|
|
57
|
+
mode: input.mode,
|
|
58
|
+
delivery: input.delivery,
|
|
59
|
+
grader: input.grader,
|
|
60
|
+
images: input.images.map((image) => sha256(readFileSync(image))),
|
|
61
|
+
seedTree: input.seedDir === undefined ? "none" : hashTree(input.seedDir),
|
|
62
|
+
baselineSkillTrees: input.delivery === "install" ? input.baselineSkills.map(hashTree) : [],
|
|
63
|
+
});
|
|
64
|
+
return sha256(material);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stored summary for the key, or undefined on a miss. */
|
|
68
|
+
export function loadCachedArm(cacheDir: string, key: string): ArmSummary | undefined {
|
|
69
|
+
const file = entryPath(cacheDir, key);
|
|
70
|
+
if (!existsSync(file)) return undefined;
|
|
71
|
+
let parsed: unknown;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error(`cache entry is not valid JSON: ${file} — delete it (or the cache dir) and re-run`);
|
|
76
|
+
}
|
|
77
|
+
if (!isCacheRecord(parsed)) {
|
|
78
|
+
throw new Error(`cache entry has an unexpected shape: ${file} — delete it (or the cache dir) and re-run`);
|
|
79
|
+
}
|
|
80
|
+
return parsed.armSummary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function storeCachedArm(cacheDir: string, key: string, armSummary: ArmSummary): void {
|
|
84
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
85
|
+
const record: CacheRecord = { key, createdAt: new Date().toISOString(), armSummary };
|
|
86
|
+
writeFileSync(entryPath(cacheDir, key), JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function entryPath(cacheDir: string, key: string): string {
|
|
90
|
+
return join(cacheDir, `${key}.json`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isCacheRecord(value: unknown): value is CacheRecord {
|
|
94
|
+
if (typeof value !== "object" || value === null) return false;
|
|
95
|
+
const record = value as Record<string, unknown>;
|
|
96
|
+
if (typeof record.key !== "string" || typeof record.createdAt !== "string") return false;
|
|
97
|
+
const summary = record.armSummary;
|
|
98
|
+
if (typeof summary !== "object" || summary === null) return false;
|
|
99
|
+
const arm = summary as Record<string, unknown>;
|
|
100
|
+
return (
|
|
101
|
+
arm.name === "baseline" &&
|
|
102
|
+
typeof arm.passes === "number" &&
|
|
103
|
+
typeof arm.totalRuns === "number" &&
|
|
104
|
+
typeof arm.passRate === "number" &&
|
|
105
|
+
typeof arm.totalCostUsd === "number" &&
|
|
106
|
+
Array.isArray(arm.runs)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deterministic content hash of a fixture tree: sorted relative paths plus
|
|
112
|
+
* file bytes. Accepts a lone file too (a baseline "skill" may be a SKILL.md
|
|
113
|
+
* path rather than a directory).
|
|
114
|
+
*/
|
|
115
|
+
function hashTree(path: string): string {
|
|
116
|
+
const hash = createHash("sha256");
|
|
117
|
+
addTreeEntry(hash, path, "");
|
|
118
|
+
return hash.digest("hex");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function addTreeEntry(hash: Hash, path: string, relative: string): void {
|
|
122
|
+
if (statSync(path).isDirectory()) {
|
|
123
|
+
for (const entry of [...readdirSync(path)].sort()) {
|
|
124
|
+
addTreeEntry(hash, join(path, entry), relative === "" ? entry : `${relative}/${entry}`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
hash.update(`${relative}\0`);
|
|
129
|
+
hash.update(readFileSync(path));
|
|
130
|
+
hash.update("\0");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** JSON with recursively sorted object keys — key material must not depend on property order. */
|
|
134
|
+
function stableStringify(value: unknown): string {
|
|
135
|
+
if (Array.isArray(value)) {
|
|
136
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === "object" && value !== null) {
|
|
139
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
140
|
+
.filter(([, entry]) => entry !== undefined)
|
|
141
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
142
|
+
.map(([name, entry]) => `${JSON.stringify(name)}:${stableStringify(entry)}`);
|
|
143
|
+
return `{${entries.join(",")}}`;
|
|
144
|
+
}
|
|
145
|
+
return JSON.stringify(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sha256(content: string | Buffer): string {
|
|
149
|
+
return createHash("sha256").update(content).digest("hex");
|
|
150
|
+
}
|