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.
@@ -14,7 +14,10 @@ async function attemptWithRecovery(page, fn, logger, model) {
14
14
  throw error;
15
15
  }
16
16
  if (!model) {
17
- throw error;
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.33",
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.1"
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
- finalText = block.text as string;
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
- await agent.prompt("Generate the release notes now.");
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);
@@ -4,7 +4,7 @@ description: "Browser automation CLI for building, maintaining, and running brow
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.6.33"
7
+ version: "0.6.35"
8
8
  ---
9
9
 
10
10
  ## How Libretto Works
@@ -4,7 +4,7 @@ description: "Read-only Libretto workflow for diagnosing live browser state with
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.6.33"
7
+ version: "0.6.35"
8
8
  ---
9
9
 
10
10
  ## How Libretto Read-Only Works
@@ -105,7 +105,7 @@ async function getProviderModel(
105
105
  if (!apiKey) {
106
106
  throw new Error(missingProviderCredentialsMessage(provider));
107
107
  }
108
- // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
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
- // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
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
- // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
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
- // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
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
- // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
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
- // @lintc-ignore Human-approved: user workflow files must be loaded dynamically from the CLI argument.
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 error;
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", {