@growthub/cli 0.10.0 → 0.10.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.
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * helpers/grade-raw-pairs.mjs — Distillation Pipeline V1, Phase 2
4
+ *
5
+ * Grades pairs from `raw-pairs.jsonl` (Phase 1 output) by routing each one
6
+ * through the live `critic-grader` sandbox row (local-intelligence /
7
+ * gemma3:4b). The script never bypasses the workspace API: it PATCHes the
8
+ * critic row's `command`, calls `POST /api/workspace/sandbox-run`, then
9
+ * parses the strict-JSON `{score, reason}` envelope the grader returns.
10
+ *
11
+ * Quality boost: pairs whose `mergedToMain === true` (Phase 1 ground-truth
12
+ * signal that the work was squash-merged on `main`) get a floor of 4.
13
+ *
14
+ * Output: newline-delimited JSON with the original pair fields plus
15
+ * - `qualityScore` 1-5 (string for downstream parity with training-traces)
16
+ * - `qualityReason` one-sentence rationale from the grader
17
+ * - `criticRunMs` latency
18
+ * - `criticRunId` run id for traceability
19
+ * - `gradedAt` ISO timestamp
20
+ * - `boostedByMerge` true if mergedToMain forced the floor
21
+ *
22
+ * Streams to disk after each pair so partial progress survives a kill.
23
+ *
24
+ * Usage:
25
+ * node helpers/grade-raw-pairs.mjs \
26
+ * --in ./antonio/distillation/raw-pairs.jsonl \
27
+ * --out ./antonio/distillation/graded-batch-001.jsonl \
28
+ * --workspace http://localhost:3000 \
29
+ * --grader-row critic-grader \
30
+ * --sandbox-object sandboxes-alignment-loop \
31
+ * --limit 20 \
32
+ * --offset 0 \
33
+ * --max-input-chars 6000 # cap pair text to keep grader prompts safe
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+
39
+ function parseArgs(argv) {
40
+ const a = {
41
+ in: "",
42
+ out: "",
43
+ workspace: "http://localhost:3000",
44
+ graderRow: "critic-grader",
45
+ sandboxObject: "sandboxes-alignment-loop",
46
+ limit: 20,
47
+ offset: 0,
48
+ maxInputChars: 6000,
49
+ mergedOnly: false,
50
+ };
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const t = argv[i];
53
+ const next = () => String(argv[++i] || "").trim();
54
+ if (t === "--in") a.in = next();
55
+ else if (t === "--out") a.out = next();
56
+ else if (t === "--workspace") a.workspace = next().replace(/\/+$/, "");
57
+ else if (t === "--grader-row") a.graderRow = next();
58
+ else if (t === "--sandbox-object") a.sandboxObject = next();
59
+ else if (t === "--limit") a.limit = Number(next()) || 20;
60
+ else if (t === "--offset") a.offset = Number(next()) || 0;
61
+ else if (t === "--max-input-chars") a.maxInputChars = Number(next()) || 6000;
62
+ else if (t === "--merged-only") a.mergedOnly = true;
63
+ else if (t === "--help" || t === "-h") {
64
+ process.stdout.write(
65
+ "Usage: grade-raw-pairs.mjs --in <raw-pairs.jsonl> --out <graded.jsonl> [--workspace URL] [--grader-row name] [--sandbox-object id] [--limit N] [--offset N] [--max-input-chars N] [--merged-only]\n",
66
+ );
67
+ process.exit(0);
68
+ }
69
+ }
70
+ if (!a.in || !a.out) {
71
+ process.stderr.write("error: --in and --out are required\n");
72
+ process.exit(2);
73
+ }
74
+ return a;
75
+ }
76
+
77
+ const args = parseArgs(process.argv.slice(2));
78
+
79
+ async function getWorkspaceObjects() {
80
+ const r = await fetch(`${args.workspace}/api/workspace`, { cache: "no-store" });
81
+ if (!r.ok) throw new Error(`GET /api/workspace ${r.status}`);
82
+ const j = await r.json();
83
+ return j.workspaceConfig.dataModel.objects;
84
+ }
85
+
86
+ async function patchObjects(objects) {
87
+ const r = await fetch(`${args.workspace}/api/workspace`, {
88
+ method: "PATCH",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({ dataModel: { objects } }),
91
+ });
92
+ if (!r.ok) {
93
+ const t = await r.text();
94
+ throw new Error(`PATCH /api/workspace ${r.status}: ${t.slice(0, 300)}`);
95
+ }
96
+ }
97
+
98
+ async function runGraderSandbox() {
99
+ const r = await fetch(`${args.workspace}/api/workspace/sandbox-run`, {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify({ objectId: args.sandboxObject, name: args.graderRow }),
103
+ });
104
+ return r.json();
105
+ }
106
+
107
+ function setRowCommand(objects, rowName, command) {
108
+ return objects.map((obj) => {
109
+ if (obj.id !== args.sandboxObject) return obj;
110
+ return {
111
+ ...obj,
112
+ rows: (obj.rows || []).map((row) => (row.Name === rowName ? { ...row, command } : row)),
113
+ };
114
+ });
115
+ }
116
+
117
+ function parseScoreFromGraderEnvelope(stdout) {
118
+ if (!stdout) return null;
119
+ try {
120
+ const env = JSON.parse(stdout);
121
+ if (env?.result?.json && typeof env.result.json.score === "number") {
122
+ return { score: env.result.json.score, reason: String(env.result.json.reason || "") };
123
+ }
124
+ if (typeof env?.rawText === "string") {
125
+ const outer = JSON.parse(env.rawText);
126
+ const content = outer?.choices?.[0]?.message?.content;
127
+ if (typeof content === "string") {
128
+ const inner = JSON.parse(content);
129
+ if (typeof inner?.score === "number") {
130
+ return { score: inner.score, reason: String(inner.reason || "") };
131
+ }
132
+ }
133
+ }
134
+ } catch {
135
+ // fall through
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function buildGraderPrompt(pair, maxChars) {
141
+ const promptHead = pair.inputPrompt.slice(0, Math.floor(maxChars / 3));
142
+ const outputHead = pair.agentOutput.slice(0, maxChars - promptHead.length - 800);
143
+ const lines = [
144
+ "You are critic-grader for AWaC V2. Score this user→assistant pair from a Cursor session on the growthub-local repo.",
145
+ "Criteria:",
146
+ " 1) Clear understanding of the user request",
147
+ " 2) Used appropriate tools / primitives",
148
+ " 3) Respects AWaC V2 invariants (PATCH allowlist, no secret leaks, no protected-boundary edits)",
149
+ " 4) Output is correct and actionable",
150
+ " 5) Production-quality (would survive code review on this repo)",
151
+ "Return ONLY strict JSON: {\"score\": <1-5 integer>, \"reason\": \"one short sentence\"}.",
152
+ "",
153
+ "USER PROMPT (truncated):",
154
+ promptHead,
155
+ "",
156
+ "ASSISTANT RESPONSE (truncated):",
157
+ outputHead,
158
+ ];
159
+ return lines.join("\n");
160
+ }
161
+
162
+ // ---------- read pairs ----------
163
+ const inAbs = path.resolve(args.in);
164
+ const outAbs = path.resolve(args.out);
165
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
166
+
167
+ const allLines = fs.readFileSync(inAbs, "utf8").split("\n").filter(Boolean);
168
+ let pool = allLines;
169
+ if (args.mergedOnly) {
170
+ pool = allLines.filter((ln) => {
171
+ try {
172
+ return JSON.parse(ln).mergedToMain === true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ });
177
+ }
178
+ const slice = pool.slice(args.offset, args.offset + args.limit);
179
+
180
+ process.stdout.write(
181
+ `[grade] in=${path.basename(inAbs)} totalLines=${allLines.length} pool=${pool.length}${args.mergedOnly ? " (mergedOnly)" : ""} offset=${args.offset} batch=${slice.length} -> ${path.basename(outAbs)}\n`,
182
+ );
183
+
184
+ const outStream = fs.createWriteStream(outAbs, { encoding: "utf8" });
185
+ const summary = {
186
+ graded: 0,
187
+ parseFailures: 0,
188
+ scoreCounts: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 0: 0 },
189
+ boostedByMerge: 0,
190
+ scoreSum: 0,
191
+ startedAt: new Date().toISOString(),
192
+ };
193
+
194
+ for (let i = 0; i < slice.length; i += 1) {
195
+ let pair;
196
+ try {
197
+ pair = JSON.parse(slice[i]);
198
+ } catch {
199
+ process.stderr.write(`[grade] skip line ${i}: not JSON\n`);
200
+ continue;
201
+ }
202
+
203
+ const command = buildGraderPrompt(pair, args.maxInputChars);
204
+ let parsedScore = null;
205
+ let runMs = 0;
206
+ let runId = "";
207
+ let boosted = false;
208
+
209
+ try {
210
+ const objects = await getWorkspaceObjects();
211
+ await patchObjects(setRowCommand(objects, args.graderRow, command));
212
+ const startedAt = Date.now();
213
+ const run = await runGraderSandbox();
214
+ runMs = Date.now() - startedAt;
215
+ runId = run?.runId || "";
216
+ parsedScore = parseScoreFromGraderEnvelope(run?.response?.stdout);
217
+ } catch (e) {
218
+ process.stderr.write(`[grade] pair ${i + args.offset} sandbox-run error: ${e.message}\n`);
219
+ }
220
+
221
+ if (!parsedScore) {
222
+ summary.parseFailures += 1;
223
+ parsedScore = { score: 0, reason: "grader did not return parseable JSON" };
224
+ }
225
+
226
+ // Apply mergedToMain floor=4 boost
227
+ if (pair.mergedToMain === true && parsedScore.score < 4) {
228
+ boosted = true;
229
+ summary.boostedByMerge += 1;
230
+ parsedScore = { score: 4, reason: `[boosted by squash-merge to main; original: ${parsedScore.score} - ${parsedScore.reason}]` };
231
+ }
232
+
233
+ summary.graded += 1;
234
+ summary.scoreCounts[parsedScore.score] = (summary.scoreCounts[parsedScore.score] || 0) + 1;
235
+ summary.scoreSum += parsedScore.score;
236
+
237
+ const out = {
238
+ ...pair,
239
+ qualityScore: String(parsedScore.score),
240
+ qualityReason: parsedScore.reason,
241
+ criticRunMs: runMs,
242
+ criticRunId: runId,
243
+ boostedByMerge: boosted,
244
+ gradedAt: new Date().toISOString(),
245
+ };
246
+ outStream.write(`${JSON.stringify(out)}\n`);
247
+
248
+ process.stdout.write(
249
+ `[grade] ${i + 1}/${slice.length} session=${pair.sessionId.slice(0, 8)} pair=${pair.pairIndex} score=${out.qualityScore}${boosted ? "*" : ""} ms=${runMs}\n`,
250
+ );
251
+ }
252
+
253
+ await new Promise((r) => outStream.end(r));
254
+
255
+ const avg = summary.graded ? (summary.scoreSum / summary.graded).toFixed(2) : "0.00";
256
+ const highCount = (summary.scoreCounts[4] || 0) + (summary.scoreCounts[5] || 0);
257
+ const finishedAt = new Date().toISOString();
258
+
259
+ process.stdout.write(
260
+ "\n" +
261
+ JSON.stringify(
262
+ {
263
+ ok: true,
264
+ out: outAbs,
265
+ startedAt: summary.startedAt,
266
+ finishedAt,
267
+ graded: summary.graded,
268
+ parseFailures: summary.parseFailures,
269
+ boostedByMerge: summary.boostedByMerge,
270
+ averageScore: Number(avg),
271
+ scoreCounts: summary.scoreCounts,
272
+ highQualityCount: highCount,
273
+ highQualityRatio: summary.graded ? Number((highCount / summary.graded).toFixed(3)) : 0,
274
+ },
275
+ null,
276
+ 2,
277
+ ) +
278
+ "\n",
279
+ );
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * helpers/harvest-cursor-traces.mjs — Distillation Pipeline V1, Phase 1
4
+ *
5
+ * Reads Cursor agent transcript JSONL files (one folder per session, one
6
+ * `<uuid>.jsonl` inside it), pairs each user query with the assistant turn(s)
7
+ * that follow it, filters to pairs where the assistant actually executed work
8
+ * (≥1 `tool_use` block), and emits a single newline-delimited JSON file ready
9
+ * for Phase 2 (critic-grader scoring) and Phase 3 (Unsloth QLoRA export).
10
+ *
11
+ * Quality signals captured per pair (used by the grader, not graded here):
12
+ * - `executedWork` true ⇔ assistant turn produced ≥1 tool_use
13
+ * - `toolUseCount` how many tool calls were issued
14
+ * - `toolNames` deduped list of tool names invoked
15
+ * - `branchesTouched` branch names parsed out of git/gh shell commands
16
+ * - `mergedToMain` true if any branch matches a squash-merged PR on main
17
+ * - `mergedPrNumbers` PR numbers whose `headRefName` matched
18
+ *
19
+ * Squash-to-main is the highest-signal heuristic: it means the work was
20
+ * accepted by the maintainer and shipped. The grader can boost those rows.
21
+ *
22
+ * Usage:
23
+ * node helpers/harvest-cursor-traces.mjs \
24
+ * --in /Users/antonio/.cursor/projects/Users-antonio-growthub-local/agent-transcripts \
25
+ * --out ./antonio/distillation/raw-pairs.jsonl \
26
+ * --repo /Users/antonio/growthub-local # optional: enables gh PR enrichment
27
+ * --pr-limit 200 # optional: how many merged PRs to fetch
28
+ * --min-prompt-chars 12 # optional: drop trivial prompts
29
+ *
30
+ * No network calls beyond `gh pr list` (only when --repo is provided).
31
+ */
32
+
33
+ import { spawnSync } from "node:child_process";
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+
37
+ // ---------- arg parsing ----------
38
+ function parseArgs(argv) {
39
+ const a = { in: "", out: "", repo: "", prLimit: 200, minPromptChars: 12 };
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ const t = argv[i];
42
+ const next = () => String(argv[++i] || "").trim();
43
+ if (t === "--in") a.in = next();
44
+ else if (t === "--out") a.out = next();
45
+ else if (t === "--repo") a.repo = next();
46
+ else if (t === "--pr-limit") a.prLimit = Number(next()) || 200;
47
+ else if (t === "--min-prompt-chars") a.minPromptChars = Number(next()) || 0;
48
+ else if (t === "--help" || t === "-h") {
49
+ process.stdout.write(
50
+ "Usage: harvest-cursor-traces.mjs --in <transcripts-dir> --out <raw-pairs.jsonl> [--repo <git-repo>] [--pr-limit N] [--min-prompt-chars N]\n",
51
+ );
52
+ process.exit(0);
53
+ }
54
+ }
55
+ if (!a.in || !a.out) {
56
+ process.stderr.write("error: --in and --out are required\n");
57
+ process.exit(2);
58
+ }
59
+ return a;
60
+ }
61
+
62
+ const args = parseArgs(process.argv.slice(2));
63
+ const transcriptsDir = path.resolve(args.in);
64
+ const outPath = path.resolve(args.out);
65
+ const repoDir = args.repo ? path.resolve(args.repo) : "";
66
+
67
+ // ---------- gh squash-merge index ----------
68
+ /** Map<branchName, { number, mergeSha, mergedAt, title }> */
69
+ const mergedByBranch = new Map();
70
+ if (repoDir) {
71
+ if (!fs.existsSync(path.join(repoDir, ".git"))) {
72
+ process.stderr.write(`warn: --repo ${repoDir} has no .git dir; skipping gh enrichment\n`);
73
+ } else {
74
+ const r = spawnSync(
75
+ "gh",
76
+ [
77
+ "pr",
78
+ "list",
79
+ "--state",
80
+ "merged",
81
+ "--limit",
82
+ String(args.prLimit),
83
+ "--json",
84
+ "number,title,headRefName,mergeCommit,mergedAt",
85
+ ],
86
+ { cwd: repoDir, encoding: "utf8" },
87
+ );
88
+ if (r.status === 0) {
89
+ try {
90
+ const list = JSON.parse(r.stdout || "[]");
91
+ for (const pr of list) {
92
+ if (typeof pr.headRefName === "string" && pr.headRefName.trim()) {
93
+ mergedByBranch.set(pr.headRefName.trim(), {
94
+ number: pr.number,
95
+ mergeSha: pr.mergeCommit?.oid || null,
96
+ mergedAt: pr.mergedAt || null,
97
+ title: pr.title || "",
98
+ });
99
+ }
100
+ }
101
+ } catch (e) {
102
+ process.stderr.write(`warn: gh JSON parse failed: ${e.message}\n`);
103
+ }
104
+ } else {
105
+ process.stderr.write(`warn: gh pr list failed (${r.status}); continuing without merge index\n`);
106
+ }
107
+ }
108
+ }
109
+
110
+ // ---------- transcript walker ----------
111
+ function listTranscriptFiles(rootDir) {
112
+ if (!fs.existsSync(rootDir)) {
113
+ process.stderr.write(`error: transcripts dir not found: ${rootDir}\n`);
114
+ process.exit(2);
115
+ }
116
+ const out = [];
117
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
118
+ const sessionDir = path.join(rootDir, entry.name);
119
+ if (!entry.isDirectory()) continue;
120
+ for (const child of fs.readdirSync(sessionDir, { withFileTypes: true })) {
121
+ if (child.isFile() && child.name.endsWith(".jsonl")) {
122
+ out.push({ sessionId: entry.name, file: path.join(sessionDir, child.name) });
123
+ }
124
+ }
125
+ }
126
+ return out.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
127
+ }
128
+
129
+ // ---------- helpers ----------
130
+ function flattenAssistantText(blocks) {
131
+ if (!Array.isArray(blocks)) return "";
132
+ return blocks
133
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
134
+ .map((b) => b.text)
135
+ .join("\n\n")
136
+ .trim();
137
+ }
138
+
139
+ function extractToolNames(blocks) {
140
+ if (!Array.isArray(blocks)) return [];
141
+ return blocks
142
+ .filter((b) => b && b.type === "tool_use" && typeof b.name === "string")
143
+ .map((b) => b.name);
144
+ }
145
+
146
+ function extractToolInputs(blocks) {
147
+ if (!Array.isArray(blocks)) return [];
148
+ return blocks
149
+ .filter((b) => b && b.type === "tool_use")
150
+ .map((b) => ({ name: b.name, input: b.input ?? {} }));
151
+ }
152
+
153
+ const BRANCH_FROM_GH_PR = /(?:gh\s+pr\s+(?:create|merge|view|checkout)[^\n]*?)(?:--head|head\s*[:=])\s+([\w./-]+)/g;
154
+ const BRANCH_FROM_GIT_PUSH = /git\s+push\s+(?:-u\s+)?(?:origin)\s+([\w./-]+)/g;
155
+ const BRANCH_FROM_GIT_CHECKOUT = /git\s+checkout\s+(?:-b\s+)?([\w./-]+)/g;
156
+ const BRANCH_FROM_BRANCH_FLAG = /(?:^|\s)(?:--branch|--head|--base)\s+([\w./-]+)/g;
157
+
158
+ function extractBranchesFromShell(toolUses) {
159
+ const out = new Set();
160
+ for (const tu of toolUses) {
161
+ if (tu.name !== "Shell") continue;
162
+ const cmd = String(tu.input?.command || "");
163
+ if (!cmd) continue;
164
+ for (const re of [BRANCH_FROM_GH_PR, BRANCH_FROM_GIT_PUSH, BRANCH_FROM_GIT_CHECKOUT, BRANCH_FROM_BRANCH_FLAG]) {
165
+ let m;
166
+ re.lastIndex = 0;
167
+ while ((m = re.exec(cmd))) {
168
+ const name = (m[1] || "").trim();
169
+ if (name && name !== "main" && name !== "HEAD") out.add(name);
170
+ }
171
+ }
172
+ }
173
+ return [...out];
174
+ }
175
+
176
+ // ---------- main pairing pass ----------
177
+ const files = listTranscriptFiles(transcriptsDir);
178
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
179
+ const outStream = fs.createWriteStream(outPath, { encoding: "utf8" });
180
+
181
+ const stats = {
182
+ sessions: 0,
183
+ pairsConsidered: 0,
184
+ pairsKept: 0,
185
+ pairsDroppedNoTool: 0,
186
+ pairsDroppedShortPrompt: 0,
187
+ pairsMergedToMain: 0,
188
+ toolNameTotals: {},
189
+ };
190
+
191
+ for (const { sessionId, file } of files) {
192
+ stats.sessions += 1;
193
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
194
+
195
+ /** @type {Array<{role:string,content:any}>} */
196
+ const turns = [];
197
+ for (const ln of lines) {
198
+ try {
199
+ const j = JSON.parse(ln);
200
+ if (j && typeof j.role === "string" && j.message?.content) {
201
+ turns.push({ role: j.role, content: j.message.content });
202
+ }
203
+ } catch {
204
+ // skip malformed line
205
+ }
206
+ }
207
+
208
+ let pairIndex = 0;
209
+ for (let i = 0; i < turns.length; i += 1) {
210
+ if (turns[i].role !== "user") continue;
211
+ const userBlocks = Array.isArray(turns[i].content) ? turns[i].content : [];
212
+ const userText = userBlocks
213
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
214
+ .map((b) => b.text)
215
+ .join("\n\n")
216
+ .trim();
217
+ if (!userText) continue;
218
+
219
+ // Collect every consecutive assistant turn until the next user turn.
220
+ const assistantBlocks = [];
221
+ let j = i + 1;
222
+ while (j < turns.length && turns[j].role !== "user") {
223
+ const c = turns[j].content;
224
+ if (Array.isArray(c)) assistantBlocks.push(...c);
225
+ j += 1;
226
+ }
227
+ if (assistantBlocks.length === 0) continue;
228
+
229
+ stats.pairsConsidered += 1;
230
+
231
+ if (userText.length < args.minPromptChars) {
232
+ stats.pairsDroppedShortPrompt += 1;
233
+ continue;
234
+ }
235
+
236
+ const toolUses = extractToolInputs(assistantBlocks);
237
+ if (toolUses.length === 0) {
238
+ stats.pairsDroppedNoTool += 1;
239
+ continue;
240
+ }
241
+
242
+ const assistantText = flattenAssistantText(assistantBlocks);
243
+ const toolNames = [...new Set(extractToolNames(assistantBlocks))];
244
+ for (const n of toolNames) stats.toolNameTotals[n] = (stats.toolNameTotals[n] || 0) + 1;
245
+
246
+ const branchesTouched = extractBranchesFromShell(toolUses);
247
+ const mergedPrNumbers = [];
248
+ for (const b of branchesTouched) {
249
+ const hit = mergedByBranch.get(b);
250
+ if (hit) mergedPrNumbers.push(hit.number);
251
+ }
252
+ const mergedToMain = mergedPrNumbers.length > 0;
253
+ if (mergedToMain) stats.pairsMergedToMain += 1;
254
+
255
+ const row = {
256
+ sessionId,
257
+ pairIndex,
258
+ inputPrompt: userText,
259
+ agentOutput: assistantText,
260
+ executedWork: true,
261
+ toolUseCount: toolUses.length,
262
+ toolNames,
263
+ branchesTouched,
264
+ mergedToMain,
265
+ mergedPrNumbers,
266
+ };
267
+ outStream.write(`${JSON.stringify(row)}\n`);
268
+ stats.pairsKept += 1;
269
+ pairIndex += 1;
270
+ }
271
+ }
272
+
273
+ await new Promise((resolve) => outStream.end(resolve));
274
+
275
+ // ---------- summary ----------
276
+ process.stdout.write(
277
+ JSON.stringify(
278
+ {
279
+ ok: true,
280
+ out: outPath,
281
+ transcriptsDir,
282
+ mergeIndexEntries: mergedByBranch.size,
283
+ ...stats,
284
+ },
285
+ null,
286
+ 2,
287
+ ) + "\n",
288
+ );
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * helpers/upload-graded-traces.mjs — Distillation Pipeline V1, Phase 2.5
4
+ *
5
+ * Reads graded pairs JSONL (Phase 2 output) and appends rows whose
6
+ * qualityScore >= --min-score into the live workspace's `training-traces`
7
+ * dataModel object via PATCH /api/workspace.
8
+ *
9
+ * - Append-only. Existing rows are preserved.
10
+ * - Marks every uploaded row with `exported: "false"` so the export script
11
+ * can pick them up later.
12
+ * - Truncates `agentOutput` to keep the workspace config file lean.
13
+ *
14
+ * Usage:
15
+ * node helpers/upload-graded-traces.mjs \
16
+ * --in ./antonio/distillation/graded-batch-001.jsonl \
17
+ * --workspace http://localhost:3000 \
18
+ * --traces-object training-traces \
19
+ * --min-score 4 \
20
+ * --max-output-chars 4000
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ function parseArgs(argv) {
27
+ const a = {
28
+ in: "",
29
+ workspace: "http://localhost:3000",
30
+ tracesObject: "training-traces",
31
+ minScore: 4,
32
+ maxOutputChars: 4000,
33
+ };
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const t = argv[i];
36
+ const next = () => String(argv[++i] || "").trim();
37
+ if (t === "--in") a.in = next();
38
+ else if (t === "--workspace") a.workspace = next().replace(/\/+$/, "");
39
+ else if (t === "--traces-object") a.tracesObject = next();
40
+ else if (t === "--min-score") a.minScore = Number(next()) || 4;
41
+ else if (t === "--max-output-chars") a.maxOutputChars = Number(next()) || 4000;
42
+ else if (t === "--help" || t === "-h") {
43
+ process.stdout.write(
44
+ "Usage: upload-graded-traces.mjs --in <graded.jsonl> [--workspace URL] [--traces-object id] [--min-score N] [--max-output-chars N]\n",
45
+ );
46
+ process.exit(0);
47
+ }
48
+ }
49
+ if (!a.in) {
50
+ process.stderr.write("error: --in is required\n");
51
+ process.exit(2);
52
+ }
53
+ return a;
54
+ }
55
+
56
+ const args = parseArgs(process.argv.slice(2));
57
+ const inAbs = path.resolve(args.in);
58
+ if (!fs.existsSync(inAbs)) {
59
+ process.stderr.write(`error: input not found: ${inAbs}\n`);
60
+ process.exit(2);
61
+ }
62
+
63
+ async function getObjects() {
64
+ const r = await fetch(`${args.workspace}/api/workspace`, { cache: "no-store" });
65
+ if (!r.ok) throw new Error(`GET /api/workspace ${r.status}`);
66
+ return (await r.json()).workspaceConfig.dataModel.objects;
67
+ }
68
+ async function patchObjects(objects) {
69
+ const r = await fetch(`${args.workspace}/api/workspace`, {
70
+ method: "PATCH",
71
+ headers: { "content-type": "application/json" },
72
+ body: JSON.stringify({ dataModel: { objects } }),
73
+ });
74
+ if (!r.ok) throw new Error(`PATCH ${r.status}: ${(await r.text()).slice(0, 300)}`);
75
+ }
76
+
77
+ const lines = fs.readFileSync(inAbs, "utf8").split("\n").filter(Boolean);
78
+ const candidates = [];
79
+ for (const ln of lines) {
80
+ try {
81
+ const j = JSON.parse(ln);
82
+ if (Number(j.qualityScore) >= args.minScore) candidates.push(j);
83
+ } catch {
84
+ /* skip */
85
+ }
86
+ }
87
+
88
+ if (candidates.length === 0) {
89
+ process.stdout.write(JSON.stringify({ ok: true, uploaded: 0, reason: "no rows >= --min-score" }) + "\n");
90
+ process.exit(0);
91
+ }
92
+
93
+ const objects = await getObjects();
94
+ const tracesIdx = objects.findIndex((o) => o.id === args.tracesObject);
95
+ if (tracesIdx < 0) {
96
+ process.stderr.write(`error: object ${args.tracesObject} not found in workspace\n`);
97
+ process.exit(3);
98
+ }
99
+ const tracesObj = objects[tracesIdx];
100
+ const newRows = candidates.map((p) => ({
101
+ sessionDate: p.gradedAt || new Date().toISOString(),
102
+ inputPrompt: String(p.inputPrompt || "").slice(0, args.maxOutputChars),
103
+ agentOutput: String(p.agentOutput || "").slice(0, args.maxOutputChars),
104
+ qualityScore: String(p.qualityScore),
105
+ reason: String(p.qualityReason || ""),
106
+ exported: "false",
107
+ }));
108
+
109
+ const nextObjects = objects.map((o, i) =>
110
+ i !== tracesIdx ? o : { ...o, rows: [...(o.rows || []), ...newRows] },
111
+ );
112
+ await patchObjects(nextObjects);
113
+
114
+ const after = await getObjects();
115
+ const finalCount = after.find((o) => o.id === args.tracesObject)?.rows?.length || 0;
116
+ process.stdout.write(
117
+ JSON.stringify(
118
+ {
119
+ ok: true,
120
+ candidates: candidates.length,
121
+ uploaded: newRows.length,
122
+ tracesTotalRows: finalCount,
123
+ sourceFile: path.basename(inAbs),
124
+ },
125
+ null,
126
+ 2,
127
+ ) + "\n",
128
+ );