portable-agent-layer 0.3.0 → 0.5.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.
- package/package.json +3 -1
- package/src/cli/index.ts +59 -2
- package/src/hooks/handlers/failure.ts +42 -44
- package/src/hooks/handlers/rating.ts +12 -18
- package/src/hooks/handlers/work-learning.ts +21 -13
- package/src/hooks/lib/context.ts +82 -24
- package/src/hooks/lib/frontmatter.ts +95 -0
- package/src/hooks/lib/graduation.ts +483 -0
- package/src/hooks/lib/models.ts +4 -4
- package/src/hooks/lib/prompts.ts +11 -0
- package/src/hooks/lib/security.ts +1 -0
- package/src/targets/opencode/plugin.ts +7 -6
- package/src/tools/eval-principles.ts +234 -0
- package/src/tools/graduate.ts +55 -0
- package/src/tools/pattern-synthesis.ts +11 -14
- package/src/tools/token-cost.ts +35 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"ai:fyzz-api": "bun run src/tools/fyzz-api.ts",
|
|
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
|
+
"tool:eval": "bun run src/tools/eval-principles.ts",
|
|
52
|
+
"tool:graduate": "bun run src/tools/graduate.ts",
|
|
51
53
|
"tool:patterns": "bun run src/tools/pattern-synthesis.ts",
|
|
52
54
|
"tool:reflect": "bun run src/tools/relationship-reflect.ts",
|
|
53
55
|
"tool:export": "bun run src/tools/export.ts",
|
package/src/cli/index.ts
CHANGED
|
@@ -252,6 +252,43 @@ function resolveTargets(
|
|
|
252
252
|
return targets;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// ── Hook health ──
|
|
256
|
+
|
|
257
|
+
interface HookHealth {
|
|
258
|
+
totalErrors: number;
|
|
259
|
+
lastError: string | null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function checkHookHealth(home: string): HookHealth {
|
|
263
|
+
const logPath = resolve(home, "memory", "state", "debug.log");
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
if (!existsSync(logPath)) return { totalErrors: 0, lastError: null };
|
|
267
|
+
|
|
268
|
+
const content = readFileSync(logPath, "utf-8");
|
|
269
|
+
const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
|
|
270
|
+
|
|
271
|
+
// Filter to last 24h
|
|
272
|
+
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
273
|
+
const recentErrors = lines.filter((line) => {
|
|
274
|
+
const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
|
|
275
|
+
if (!match) return false;
|
|
276
|
+
return new Date(match[1]) > cutoff;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const lastError =
|
|
280
|
+
recentErrors.length > 0
|
|
281
|
+
? recentErrors[recentErrors.length - 1]
|
|
282
|
+
.replace(/^\[.*?\] ERROR /, "")
|
|
283
|
+
.slice(0, 120)
|
|
284
|
+
: null;
|
|
285
|
+
|
|
286
|
+
return { totalErrors: recentErrors.length, lastError };
|
|
287
|
+
} catch {
|
|
288
|
+
return { totalErrors: 0, lastError: null };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
255
292
|
// ── Doctor ──
|
|
256
293
|
|
|
257
294
|
interface DoctorResult {
|
|
@@ -288,8 +325,9 @@ function doctor(silent = false): DoctorResult {
|
|
|
288
325
|
})();
|
|
289
326
|
|
|
290
327
|
if (!silent) {
|
|
291
|
-
const ok = (msg: string) => log
|
|
292
|
-
const
|
|
328
|
+
const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
|
|
329
|
+
const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
|
|
330
|
+
const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
|
|
293
331
|
|
|
294
332
|
console.log("");
|
|
295
333
|
log.info("Doctor");
|
|
@@ -303,6 +341,25 @@ function doctor(silent = false): DoctorResult {
|
|
|
303
341
|
ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
|
|
304
342
|
telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
|
|
305
343
|
|
|
344
|
+
// API key checks
|
|
345
|
+
process.env.ANTHROPIC_API_KEY
|
|
346
|
+
? ok("ANTHROPIC_API_KEY is set")
|
|
347
|
+
: fail("ANTHROPIC_API_KEY — not set (hooks need it for inference)");
|
|
348
|
+
process.env.GEMINI_API_KEY
|
|
349
|
+
? ok("GEMINI_API_KEY is set")
|
|
350
|
+
: warn("GEMINI_API_KEY — not set (optional, for YouTube analysis)");
|
|
351
|
+
|
|
352
|
+
// Hook health from debug.log
|
|
353
|
+
const hookHealth = checkHookHealth(home);
|
|
354
|
+
if (hookHealth.totalErrors === 0) {
|
|
355
|
+
ok("Hooks: no recent errors");
|
|
356
|
+
} else {
|
|
357
|
+
fail(`Hooks: ${hookHealth.totalErrors} error(s) in last 24h`);
|
|
358
|
+
if (hookHealth.lastError) {
|
|
359
|
+
log.warn(` Last: ${hookHealth.lastError}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
306
363
|
if (!hasAgent) {
|
|
307
364
|
console.log("");
|
|
308
365
|
log.error("No supported agent found. Install Claude Code or opencode.");
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* Deep Failure Capture — full context dump for ratings 1–3.
|
|
3
3
|
*
|
|
4
4
|
* Writes to memory/learning/failures/YYYY-MM/{timestamp}_{slug}/
|
|
5
|
-
*
|
|
6
|
-
* sentiment.json —
|
|
5
|
+
* capture.md — frontmatter metadata + failure context body
|
|
6
|
+
* sentiment.json — DEPRECATED legacy format (kept for backward compat)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { writeFileSync } from "node:fs";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { stringify } from "../lib/frontmatter";
|
|
11
12
|
import { inference } from "../lib/inference";
|
|
12
13
|
import { ensureDir, paths } from "../lib/paths";
|
|
14
|
+
import { FAILURE_PRINCIPLE_PROMPT } from "../lib/prompts";
|
|
13
15
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
14
16
|
import { logTokenUsage } from "../lib/token-usage";
|
|
15
17
|
import {
|
|
@@ -53,32 +55,33 @@ export async function captureFailure(
|
|
|
53
55
|
resolve(paths.failures(), monthPath(), `${fileTimestamp()}_${slug}`)
|
|
54
56
|
);
|
|
55
57
|
|
|
56
|
-
// Attempt inference to fill root cause analysis
|
|
58
|
+
// Attempt inference to fill root cause analysis + candidate principle
|
|
57
59
|
let whatWentWrong = "";
|
|
58
60
|
let whatToDoDifferently = "";
|
|
61
|
+
let principle = "";
|
|
59
62
|
try {
|
|
60
63
|
const analysisResult = await inference({
|
|
61
|
-
system:
|
|
62
|
-
"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.",
|
|
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}`,
|
|
63
65
|
user: [
|
|
64
66
|
`Rating: ${rating}/10`,
|
|
65
67
|
`Context: ${context}`,
|
|
66
68
|
detailedContext ? `Analysis: ${detailedContext}` : "",
|
|
67
|
-
`
|
|
68
|
-
`
|
|
69
|
+
`Assistant response (what the user reacted to): ${lastAssistant}`,
|
|
70
|
+
`User reaction (the frustrated message): ${lastUser}`,
|
|
69
71
|
]
|
|
70
72
|
.filter(Boolean)
|
|
71
73
|
.join("\n"),
|
|
72
|
-
maxTokens:
|
|
73
|
-
timeout:
|
|
74
|
+
maxTokens: 400,
|
|
75
|
+
timeout: 15000,
|
|
74
76
|
jsonSchema: {
|
|
75
77
|
type: "object" as const,
|
|
76
78
|
additionalProperties: false,
|
|
77
79
|
properties: {
|
|
78
80
|
what_went_wrong: { type: "string" as const },
|
|
79
81
|
what_to_do_differently: { type: "string" as const },
|
|
82
|
+
principle: { type: "string" as const },
|
|
80
83
|
},
|
|
81
|
-
required: ["what_went_wrong", "what_to_do_differently"],
|
|
84
|
+
required: ["what_went_wrong", "what_to_do_differently", "principle"],
|
|
82
85
|
},
|
|
83
86
|
});
|
|
84
87
|
if (analysisResult.usage) logTokenUsage("failure", analysisResult.usage);
|
|
@@ -86,51 +89,46 @@ export async function captureFailure(
|
|
|
86
89
|
const parsed = JSON.parse(analysisResult.output) as {
|
|
87
90
|
what_went_wrong?: string;
|
|
88
91
|
what_to_do_differently?: string;
|
|
92
|
+
principle?: string;
|
|
89
93
|
};
|
|
90
94
|
whatWentWrong = parsed.what_went_wrong ?? "";
|
|
91
95
|
whatToDoDifferently = parsed.what_to_do_differently ?? "";
|
|
96
|
+
principle = parsed.principle ?? "";
|
|
92
97
|
}
|
|
93
98
|
} catch {
|
|
94
99
|
// Graceful fallback — empty sections are still useful with the other context
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"## Last User Message",
|
|
106
|
-
lastUser || "*(unavailable)*",
|
|
107
|
-
"",
|
|
108
|
-
"## Last Assistant Response",
|
|
109
|
-
lastAssistant || "*(unavailable)*",
|
|
110
|
-
"",
|
|
111
|
-
...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
|
|
112
|
-
"## What Went Wrong?",
|
|
113
|
-
whatWentWrong || "",
|
|
114
|
-
"",
|
|
115
|
-
"## What Should Be Done Differently?",
|
|
116
|
-
whatToDoDifferently || "",
|
|
117
|
-
"",
|
|
118
|
-
].join("\n"),
|
|
119
|
-
"utf-8"
|
|
120
|
-
);
|
|
102
|
+
const meta: Record<string, unknown> = {
|
|
103
|
+
rating,
|
|
104
|
+
context,
|
|
105
|
+
date: new Date().toISOString().slice(0, 10),
|
|
106
|
+
ts: new Date().toISOString(),
|
|
107
|
+
slug,
|
|
108
|
+
};
|
|
109
|
+
if (principle) meta.principle = principle;
|
|
121
110
|
|
|
111
|
+
const body = [
|
|
112
|
+
"## Last User Message",
|
|
113
|
+
lastUser || "*(unavailable)*",
|
|
114
|
+
"",
|
|
115
|
+
"## Last Assistant Response",
|
|
116
|
+
lastAssistant || "*(unavailable)*",
|
|
117
|
+
"",
|
|
118
|
+
...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
|
|
119
|
+
"## What Went Wrong?",
|
|
120
|
+
whatWentWrong || "",
|
|
121
|
+
"",
|
|
122
|
+
"## What Should Be Done Differently?",
|
|
123
|
+
whatToDoDifferently || "",
|
|
124
|
+
].join("\n");
|
|
125
|
+
|
|
126
|
+
writeFileSync(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
|
|
127
|
+
|
|
128
|
+
// DEPRECATED: legacy sentiment.json — remove once all readers use capture.md frontmatter
|
|
122
129
|
writeFileSync(
|
|
123
130
|
resolve(dir, "sentiment.json"),
|
|
124
|
-
JSON.stringify(
|
|
125
|
-
{
|
|
126
|
-
rating,
|
|
127
|
-
context,
|
|
128
|
-
ts: new Date().toISOString(),
|
|
129
|
-
slug,
|
|
130
|
-
},
|
|
131
|
-
null,
|
|
132
|
-
2
|
|
133
|
-
),
|
|
131
|
+
JSON.stringify({ rating, context, ts: new Date().toISOString(), slug }, null, 2),
|
|
134
132
|
"utf-8"
|
|
135
133
|
);
|
|
136
134
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
+
import { stringify } from "../lib/frontmatter";
|
|
13
14
|
import { inference } from "../lib/inference";
|
|
14
15
|
import { categorizeLearning } from "../lib/learning-category";
|
|
15
16
|
import { ensureDir, paths } from "../lib/paths";
|
|
@@ -244,24 +245,24 @@ function writeLearningMarkdown(
|
|
|
244
245
|
const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
|
|
245
246
|
const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
|
|
246
247
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
const meta: Record<string, unknown> = {
|
|
249
|
+
title: context.slice(0, 100) || "(low rating)",
|
|
250
|
+
category,
|
|
251
|
+
date: new Date().toISOString().slice(0, 10),
|
|
252
|
+
rating,
|
|
253
|
+
source,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const body = [
|
|
255
257
|
"## Context",
|
|
256
258
|
context || "*(unavailable)*",
|
|
257
259
|
"",
|
|
258
260
|
...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
|
|
259
261
|
"## Last Response",
|
|
260
262
|
responsePreview || "*(unavailable)*",
|
|
261
|
-
"",
|
|
262
263
|
].join("\n");
|
|
263
264
|
|
|
264
|
-
writeFileSync(resolve(dir, filename),
|
|
265
|
+
writeFileSync(resolve(dir, filename), stringify(meta, body), "utf-8");
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
function handleRating(
|
|
@@ -295,14 +296,7 @@ function handleRating(
|
|
|
295
296
|
),
|
|
296
297
|
"utf-8"
|
|
297
298
|
);
|
|
298
|
-
//
|
|
299
|
-
writeLearningMarkdown(
|
|
300
|
-
rating,
|
|
301
|
-
source,
|
|
302
|
-
context,
|
|
303
|
-
detailedContext ?? "",
|
|
304
|
-
responsePreview
|
|
305
|
-
);
|
|
299
|
+
// No learning markdown for ≤3 — failure capture covers it with richer analysis + tags
|
|
306
300
|
} else if (rating < 5) {
|
|
307
301
|
// Low but not critical — write learning markdown
|
|
308
302
|
writeLearningMarkdown(
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
|
+
import { stringify } from "../lib/frontmatter";
|
|
10
11
|
import { inference } from "../lib/inference";
|
|
11
12
|
import { categorizeLearning } from "../lib/learning-category";
|
|
12
13
|
import { ensureDir, paths } from "../lib/paths";
|
|
14
|
+
import { LEARNING_PRINCIPLE_PROMPT } from "../lib/prompts";
|
|
13
15
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
14
16
|
import { logTokenUsage } from "../lib/token-usage";
|
|
15
17
|
import {
|
|
@@ -106,6 +108,7 @@ export async function captureWorkLearning(
|
|
|
106
108
|
let title = rawTitle;
|
|
107
109
|
let summary = rawSummary;
|
|
108
110
|
let insights = "";
|
|
111
|
+
let principle = "";
|
|
109
112
|
try {
|
|
110
113
|
const userMessages = messages
|
|
111
114
|
.filter((m) => m.role === "user")
|
|
@@ -113,11 +116,10 @@ export async function captureWorkLearning(
|
|
|
113
116
|
.slice(-8)
|
|
114
117
|
.join("\n");
|
|
115
118
|
const result = await inference({
|
|
116
|
-
system:
|
|
117
|
-
"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).",
|
|
119
|
+
system: `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), 4) principle — ${LEARNING_PRINCIPLE_PROMPT}`,
|
|
118
120
|
user: `Human messages:\n${userMessages}\n\nAI response:\n${rawSummary.slice(0, 400)}`,
|
|
119
|
-
maxTokens:
|
|
120
|
-
timeout:
|
|
121
|
+
maxTokens: 350,
|
|
122
|
+
timeout: 15000,
|
|
121
123
|
jsonSchema: {
|
|
122
124
|
type: "object" as const,
|
|
123
125
|
additionalProperties: false,
|
|
@@ -125,8 +127,9 @@ export async function captureWorkLearning(
|
|
|
125
127
|
title: { type: "string" as const },
|
|
126
128
|
summary: { type: "string" as const },
|
|
127
129
|
insights: { type: "string" as const },
|
|
130
|
+
principle: { type: "string" as const },
|
|
128
131
|
},
|
|
129
|
-
required: ["title", "summary", "insights"],
|
|
132
|
+
required: ["title", "summary", "insights", "principle"],
|
|
130
133
|
},
|
|
131
134
|
});
|
|
132
135
|
if (result.usage) logTokenUsage("work-learning", result.usage);
|
|
@@ -135,10 +138,12 @@ export async function captureWorkLearning(
|
|
|
135
138
|
title?: string;
|
|
136
139
|
summary?: string;
|
|
137
140
|
insights?: string;
|
|
141
|
+
principle?: string;
|
|
138
142
|
};
|
|
139
143
|
if (parsed.title) title = parsed.title.slice(0, 100);
|
|
140
144
|
if (parsed.summary) summary = parsed.summary;
|
|
141
145
|
if (parsed.insights) insights = parsed.insights;
|
|
146
|
+
if (parsed.principle) principle = parsed.principle;
|
|
142
147
|
}
|
|
143
148
|
} catch {
|
|
144
149
|
// Fallback to raw values
|
|
@@ -149,21 +154,24 @@ export async function captureWorkLearning(
|
|
|
149
154
|
const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
|
|
150
155
|
const filename = `${fileTimestamp()}_${category}_${slug}.md`;
|
|
151
156
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const meta: Record<string, unknown> = {
|
|
158
|
+
title,
|
|
159
|
+
category,
|
|
160
|
+
date: new Date().toISOString().slice(0, 10),
|
|
161
|
+
};
|
|
162
|
+
if (principle) meta.principle = principle;
|
|
163
|
+
if (sessionId) meta.session = sessionId;
|
|
164
|
+
|
|
165
|
+
const body = [
|
|
159
166
|
"## What Was Done",
|
|
160
167
|
summary,
|
|
161
168
|
"",
|
|
162
169
|
"## Insights",
|
|
163
170
|
insights || "*No insights captured.*",
|
|
164
|
-
"",
|
|
165
171
|
].join("\n");
|
|
166
172
|
|
|
173
|
+
const content = stringify(meta, body);
|
|
174
|
+
|
|
167
175
|
// Remove previous capture for this session (overwrite on continued conversations)
|
|
168
176
|
if (sessionId) {
|
|
169
177
|
const prev = getPreviousCapture(sessionId);
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
+
import { hasFrontmatter, parse } from "./frontmatter";
|
|
8
9
|
import { paths } from "./paths";
|
|
9
10
|
import { loadRecentNotes } from "./relationship";
|
|
10
11
|
import { readSessionNames } from "./session-names";
|
|
@@ -247,28 +248,53 @@ export function loadLearningDigest(): string {
|
|
|
247
248
|
if (files.length >= 6) break;
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
function
|
|
251
|
+
function extractMeta(filePath: string): {
|
|
252
|
+
title: string;
|
|
253
|
+
category: string;
|
|
254
|
+
} {
|
|
251
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
|
|
252
267
|
const titleLine = content.split("\n").find((l) => l.startsWith("**Title:**"));
|
|
253
|
-
if (titleLine) return titleLine;
|
|
254
|
-
// Fallback: first non-heading, non-empty line
|
|
255
268
|
const fallback = content.split("\n").find((l) => l.trim() && !l.startsWith("#"));
|
|
256
|
-
return
|
|
269
|
+
return {
|
|
270
|
+
title: titleLine ?? fallback?.slice(0, 100) ?? content.slice(0, 80),
|
|
271
|
+
category: "algorithm", // legacy files use filename for category
|
|
272
|
+
};
|
|
257
273
|
}
|
|
258
274
|
|
|
259
|
-
|
|
260
|
-
const
|
|
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
|
+
});
|
|
284
|
+
|
|
285
|
+
const algorithm = enriched.filter((f) => f.category === "algorithm").slice(0, 2);
|
|
286
|
+
const system = enriched.filter((f) => f.category === "system").slice(0, 2);
|
|
261
287
|
|
|
262
288
|
if (algorithm.length === 0 && system.length === 0) return "";
|
|
263
289
|
|
|
264
290
|
const lines: string[] = ["## Recent Session Learnings"];
|
|
265
291
|
if (algorithm.length > 0) {
|
|
266
292
|
lines.push("### Approach");
|
|
267
|
-
for (const f of algorithm) lines.push(`- ${
|
|
293
|
+
for (const f of algorithm) lines.push(`- ${f.title}`);
|
|
268
294
|
}
|
|
269
295
|
if (system.length > 0) {
|
|
270
296
|
lines.push("### System");
|
|
271
|
-
for (const f of system) lines.push(`- ${
|
|
297
|
+
for (const f of system) lines.push(`- ${f.title}`);
|
|
272
298
|
}
|
|
273
299
|
return lines.join("\n");
|
|
274
300
|
} catch {
|
|
@@ -292,22 +318,38 @@ export function loadFailurePatterns(): string {
|
|
|
292
318
|
const dirs = readdirSync(monthPath).sort().reverse();
|
|
293
319
|
for (const dir of dirs) {
|
|
294
320
|
if (!/^\d{8}-\d{6}_/.test(dir)) continue;
|
|
295
|
-
//
|
|
321
|
+
// Try capture.md (new format), fall back to sentiment.json (legacy)
|
|
322
|
+
const capturePath = resolve(monthPath, dir, "capture.md");
|
|
296
323
|
const sentimentPath = resolve(monthPath, dir, "sentiment.json");
|
|
297
|
-
|
|
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)) {
|
|
298
341
|
try {
|
|
299
|
-
const data = JSON.parse(readFileSync(sentimentPath, "utf-8"))
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
};
|
|
303
|
-
if (data.context) {
|
|
304
|
-
const label = data.rating ? `[${data.rating}/10]` : "";
|
|
305
|
-
failures.push(`${label} ${data.context}`.trim());
|
|
306
|
-
}
|
|
342
|
+
const data = JSON.parse(readFileSync(sentimentPath, "utf-8"));
|
|
343
|
+
rating = data.rating;
|
|
344
|
+
ctx = data.context;
|
|
307
345
|
} catch {
|
|
308
|
-
|
|
309
|
-
failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
|
|
346
|
+
/* skip */
|
|
310
347
|
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (ctx) {
|
|
351
|
+
const label = rating ? `[${rating}/10]` : "";
|
|
352
|
+
failures.push(`${label} ${ctx}`.trim());
|
|
311
353
|
} else {
|
|
312
354
|
failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
|
|
313
355
|
}
|
|
@@ -361,12 +403,28 @@ export function loadSynthesisRecommendations(): string {
|
|
|
361
403
|
|
|
362
404
|
if (recs.length === 0) continue;
|
|
363
405
|
|
|
364
|
-
// Extract metadata
|
|
365
|
-
|
|
366
|
-
|
|
406
|
+
// Extract metadata — frontmatter or legacy
|
|
407
|
+
let period = "";
|
|
408
|
+
let avgRating = "";
|
|
409
|
+
|
|
410
|
+
if (hasFrontmatter(content)) {
|
|
411
|
+
const { meta } = parse<{
|
|
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
|
+
}
|
|
424
|
+
|
|
367
425
|
const header = [
|
|
368
426
|
"## Pattern Synthesis",
|
|
369
|
-
|
|
427
|
+
period ? `*${period} — ${avgRating}*` : "",
|
|
370
428
|
]
|
|
371
429
|
.filter(Boolean)
|
|
372
430
|
.join("\n");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight YAML frontmatter parser/serializer.
|
|
3
|
+
*
|
|
4
|
+
* No external dependencies — parses simple key: value YAML between --- delimiters.
|
|
5
|
+
* Supports strings, numbers, booleans, and inline JSON arrays.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Parsed<T = Record<string, string>> {
|
|
9
|
+
meta: T;
|
|
10
|
+
body: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DELIMITER = /^---\s*$/m;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse frontmatter from a markdown string.
|
|
17
|
+
* Returns typed meta + body. If no frontmatter found, meta is empty and body is the full content.
|
|
18
|
+
*/
|
|
19
|
+
export function parse<T = Record<string, string>>(content: string): Parsed<T> {
|
|
20
|
+
const parts = content.split(DELIMITER);
|
|
21
|
+
|
|
22
|
+
// Need at least 3 parts: before --- | frontmatter | after ---
|
|
23
|
+
// parts[0] should be empty (content starts with ---)
|
|
24
|
+
if (parts.length < 3 || parts[0].trim() !== "") {
|
|
25
|
+
return { meta: {} as T, body: content };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rawMeta = parts[1];
|
|
29
|
+
const body = parts.slice(2).join("---").trim();
|
|
30
|
+
|
|
31
|
+
const meta: Record<string, unknown> = {};
|
|
32
|
+
for (const line of rawMeta.split("\n")) {
|
|
33
|
+
const match = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
|
34
|
+
if (!match) continue;
|
|
35
|
+
const [, key, rawValue] = match;
|
|
36
|
+
const value = rawValue.trim();
|
|
37
|
+
|
|
38
|
+
// Inline JSON array
|
|
39
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
40
|
+
try {
|
|
41
|
+
meta[key] = JSON.parse(value);
|
|
42
|
+
continue;
|
|
43
|
+
} catch {
|
|
44
|
+
// Fall through to string handling
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Strip quotes
|
|
49
|
+
if (
|
|
50
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
51
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
52
|
+
) {
|
|
53
|
+
meta[key] = value.slice(1, -1).replace(/\\"/g, '"');
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Type coercion
|
|
58
|
+
if (value === "true") meta[key] = true;
|
|
59
|
+
else if (value === "false") meta[key] = false;
|
|
60
|
+
else if (/^\d+$/.test(value)) meta[key] = Number.parseInt(value, 10);
|
|
61
|
+
else if (/^\d+\.\d+$/.test(value)) meta[key] = Number.parseFloat(value);
|
|
62
|
+
else meta[key] = value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { meta: meta as T, body };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Serialize metadata and body into a frontmatter string.
|
|
70
|
+
* Skips undefined/null values.
|
|
71
|
+
*/
|
|
72
|
+
export function stringify(meta: Record<string, unknown>, body: string): string {
|
|
73
|
+
const lines: string[] = ["---"];
|
|
74
|
+
|
|
75
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
76
|
+
if (value === undefined || value === null) continue;
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
79
|
+
} else if (typeof value === "string") {
|
|
80
|
+
lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
|
|
81
|
+
} else {
|
|
82
|
+
lines.push(`${key}: ${String(value)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push("---");
|
|
87
|
+
return `${lines.join("\n")}\n\n${body.trim()}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if content has frontmatter (starts with ---).
|
|
92
|
+
*/
|
|
93
|
+
export function hasFrontmatter(content: string): boolean {
|
|
94
|
+
return content.trimStart().startsWith("---");
|
|
95
|
+
}
|