portable-agent-layer 0.6.1 → 0.6.2
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/package.json +2 -3
- package/src/hooks/handlers/failure.ts +20 -83
- package/src/hooks/handlers/rating.ts +1 -1
- package/src/hooks/handlers/work-learning.ts +4 -9
- package/src/hooks/lib/context.ts +24 -150
- package/src/hooks/lib/graduation.ts +199 -336
- package/src/hooks/lib/learning-store.ts +265 -0
- package/src/hooks/lib/stop.ts +1 -6
- package/src/tools/analyze.ts +118 -0
- package/src/hooks/handlers/synthesis.ts +0 -109
- package/src/tools/graduate.ts +0 -79
- package/src/tools/pattern-synthesis.ts +0 -432
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -49,8 +49,7 @@
|
|
|
49
49
|
"ai:pdf-download": "bun run src/tools/pdf-download.ts",
|
|
50
50
|
"ai:youtube-analyze": "bun run src/tools/youtube-analyze.ts",
|
|
51
51
|
"tool:eval": "bun run src/tools/eval-principles.ts",
|
|
52
|
-
"tool:
|
|
53
|
-
"tool:patterns": "bun run src/tools/pattern-synthesis.ts",
|
|
52
|
+
"tool:analyze": "bun run src/tools/analyze.ts",
|
|
54
53
|
"tool:reflect": "bun run src/tools/relationship-reflect.ts",
|
|
55
54
|
"tool:export": "bun run src/tools/export.ts",
|
|
56
55
|
"tool:import": "bun run src/tools/import.ts",
|
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deep Failure Capture — full context dump for ratings 1–3.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* capture.md
|
|
6
|
-
*
|
|
4
|
+
* Stores raw conversation data for later review, matching the original PAI pattern:
|
|
5
|
+
* capture.md — frontmatter metadata + conversation summary
|
|
6
|
+
*
|
|
7
|
+
* Analysis is left to the human or the graduation pipeline, not auto-generated.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { writeFileSync } from "node:fs";
|
|
10
11
|
import { resolve } from "node:path";
|
|
11
12
|
import { stringify } from "../lib/frontmatter";
|
|
12
|
-
import { inference } from "../lib/inference";
|
|
13
13
|
import { ensureDir, paths } from "../lib/paths";
|
|
14
|
-
import { FAILURE_PRINCIPLE_PROMPT } from "../lib/prompts";
|
|
15
14
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
extractContent,
|
|
19
|
-
extractLastAssistant,
|
|
20
|
-
extractLastUser,
|
|
21
|
-
parseMessages,
|
|
22
|
-
} from "../lib/transcript";
|
|
15
|
+
import { extractContent, parseMessages } from "../lib/transcript";
|
|
23
16
|
|
|
24
17
|
function slugify(text: string): string {
|
|
25
18
|
return (
|
|
@@ -37,68 +30,24 @@ export async function captureFailure(
|
|
|
37
30
|
rating: number,
|
|
38
31
|
context: string,
|
|
39
32
|
transcript: string,
|
|
40
|
-
detailedContext?: string
|
|
41
|
-
savedResponse?: string,
|
|
42
|
-
savedUserMessage?: string
|
|
33
|
+
detailedContext?: string
|
|
43
34
|
): Promise<void> {
|
|
44
35
|
const messages = parseMessages(transcript);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
|
|
37
|
+
// Conversation summary — last 10 exchanges, like the original PAI
|
|
38
|
+
const recentMessages = messages.slice(-10);
|
|
39
|
+
const conversationSummary = recentMessages
|
|
40
|
+
.map((m) => {
|
|
41
|
+
const text = extractContent(m).slice(0, 500);
|
|
42
|
+
return `**${m.role.toUpperCase()}:** ${text}`;
|
|
43
|
+
})
|
|
44
|
+
.join("\n\n");
|
|
52
45
|
|
|
53
46
|
const slug = slugify(context);
|
|
54
47
|
const dir = ensureDir(
|
|
55
48
|
resolve(paths.failures(), monthPath(), `${fileTimestamp()}_${slug}`)
|
|
56
49
|
);
|
|
57
50
|
|
|
58
|
-
// Attempt inference to fill root cause analysis + candidate principle
|
|
59
|
-
let whatWentWrong = "";
|
|
60
|
-
let whatToDoDifferently = "";
|
|
61
|
-
let principle = "";
|
|
62
|
-
try {
|
|
63
|
-
const analysisResult = await inference({
|
|
64
|
-
system: `You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable. Also write a principle — ${FAILURE_PRINCIPLE_PROMPT}`,
|
|
65
|
-
user: [
|
|
66
|
-
`Rating: ${rating}/10`,
|
|
67
|
-
`Context: ${context}`,
|
|
68
|
-
detailedContext ? `Analysis: ${detailedContext}` : "",
|
|
69
|
-
`Assistant response (what the user reacted to): ${lastAssistant}`,
|
|
70
|
-
`User reaction (the frustrated message): ${lastUser}`,
|
|
71
|
-
]
|
|
72
|
-
.filter(Boolean)
|
|
73
|
-
.join("\n"),
|
|
74
|
-
maxTokens: 400,
|
|
75
|
-
timeout: 15000,
|
|
76
|
-
jsonSchema: {
|
|
77
|
-
type: "object" as const,
|
|
78
|
-
additionalProperties: false,
|
|
79
|
-
properties: {
|
|
80
|
-
what_went_wrong: { type: "string" as const },
|
|
81
|
-
what_to_do_differently: { type: "string" as const },
|
|
82
|
-
principle: { type: "string" as const },
|
|
83
|
-
},
|
|
84
|
-
required: ["what_went_wrong", "what_to_do_differently", "principle"],
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
if (analysisResult.usage) logTokenUsage("failure", analysisResult.usage);
|
|
88
|
-
if (analysisResult.success && analysisResult.output) {
|
|
89
|
-
const parsed = JSON.parse(analysisResult.output) as {
|
|
90
|
-
what_went_wrong?: string;
|
|
91
|
-
what_to_do_differently?: string;
|
|
92
|
-
principle?: string;
|
|
93
|
-
};
|
|
94
|
-
whatWentWrong = parsed.what_went_wrong ?? "";
|
|
95
|
-
whatToDoDifferently = parsed.what_to_do_differently ?? "";
|
|
96
|
-
principle = parsed.principle ?? "";
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Graceful fallback — empty sections are still useful with the other context
|
|
100
|
-
}
|
|
101
|
-
|
|
102
51
|
const meta: Record<string, unknown> = {
|
|
103
52
|
rating,
|
|
104
53
|
context,
|
|
@@ -106,29 +55,17 @@ export async function captureFailure(
|
|
|
106
55
|
ts: new Date().toISOString(),
|
|
107
56
|
slug,
|
|
108
57
|
};
|
|
109
|
-
if (principle) meta.principle = principle;
|
|
110
58
|
|
|
111
59
|
const body = [
|
|
112
|
-
"##
|
|
113
|
-
lastUser || "*(unavailable)*",
|
|
60
|
+
"## What Happened",
|
|
114
61
|
"",
|
|
115
|
-
|
|
116
|
-
|
|
62
|
+
detailedContext ||
|
|
63
|
+
"No detailed analysis available. Review the conversation for context.",
|
|
117
64
|
"",
|
|
118
|
-
|
|
119
|
-
"## What Went Wrong?",
|
|
120
|
-
whatWentWrong || "",
|
|
65
|
+
"## Conversation Summary",
|
|
121
66
|
"",
|
|
122
|
-
|
|
123
|
-
whatToDoDifferently || "",
|
|
67
|
+
conversationSummary || "*(unavailable)*",
|
|
124
68
|
].join("\n");
|
|
125
69
|
|
|
126
70
|
writeFileSync(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
|
|
127
|
-
|
|
128
|
-
// DEPRECATED: legacy sentiment.json — remove once all readers use capture.md frontmatter
|
|
129
|
-
writeFileSync(
|
|
130
|
-
resolve(dir, "sentiment.json"),
|
|
131
|
-
JSON.stringify({ rating, context, ts: new Date().toISOString(), slug }, null, 2),
|
|
132
|
-
"utf-8"
|
|
133
|
-
);
|
|
134
71
|
}
|
|
@@ -365,7 +365,7 @@ async function handleImplicitSentiment(
|
|
|
365
365
|
if (typeof rating === "number" && rating >= 1 && rating <= 10 && rating !== 5) {
|
|
366
366
|
handleRating(
|
|
367
367
|
rating,
|
|
368
|
-
`${parsed.summary}: ${trimmed.slice(0,
|
|
368
|
+
`${parsed.summary}: ${trimmed.slice(0, 200)}`,
|
|
369
369
|
"implicit",
|
|
370
370
|
parsed.detailed_context,
|
|
371
371
|
sessionId,
|
|
@@ -11,7 +11,6 @@ import { stringify } from "../lib/frontmatter";
|
|
|
11
11
|
import { inference } from "../lib/inference";
|
|
12
12
|
import { categorizeLearning } from "../lib/learning-category";
|
|
13
13
|
import { ensureDir, paths } from "../lib/paths";
|
|
14
|
-
import { LEARNING_PRINCIPLE_PROMPT } from "../lib/prompts";
|
|
15
14
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
16
15
|
import { logTokenUsage } from "../lib/token-usage";
|
|
17
16
|
import {
|
|
@@ -108,7 +107,6 @@ export async function captureWorkLearning(
|
|
|
108
107
|
let title = rawTitle;
|
|
109
108
|
let summary = rawSummary;
|
|
110
109
|
let insights = "";
|
|
111
|
-
let principle = "";
|
|
112
110
|
try {
|
|
113
111
|
const userMessages = messages
|
|
114
112
|
.filter((m) => m.role === "user")
|
|
@@ -116,9 +114,10 @@ export async function captureWorkLearning(
|
|
|
116
114
|
.slice(-8)
|
|
117
115
|
.join("\n");
|
|
118
116
|
const result = await inference({
|
|
119
|
-
system:
|
|
117
|
+
system:
|
|
118
|
+
"You summarize AI coding sessions between a human user and an AI assistant. The 'Human messages' are what the user said. The 'AI response' is what the assistant said. Produce: 1) a short title (5-10 words) describing what was accomplished, 2) a summary of what the AI assistant did for the user (2-4 sentences, write from the AI's perspective using 'we'), 3) insights — what worked well, what was surprising, or what should be done differently next time (2-3 bullet points, no markdown).",
|
|
120
119
|
user: `Human messages:\n${userMessages}\n\nAI response:\n${rawSummary.slice(0, 400)}`,
|
|
121
|
-
maxTokens:
|
|
120
|
+
maxTokens: 300,
|
|
122
121
|
timeout: 15000,
|
|
123
122
|
jsonSchema: {
|
|
124
123
|
type: "object" as const,
|
|
@@ -127,9 +126,8 @@ export async function captureWorkLearning(
|
|
|
127
126
|
title: { type: "string" as const },
|
|
128
127
|
summary: { type: "string" as const },
|
|
129
128
|
insights: { type: "string" as const },
|
|
130
|
-
principle: { type: "string" as const },
|
|
131
129
|
},
|
|
132
|
-
required: ["title", "summary", "insights"
|
|
130
|
+
required: ["title", "summary", "insights"],
|
|
133
131
|
},
|
|
134
132
|
});
|
|
135
133
|
if (result.usage) logTokenUsage("work-learning", result.usage);
|
|
@@ -138,12 +136,10 @@ export async function captureWorkLearning(
|
|
|
138
136
|
title?: string;
|
|
139
137
|
summary?: string;
|
|
140
138
|
insights?: string;
|
|
141
|
-
principle?: string;
|
|
142
139
|
};
|
|
143
140
|
if (parsed.title) title = parsed.title.slice(0, 100);
|
|
144
141
|
if (parsed.summary) summary = parsed.summary;
|
|
145
142
|
if (parsed.insights) insights = parsed.insights;
|
|
146
|
-
if (parsed.principle) principle = parsed.principle;
|
|
147
143
|
}
|
|
148
144
|
} catch {
|
|
149
145
|
// Fallback to raw values
|
|
@@ -159,7 +155,6 @@ export async function captureWorkLearning(
|
|
|
159
155
|
category,
|
|
160
156
|
date: new Date().toISOString().slice(0, 10),
|
|
161
157
|
};
|
|
162
|
-
if (principle) meta.principle = principle;
|
|
163
158
|
if (sessionId) meta.session = sessionId;
|
|
164
159
|
|
|
165
160
|
const body = [
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
-
import {
|
|
8
|
+
import { parse } from "./frontmatter";
|
|
9
|
+
import { readFailures, readLearnings } from "./learning-store";
|
|
9
10
|
import { paths } from "./paths";
|
|
10
11
|
import { loadRecentNotes } from "./relationship";
|
|
11
12
|
import { readSessionNames } from "./session-names";
|
|
@@ -217,84 +218,22 @@ export function loadWisdomContext(): string {
|
|
|
217
218
|
/** Load recent session learning files as digest, split by category */
|
|
218
219
|
export function loadLearningDigest(): string {
|
|
219
220
|
try {
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
221
|
+
const entries = readLearnings(paths.sessionLearning(), 6);
|
|
222
|
+
if (entries.length === 0) return "";
|
|
222
223
|
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
for (const year of readdirSync(sessionDir).sort().reverse()) {
|
|
226
|
-
const yearDir = resolve(sessionDir, year);
|
|
227
|
-
try {
|
|
228
|
-
for (const month of readdirSync(yearDir).sort().reverse()) {
|
|
229
|
-
const monthDir = resolve(yearDir, month);
|
|
230
|
-
try {
|
|
231
|
-
const monthFiles = readdirSync(monthDir)
|
|
232
|
-
.filter((f) => f.endsWith(".md"))
|
|
233
|
-
.sort()
|
|
234
|
-
.reverse()
|
|
235
|
-
.map((f) => {
|
|
236
|
-
const category = f.includes("_system") ? "system" : "algorithm";
|
|
237
|
-
return { path: resolve(monthDir, f), category };
|
|
238
|
-
});
|
|
239
|
-
files.push(...monthFiles);
|
|
240
|
-
} catch {
|
|
241
|
-
/* skip */
|
|
242
|
-
}
|
|
243
|
-
if (files.length >= 6) break;
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
/* skip */
|
|
247
|
-
}
|
|
248
|
-
if (files.length >= 6) break;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function extractMeta(filePath: string): {
|
|
252
|
-
title: string;
|
|
253
|
-
category: string;
|
|
254
|
-
} {
|
|
255
|
-
const content = readFileSync(filePath, "utf-8").trim();
|
|
256
|
-
|
|
257
|
-
// Frontmatter format (new)
|
|
258
|
-
if (hasFrontmatter(content)) {
|
|
259
|
-
const { meta } = parse<{ title?: string; category?: string }>(content);
|
|
260
|
-
return {
|
|
261
|
-
title: meta.title ? `**Title:** ${meta.title}` : content.slice(0, 80),
|
|
262
|
-
category: meta.category || "algorithm",
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// DEPRECATED: legacy **Title:** inline format — remove once old learning files are migrated
|
|
267
|
-
const titleLine = content.split("\n").find((l) => l.startsWith("**Title:**"));
|
|
268
|
-
const fallback = content.split("\n").find((l) => l.trim() && !l.startsWith("#"));
|
|
269
|
-
return {
|
|
270
|
-
title: titleLine ?? fallback?.slice(0, 100) ?? content.slice(0, 80),
|
|
271
|
-
category: "algorithm", // legacy files use filename for category
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Extract metadata, preferring frontmatter over filename for category
|
|
276
|
-
const enriched = files.map((f) => {
|
|
277
|
-
const meta = extractMeta(f.path);
|
|
278
|
-
return {
|
|
279
|
-
...f,
|
|
280
|
-
title: meta.title,
|
|
281
|
-
category: meta.category !== "algorithm" ? meta.category : f.category,
|
|
282
|
-
};
|
|
283
|
-
});
|
|
224
|
+
const approach = entries.filter((e) => e.category !== "system").slice(0, 2);
|
|
225
|
+
const system = entries.filter((e) => e.category === "system").slice(0, 2);
|
|
284
226
|
|
|
285
|
-
|
|
286
|
-
const system = enriched.filter((f) => f.category === "system").slice(0, 2);
|
|
287
|
-
|
|
288
|
-
if (algorithm.length === 0 && system.length === 0) return "";
|
|
227
|
+
if (approach.length === 0 && system.length === 0) return "";
|
|
289
228
|
|
|
290
229
|
const lines: string[] = ["## Recent Session Learnings"];
|
|
291
|
-
if (
|
|
230
|
+
if (approach.length > 0) {
|
|
292
231
|
lines.push("### Approach");
|
|
293
|
-
for (const
|
|
232
|
+
for (const e of approach) lines.push(`- **Title:** ${e.title}`);
|
|
294
233
|
}
|
|
295
234
|
if (system.length > 0) {
|
|
296
235
|
lines.push("### System");
|
|
297
|
-
for (const
|
|
236
|
+
for (const e of system) lines.push(`- **Title:** ${e.title}`);
|
|
298
237
|
}
|
|
299
238
|
return lines.join("\n");
|
|
300
239
|
} catch {
|
|
@@ -305,68 +244,15 @@ export function loadLearningDigest(): string {
|
|
|
305
244
|
/** Load 5 most recent failure contexts as an "avoid" list */
|
|
306
245
|
export function loadFailurePatterns(): string {
|
|
307
246
|
try {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
// Structure: failures/{year}/{month}/{timestamp}_{slug}/
|
|
312
|
-
const failures: string[] = [];
|
|
313
|
-
for (const year of readdirSync(failuresDir).sort().reverse()) {
|
|
314
|
-
const yearPath = resolve(failuresDir, year);
|
|
315
|
-
for (const month of readdirSync(yearPath).sort().reverse()) {
|
|
316
|
-
const monthPath = resolve(yearPath, month);
|
|
317
|
-
try {
|
|
318
|
-
const dirs = readdirSync(monthPath).sort().reverse();
|
|
319
|
-
for (const dir of dirs) {
|
|
320
|
-
if (!/^\d{8}-\d{6}_/.test(dir)) continue;
|
|
321
|
-
// Try capture.md (new format), fall back to sentiment.json (legacy)
|
|
322
|
-
const capturePath = resolve(monthPath, dir, "capture.md");
|
|
323
|
-
const sentimentPath = resolve(monthPath, dir, "sentiment.json");
|
|
324
|
-
|
|
325
|
-
let rating: number | undefined;
|
|
326
|
-
let ctx: string | undefined;
|
|
327
|
-
|
|
328
|
-
if (existsSync(capturePath)) {
|
|
329
|
-
try {
|
|
330
|
-
const content = readFileSync(capturePath, "utf-8");
|
|
331
|
-
const { meta } = parse<{ rating?: number; context?: string }>(content);
|
|
332
|
-
rating = meta.rating;
|
|
333
|
-
ctx = meta.context;
|
|
334
|
-
} catch {
|
|
335
|
-
/* fallback below */
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// DEPRECATED: legacy sentiment.json fallback — remove once old failures have capture.md
|
|
340
|
-
if (!ctx && existsSync(sentimentPath)) {
|
|
341
|
-
try {
|
|
342
|
-
const data = JSON.parse(readFileSync(sentimentPath, "utf-8"));
|
|
343
|
-
rating = data.rating;
|
|
344
|
-
ctx = data.context;
|
|
345
|
-
} catch {
|
|
346
|
-
/* skip */
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (ctx) {
|
|
351
|
-
const label = rating ? `[${rating}/10]` : "";
|
|
352
|
-
failures.push(`${label} ${ctx}`.trim());
|
|
353
|
-
} else {
|
|
354
|
-
failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
|
|
355
|
-
}
|
|
356
|
-
if (failures.length >= 5) break;
|
|
357
|
-
}
|
|
358
|
-
} catch {
|
|
359
|
-
/* skip */
|
|
360
|
-
}
|
|
361
|
-
if (failures.length >= 5) break;
|
|
362
|
-
}
|
|
363
|
-
if (failures.length >= 5) break;
|
|
364
|
-
}
|
|
247
|
+
const entries = readFailures(paths.failures(), 5);
|
|
248
|
+
if (entries.length === 0) return "";
|
|
365
249
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
);
|
|
250
|
+
const lines = entries.map((e) => {
|
|
251
|
+
const label = e.rating ? `[${e.rating}/10]` : "";
|
|
252
|
+
return `- ${label} ${e.context}`.trim();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return ["## Recent Failure Patterns (Avoid)", ...lines].join("\n");
|
|
370
256
|
} catch {
|
|
371
257
|
return "";
|
|
372
258
|
}
|
|
@@ -403,24 +289,12 @@ export function loadSynthesisRecommendations(): string {
|
|
|
403
289
|
|
|
404
290
|
if (recs.length === 0) continue;
|
|
405
291
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
period?: string;
|
|
413
|
-
average_rating?: string;
|
|
414
|
-
}>(content);
|
|
415
|
-
period = meta.period || "";
|
|
416
|
-
avgRating = meta.average_rating ? `${meta.average_rating}/10` : "";
|
|
417
|
-
} else {
|
|
418
|
-
// DEPRECATED: legacy **Key:** format
|
|
419
|
-
const periodMatch = content.match(/\*\*Period:\*\* (.+)/);
|
|
420
|
-
const avgMatch = content.match(/\*\*Average Rating:\*\* (.+)/);
|
|
421
|
-
period = periodMatch?.[1] || "";
|
|
422
|
-
avgRating = avgMatch?.[1] || "";
|
|
423
|
-
}
|
|
292
|
+
const { meta } = parse<{
|
|
293
|
+
period?: string;
|
|
294
|
+
average_rating?: string;
|
|
295
|
+
}>(content);
|
|
296
|
+
const period = meta.period || "";
|
|
297
|
+
const avgRating = meta.average_rating ? `${meta.average_rating}/10` : "";
|
|
424
298
|
|
|
425
299
|
const header = [
|
|
426
300
|
"## Pattern Synthesis",
|