@usepipr/runtime 0.4.3 → 0.6.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -5
  3. package/dist/{commands-DA0Ij4Di.d.mts → commands-BHXh7P28.d.mts} +2 -2
  4. package/dist/{commands-DA0Ij4Di.d.mts.map → commands-BHXh7P28.d.mts.map} +1 -1
  5. package/dist/{commands-B9qNW4pk.mjs → commands-GWHklzvq.mjs} +1612 -466
  6. package/dist/commands-GWHklzvq.mjs.map +1 -0
  7. package/dist/index.d.mts +17 -4
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +117 -15
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/internal/action-result.d.mts +4 -2
  12. package/dist/internal/action-result.d.mts.map +1 -1
  13. package/dist/internal/action-result.mjs +28 -12
  14. package/dist/internal/action-result.mjs.map +1 -1
  15. package/dist/internal/docs.d.mts +1 -1
  16. package/dist/internal/docs.d.mts.map +1 -1
  17. package/dist/internal/docs.mjs +1 -1
  18. package/dist/internal/pipr-result.d.mts +24 -0
  19. package/dist/internal/pipr-result.d.mts.map +1 -0
  20. package/dist/internal/pipr-result.mjs +126 -0
  21. package/dist/internal/pipr-result.mjs.map +1 -0
  22. package/dist/internal/testing.d.mts +12 -11
  23. package/dist/internal/testing.d.mts.map +1 -1
  24. package/dist/internal/testing.mjs +1 -1
  25. package/dist/main-comment-envelope-DFirHYhW.mjs +69 -0
  26. package/dist/main-comment-envelope-DFirHYhW.mjs.map +1 -0
  27. package/dist/{official-github-workflow-Bjlu-FL3.mjs → official-github-workflow-B3yeaCgq.mjs} +421 -114
  28. package/dist/official-github-workflow-B3yeaCgq.mjs.map +1 -0
  29. package/dist/pi/runtime-tools-extension.d.mts.map +1 -1
  30. package/dist/pi/runtime-tools-extension.mjs +128 -2
  31. package/dist/pi/runtime-tools-extension.mjs.map +1 -1
  32. package/dist/{recipes-w72EpAOi.d.mts → recipes-C1kxJCgQ.d.mts} +2 -1
  33. package/dist/recipes-C1kxJCgQ.d.mts.map +1 -0
  34. package/dist/runtime-tools-core-BF8rSeNS.mjs +476 -0
  35. package/dist/runtime-tools-core-BF8rSeNS.mjs.map +1 -0
  36. package/dist/{types-BAis_Lwm.d.mts → types-w22wbdo9.d.mts} +72 -19
  37. package/dist/types-w22wbdo9.d.mts.map +1 -0
  38. package/package.json +16 -8
  39. package/dist/commands-B9qNW4pk.mjs.map +0 -1
  40. package/dist/official-github-workflow-Bjlu-FL3.mjs.map +0 -1
  41. package/dist/recipes-w72EpAOi.d.mts.map +0 -1
  42. package/dist/runtime-tools-core-D8_WsbL_.mjs +0 -179
  43. package/dist/runtime-tools-core-D8_WsbL_.mjs.map +0 -1
  44. package/dist/types-BAis_Lwm.d.mts.map +0 -1
@@ -16,7 +16,7 @@ export default definePipr((pipr) => {
16
16
  provider: "deepseek",
17
17
  model: "deepseek-v4-pro",
18
18
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
19
- options: { thinking: "high" },
19
+ thinking: "high",
20
20
  });
21
21
 
22
22
  const fallback = pipr.model({
@@ -24,29 +24,29 @@ export default definePipr((pipr) => {
24
24
  provider: "deepseek",
25
25
  model: "deepseek-v4-pro",
26
26
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
27
- options: { thinking: "medium" },
27
+ thinking: "medium",
28
28
  });
29
29
 
30
30
  pipr.config({ publication: { maxInlineComments: 8 } });
31
31
 
32
- const reviewer = pipr.reviewer({
33
- name: "bug-hunter",
34
- model: primary,
35
- fallbacks: [fallback],
36
- instructions: \`
37
- Review only defects with a reproducible failure path or a violated
38
- repository contract: broken logic, edge cases, concurrency risks, data
39
- loss, performance regressions, and behavior changes missing meaningful
40
- tests. For API, async, state, and concurrency changes, inspect relevant
41
- callers and tests before reporting. Suppress generic maintainability,
42
- style-only, and broad refactor feedback.
43
- \`,
44
- timeout: "7m",
45
- });
46
-
47
32
  pipr.review({
48
33
  id: "bug-hunter",
49
- reviewer,
34
+ model: primary,
35
+ fallbacks: [fallback],
36
+ instructions: {
37
+ findings: \`
38
+ Review only defects with a reproducible failure path or a violated
39
+ repository contract: broken logic, edge cases, concurrency risks, data
40
+ loss, performance regressions, and behavior changes missing meaningful
41
+ tests. For API, async, state, and concurrency changes, inspect relevant
42
+ callers and tests before reporting. Suppress generic maintainability,
43
+ style-only, and broad refactor feedback.
44
+ \`,
45
+ summary: \`
46
+ Summarize the changed behavior and concrete defect risk. Use merged
47
+ findings as evidence and omit generic praise or speculative concerns.
48
+ \`,
49
+ },
50
50
  paths: {
51
51
  exclude: ["docs/**", "**/*.md"],
52
52
  },
@@ -77,7 +77,7 @@ export default definePipr((pipr) => {
77
77
  provider: "deepseek",
78
78
  model: "deepseek-v4-pro",
79
79
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
80
- options: { thinking: "medium" },
80
+ thinking: "medium",
81
81
  });
82
82
 
83
83
  const changelogOutput = pipr.schema({
@@ -140,7 +140,7 @@ export default definePipr((pipr) => {
140
140
  provider: "deepseek",
141
141
  model: "deepseek-v4-pro",
142
142
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
143
- options: { thinking: "high" },
143
+ thinking: "high",
144
144
  });
145
145
 
146
146
  const ciTriageOutput = pipr.schema({
@@ -241,7 +241,7 @@ export default definePipr((pipr) => {
241
241
  provider: "deepseek",
242
242
  model: "deepseek-v4-pro",
243
243
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
244
- options: { thinking: "high" },
244
+ thinking: "high",
245
245
  });
246
246
 
247
247
  pipr.config({ publication: { maxInlineComments: 5 } });
@@ -249,12 +249,18 @@ export default definePipr((pipr) => {
249
249
  pipr.review({
250
250
  id: "review",
251
251
  model,
252
- instructions: \`
253
- Review changed behavior for correctness, security, maintainability, and
254
- meaningful regression gaps. Focus on concrete impact and compatibility
255
- with repository contracts. Return only actionable findings that target
256
- valid diff ranges.
257
- \`,
252
+ instructions: {
253
+ findings: \`
254
+ Review changed behavior for correctness, security, maintainability, and
255
+ meaningful regression gaps. Focus on concrete impact and compatibility
256
+ with repository contracts. Return only actionable findings that target
257
+ valid diff ranges.
258
+ \`,
259
+ summary: \`
260
+ Summarize the changed behavior, overall risk, and useful reviewer focus.
261
+ Use merged findings as evidence without introducing new defects.
262
+ \`,
263
+ },
258
264
  timeout: "10m",
259
265
  comment: (result, context) => {
260
266
  const inlineFindingSummary =
@@ -281,7 +287,7 @@ export default definePipr((pipr) => {
281
287
  "| --- | ---: |",
282
288
  \`| Inline findings | \${result.inlineFindings.length} |\`,
283
289
  "",
284
- context.platform.id === "local" ? localInlineFindingSummary : inlineFindingSummary,
290
+ context.run.trigger === "local" ? localInlineFindingSummary : inlineFindingSummary,
285
291
  ].join("\\n"),
286
292
  inlineFindings: result.inlineFindings,
287
293
  };
@@ -304,7 +310,7 @@ export default definePipr((pipr) => {
304
310
  provider: "deepseek",
305
311
  model: "deepseek-v4-pro",
306
312
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
307
- options: { thinking: "high" },
313
+ thinking: "high",
308
314
  });
309
315
 
310
316
  const dependencyOutput = pipr.schema({
@@ -404,7 +410,7 @@ export default definePipr((pipr) => {
404
410
  provider: "deepseek",
405
411
  model: "deepseek-v4-pro",
406
412
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
407
- options: { thinking: "high" },
413
+ thinking: "high",
408
414
  });
409
415
 
410
416
  const diagnosticOutput = pipr.schema({
@@ -480,7 +486,7 @@ export default definePipr((pipr) => {
480
486
  provider: "deepseek",
481
487
  model: "deepseek-v4-pro",
482
488
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
483
- options: { thinking: "high" },
489
+ thinking: "high",
484
490
  });
485
491
 
486
492
  pipr.config({ publication: { maxInlineComments: 6 } });
@@ -1063,7 +1069,7 @@ export default definePipr((pipr) => {
1063
1069
  provider: "deepseek",
1064
1070
  model: "deepseek-v4-pro",
1065
1071
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1066
- options: { thinking: "high" },
1072
+ thinking: "high",
1067
1073
  });
1068
1074
 
1069
1075
  const askAgent = pipr.agent({
@@ -1109,6 +1115,7 @@ export default definePipr((pipr) => {
1109
1115
  //#region src/config/recipes/multi-agent-review.ts
1110
1116
  const multiAgentReviewRecipe = {
1111
1117
  id: "multi-agent-review",
1118
+ requiresChecksPermission: true,
1112
1119
  title: "Multi-agent Review",
1113
1120
  description: "Security, test, and maintainability agents with an aggregator agent.",
1114
1121
  sourceTools: [
@@ -1124,7 +1131,7 @@ export default definePipr((pipr) => {
1124
1131
  provider: "deepseek",
1125
1132
  model: "deepseek-v4-pro",
1126
1133
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1127
- options: { thinking: "high" },
1134
+ thinking: "high",
1128
1135
  });
1129
1136
 
1130
1137
  const fast = pipr.model({
@@ -1132,7 +1139,7 @@ export default definePipr((pipr) => {
1132
1139
  provider: "deepseek",
1133
1140
  model: "deepseek-v4-pro",
1134
1141
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1135
- options: { thinking: "medium" },
1142
+ thinking: "medium",
1136
1143
  });
1137
1144
 
1138
1145
  const specialistPrompt = (input: { manifest: unknown; focus: string }) => pipr.prompt\`
@@ -1222,17 +1229,17 @@ export default definePipr((pipr) => {
1222
1229
  const pluginToolReviewRecipe = {
1223
1230
  id: "plugin-tool-review",
1224
1231
  title: "Plugin Tool Review",
1225
- description: "Typed R2-backed memory plugin for reviewer agents that need durable project context.",
1232
+ description: "Typed R2-backed memory plugin with search-only review and explicit maintainer curation.",
1226
1233
  sourceTools: ["Cloudflare R2", "Reviewer memory"],
1227
1234
  configTs: `import { definePipr } from "@usepipr/sdk";
1228
- import { r2MemoryPlugin } from "./r2-memory";
1235
+ import { memoryLimits, r2MemoryPlugin } from "./r2-memory";
1229
1236
 
1230
1237
  export default definePipr((pipr) => {
1231
1238
  const model = pipr.model({
1232
1239
  provider: "deepseek",
1233
1240
  model: "deepseek-v4-pro",
1234
1241
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1235
- options: { thinking: "high" },
1242
+ thinking: "high",
1236
1243
  });
1237
1244
 
1238
1245
  const memory = pipr.use(
@@ -1277,8 +1284,44 @@ export default definePipr((pipr) => {
1277
1284
  },
1278
1285
  });
1279
1286
 
1287
+ const rememberTask = pipr.task<{ lesson: string }>({
1288
+ name: "remember-review-memory",
1289
+ async run(ctx, input) {
1290
+ if (!ctx.command) {
1291
+ throw new Error("remember-review-memory is a command-only task");
1292
+ }
1293
+ const lesson = input.lesson.trim();
1294
+ if (lesson.length === 0) {
1295
+ await ctx.command.reply("Usage: @pipr remember <lesson...>");
1296
+ return;
1297
+ }
1298
+ if (lesson.length > memoryLimits.bodyCharacters) {
1299
+ await ctx.command.reply(
1300
+ "Reviewer memory must be " + memoryLimits.bodyCharacters + " characters or fewer.",
1301
+ );
1302
+ return;
1303
+ }
1304
+ const stored = await memory.curate(
1305
+ {
1306
+ subject: lesson.slice(0, memoryLimits.subjectCharacters),
1307
+ body: lesson,
1308
+ tags: ["maintainer-curated"],
1309
+ },
1310
+ ctx,
1311
+ );
1312
+ await ctx.command.reply("Stored reviewer memory \`" + stored.id + "\`.");
1313
+ },
1314
+ });
1315
+
1280
1316
  pipr.on.changeRequest({ actions: ["opened", "updated"], task });
1281
1317
  pipr.command({ pattern: "@pipr memory-review", permission: "write", task });
1318
+ pipr.command({
1319
+ pattern: "@pipr remember <lesson...>",
1320
+ permission: "write",
1321
+ description: "Store an explicit maintainer-curated reviewer lesson.",
1322
+ parse: (args) => ({ lesson: args.lesson ?? "" }),
1323
+ task: rememberTask,
1324
+ });
1282
1325
  });
1283
1326
  `,
1284
1327
  files: [{
@@ -1286,22 +1329,55 @@ export default definePipr((pipr) => {
1286
1329
  contents: `import { S3Client } from "bun";
1287
1330
  import { definePlugin, type SecretRef, type TaskContext, z } from "@usepipr/sdk";
1288
1331
 
1332
+ export const memoryLimits = {
1333
+ subjectCharacters: 120,
1334
+ bodyCharacters: 4000,
1335
+ tagCount: 12,
1336
+ tagCharacters: 50,
1337
+ queryCharacters: 500,
1338
+ resultDefault: 5,
1339
+ resultMinimum: 1,
1340
+ resultMaximum: 20,
1341
+ searchObjectMaximum: 2000,
1342
+ } as const;
1343
+
1344
+ const memorySource = z.strictObject({
1345
+ kind: z.enum(["maintainer-command", "agent-tool"]),
1346
+ runId: z.string().min(1).max(200),
1347
+ platform: z.string().min(1).max(50),
1348
+ changeRequestNumber: z.number().int().nonnegative().optional(),
1349
+ headSha: z.string().min(1).max(200),
1350
+ });
1351
+
1289
1352
  const memoryItem = z.strictObject({
1290
- subject: z.string(),
1291
- body: z.string(),
1292
- tags: z.array(z.string()).optional(),
1293
- updatedAt: z.string().optional(),
1353
+ id: z.string().uuid().optional(),
1354
+ subject: z.string().trim().min(1).max(memoryLimits.subjectCharacters),
1355
+ body: z.string().trim().min(1).max(memoryLimits.bodyCharacters),
1356
+ tags: z
1357
+ .array(z.string().trim().min(1).max(memoryLimits.tagCharacters))
1358
+ .max(memoryLimits.tagCount)
1359
+ .optional(),
1360
+ source: memorySource.optional(),
1361
+ updatedAt: z.string().max(50).optional(),
1294
1362
  });
1295
1363
 
1296
1364
  const memorySearchInput = z.strictObject({
1297
- query: z.string(),
1298
- limit: z.number().optional(),
1365
+ query: z.string().trim().min(1).max(memoryLimits.queryCharacters),
1366
+ limit: z
1367
+ .number()
1368
+ .int()
1369
+ .min(memoryLimits.resultMinimum)
1370
+ .max(memoryLimits.resultMaximum)
1371
+ .optional(),
1299
1372
  });
1300
1373
 
1301
1374
  const memoryStoreInput = z.strictObject({
1302
- subject: z.string(),
1303
- body: z.string(),
1304
- tags: z.array(z.string()).optional(),
1375
+ subject: z.string().trim().min(1).max(memoryLimits.subjectCharacters),
1376
+ body: z.string().trim().min(1).max(memoryLimits.bodyCharacters),
1377
+ tags: z
1378
+ .array(z.string().trim().min(1).max(memoryLimits.tagCharacters))
1379
+ .max(memoryLimits.tagCount)
1380
+ .optional(),
1305
1381
  });
1306
1382
 
1307
1383
  type MemoryItem = ReturnType<typeof memoryItem.parse>;
@@ -1328,6 +1404,7 @@ export function r2MemoryPlugin(options: R2MemoryOptions) {
1328
1404
  id: "memory/search-output",
1329
1405
  schema: z.strictObject({
1330
1406
  memories: z.array(memoryItem),
1407
+ skippedObjects: z.number().int().nonnegative(),
1331
1408
  }),
1332
1409
  });
1333
1410
  const storeInput = pipr.schema({
@@ -1339,6 +1416,7 @@ export function r2MemoryPlugin(options: R2MemoryOptions) {
1339
1416
  schema: z.strictObject({
1340
1417
  stored: z.boolean(),
1341
1418
  key: z.string(),
1419
+ id: z.string().uuid(),
1342
1420
  }),
1343
1421
  });
1344
1422
 
@@ -1361,12 +1439,15 @@ export function r2MemoryPlugin(options: R2MemoryOptions) {
1361
1439
  input: storeInput,
1362
1440
  output: storeOutput,
1363
1441
  async run({ input, ctx, signal }) {
1364
- return await storeMemory(input, ctx, options, signal);
1442
+ return await storeMemory(input, ctx, options, "agent-tool", signal);
1365
1443
  },
1366
1444
  toModelOutput(output) {
1367
1445
  return output;
1368
1446
  },
1369
1447
  }),
1448
+ curate(input: MemoryStoreInput, ctx: TaskContext, signal?: AbortSignal) {
1449
+ return storeMemory(input, ctx, options, "maintainer-command", signal);
1450
+ },
1370
1451
  };
1371
1452
  });
1372
1453
  }
@@ -1376,29 +1457,52 @@ async function searchMemory(
1376
1457
  ctx: TaskContext,
1377
1458
  options: R2MemoryOptions,
1378
1459
  signal?: AbortSignal,
1379
- ): Promise<{ memories: MemoryItem[] }> {
1460
+ ): Promise<{ memories: MemoryItem[]; skippedObjects: number }> {
1380
1461
  signal?.throwIfAborted();
1381
1462
  const bucket = r2Bucket(ctx, options);
1382
- const listed = await bucket.list({ prefix: memoryPrefix(ctx, options) + "/", maxKeys: 200 });
1383
1463
  const memories: MemoryItem[] = [];
1464
+ let continuationToken: string | undefined;
1465
+ let scannedObjects = 0;
1466
+ let skippedObjects = 0;
1384
1467
 
1385
- for (const object of listed.contents ?? []) {
1468
+ do {
1386
1469
  signal?.throwIfAborted();
1387
- try {
1388
- const value = memoryItem.parse(await bucket.file(object.key).json());
1389
- if (matchesMemory(value, input.query)) {
1390
- memories.push(value);
1470
+ const listed = await bucket.list({
1471
+ prefix: memoryPrefix(ctx, options) + "/",
1472
+ maxKeys: 200,
1473
+ continuationToken,
1474
+ });
1475
+
1476
+ const objects = (listed.contents ?? []).slice(
1477
+ 0,
1478
+ memoryLimits.searchObjectMaximum - scannedObjects,
1479
+ );
1480
+ scannedObjects += objects.length;
1481
+ for (const object of objects) {
1482
+ signal?.throwIfAborted();
1483
+ try {
1484
+ const value = memoryItem.parse(await bucket.file(object.key).json());
1485
+ if (matchesMemory(value, input.query)) {
1486
+ memories.push(value);
1487
+ }
1488
+ } catch {
1489
+ // Exclude malformed or concurrently deleted objects and report the count.
1490
+ skippedObjects += 1;
1391
1491
  }
1392
- } catch {
1393
- // Ignore malformed or concurrently deleted memory objects.
1394
1492
  }
1395
- }
1396
1493
 
1397
- const limit = Math.min(Math.max(Math.trunc(input.limit ?? 5), 1), 20);
1494
+ continuationToken = listed.isTruncated ? listed.nextContinuationToken : undefined;
1495
+ } while (continuationToken && scannedObjects < memoryLimits.searchObjectMaximum);
1496
+
1497
+ const limit = Math.min(
1498
+ Math.max(Math.trunc(input.limit ?? memoryLimits.resultDefault), memoryLimits.resultMinimum),
1499
+ memoryLimits.resultMaximum,
1500
+ );
1398
1501
  return {
1399
1502
  memories: memories
1400
1503
  .sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""))
1401
1504
  .slice(0, limit),
1505
+ skippedObjects,
1402
1506
  };
1403
1507
  }
1404
1508
 
@@ -1406,14 +1510,53 @@ async function storeMemory(
1406
1510
  input: MemoryStoreInput,
1407
1511
  ctx: TaskContext,
1408
1512
  options: R2MemoryOptions,
1513
+ sourceKind: "maintainer-command" | "agent-tool",
1409
1514
  signal?: AbortSignal,
1410
- ): Promise<{ stored: boolean; key: string }> {
1515
+ ): Promise<{ stored: boolean; key: string; id: string }> {
1411
1516
  signal?.throwIfAborted();
1412
1517
  const bucket = r2Bucket(ctx, options);
1413
- const entry: MemoryItem = { ...input, updatedAt: new Date().toISOString() };
1414
- const key = memoryKey(input.subject, ctx, options);
1518
+ const parsedInput = memoryStoreInput.parse(input);
1519
+ const curatedKey =
1520
+ sourceKind === "maintainer-command"
1521
+ ? memoryPrefix(ctx, options) +
1522
+ "/maintainer-command/" +
1523
+ encodeURIComponent(ctx.run.id) +
1524
+ ".json"
1525
+ : undefined;
1526
+
1527
+ const id = curatedKey ? await stableCommandMemoryId(ctx.run.id) : crypto.randomUUID();
1528
+ const entry = memoryItem.parse({
1529
+ ...parsedInput,
1530
+ id,
1531
+ source: {
1532
+ kind: sourceKind,
1533
+ runId: ctx.run.id,
1534
+ platform: ctx.platform.id,
1535
+ changeRequestNumber: ctx.change.number,
1536
+ headSha: ctx.change.head.sha,
1537
+ },
1538
+ updatedAt: new Date().toISOString(),
1539
+ });
1540
+ const key = curatedKey ?? memoryKey(id, parsedInput.subject, ctx, options);
1415
1541
  await bucket.write(key, JSON.stringify(entry, null, 2), { type: "application/json" });
1416
- return { stored: true, key };
1542
+ return { stored: true, key, id };
1543
+ }
1544
+
1545
+ async function stableCommandMemoryId(runId: string): Promise<string> {
1546
+ const digest = new Uint8Array(
1547
+ await crypto.subtle.digest(
1548
+ "SHA-256",
1549
+ new TextEncoder().encode("pipr-memory/maintainer-command/" + runId),
1550
+ ),
1551
+ );
1552
+ digest[6] = (digest[6]! & 0x0f) | 0x50;
1553
+ digest[8] = (digest[8]! & 0x3f) | 0x80;
1554
+ const hex = Array.from(digest.slice(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join(
1555
+ "",
1556
+ );
1557
+ return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join(
1558
+ "-",
1559
+ );
1417
1560
  }
1418
1561
 
1419
1562
  function r2Bucket(ctx: TaskContext, options: R2MemoryOptions): S3Client {
@@ -1444,14 +1587,26 @@ function cleanPathSegment(value: string): string {
1444
1587
  );
1445
1588
  }
1446
1589
 
1447
- function memoryKey(subject: string, ctx: TaskContext, options: R2MemoryOptions): string {
1590
+ function memoryKey(
1591
+ id: string,
1592
+ subject: string,
1593
+ ctx: TaskContext,
1594
+ options: R2MemoryOptions,
1595
+ ): string {
1448
1596
  const slug = subject
1449
1597
  .toLowerCase()
1450
1598
  .replace(/[^a-z0-9]+/g, "-")
1451
1599
  .replace(/^-|-$/g, "")
1452
1600
  .slice(0, 60);
1453
1601
  return (
1454
- memoryPrefix(ctx, options) + "/" + new Date().toISOString() + "-" + (slug || "memory") + ".json"
1602
+ memoryPrefix(ctx, options) +
1603
+ "/" +
1604
+ new Date().toISOString() +
1605
+ "-" +
1606
+ id +
1607
+ "-" +
1608
+ (slug || "memory") +
1609
+ ".json"
1455
1610
  );
1456
1611
  }
1457
1612
 
@@ -1488,7 +1643,9 @@ function matchesMemory(item: MemoryItem, query: string): boolean {
1488
1643
 
1489
1644
  This recipe uses Bun's S3-compatible client against Cloudflare R2. R2 credentials are declared with \`pipr.secret(...)\`, then resolved inside tool execution with \`ctx.secret(...)\`. The generated GitHub workflow maps \`PIPR_R2_MEMORY_BUCKET\`, \`PIPR_R2_MEMORY_ENDPOINT\`, \`PIPR_R2_MEMORY_ACCESS_KEY_ID\`, and \`PIPR_R2_MEMORY_SECRET_ACCESS_KEY\` repository secrets into matching runtime environment variables.
1490
1645
 
1491
- R2 is object storage, not a search index. The sample lists recent JSON memory objects under \`prefix/repository-owner/repository-name\` and filters them locally, which is enough for small reviewer-memory sets. Change \`prefix\` in \`.pipr/config.ts\` when multiple repositories share one bucket; Pipr still adds the repository scope below it. The generated reviewer treats memory as untrusted historical context and requires current repository evidence for findings. It only searches memory by default. The store tool is available for explicit customization, but full review summaries are not persisted automatically.
1646
+ R2 is object storage, not a search index. The sample paginates up to 2,000 JSON memory objects under \`prefix/repository-owner/repository-name\` and filters them locally, which keeps each search bounded and is enough for small reviewer-memory sets. Change \`prefix\` in \`.pipr/config.ts\` when multiple repositories share one bucket; Pipr still adds the repository scope below it. The generated defaults cap subjects at 120 characters, bodies at 4,000 characters, tags at 12 entries of 50 characters, queries at 500 characters, and results at 20. Existing entries without ids or provenance remain readable when they satisfy those bounds. Search results include \`skippedObjects\`, the number of malformed, unavailable, or over-limit objects excluded during that search, so upgrades do not hide incompatible entries without a diagnostic.
1647
+
1648
+ The generated reviewer treats memory as untrusted historical context and requires current repository evidence for findings. It only searches memory by default. A repository user with write permission can store one bounded, provenance-bearing lesson with \`@pipr remember <lesson...>\`; re-delivery of the same command run deterministically reuses its stored object and UUID. Full review summaries and human feedback are not persisted automatically; feedback collection, eval generation, scheduling, and proposal pull requests remain user-owned extensions.
1492
1649
 
1493
1650
  `
1494
1651
  };
@@ -1506,7 +1663,7 @@ export default definePipr((pipr) => {
1506
1663
  provider: "deepseek",
1507
1664
  model: "deepseek-v4-pro",
1508
1665
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1509
- options: { thinking: "medium" },
1666
+ thinking: "medium",
1510
1667
  });
1511
1668
 
1512
1669
  pipr.config({ publication: { maxInlineComments: 0 } });
@@ -1693,6 +1850,7 @@ function markdownFenceFor(value: string): string {
1693
1850
  //#region src/config/recipes/pr-hygiene.ts
1694
1851
  const prHygieneRecipe = {
1695
1852
  id: "pr-hygiene",
1853
+ requiresChecksPermission: true,
1696
1854
  title: "PR Hygiene",
1697
1855
  description: "Change request hygiene checks for tests, docs, lockfiles, and size.",
1698
1856
  sourceTools: ["Danger JS"],
@@ -1704,7 +1862,7 @@ export default definePipr((pipr) => {
1704
1862
  provider: "deepseek",
1705
1863
  model: "deepseek-v4-pro",
1706
1864
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1707
- options: { thinking: "medium" },
1865
+ thinking: "medium",
1708
1866
  });
1709
1867
 
1710
1868
  pipr.config({ publication: { maxInlineComments: 5 } });
@@ -1852,6 +2010,7 @@ function policyTable(checks: PolicyCheck[]): string {
1852
2010
  //#region src/config/recipes/quality-gate.ts
1853
2011
  const qualityGateRecipe = {
1854
2012
  id: "quality-gate",
2013
+ requiresChecksPermission: true,
1855
2014
  title: "Quality Gate",
1856
2015
  description: "Required review check that fails on blocking correctness and test risks.",
1857
2016
  sourceTools: ["SonarQube", "Snyk"],
@@ -1863,7 +2022,7 @@ export default definePipr((pipr) => {
1863
2022
  provider: "deepseek",
1864
2023
  model: "deepseek-v4-pro",
1865
2024
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1866
- options: { thinking: "high" },
2025
+ thinking: "high",
1867
2026
  });
1868
2027
 
1869
2028
  pipr.config({
@@ -2144,7 +2303,11 @@ const structuredReviewRecipe = {
2144
2303
  "Greptile"
2145
2304
  ],
2146
2305
  configTs: `import { definePipr, z } from "@usepipr/sdk";
2147
- import type { ReviewFinding } from "@usepipr/sdk";
2306
+ import type {
2307
+ DefaultReviewSummaryManifest,
2308
+ DiffManifest,
2309
+ ReviewFinding,
2310
+ } from "@usepipr/sdk";
2148
2311
 
2149
2312
  type ReviewSummary = {
2150
2313
  headline: string;
@@ -2155,7 +2318,7 @@ type ReviewSummary = {
2155
2318
  };
2156
2319
 
2157
2320
  const categorizedFindingSchema = z.strictObject({
2158
- title: z.string().regex(/^[^\\r\\n]+$/),
2321
+ title: z.string().min(1).max(160),
2159
2322
  severity: z.enum(["critical", "high", "medium", "low"]),
2160
2323
  category: z.enum([
2161
2324
  "correctness",
@@ -2166,24 +2329,121 @@ const categorizedFindingSchema = z.strictObject({
2166
2329
  "maintainability",
2167
2330
  "documentation",
2168
2331
  ]),
2169
- rationale: z.string(),
2170
- body: z.string(),
2171
- path: z.string(),
2172
- rangeId: z.string(),
2332
+ rationale: z.string().min(1).max(1200),
2333
+ body: z.string().min(1).max(700),
2334
+ path: z.string().min(1),
2335
+ rangeId: z.string().min(1),
2173
2336
  side: z.enum(["RIGHT", "LEFT"]),
2174
2337
  startLine: z.number().int().positive(),
2175
2338
  endLine: z.number().int().positive(),
2176
- suggestedFix: z.string().optional(),
2339
+ suggestedFix: z.string().min(1).optional(),
2177
2340
  });
2341
+ type CategorizedFinding = ReviewFinding & {
2342
+ title: string;
2343
+ severity: "critical" | "high" | "medium" | "low";
2344
+ category:
2345
+ | "correctness"
2346
+ | "security"
2347
+ | "reliability"
2348
+ | "performance"
2349
+ | "test-coverage"
2350
+ | "maintainability"
2351
+ | "documentation";
2352
+ rationale: string;
2353
+ };
2178
2354
 
2179
2355
  const reviewSummarySchema = z.strictObject({
2180
- headline: z.string(),
2181
- changeSummary: z.array(z.string()).min(1).max(4),
2356
+ headline: z.string().min(1).max(160),
2357
+ changeSummary: z.array(z.string().min(1).max(500)).min(1).max(4),
2182
2358
  riskLevel: z.enum(["low", "medium", "high"]),
2183
- riskSummary: z.string(),
2184
- reviewerFocus: z.array(z.string()).max(4),
2359
+ riskSummary: z.string().min(1).max(500),
2360
+ reviewerFocus: z.array(z.string().min(1).max(500)).max(4),
2185
2361
  });
2186
2362
 
2363
+ const severityRank = { critical: 0, high: 1, medium: 2, low: 3 } as const;
2364
+
2365
+ function hasCommentableAnchor(
2366
+ finding: ReviewFinding,
2367
+ manifest: DiffManifest,
2368
+ ): boolean {
2369
+ const range = manifest.files
2370
+ .find((file) => file.path === finding.path)
2371
+ ?.commentableRanges.find(
2372
+ (candidate) =>
2373
+ candidate.id === finding.rangeId &&
2374
+ candidate.path === finding.path &&
2375
+ candidate.side === finding.side,
2376
+ );
2377
+ return Boolean(
2378
+ range &&
2379
+ finding.startLine <= finding.endLine &&
2380
+ finding.startLine >= range.startLine &&
2381
+ finding.endLine <= range.endLine,
2382
+ );
2383
+ }
2384
+
2385
+ function deduplicateFindings(findings: CategorizedFinding[]): CategorizedFinding[] {
2386
+ const seen = new Set<string>();
2387
+ return findings.filter((finding) => {
2388
+ const key = JSON.stringify([
2389
+ finding.path,
2390
+ finding.rangeId,
2391
+ finding.side,
2392
+ finding.startLine,
2393
+ finding.endLine,
2394
+ finding.body,
2395
+ ]);
2396
+ if (seen.has(key)) {
2397
+ return false;
2398
+ }
2399
+ seen.add(key);
2400
+ return true;
2401
+ });
2402
+ }
2403
+
2404
+ function summaryManifest(manifest: DiffManifest): DefaultReviewSummaryManifest {
2405
+ const files: DefaultReviewSummaryManifest["files"][number][] = [];
2406
+ let serializedCharacters = 0;
2407
+
2408
+ for (const file of manifest.files) {
2409
+ const projected = {
2410
+ path: file.path.slice(0, 1_000),
2411
+ ...(file.previousPath
2412
+ ? { previousPath: file.previousPath.slice(0, 1_000) }
2413
+ : {}),
2414
+ status: file.status,
2415
+ ...(file.language ? { language: file.language.slice(0, 100) } : {}),
2416
+ additions: file.additions,
2417
+ deletions: file.deletions,
2418
+ ...(file.changedSymbols?.length
2419
+ ? {
2420
+ changedSymbols: file.changedSymbols
2421
+ .slice(0, 20)
2422
+ .map((symbol) => symbol.slice(0, 200)),
2423
+ }
2424
+ : {}),
2425
+ ...(file.excludedReason
2426
+ ? { excludedReason: file.excludedReason.slice(0, 500) }
2427
+ : {}),
2428
+ };
2429
+ const projectedCharacters = JSON.stringify(projected).length;
2430
+ if (serializedCharacters + projectedCharacters > 40_000) {
2431
+ continue;
2432
+ }
2433
+ files.push(projected);
2434
+ serializedCharacters += projectedCharacters;
2435
+ }
2436
+
2437
+ return {
2438
+ baseSha: manifest.baseSha,
2439
+ headSha: manifest.headSha,
2440
+ mergeBaseSha: manifest.mergeBaseSha,
2441
+ fileCount: manifest.files.length,
2442
+ omittedFileCount: manifest.files.length - files.length,
2443
+ files,
2444
+ };
2445
+ }
2446
+
2187
2447
  function escapeInlineCommentHtml(value: string): string {
2188
2448
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
2189
2449
  }
@@ -2193,21 +2453,33 @@ export default definePipr((pipr) => {
2193
2453
  provider: "deepseek",
2194
2454
  model: "deepseek-v4-pro",
2195
2455
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
2196
- options: { thinking: "high" },
2456
+ thinking: "high",
2197
2457
  });
2198
2458
 
2199
- pipr.config({ publication: { maxInlineComments: 8 } });
2459
+ pipr.config({
2460
+ publication: {
2461
+ maxInlineComments: 8,
2462
+ autoResolve: {
2463
+ enabled: true,
2464
+ synchronize: true,
2465
+ userReplies: {
2466
+ enabled: true,
2467
+ respondWhenStillValid: true,
2468
+ allowedActors: "write",
2469
+ },
2470
+ },
2471
+ },
2472
+ });
2200
2473
 
2201
- const reviewOutput = pipr.schema({
2474
+ const findingsOutput = pipr.schema({
2202
2475
  id: "review/categorized-findings",
2203
2476
  schema: z.strictObject({
2204
- summary: reviewSummarySchema,
2205
- findings: z.array(categorizedFindingSchema),
2477
+ inlineFindings: z.array(categorizedFindingSchema),
2206
2478
  }),
2207
2479
  });
2208
2480
 
2209
- const reviewer = pipr.agent({
2210
- name: "reviewer",
2481
+ const findingsReviewer = pipr.agent({
2482
+ name: "findings-reviewer",
2211
2483
  model,
2212
2484
  instructions: \`
2213
2485
  Review the change request diff for correctness, security, reliability,
@@ -2217,30 +2489,67 @@ export default definePipr((pipr) => {
2217
2489
  concrete non-blocking defects; and low for small but actionable issues. Each
2218
2490
  rationale must connect repository evidence to the defect and its concrete
2219
2491
  impact. Keep each finding title to one line. Put supporting evidence and
2220
- reasoning in rationale instead of appending it to the body.
2221
-
2222
- Make summary maintainer-facing and scannable: one concrete headline, one
2223
- to four behavior-focused change bullets, a risk level with rationale, and
2224
- reviewer focus only for useful human follow-up.
2492
+ reasoning in rationale instead of appending it to the body. Return no more
2493
+ than 20 findings for each diff shard.
2494
+ Never copy secret-looking literals into title, body, rationale, or
2495
+ suggestedFix. Describe only the secret kind and location.
2225
2496
  \`,
2226
- output: reviewOutput,
2497
+ output: findingsOutput,
2227
2498
  tools: pipr.tools.readOnly,
2228
2499
  retry: { invalidOutput: 1, transientFailure: 1 },
2229
2500
  timeout: "10m",
2230
2501
  prompt: () => "Review this change with severity and category metadata.",
2231
2502
  });
2232
2503
 
2504
+ const summaryOutput = pipr.schema({
2505
+ id: "review/rich-summary",
2506
+ schema: reviewSummarySchema,
2507
+ });
2508
+
2509
+ const summaryReviewer = pipr.agent({
2510
+ name: "summary-reviewer",
2511
+ model,
2512
+ instructions: \`
2513
+ Make the summary maintainer-facing and scannable: one concrete headline,
2514
+ one to four behavior-focused change bullets, a risk level with rationale,
2515
+ and reviewer focus only for useful human follow-up. Use the selected
2516
+ findings as evidence, but do not invent additional defects or copy
2517
+ secret-looking literals into any summary field.
2518
+ \`,
2519
+ output: summaryOutput,
2520
+ tools: pipr.tools.readOnly,
2521
+ retry: { invalidOutput: 1, transientFailure: 1 },
2522
+ timeout: "10m",
2523
+ prompt: ({ inlineFindings, manifestSummary }) =>
2524
+ pipr.prompt\`
2525
+ Summarize this change using the selected findings.
2526
+
2527
+ \${pipr.section("Scoped compressed manifest", pipr.json(manifestSummary, { maxCharacters: 60_000 }))}
2528
+
2529
+ \${pipr.section("Selected findings", pipr.json(inlineFindings, { maxCharacters: 60_000 }))}
2530
+ \`,
2531
+ });
2532
+
2233
2533
  const task = pipr.task({
2234
2534
  name: "review",
2235
2535
  async run(ctx) {
2236
2536
  const manifest = await ctx.change.diffManifest({ compressed: true });
2237
- const result = await ctx.pi.run(reviewer, { manifest });
2238
- const inlineFindings: ReviewFinding[] = result.findings.map((finding) => {
2537
+ const result = await ctx.pi.run(findingsReviewer, { manifest });
2538
+ const selectedFindings = deduplicateFindings(
2539
+ result.inlineFindings.filter((finding) => hasCommentableAnchor(finding, manifest)),
2540
+ )
2541
+ .sort((left, right) => severityRank[left.severity] - severityRank[right.severity])
2542
+ .slice(0, 8);
2543
+ const summary = await ctx.pi.run(summaryReviewer, {
2544
+ manifestSummary: summaryManifest(manifest),
2545
+ inlineFindings: selectedFindings,
2546
+ });
2547
+ const inlineFindings: ReviewFinding[] = selectedFindings.map((finding) => {
2239
2548
  const severity = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2240
2549
  const category = finding.category.replaceAll("-", " ");
2241
2550
  return {
2242
2551
  body: [
2243
- \`**\${severity} \${category}:** \${escapeInlineCommentHtml(finding.title)}\`,
2552
+ \`**\${severity} \${category}:** \${escapeInlineCommentHtml(lineText(finding.title))}\`,
2244
2553
  "",
2245
2554
  escapeInlineCommentHtml(finding.body),
2246
2555
  "",
@@ -2263,17 +2572,17 @@ export default definePipr((pipr) => {
2263
2572
  main: [
2264
2573
  "## Summary",
2265
2574
  "",
2266
- \`**\${result.summary.headline}**\`,
2575
+ \`**\${lineText(summary.headline)}**\`,
2267
2576
  "",
2268
- summaryTable(result.summary),
2577
+ summaryTable(summary),
2269
2578
  "",
2270
2579
  "## What Changed",
2271
2580
  "",
2272
- bulletList(result.summary.changeSummary, "No changed behavior summarized."),
2581
+ bulletList(summary.changeSummary, "No changed behavior summarized."),
2273
2582
  "",
2274
2583
  "## Reviewer Focus",
2275
2584
  "",
2276
- bulletList(result.summary.reviewerFocus, "No special reviewer focus."),
2585
+ bulletList(summary.reviewerFocus, "No special reviewer focus."),
2277
2586
  "",
2278
2587
  ].join("\\n"),
2279
2588
  inlineFindings,
@@ -2307,7 +2616,7 @@ function labelValue(value: string): string {
2307
2616
  }
2308
2617
 
2309
2618
  function lineText(value: string): string {
2310
- return value.replaceAll("\\n", " ").trim();
2619
+ return value.replace(/\\r\\n?|\\n/g, " ").trim();
2311
2620
  }
2312
2621
 
2313
2622
  function tableCell(value: string): string {
@@ -2319,6 +2628,7 @@ function tableCell(value: string): string {
2319
2628
  //#region src/config/recipes/security-sast.ts
2320
2629
  const securitySastRecipe = {
2321
2630
  id: "security-sast",
2631
+ requiresChecksPermission: true,
2322
2632
  title: "Security SAST",
2323
2633
  description: "Security review with custom severity and category output.",
2324
2634
  sourceTools: [
@@ -2354,7 +2664,7 @@ export default definePipr((pipr) => {
2354
2664
  provider: "deepseek",
2355
2665
  model: "deepseek-v4-pro",
2356
2666
  apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
2357
- options: { thinking: "high" },
2667
+ thinking: "high",
2358
2668
  });
2359
2669
 
2360
2670
  const securityOutput = pipr.jsonSchema<SecurityReview>({
@@ -2693,6 +3003,9 @@ function officialInitRecipeFiles(recipe) {
2693
3003
  function officialInitRecipeWorkflowEnvSecrets(recipe) {
2694
3004
  return resolveOfficialInitRecipe(recipe).workflowEnvSecrets ?? [];
2695
3005
  }
3006
+ function officialInitRecipeRequiresChecksPermission(recipe) {
3007
+ return resolveOfficialInitRecipe(recipe).requiresChecksPermission ?? false;
3008
+ }
2696
3009
  function resolveOfficialInitRecipe(recipe) {
2697
3010
  const id = recipe ?? "default-review";
2698
3011
  if (!isOfficialInitRecipeId(id)) throw new Error(`Unsupported pipr init recipe '${id}'. Supported recipes: ${supportedOfficialInitRecipes.join(", ")}.`);
@@ -2703,7 +3016,7 @@ function isOfficialInitRecipeId(recipe) {
2703
3016
  }
2704
3017
  //#endregion
2705
3018
  //#region src/config/official-github-workflow.ts
2706
- const defaultWorkflowActionRef = "somus/pipr@v0.4.3";
3019
+ const defaultWorkflowActionRef = "somus/pipr@v0.6.0";
2707
3020
  /** Internal shared renderer for `pipr init` and generated recipe docs. */
2708
3021
  function renderOfficialGithubWorkflow(options = {}) {
2709
3022
  const relativeConfigDir = options.relativeConfigDir ?? ".pipr";
@@ -2712,25 +3025,19 @@ function renderOfficialGithubWorkflow(options = {}) {
2712
3025
  "",
2713
3026
  "on:",
2714
3027
  " pull_request:",
3028
+ " types: [opened, synchronize, reopened, ready_for_review]",
2715
3029
  " issue_comment:",
2716
3030
  " types: [created]",
2717
3031
  " pull_request_review_comment:",
2718
3032
  " types: [created]",
2719
3033
  "",
2720
3034
  "permissions:",
2721
- " contents: write",
3035
+ " contents: read",
2722
3036
  " pull-requests: write",
2723
- " issues: write",
2724
- " checks: write",
2725
- "",
2726
- "jobs:",
2727
- " review:",
2728
- " runs-on: ubuntu-latest",
2729
- " steps:",
2730
- " - uses: actions/checkout@v6",
2731
- " with:",
2732
- " fetch-depth: 0"
3037
+ " issues: write"
2733
3038
  ];
3039
+ if (officialInitRecipeRequiresChecksPermission(options.recipe)) lines.push(" checks: write");
3040
+ lines.push("", "jobs:", " review:", " runs-on: ubuntu-latest", " steps:", " - uses: actions/checkout@v6", " with:", " fetch-depth: 0");
2734
3041
  if (!options.minimal) lines.push(" - uses: actions/cache@v4", " with:", " path: /home/runner/work/_temp/_github_home/.bun/install/cache", ` key: pipr-bun-${githubExpression(`hashFiles('${relativeConfigDir}/bun.lock')`)}`);
2735
3042
  lines.push(` - uses: ${defaultWorkflowActionRef}${options.includeReleasePleaseVersionMarker ? " # x-release-please-version" : ""}`, " env:", ` DEEPSEEK_API_KEY: ${githubExpression("secrets.DEEPSEEK_API_KEY")}`, ` GITHUB_TOKEN: ${githubExpression("github.token")}`);
2736
3043
  for (const secret of officialInitRecipeWorkflowEnvSecrets(options.recipe)) lines.push(` ${secret.env}: ${githubExpression(`secrets.${secret.secret}`)}`);
@@ -2748,4 +3055,4 @@ function githubExpression(expression) {
2748
3055
  //#endregion
2749
3056
  export { officialInitRecipeWorkflowEnvSecrets as a, officialInitRecipeFiles as i, listOfficialInitRecipes as n, supportedOfficialInitRecipes as o, officialInitRecipeConfigTs as r, renderOfficialGithubWorkflow as t };
2750
3057
 
2751
- //# sourceMappingURL=official-github-workflow-Bjlu-FL3.mjs.map
3058
+ //# sourceMappingURL=official-github-workflow-B3yeaCgq.mjs.map