portable-agent-layer 0.2.1 → 0.4.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/README.md +25 -0
- package/package.json +2 -1
- package/src/cli/index.ts +222 -7
- package/src/hooks/StopOrchestrator.ts +12 -0
- package/src/hooks/handlers/failure.ts +49 -44
- package/src/hooks/handlers/rating.ts +12 -18
- package/src/hooks/handlers/readme-sync.ts +61 -0
- package/src/hooks/handlers/work-learning.ts +28 -13
- package/src/hooks/lib/claude-md.ts +2 -1
- package/src/hooks/lib/context.ts +82 -24
- package/src/hooks/lib/frontmatter.ts +95 -0
- package/src/hooks/lib/graduation.ts +499 -0
- package/src/hooks/lib/models.ts +4 -4
- package/src/hooks/lib/readme-sync.ts +129 -0
- package/src/hooks/lib/security.ts +2 -0
- package/src/hooks/lib/tags.ts +89 -0
- package/src/targets/lib.ts +1 -0
- package/src/targets/opencode/plugin.ts +7 -6
- package/src/tools/graduate.ts +51 -0
- package/src/tools/pattern-synthesis.ts +11 -14
- package/src/tools/token-cost.ts +35 -5
|
@@ -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 { getVocabulary, recordSuggestedTag } from "../lib/tags";
|
|
13
15
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
14
16
|
import { logTokenUsage } from "../lib/token-usage";
|
|
15
17
|
import {
|
|
@@ -106,18 +108,19 @@ export async function captureWorkLearning(
|
|
|
106
108
|
let title = rawTitle;
|
|
107
109
|
let summary = rawSummary;
|
|
108
110
|
let insights = "";
|
|
111
|
+
let tags: string[] = [];
|
|
109
112
|
try {
|
|
113
|
+
const vocab = getVocabulary();
|
|
110
114
|
const userMessages = messages
|
|
111
115
|
.filter((m) => m.role === "user")
|
|
112
116
|
.map((m) => extractContent(m).slice(0, 100))
|
|
113
117
|
.slice(-8)
|
|
114
118
|
.join("\n");
|
|
115
119
|
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).",
|
|
120
|
+
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) tags — pick 1-3 from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
|
|
118
121
|
user: `Human messages:\n${userMessages}\n\nAI response:\n${rawSummary.slice(0, 400)}`,
|
|
119
|
-
maxTokens:
|
|
120
|
-
timeout:
|
|
122
|
+
maxTokens: 350,
|
|
123
|
+
timeout: 15000,
|
|
121
124
|
jsonSchema: {
|
|
122
125
|
type: "object" as const,
|
|
123
126
|
additionalProperties: false,
|
|
@@ -125,8 +128,13 @@ export async function captureWorkLearning(
|
|
|
125
128
|
title: { type: "string" as const },
|
|
126
129
|
summary: { type: "string" as const },
|
|
127
130
|
insights: { type: "string" as const },
|
|
131
|
+
tags: {
|
|
132
|
+
type: "array" as const,
|
|
133
|
+
items: { type: "string" as const },
|
|
134
|
+
},
|
|
135
|
+
suggested_tag: { type: "string" as const },
|
|
128
136
|
},
|
|
129
|
-
required: ["title", "summary", "insights"],
|
|
137
|
+
required: ["title", "summary", "insights", "tags"],
|
|
130
138
|
},
|
|
131
139
|
});
|
|
132
140
|
if (result.usage) logTokenUsage("work-learning", result.usage);
|
|
@@ -135,10 +143,14 @@ export async function captureWorkLearning(
|
|
|
135
143
|
title?: string;
|
|
136
144
|
summary?: string;
|
|
137
145
|
insights?: string;
|
|
146
|
+
tags?: string[];
|
|
147
|
+
suggested_tag?: string;
|
|
138
148
|
};
|
|
139
149
|
if (parsed.title) title = parsed.title.slice(0, 100);
|
|
140
150
|
if (parsed.summary) summary = parsed.summary;
|
|
141
151
|
if (parsed.insights) insights = parsed.insights;
|
|
152
|
+
if (parsed.tags?.length) tags = parsed.tags;
|
|
153
|
+
if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
|
|
142
154
|
}
|
|
143
155
|
} catch {
|
|
144
156
|
// Fallback to raw values
|
|
@@ -149,21 +161,24 @@ export async function captureWorkLearning(
|
|
|
149
161
|
const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
|
|
150
162
|
const filename = `${fileTimestamp()}_${category}_${slug}.md`;
|
|
151
163
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
const meta: Record<string, unknown> = {
|
|
165
|
+
title,
|
|
166
|
+
category,
|
|
167
|
+
date: new Date().toISOString().slice(0, 10),
|
|
168
|
+
};
|
|
169
|
+
if (tags.length > 0) meta.tags = tags;
|
|
170
|
+
if (sessionId) meta.session = sessionId;
|
|
171
|
+
|
|
172
|
+
const body = [
|
|
159
173
|
"## What Was Done",
|
|
160
174
|
summary,
|
|
161
175
|
"",
|
|
162
176
|
"## Insights",
|
|
163
177
|
insights || "*No insights captured.*",
|
|
164
|
-
"",
|
|
165
178
|
].join("\n");
|
|
166
179
|
|
|
180
|
+
const content = stringify(meta, body);
|
|
181
|
+
|
|
167
182
|
// Remove previous capture for this session (overwrite on continued conversations)
|
|
168
183
|
if (sessionId) {
|
|
169
184
|
const prev = getPreviousCapture(sessionId);
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "node:fs";
|
|
19
19
|
import { dirname, relative, resolve } from "node:path";
|
|
20
20
|
import { loadTelos } from "./context";
|
|
21
|
-
import { assets, palHome, paths, platform } from "./paths";
|
|
21
|
+
import { assets, ensureDir, palHome, paths, platform } from "./paths";
|
|
22
22
|
import { buildSetupPrompt, readSetupState } from "./setup";
|
|
23
23
|
|
|
24
24
|
const TEMPLATE_PATH = assets.agentsMdTemplate();
|
|
@@ -116,6 +116,7 @@ export function regenerateIfNeeded(): boolean {
|
|
|
116
116
|
const { outputPath } = getOutputPaths();
|
|
117
117
|
ensureSymlink();
|
|
118
118
|
if (!needsRebuild()) return false;
|
|
119
|
+
ensureDir(dirname(outputPath));
|
|
119
120
|
writeFileSync(outputPath, buildClaudeMd(), "utf-8");
|
|
120
121
|
return true;
|
|
121
122
|
}
|
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
|
+
}
|