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.
@@ -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
- const lines: string[] = [
264
- "# Learning Pattern Synthesis",
265
- "",
266
- `**Period:** ${result.period}`,
267
- `**Generated:** ${date}`,
268
- `**Total Ratings:** ${result.totalRatings}`,
269
- `**Average Rating:** ${result.avgRating.toFixed(1)}/10`,
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 {
@@ -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 = MODEL_PRICING[model];
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
- const projName = projDir.split("-").pop() ?? projDir;
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
- const jsonlFiles = readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
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 file of jsonlFiles) {
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");