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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Principle Evaluation — generate, regenerate, or compare candidate principles.
|
|
4
|
+
*
|
|
5
|
+
* Reads failures (capture.md) and learnings (frontmatter .md) and uses Haiku
|
|
6
|
+
* to generate candidate principles. Useful for tuning prompt quality.
|
|
7
|
+
*
|
|
8
|
+
* Modes:
|
|
9
|
+
* --dry-run Preview which files would be updated
|
|
10
|
+
* --evaluate Show current vs new principle for comparison (does not write)
|
|
11
|
+
* --force Regenerate principles even if one already exists
|
|
12
|
+
* (default) Generate missing principles only
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* bun run tool:eval # generate missing
|
|
16
|
+
* bun run tool:eval -- --dry-run # preview
|
|
17
|
+
* bun run tool:eval -- --evaluate # compare current vs new
|
|
18
|
+
* bun run tool:eval -- --force # regenerate all
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
import { hasFrontmatter, parse, stringify } from "../hooks/lib/frontmatter";
|
|
24
|
+
import { inference } from "../hooks/lib/inference";
|
|
25
|
+
import { palHome } from "../hooks/lib/paths";
|
|
26
|
+
import {
|
|
27
|
+
FAILURE_PRINCIPLE_PROMPT,
|
|
28
|
+
LEARNING_PRINCIPLE_PROMPT,
|
|
29
|
+
} from "../hooks/lib/prompts";
|
|
30
|
+
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
const dryRun = args.includes("--dry-run");
|
|
33
|
+
const evaluate = args.includes("--evaluate");
|
|
34
|
+
const force = args.includes("--force");
|
|
35
|
+
|
|
36
|
+
const home = palHome();
|
|
37
|
+
let processed = 0;
|
|
38
|
+
let skipped = 0;
|
|
39
|
+
let failed = 0;
|
|
40
|
+
|
|
41
|
+
async function generatePrinciple(systemPrompt: string, context: string): Promise<string> {
|
|
42
|
+
const result = await inference({
|
|
43
|
+
system: systemPrompt,
|
|
44
|
+
user: context,
|
|
45
|
+
maxTokens: 100,
|
|
46
|
+
timeout: 10000,
|
|
47
|
+
jsonSchema: {
|
|
48
|
+
type: "object" as const,
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
principle: { type: "string" as const },
|
|
52
|
+
},
|
|
53
|
+
required: ["principle"],
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (result.success && result.output) {
|
|
58
|
+
const parsed = JSON.parse(result.output) as { principle?: string };
|
|
59
|
+
const principle = parsed.principle?.trim() || "";
|
|
60
|
+
if (principle.length > 10) return principle;
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Failures ──
|
|
66
|
+
|
|
67
|
+
async function processFailures() {
|
|
68
|
+
const failuresDir = resolve(home, "memory", "learning", "failures");
|
|
69
|
+
if (!existsSync(failuresDir)) return;
|
|
70
|
+
|
|
71
|
+
for (const year of readdirSync(failuresDir)) {
|
|
72
|
+
const yearDir = resolve(failuresDir, year);
|
|
73
|
+
for (const month of readdirSync(yearDir)) {
|
|
74
|
+
const monthDir = resolve(yearDir, month);
|
|
75
|
+
for (const slug of readdirSync(monthDir)) {
|
|
76
|
+
const capturePath = resolve(monthDir, slug, "capture.md");
|
|
77
|
+
if (!existsSync(capturePath)) continue;
|
|
78
|
+
|
|
79
|
+
const content = readFileSync(capturePath, "utf-8");
|
|
80
|
+
if (!hasFrontmatter(content)) continue;
|
|
81
|
+
|
|
82
|
+
const { meta, body } = parse<{
|
|
83
|
+
principle?: string;
|
|
84
|
+
context?: string;
|
|
85
|
+
rating?: number;
|
|
86
|
+
}>(content);
|
|
87
|
+
|
|
88
|
+
const hasPrinciple = !!meta.principle;
|
|
89
|
+
if (hasPrinciple && !force && !evaluate) {
|
|
90
|
+
skipped++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const context = meta.context || "";
|
|
95
|
+
if (!context) {
|
|
96
|
+
skipped++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const inputContext = `Rating: ${meta.rating}/10\nContext: ${context}\n\n${body.slice(0, 400)}`;
|
|
101
|
+
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
console.log(` [failure] ${slug.slice(0, 60)}`);
|
|
104
|
+
processed++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const newPrinciple = await generatePrinciple(
|
|
110
|
+
FAILURE_PRINCIPLE_PROMPT,
|
|
111
|
+
inputContext
|
|
112
|
+
);
|
|
113
|
+
if (!newPrinciple) {
|
|
114
|
+
skipped++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (evaluate) {
|
|
119
|
+
console.log(` [failure] ${slug.slice(0, 50)}`);
|
|
120
|
+
if (hasPrinciple) {
|
|
121
|
+
console.log(` OLD: ${meta.principle}`);
|
|
122
|
+
}
|
|
123
|
+
console.log(` NEW: ${newPrinciple}`);
|
|
124
|
+
console.log("");
|
|
125
|
+
processed++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const newMeta = { ...meta, principle: newPrinciple } as Record<string, unknown>;
|
|
130
|
+
writeFileSync(capturePath, stringify(newMeta, body), "utf-8");
|
|
131
|
+
console.log(` [failure] ${slug.slice(0, 60)}`);
|
|
132
|
+
processed++;
|
|
133
|
+
} catch {
|
|
134
|
+
failed++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Learnings ──
|
|
142
|
+
|
|
143
|
+
async function processLearnings() {
|
|
144
|
+
const learningDir = resolve(home, "memory", "learning", "session");
|
|
145
|
+
if (!existsSync(learningDir)) return;
|
|
146
|
+
|
|
147
|
+
for (const year of readdirSync(learningDir)) {
|
|
148
|
+
const yearDir = resolve(learningDir, year);
|
|
149
|
+
for (const month of readdirSync(yearDir)) {
|
|
150
|
+
const monthDir = resolve(yearDir, month);
|
|
151
|
+
for (const file of readdirSync(monthDir).filter((f) => f.endsWith(".md"))) {
|
|
152
|
+
const filepath = resolve(monthDir, file);
|
|
153
|
+
const content = readFileSync(filepath, "utf-8");
|
|
154
|
+
|
|
155
|
+
if (!hasFrontmatter(content)) {
|
|
156
|
+
skipped++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { meta, body } = parse<{
|
|
161
|
+
principle?: string;
|
|
162
|
+
title?: string;
|
|
163
|
+
}>(content);
|
|
164
|
+
|
|
165
|
+
const hasPrinciple = !!meta.principle;
|
|
166
|
+
if (hasPrinciple && !force && !evaluate) {
|
|
167
|
+
skipped++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const title = meta.title || "";
|
|
172
|
+
if (!title) {
|
|
173
|
+
skipped++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const inputContext = `Title: ${title}\n\n${body.slice(0, 400)}`;
|
|
178
|
+
|
|
179
|
+
if (dryRun) {
|
|
180
|
+
console.log(` [learning] ${file.slice(0, 60)}`);
|
|
181
|
+
processed++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const newPrinciple = await generatePrinciple(
|
|
187
|
+
LEARNING_PRINCIPLE_PROMPT,
|
|
188
|
+
inputContext
|
|
189
|
+
);
|
|
190
|
+
if (!newPrinciple) {
|
|
191
|
+
skipped++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (evaluate) {
|
|
196
|
+
console.log(` [learning] ${file.slice(0, 50)}`);
|
|
197
|
+
if (hasPrinciple) {
|
|
198
|
+
console.log(` OLD: ${meta.principle}`);
|
|
199
|
+
}
|
|
200
|
+
console.log(` NEW: ${newPrinciple}`);
|
|
201
|
+
console.log("");
|
|
202
|
+
processed++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const newMeta = { ...meta, principle: newPrinciple } as Record<string, unknown>;
|
|
207
|
+
writeFileSync(filepath, stringify(newMeta, body), "utf-8");
|
|
208
|
+
console.log(` [learning] ${file.slice(0, 60)}`);
|
|
209
|
+
processed++;
|
|
210
|
+
} catch {
|
|
211
|
+
failed++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Main ──
|
|
219
|
+
|
|
220
|
+
const mode = evaluate
|
|
221
|
+
? "evaluate"
|
|
222
|
+
: force
|
|
223
|
+
? "force regenerate"
|
|
224
|
+
: dryRun
|
|
225
|
+
? "dry run"
|
|
226
|
+
: "backfill";
|
|
227
|
+
console.log(`\n Principle ${mode}...\n`);
|
|
228
|
+
|
|
229
|
+
await processFailures();
|
|
230
|
+
await processLearnings();
|
|
231
|
+
|
|
232
|
+
console.log(
|
|
233
|
+
`\n Done: ${processed} ${evaluate ? "compared" : "processed"}, ${skipped} skipped, ${failed} failed\n`
|
|
234
|
+
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Graduation Report — surface recurring patterns for manual crystallization.
|
|
4
|
+
*
|
|
5
|
+
* Reads failures and session learnings, finds patterns that recur 3+ times,
|
|
6
|
+
* and generates a readable report with context for each candidate.
|
|
7
|
+
* You decide what to add to wisdom frames.
|
|
8
|
+
*
|
|
9
|
+
* Usage: bun run tool:graduate
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { graduate } from "../hooks/lib/graduation";
|
|
13
|
+
|
|
14
|
+
const result = graduate();
|
|
15
|
+
|
|
16
|
+
if (result.candidates.length === 0) {
|
|
17
|
+
console.log("\n No recurring patterns found (need 3+ similar entries).\n");
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(`\n Graduation Report — ${result.candidates.length} pattern(s) detected\n`);
|
|
22
|
+
console.log(" ─────────────────────────────────────────────────\n");
|
|
23
|
+
|
|
24
|
+
for (const candidate of result.candidates) {
|
|
25
|
+
// Collect unique candidate principles
|
|
26
|
+
const principles = [
|
|
27
|
+
...new Set(candidate.entries.map((e) => e.principle).filter((p) => p.length > 0)),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
console.log(` [${candidate.domain}] ${candidate.entries.length}x occurrences`);
|
|
31
|
+
console.log("");
|
|
32
|
+
|
|
33
|
+
// Show each entry with date and source
|
|
34
|
+
for (const entry of candidate.entries) {
|
|
35
|
+
const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
|
|
36
|
+
console.log(
|
|
37
|
+
` ${entry.date || "unknown"} [${sourceType}] ${entry.text.slice(0, 100)}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Show candidate principles from Haiku
|
|
42
|
+
if (principles.length > 0) {
|
|
43
|
+
console.log("\n Suggested principles:");
|
|
44
|
+
for (const p of principles) {
|
|
45
|
+
console.log(` → ${p}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log("");
|
|
50
|
+
console.log(" Target frame:", `memory/wisdom/frames/${candidate.domain}.md`);
|
|
51
|
+
console.log(" ─────────────────────────────────────────────────\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(" To crystallize: add a line to the wisdom frame file.");
|
|
55
|
+
console.log(" Format: - Your principle here [CRYSTAL: 85%]\n");
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { resolve } from "node:path";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
|
+
import { stringify } from "../hooks/lib/frontmatter";
|
|
18
19
|
import { HAIKU_MODEL } from "../hooks/lib/models";
|
|
19
20
|
import { palHome } from "../hooks/lib/paths";
|
|
20
21
|
|
|
@@ -260,19 +261,15 @@ async function analyzeRatings(
|
|
|
260
261
|
|
|
261
262
|
function formatReport(result: SynthesisResult): string {
|
|
262
263
|
const date = new Date().toISOString().slice(0, 10);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"",
|
|
273
|
-
"## Top Issues",
|
|
274
|
-
"",
|
|
275
|
-
];
|
|
264
|
+
|
|
265
|
+
const meta: Record<string, unknown> = {
|
|
266
|
+
period: result.period,
|
|
267
|
+
date,
|
|
268
|
+
total_ratings: result.totalRatings,
|
|
269
|
+
average_rating: result.avgRating.toFixed(1),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const lines: string[] = ["## Top Issues", ""];
|
|
276
273
|
|
|
277
274
|
if (result.topIssues.length > 0) {
|
|
278
275
|
for (let i = 0; i < result.topIssues.length; i++) {
|
|
@@ -324,7 +321,7 @@ function formatReport(result: SynthesisResult): string {
|
|
|
324
321
|
""
|
|
325
322
|
);
|
|
326
323
|
|
|
327
|
-
return lines.join("\n");
|
|
324
|
+
return stringify(meta, lines.join("\n"));
|
|
328
325
|
}
|
|
329
326
|
|
|
330
327
|
function writeReport(result: SynthesisResult, period: string): string {
|
package/src/tools/token-cost.ts
CHANGED
|
@@ -47,6 +47,16 @@ function emptyBucket(): Bucket {
|
|
|
47
47
|
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, calls: 0 };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function findPricing(model: string): (typeof MODEL_PRICING)[string] | null {
|
|
51
|
+
// Exact match first
|
|
52
|
+
if (MODEL_PRICING[model]) return MODEL_PRICING[model];
|
|
53
|
+
// Prefix match (e.g. "claude-sonnet-4-5-20250929" matches "claude-sonnet-4-5")
|
|
54
|
+
for (const key of Object.keys(MODEL_PRICING)) {
|
|
55
|
+
if (model.startsWith(key)) return MODEL_PRICING[key];
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
function costForUsage(
|
|
51
61
|
model: string,
|
|
52
62
|
input: number,
|
|
@@ -54,7 +64,7 @@ function costForUsage(
|
|
|
54
64
|
cacheWrite: number,
|
|
55
65
|
cacheRead: number
|
|
56
66
|
): number {
|
|
57
|
-
const p =
|
|
67
|
+
const p = findPricing(model);
|
|
58
68
|
if (!p) return 0;
|
|
59
69
|
return (
|
|
60
70
|
(input * p.input +
|
|
@@ -159,14 +169,34 @@ function readClaudeCode(): {
|
|
|
159
169
|
|
|
160
170
|
for (const projDir of projectDirs) {
|
|
161
171
|
const projPath = resolve(claudeDir, projDir);
|
|
162
|
-
|
|
172
|
+
// Project dir is like "-Users-rico-Development-git-myproject" — extract last meaningful segment
|
|
173
|
+
const segments = projDir.replace(/^-/, "").split("-");
|
|
174
|
+
const projName = segments.length > 1 ? segments.slice(-1)[0] : projDir;
|
|
163
175
|
|
|
164
176
|
if (typeof args.project === "string" && !projName.includes(args.project)) continue;
|
|
165
177
|
|
|
166
|
-
|
|
178
|
+
// Collect all JSONL files: top-level + subagent directories
|
|
179
|
+
const jsonlFiles: string[] = [];
|
|
180
|
+
|
|
181
|
+
for (const entry of readdirSync(projPath, { withFileTypes: true })) {
|
|
182
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
183
|
+
jsonlFiles.push(resolve(projPath, entry.name));
|
|
184
|
+
} else if (entry.isDirectory()) {
|
|
185
|
+
// Check for subagent transcripts inside session directories
|
|
186
|
+
const subagentsDir = resolve(projPath, entry.name, "subagents");
|
|
187
|
+
try {
|
|
188
|
+
for (const sub of readdirSync(subagentsDir)) {
|
|
189
|
+
if (sub.endsWith(".jsonl")) {
|
|
190
|
+
jsonlFiles.push(resolve(subagentsDir, sub));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
/* no subagents dir */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
167
198
|
|
|
168
|
-
for (const
|
|
169
|
-
const filepath = resolve(projPath, file);
|
|
199
|
+
for (const filepath of jsonlFiles) {
|
|
170
200
|
let content: string;
|
|
171
201
|
try {
|
|
172
202
|
content = readFileSync(filepath, "utf-8");
|