libretto 0.6.33 → 0.6.35
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/runtime/recovery/recovery.js +4 -1
- package/docs/releasing.md +3 -1
- package/package.json +2 -3
- package/scripts/generate-changelog.ts +196 -2
- package/skills/libretto/SKILL.md +1 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/core/resolve-model.ts +5 -5
- package/src/cli/core/workflow-runtime.ts +1 -1
- package/src/runtime/recovery/recovery.ts +4 -1
|
@@ -14,7 +14,10 @@ async function attemptWithRecovery(page, fn, logger, model) {
|
|
|
14
14
|
throw error;
|
|
15
15
|
}
|
|
16
16
|
if (!model) {
|
|
17
|
-
throw
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Recovery was not attempted because no model is configured. Pass a LanguageModel as the fourth argument to attemptWithRecovery, or call the action directly without recovery.",
|
|
19
|
+
{ cause: error }
|
|
20
|
+
);
|
|
18
21
|
}
|
|
19
22
|
log.info("Action failed, attempting popup recovery", {
|
|
20
23
|
error: error instanceof Error ? error.message : String(error)
|
package/docs/releasing.md
CHANGED
|
@@ -49,7 +49,7 @@ The workflow needs `contents: write` to create the GitHub release and tag, and `
|
|
|
49
49
|
|
|
50
50
|
After trusted publishing is working, remove any old npm publish token from the repo secrets. npm recommends restricting token-based publishing after the migration.
|
|
51
51
|
|
|
52
|
-
GitHub release notes are generated by `packages/libretto/scripts/generate-changelog.ts`. The script finds the previous release, compares that tag to the release commit, and passes only the merged PRs in that range to the release notes agent.
|
|
52
|
+
GitHub release notes are generated by `packages/libretto/scripts/generate-changelog.ts`. The script finds the previous release, compares that tag to the release commit, and passes only the merged PRs in that range to the release notes agent. The release workflow uploads the generated notes and a changelog audit JSON artifact containing the release range, eligible PRs, inspected PR diffs, tool calls, and final model output.
|
|
53
53
|
|
|
54
54
|
## Prepare a release PR
|
|
55
55
|
|
|
@@ -108,6 +108,8 @@ The GitHub Releases page is the changelog for this repo.
|
|
|
108
108
|
|
|
109
109
|
When the workflow runs `pnpm generate-changelog vX.Y.Z`, the script finds the previous GitHub release, compares that tag to the release commit, and extracts PR numbers from the compare commits. The release notes agent can inspect only those PRs with `gh pr view` and `gh pr diff`.
|
|
110
110
|
|
|
111
|
+
The generator logs the release range and changelog-eligible PRs to stderr. When `CHANGELOG_AUDIT_FILE` is set, it also writes a JSON audit file with the eligible PR list, inspected diff PRs, tool-call summaries, and raw/final release notes. Generation fails if the agent does not inspect every eligible PR diff, so omissions are easier to diagnose before a GitHub release is created.
|
|
112
|
+
|
|
111
113
|
To keep release notes readable, use clear PR titles and descriptions before merging. If a PR should not appear in the changelog, add the `skip-changelog` label.
|
|
112
114
|
|
|
113
115
|
## Notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libretto",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.35",
|
|
4
4
|
"description": "AI-powered browser automation library and CLI built on Playwright",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://libretto.sh",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"playwright": "^1.58.2",
|
|
71
71
|
"tsx": "^4.21.0",
|
|
72
72
|
"zod": "^4.3.6",
|
|
73
|
-
"affordance": "^0.2.
|
|
73
|
+
"affordance": "^0.2.2"
|
|
74
74
|
},
|
|
75
75
|
"scripts": {
|
|
76
76
|
"sync:mirrors": "node ../dev-tools/scripts/sync-mirrors.mjs",
|
|
@@ -78,7 +78,6 @@
|
|
|
78
78
|
"sync-skills": "pnpm run sync:mirrors",
|
|
79
79
|
"check:skills": "pnpm run check:mirrors",
|
|
80
80
|
"build": "tsup --config tsup.config.ts",
|
|
81
|
-
"lint": "lintcn lint --tsconfig tsconfig.json",
|
|
82
81
|
"type-check": "tsc --noEmit",
|
|
83
82
|
"test": "turbo run test:vitest --filter=libretto --log-order=grouped",
|
|
84
83
|
"test:vitest": "vitest run",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
4
|
import { Agent, type AgentTool, type AgentEvent } from "@mariozechner/pi-agent-core";
|
|
3
5
|
import { getModel } from "@mariozechner/pi-ai";
|
|
4
6
|
import { Type, type Static } from "@sinclair/typebox";
|
|
@@ -60,6 +62,34 @@ interface ReleaseContext {
|
|
|
60
62
|
pullRequests: PullRequestDetails[];
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
interface ToolCallAudit {
|
|
66
|
+
toolCallId: string;
|
|
67
|
+
toolName: string;
|
|
68
|
+
args: unknown;
|
|
69
|
+
isError?: boolean;
|
|
70
|
+
resultTextBytes?: number;
|
|
71
|
+
resultTextPreview?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ChangelogAudit {
|
|
75
|
+
tag: string;
|
|
76
|
+
generatedAt: string;
|
|
77
|
+
previousTag: string;
|
|
78
|
+
currentRef: string;
|
|
79
|
+
eligiblePullRequests: Array<{
|
|
80
|
+
number: number;
|
|
81
|
+
title: string;
|
|
82
|
+
labels: string[];
|
|
83
|
+
files: string[];
|
|
84
|
+
url: string;
|
|
85
|
+
}>;
|
|
86
|
+
inspectedDiffPullRequests: number[];
|
|
87
|
+
missingDiffPullRequests: number[];
|
|
88
|
+
toolCalls: ToolCallAudit[];
|
|
89
|
+
rawFinalText: string;
|
|
90
|
+
finalText: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
function runGh(args: string[]): string {
|
|
64
94
|
return execFileSync("gh", args, {
|
|
65
95
|
encoding: "utf8",
|
|
@@ -190,6 +220,7 @@ function buildReleaseContext(currentTag: string): ReleaseContext {
|
|
|
190
220
|
|
|
191
221
|
const releaseContext = buildReleaseContext(tag);
|
|
192
222
|
const allowedPullRequestNumbers = new Set(releaseContext.pullRequests.map((pr) => String(pr.number)));
|
|
223
|
+
const auditFilePath = process.env.CHANGELOG_AUDIT_FILE;
|
|
193
224
|
const pullRequestSummary = releaseContext.pullRequests
|
|
194
225
|
.map((pr) => {
|
|
195
226
|
const labels = pr.labels.map((label) => label.name).join(", ") || "none";
|
|
@@ -204,6 +235,13 @@ const pullRequestSummary = releaseContext.pullRequests
|
|
|
204
235
|
})
|
|
205
236
|
.join("\n\n");
|
|
206
237
|
|
|
238
|
+
console.error(`Changelog release range: ${releaseContext.previousTag}...${releaseContext.currentRef}`);
|
|
239
|
+
console.error("Changelog-eligible PRs:");
|
|
240
|
+
for (const pr of releaseContext.pullRequests) {
|
|
241
|
+
const labels = pr.labels.map((label) => label.name).join(", ") || "none";
|
|
242
|
+
console.error(`- #${pr.number} ${pr.title} [${labels}]`);
|
|
243
|
+
}
|
|
244
|
+
|
|
207
245
|
const GhToolParamsSchema = Type.Object({
|
|
208
246
|
args: Type.String({ description: "Arguments to pass to gh (without the leading 'gh')" }),
|
|
209
247
|
});
|
|
@@ -242,6 +280,12 @@ const ghTool: AgentTool = {
|
|
|
242
280
|
`PR #${number ?? "(missing)"} is outside the ${releaseContext.previousTag}...${releaseContext.currentRef} release range.`,
|
|
243
281
|
);
|
|
244
282
|
}
|
|
283
|
+
|
|
284
|
+
// Diff inspection enforcement counts successful 'pr diff NUMBER' calls,
|
|
285
|
+
// so reject flags like --name-only that would return less than the full diff.
|
|
286
|
+
if (action === "diff" && parts.length > 3) {
|
|
287
|
+
throw new Error("'pr diff' accepts only a PR number. Run 'pr diff NUMBER' with no extra arguments.");
|
|
288
|
+
}
|
|
245
289
|
}
|
|
246
290
|
try {
|
|
247
291
|
const output = runGh(parts);
|
|
@@ -277,6 +321,9 @@ const agent = new Agent({
|
|
|
277
321
|
"- Write concise, user-facing release notes in markdown.",
|
|
278
322
|
"- Group changes into sections like Features, Fixes, and Improvements. Only include sections that have entries.",
|
|
279
323
|
"- Focus on what changed from the user's perspective, not internal implementation details.",
|
|
324
|
+
"- For feature and improvement entries, include minimal code blocks when they make the change easier to use.",
|
|
325
|
+
"- Each relevant feature or improvement can have its own snippet.",
|
|
326
|
+
"- Keep each code block to at most 10 lines, and use at most one comment line inside the block if helpful.",
|
|
280
327
|
"- Do NOT include PR numbers or links.",
|
|
281
328
|
"- Skip PRs labeled 'skip-changelog'.",
|
|
282
329
|
"- Your response must contain ONLY the raw markdown release notes. No preamble like 'Here are the release notes'. No commentary or explanation. No '---' separators. The very first character of your response must be '#'. Example format:",
|
|
@@ -291,8 +338,123 @@ const agent = new Agent({
|
|
|
291
338
|
});
|
|
292
339
|
|
|
293
340
|
let finalText = "";
|
|
341
|
+
let rawFinalText = "";
|
|
342
|
+
const toolCalls = new Map<string, ToolCallAudit>();
|
|
343
|
+
|
|
344
|
+
function getObjectProperty(value: unknown, key: string): unknown {
|
|
345
|
+
if (typeof value !== "object" || value === null || !(key in value)) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return (value as Record<string, unknown>)[key];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function getTextContent(value: unknown): string {
|
|
353
|
+
const content = getObjectProperty(value, "content");
|
|
354
|
+
if (!Array.isArray(content)) {
|
|
355
|
+
return "";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return content
|
|
359
|
+
.map((block) => {
|
|
360
|
+
if (typeof block !== "object" || block === null) {
|
|
361
|
+
return "";
|
|
362
|
+
}
|
|
363
|
+
const type = getObjectProperty(block, "type");
|
|
364
|
+
const text = getObjectProperty(block, "text");
|
|
365
|
+
return type === "text" && typeof text === "string" ? text : "";
|
|
366
|
+
})
|
|
367
|
+
.filter((text) => text.length > 0)
|
|
368
|
+
.join("\n");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function getInspectedDiffPullRequests(): number[] {
|
|
372
|
+
const inspected = new Set<number>();
|
|
373
|
+
for (const toolCall of toolCalls.values()) {
|
|
374
|
+
if (toolCall.isError !== false) {
|
|
375
|
+
// Only count tool calls that completed successfully.
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const args = getObjectProperty(toolCall.args, "args");
|
|
380
|
+
if (typeof args !== "string") {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const parts = args.trim().split(/\s+/);
|
|
385
|
+
if (parts[0] !== "pr" || parts[1] !== "diff") {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const number = Number(parts[2]);
|
|
390
|
+
if (Number.isInteger(number)) {
|
|
391
|
+
inspected.add(number);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return [...inspected].sort((a, b) => a - b);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function buildAudit(): ChangelogAudit {
|
|
399
|
+
const inspectedDiffPullRequests = getInspectedDiffPullRequests();
|
|
400
|
+
const inspected = new Set(inspectedDiffPullRequests);
|
|
401
|
+
const missingDiffPullRequests = releaseContext.pullRequests
|
|
402
|
+
.map((pr) => pr.number)
|
|
403
|
+
.filter((number) => !inspected.has(number));
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
tag,
|
|
407
|
+
generatedAt: new Date().toISOString(),
|
|
408
|
+
previousTag: releaseContext.previousTag,
|
|
409
|
+
currentRef: releaseContext.currentRef,
|
|
410
|
+
eligiblePullRequests: releaseContext.pullRequests.map((pr) => ({
|
|
411
|
+
number: pr.number,
|
|
412
|
+
title: pr.title,
|
|
413
|
+
labels: pr.labels.map((label) => label.name),
|
|
414
|
+
files: pr.files.map((file) => file.path),
|
|
415
|
+
url: pr.url,
|
|
416
|
+
})),
|
|
417
|
+
inspectedDiffPullRequests,
|
|
418
|
+
missingDiffPullRequests,
|
|
419
|
+
toolCalls: [...toolCalls.values()],
|
|
420
|
+
rawFinalText,
|
|
421
|
+
finalText,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function writeAuditFile(): void {
|
|
426
|
+
if (!auditFilePath) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
mkdirSync(dirname(auditFilePath), { recursive: true });
|
|
431
|
+
writeFileSync(auditFilePath, `${JSON.stringify(buildAudit(), null, 2)}\n`);
|
|
432
|
+
}
|
|
294
433
|
|
|
295
434
|
agent.subscribe((event: AgentEvent) => {
|
|
435
|
+
if (event.type === "tool_execution_start") {
|
|
436
|
+
toolCalls.set(event.toolCallId, {
|
|
437
|
+
toolCallId: event.toolCallId,
|
|
438
|
+
toolName: event.toolName,
|
|
439
|
+
args: event.args,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (event.type === "tool_execution_end") {
|
|
444
|
+
const text = getTextContent(event.result);
|
|
445
|
+
const existing = toolCalls.get(event.toolCallId) ?? {
|
|
446
|
+
toolCallId: event.toolCallId,
|
|
447
|
+
toolName: event.toolName,
|
|
448
|
+
args: undefined,
|
|
449
|
+
};
|
|
450
|
+
toolCalls.set(event.toolCallId, {
|
|
451
|
+
...existing,
|
|
452
|
+
isError: event.isError,
|
|
453
|
+
resultTextBytes: Buffer.byteLength(text, "utf8"),
|
|
454
|
+
resultTextPreview: text.slice(0, 2000),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
296
458
|
if (event.type === "agent_end") {
|
|
297
459
|
const messages = event.messages;
|
|
298
460
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -300,7 +462,8 @@ agent.subscribe((event: AgentEvent) => {
|
|
|
300
462
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
301
463
|
for (const block of msg.content) {
|
|
302
464
|
if (typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
|
|
303
|
-
|
|
465
|
+
rawFinalText = block.text as string;
|
|
466
|
+
finalText = rawFinalText;
|
|
304
467
|
return;
|
|
305
468
|
}
|
|
306
469
|
}
|
|
@@ -309,10 +472,18 @@ agent.subscribe((event: AgentEvent) => {
|
|
|
309
472
|
}
|
|
310
473
|
});
|
|
311
474
|
|
|
312
|
-
|
|
475
|
+
try {
|
|
476
|
+
await agent.prompt("Generate the release notes now.");
|
|
477
|
+
} catch (error) {
|
|
478
|
+
console.error("Changelog generation failed: agent prompt threw an error.");
|
|
479
|
+
console.error(error);
|
|
480
|
+
writeAuditFile();
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
313
483
|
|
|
314
484
|
if (!finalText) {
|
|
315
485
|
console.error("Changelog generation failed: no text output from agent.");
|
|
486
|
+
writeAuditFile();
|
|
316
487
|
process.exit(1);
|
|
317
488
|
}
|
|
318
489
|
|
|
@@ -324,7 +495,30 @@ if (headingIndex >= 0) {
|
|
|
324
495
|
// Already starts with a heading, keep as-is.
|
|
325
496
|
} else {
|
|
326
497
|
console.error("Changelog generation failed: output does not contain markdown headings.");
|
|
498
|
+
writeAuditFile();
|
|
327
499
|
process.exit(1);
|
|
328
500
|
}
|
|
329
501
|
|
|
502
|
+
const inspectedDiffPullRequests = getInspectedDiffPullRequests();
|
|
503
|
+
const missingDiffPullRequests = releaseContext.pullRequests
|
|
504
|
+
.map((pr) => pr.number)
|
|
505
|
+
.filter((number) => !inspectedDiffPullRequests.includes(number));
|
|
506
|
+
|
|
507
|
+
console.error(`Inspected PR diffs: ${inspectedDiffPullRequests.map((number) => `#${number}`).join(", ") || "none"}`);
|
|
508
|
+
|
|
509
|
+
if (missingDiffPullRequests.length > 0) {
|
|
510
|
+
console.error(
|
|
511
|
+
`Changelog generation failed: the agent did not inspect diffs for PRs ${missingDiffPullRequests
|
|
512
|
+
.map((number) => `#${number}`)
|
|
513
|
+
.join(", ")}.`,
|
|
514
|
+
);
|
|
515
|
+
writeAuditFile();
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
writeAuditFile();
|
|
520
|
+
if (auditFilePath) {
|
|
521
|
+
console.error(`Wrote changelog audit: ${auditFilePath}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
330
524
|
process.stdout.write(finalText);
|
package/skills/libretto/SKILL.md
CHANGED
|
@@ -105,7 +105,7 @@ async function getProviderModel(
|
|
|
105
105
|
if (!apiKey) {
|
|
106
106
|
throw new Error(missingProviderCredentialsMessage(provider));
|
|
107
107
|
}
|
|
108
|
-
//
|
|
108
|
+
// oxlint-disable-next-line libretto/no-await-import -- Human-approved: we don't want to import unless the user is using that subagent.
|
|
109
109
|
const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
|
|
110
110
|
const google = createGoogleGenerativeAI({ apiKey });
|
|
111
111
|
return google(modelId);
|
|
@@ -115,7 +115,7 @@ async function getProviderModel(
|
|
|
115
115
|
if (!project) {
|
|
116
116
|
throw new Error(missingProviderCredentialsMessage(provider));
|
|
117
117
|
}
|
|
118
|
-
//
|
|
118
|
+
// oxlint-disable-next-line libretto/no-await-import -- Human-approved: we don't want to import unless the user is using that subagent.
|
|
119
119
|
const { createVertex } = await import("@ai-sdk/google-vertex");
|
|
120
120
|
const vertex = createVertex({
|
|
121
121
|
project,
|
|
@@ -128,7 +128,7 @@ async function getProviderModel(
|
|
|
128
128
|
if (!apiKey) {
|
|
129
129
|
throw new Error(missingProviderCredentialsMessage(provider));
|
|
130
130
|
}
|
|
131
|
-
//
|
|
131
|
+
// oxlint-disable-next-line libretto/no-await-import -- Human-approved: we don't want to import unless the user is using that subagent.
|
|
132
132
|
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
133
133
|
const anthropic = createAnthropic({ apiKey });
|
|
134
134
|
return anthropic(modelId);
|
|
@@ -138,7 +138,7 @@ async function getProviderModel(
|
|
|
138
138
|
if (!apiKey) {
|
|
139
139
|
throw new Error(missingProviderCredentialsMessage(provider));
|
|
140
140
|
}
|
|
141
|
-
//
|
|
141
|
+
// oxlint-disable-next-line libretto/no-await-import -- Human-approved: we don't want to import unless the user is using that subagent.
|
|
142
142
|
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
143
143
|
const openai = createOpenAI({ apiKey });
|
|
144
144
|
return openai(modelId);
|
|
@@ -148,7 +148,7 @@ async function getProviderModel(
|
|
|
148
148
|
if (!apiKey) {
|
|
149
149
|
throw new Error(missingProviderCredentialsMessage(provider));
|
|
150
150
|
}
|
|
151
|
-
//
|
|
151
|
+
// oxlint-disable-next-line libretto/no-await-import -- Human-approved: we don't want to import unless the user is using that subagent.
|
|
152
152
|
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
153
153
|
const openrouter = createOpenAI({
|
|
154
154
|
apiKey,
|
|
@@ -37,7 +37,7 @@ export async function loadDefaultWorkflow(
|
|
|
37
37
|
): Promise<ExportedLibrettoWorkflow> {
|
|
38
38
|
let loadedModule: Record<string, unknown>;
|
|
39
39
|
try {
|
|
40
|
-
//
|
|
40
|
+
// oxlint-disable-next-line libretto/no-await-import -- Human-approved: user workflow files must be loaded dynamically from the CLI argument.
|
|
41
41
|
loadedModule = (await import(pathToFileURL(absolutePath).href)) as Record<
|
|
42
42
|
string,
|
|
43
43
|
unknown
|
|
@@ -34,7 +34,10 @@ export async function attemptWithRecovery<T>(
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
if (!model) {
|
|
37
|
-
throw
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Recovery was not attempted because no model is configured. Pass a LanguageModel as the fourth argument to attemptWithRecovery, or call the action directly without recovery.",
|
|
39
|
+
{ cause: error },
|
|
40
|
+
);
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
log.info("Action failed, attempting popup recovery", {
|