portable-agent-layer 0.4.0 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,7 @@
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",
51
52
  "tool:graduate": "bun run src/tools/graduate.ts",
52
53
  "tool:patterns": "bun run src/tools/pattern-synthesis.ts",
53
54
  "tool:reflect": "bun run src/tools/relationship-reflect.ts",
package/src/cli/index.ts CHANGED
@@ -21,7 +21,6 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node
21
21
  import { homedir } from "node:os";
22
22
  import { resolve } from "node:path";
23
23
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
24
- import { getPendingSuggestions } from "../hooks/lib/tags";
25
24
  import { log } from "../targets/lib";
26
25
 
27
26
  const allArgs = process.argv.slice(2);
@@ -361,18 +360,6 @@ function doctor(silent = false): DoctorResult {
361
360
  }
362
361
  }
363
362
 
364
- // Pending tag suggestions
365
- const pending = getPendingSuggestions();
366
- const pendingEntries = Object.entries(pending).sort((a, b) => b[1] - a[1]);
367
- if (pendingEntries.length > 0) {
368
- warn(`Tags: ${pendingEntries.length} pending suggestion(s)`);
369
- for (const [tag, count] of pendingEntries.slice(0, 5)) {
370
- log.info(` "${tag}" (${count}/3 to promote)`);
371
- }
372
- } else {
373
- ok("Tags: no pending suggestions");
374
- }
375
-
376
363
  if (!hasAgent) {
377
364
  console.log("");
378
365
  log.error("No supported agent found. Install Claude Code or opencode.");
@@ -11,7 +11,7 @@ import { resolve } from "node:path";
11
11
  import { stringify } from "../lib/frontmatter";
12
12
  import { inference } from "../lib/inference";
13
13
  import { ensureDir, paths } from "../lib/paths";
14
- import { getVocabulary, recordSuggestedTag } from "../lib/tags";
14
+ import { FAILURE_PRINCIPLE_PROMPT } from "../lib/prompts";
15
15
  import { fileTimestamp, monthPath } from "../lib/time";
16
16
  import { logTokenUsage } from "../lib/token-usage";
17
17
  import {
@@ -55,14 +55,13 @@ export async function captureFailure(
55
55
  resolve(paths.failures(), monthPath(), `${fileTimestamp()}_${slug}`)
56
56
  );
57
57
 
58
- // Attempt inference to fill root cause analysis + tags
58
+ // Attempt inference to fill root cause analysis + candidate principle
59
59
  let whatWentWrong = "";
60
60
  let whatToDoDifferently = "";
61
- let tags: string[] = [];
61
+ let principle = "";
62
62
  try {
63
- const vocab = getVocabulary();
64
63
  const analysisResult = await inference({
65
- 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 pick 1-3 tags from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
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}`,
66
65
  user: [
67
66
  `Rating: ${rating}/10`,
68
67
  `Context: ${context}`,
@@ -80,13 +79,9 @@ export async function captureFailure(
80
79
  properties: {
81
80
  what_went_wrong: { type: "string" as const },
82
81
  what_to_do_differently: { type: "string" as const },
83
- tags: {
84
- type: "array" as const,
85
- items: { type: "string" as const },
86
- },
87
- suggested_tag: { type: "string" as const },
82
+ principle: { type: "string" as const },
88
83
  },
89
- required: ["what_went_wrong", "what_to_do_differently", "tags"],
84
+ required: ["what_went_wrong", "what_to_do_differently", "principle"],
90
85
  },
91
86
  });
92
87
  if (analysisResult.usage) logTokenUsage("failure", analysisResult.usage);
@@ -94,13 +89,11 @@ export async function captureFailure(
94
89
  const parsed = JSON.parse(analysisResult.output) as {
95
90
  what_went_wrong?: string;
96
91
  what_to_do_differently?: string;
97
- tags?: string[];
98
- suggested_tag?: string;
92
+ principle?: string;
99
93
  };
100
94
  whatWentWrong = parsed.what_went_wrong ?? "";
101
95
  whatToDoDifferently = parsed.what_to_do_differently ?? "";
102
- if (parsed.tags?.length) tags = parsed.tags;
103
- if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
96
+ principle = parsed.principle ?? "";
104
97
  }
105
98
  } catch {
106
99
  // Graceful fallback — empty sections are still useful with the other context
@@ -113,7 +106,7 @@ export async function captureFailure(
113
106
  ts: new Date().toISOString(),
114
107
  slug,
115
108
  };
116
- if (tags.length > 0) meta.tags = tags;
109
+ if (principle) meta.principle = principle;
117
110
 
118
111
  const body = [
119
112
  "## Last User Message",
@@ -11,7 +11,7 @@ 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 { getVocabulary, recordSuggestedTag } from "../lib/tags";
14
+ import { LEARNING_PRINCIPLE_PROMPT } from "../lib/prompts";
15
15
  import { fileTimestamp, monthPath } from "../lib/time";
16
16
  import { logTokenUsage } from "../lib/token-usage";
17
17
  import {
@@ -108,16 +108,15 @@ export async function captureWorkLearning(
108
108
  let title = rawTitle;
109
109
  let summary = rawSummary;
110
110
  let insights = "";
111
- let tags: string[] = [];
111
+ let principle = "";
112
112
  try {
113
- const vocab = getVocabulary();
114
113
  const userMessages = messages
115
114
  .filter((m) => m.role === "user")
116
115
  .map((m) => extractContent(m).slice(0, 100))
117
116
  .slice(-8)
118
117
  .join("\n");
119
118
  const result = await inference({
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) tagspick 1-3 from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
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}`,
121
120
  user: `Human messages:\n${userMessages}\n\nAI response:\n${rawSummary.slice(0, 400)}`,
122
121
  maxTokens: 350,
123
122
  timeout: 15000,
@@ -128,13 +127,9 @@ export async function captureWorkLearning(
128
127
  title: { type: "string" as const },
129
128
  summary: { type: "string" as const },
130
129
  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 },
130
+ principle: { type: "string" as const },
136
131
  },
137
- required: ["title", "summary", "insights", "tags"],
132
+ required: ["title", "summary", "insights", "principle"],
138
133
  },
139
134
  });
140
135
  if (result.usage) logTokenUsage("work-learning", result.usage);
@@ -143,14 +138,12 @@ export async function captureWorkLearning(
143
138
  title?: string;
144
139
  summary?: string;
145
140
  insights?: string;
146
- tags?: string[];
147
- suggested_tag?: string;
141
+ principle?: string;
148
142
  };
149
143
  if (parsed.title) title = parsed.title.slice(0, 100);
150
144
  if (parsed.summary) summary = parsed.summary;
151
145
  if (parsed.insights) insights = parsed.insights;
152
- if (parsed.tags?.length) tags = parsed.tags;
153
- if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
146
+ if (parsed.principle) principle = parsed.principle;
154
147
  }
155
148
  } catch {
156
149
  // Fallback to raw values
@@ -166,7 +159,7 @@ export async function captureWorkLearning(
166
159
  category,
167
160
  date: new Date().toISOString().slice(0, 10),
168
161
  };
169
- if (tags.length > 0) meta.tags = tags;
162
+ if (principle) meta.principle = principle;
170
163
  if (sessionId) meta.session = sessionId;
171
164
 
172
165
  const body = [
@@ -21,7 +21,7 @@ interface LearningEntry {
21
21
  source: string; // "failure:{slug}" or "learning:{filename}"
22
22
  text: string; // context or title+insights
23
23
  date: string; // YYYY-MM-DD
24
- tags: string[]; // semantic tags from inference
24
+ principle: string; // candidate principle from inference
25
25
  }
26
26
 
27
27
  interface PatternGroup {
@@ -46,6 +46,7 @@ interface GraduationState {
46
46
 
47
47
  export interface GraduationResult {
48
48
  candidates: PatternGroup[];
49
+ emerging: PatternGroup[];
49
50
  graduated: GraduatedEntry[];
50
51
  updated: GraduatedEntry[];
51
52
  }
@@ -171,7 +172,7 @@ function extractKeywords(text: string): Set<string> {
171
172
  );
172
173
  }
173
174
 
174
- function similarity(a: string, b: string): number {
175
+ export function similarity(a: string, b: string): number {
175
176
  const ka = extractKeywords(a);
176
177
  const kb = extractKeywords(b);
177
178
  if (ka.size === 0 || kb.size === 0) return 0;
@@ -199,7 +200,7 @@ function collectFailures(): LearningEntry[] {
199
200
  for (const slug of readdirSync(monthDir)) {
200
201
  let context = "";
201
202
  let ts = "";
202
- let entryTags: string[] = [];
203
+ let entryPrinciple = "";
203
204
 
204
205
  // Try capture.md (new format)
205
206
  const capturePath = resolve(monthDir, slug, "capture.md");
@@ -209,11 +210,11 @@ function collectFailures(): LearningEntry[] {
209
210
  const { meta } = parse<{
210
211
  context?: string;
211
212
  ts?: string;
212
- tags?: string[];
213
+ principle?: string;
213
214
  }>(content);
214
215
  context = meta.context || "";
215
216
  ts = (meta.ts as string) || "";
216
- if (Array.isArray(meta.tags)) entryTags = meta.tags;
217
+ entryPrinciple = meta.principle || "";
217
218
  } catch {
218
219
  /* fallback below */
219
220
  }
@@ -237,7 +238,7 @@ function collectFailures(): LearningEntry[] {
237
238
  source: `failure:${slug}`,
238
239
  text: context.slice(0, 300),
239
240
  date: ts.slice(0, 10),
240
- tags: entryTags,
241
+ principle: entryPrinciple,
241
242
  });
242
243
  }
243
244
  }
@@ -265,16 +266,16 @@ function collectLearnings(): LearningEntry[] {
265
266
  const content = readFileSync(resolve(monthDir, file), "utf-8");
266
267
  let title = "";
267
268
  let insights = "";
268
- let entryTags: string[] = [];
269
+ let entryPrinciple = "";
269
270
 
270
271
  if (hasFrontmatter(content)) {
271
272
  // New format
272
273
  const { meta, body } = parse<{
273
274
  title?: string;
274
- tags?: string[];
275
+ principle?: string;
275
276
  }>(content);
276
277
  title = meta.title || "";
277
- if (Array.isArray(meta.tags)) entryTags = meta.tags;
278
+ entryPrinciple = meta.principle || "";
278
279
  const insightsMatch = body.match(/## Insights\n([\s\S]*?)(?=\n##|$)/);
279
280
  insights = insightsMatch?.[1]?.trim() || "";
280
281
  } else {
@@ -295,7 +296,7 @@ function collectLearnings(): LearningEntry[] {
295
296
  source: `learning:${file}`,
296
297
  text: text.slice(0, 300),
297
298
  date,
298
- tags: entryTags,
299
+ principle: entryPrinciple,
299
300
  });
300
301
  }
301
302
  } catch {
@@ -313,7 +314,7 @@ function collectLearnings(): LearningEntry[] {
313
314
 
314
315
  // ── Grouping ──
315
316
 
316
- const SIMILARITY_THRESHOLD = 0.35;
317
+ export const SIMILARITY_THRESHOLD = 0.35;
317
318
  const MIN_OCCURRENCES = 3;
318
319
  const MIN_TEXT_LENGTH = 30;
319
320
 
@@ -329,33 +330,17 @@ function isActionable(text: string): boolean {
329
330
  return true;
330
331
  }
331
332
 
332
- /** Check if two entries share at least one tag. */
333
- function hasSharedTag(a: string[], b: string[]): boolean {
334
- if (a.length === 0 || b.length === 0) return false;
335
- return a.some((t) => b.includes(t));
336
- }
337
-
338
333
  function groupPatterns(entries: LearningEntry[]): PatternGroup[] {
339
334
  const groups: PatternGroup[] = [];
340
335
  const actionable = entries.filter((e) => isActionable(e.text));
341
336
 
342
337
  for (const entry of actionable) {
338
+ // Use principle for matching if available, fall back to raw text
339
+ const matchText = entry.principle || entry.text;
343
340
  let matched = false;
344
341
  for (const group of groups) {
345
- // Tag-based matching (preferred — entries with shared tags)
346
- if (
347
- entry.tags.length > 0 &&
348
- group.entries.some((e) => hasSharedTag(e.tags, entry.tags))
349
- ) {
350
- group.entries.push(entry);
351
- matched = true;
352
- break;
353
- }
354
- // Text similarity fallback (for untagged/legacy entries)
355
- if (
356
- entry.tags.length === 0 &&
357
- similarity(entry.text, group.pattern) >= SIMILARITY_THRESHOLD
358
- ) {
342
+ const groupText = group.entries[0]?.principle || group.pattern;
343
+ if (similarity(matchText, groupText) >= SIMILARITY_THRESHOLD) {
359
344
  group.entries.push(entry);
360
345
  matched = true;
361
346
  break;
@@ -370,7 +355,7 @@ function groupPatterns(entries: LearningEntry[]): PatternGroup[] {
370
355
  }
371
356
  }
372
357
 
373
- return groups.filter((g) => g.entries.length >= MIN_OCCURRENCES);
358
+ return groups.filter((g) => g.entries.length >= 2);
374
359
  }
375
360
 
376
361
  // ── State Management ──
@@ -425,9 +410,12 @@ export function graduate(): GraduationResult {
425
410
  `Collected ${failures.length} failures, ${learnings.length} learnings`
426
411
  );
427
412
 
428
- const candidates = groupPatterns(all);
413
+ const allGroups = groupPatterns(all);
414
+ const candidates = allGroups.filter((g) => g.entries.length >= MIN_OCCURRENCES);
415
+ const emerging = allGroups.filter((g) => g.entries.length === 2);
429
416
  const result: GraduationResult = {
430
417
  candidates,
418
+ emerging,
431
419
  graduated: [],
432
420
  updated: [],
433
421
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared prompt fragments — single source of truth for inference instructions.
3
+ */
4
+
5
+ /** Principle extraction instruction for failed interactions. */
6
+ export const FAILURE_PRINCIPLE_PROMPT =
7
+ "Write one actionable sentence that would prevent this issue from happening again. If no clear lesson, leave principle empty. Be concise.";
8
+
9
+ /** Principle extraction instruction for session learnings. */
10
+ export const LEARNING_PRINCIPLE_PROMPT =
11
+ "If this session taught a reusable lesson, write one actionable sentence that would prevent the same issue in the future. If no clear lesson, leave empty. Be concise.";
@@ -29,7 +29,6 @@ export const HOOK_MANAGED_FILES = [
29
29
  "signal-cache.json",
30
30
  "pending-failure.json",
31
31
  "token-usage.jsonl",
32
- "tags.json",
33
32
  "graduated.json",
34
33
  ];
35
34
 
@@ -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
+ );
@@ -13,8 +13,8 @@ import { graduate } from "../hooks/lib/graduation";
13
13
 
14
14
  const result = graduate();
15
15
 
16
- if (result.candidates.length === 0) {
17
- console.log("\n No recurring patterns found (need 3+ similar entries).\n");
16
+ if (result.candidates.length === 0 && result.emerging.length === 0) {
17
+ console.log("\n No recurring patterns found.\n");
18
18
  process.exit(0);
19
19
  }
20
20
 
@@ -22,13 +22,12 @@ console.log(`\n Graduation Report — ${result.candidates.length} pattern(s) de
22
22
  console.log(" ─────────────────────────────────────────────────\n");
23
23
 
24
24
  for (const candidate of result.candidates) {
25
- // Collect unique tags across all entries
26
- const allTags = [...new Set(candidate.entries.flatMap((e) => e.tags))];
25
+ // Collect unique candidate principles
26
+ const principles = [
27
+ ...new Set(candidate.entries.map((e) => e.principle).filter((p) => p.length > 0)),
28
+ ];
27
29
 
28
30
  console.log(` [${candidate.domain}] ${candidate.entries.length}x occurrences`);
29
- if (allTags.length > 0) {
30
- console.log(` Tags: ${allTags.join(", ")}`);
31
- }
32
31
  console.log("");
33
32
 
34
33
  // Show each entry with date and source
@@ -39,13 +38,42 @@ for (const candidate of result.candidates) {
39
38
  );
40
39
  }
41
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
+
42
49
  console.log("");
43
- console.log(
44
- " → Consider adding a principle to:",
45
- `memory/wisdom/frames/${candidate.domain}.md`
46
- );
50
+ console.log(" Target frame:", `memory/wisdom/frames/${candidate.domain}.md`);
47
51
  console.log(" ─────────────────────────────────────────────────\n");
48
52
  }
49
53
 
50
- console.log(" To crystallize: add a line to the wisdom frame file.");
51
- console.log(" Format: - Your principle here [CRYSTAL: 85%]\n");
54
+ if (result.emerging.length > 0) {
55
+ console.log(` Emerging (2x one more to graduate)\n`);
56
+ for (const group of result.emerging) {
57
+ const principles = [
58
+ ...new Set(group.entries.map((e) => e.principle).filter((p) => p.length > 0)),
59
+ ];
60
+ console.log(` [${group.domain}] ${group.entries.length}x`);
61
+ for (const entry of group.entries) {
62
+ const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
63
+ console.log(
64
+ ` ${entry.date || "unknown"} [${sourceType}] ${entry.text.slice(0, 80)}`
65
+ );
66
+ }
67
+ if (principles.length > 0) {
68
+ for (const p of principles) {
69
+ console.log(` → ${p}`);
70
+ }
71
+ }
72
+ console.log("");
73
+ }
74
+ }
75
+
76
+ if (result.candidates.length > 0) {
77
+ console.log(" To crystallize: add a line to the wisdom frame file.");
78
+ console.log(" Format: - Your principle here [CRYSTAL: 85%]\n");
79
+ }
@@ -1,89 +0,0 @@
1
- /**
2
- * Tag vocabulary management for semantic grouping.
3
- *
4
- * Tags come from a fixed vocabulary. When Haiku suggests a tag not in the
5
- * vocabulary, it's tracked as "suggested". Suggested tags that recur 3+
6
- * times get auto-promoted to the vocabulary.
7
- */
8
-
9
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
- import { resolve } from "node:path";
11
- import { ensureDir, paths } from "./paths";
12
-
13
- const DEFAULT_VOCABULARY = [
14
- "versioning",
15
- "testing",
16
- "deployment",
17
- "configuration",
18
- "communication",
19
- "documentation",
20
- "architecture",
21
- "debugging",
22
- "performance",
23
- "security",
24
- "cross-platform",
25
- "dependencies",
26
- "incomplete-work",
27
- "wrong-approach",
28
- "tooling",
29
- ];
30
-
31
- const PROMOTION_THRESHOLD = 3;
32
-
33
- export interface TagState {
34
- vocabulary: string[];
35
- suggested: Record<string, number>;
36
- }
37
-
38
- function stateFilePath(): string {
39
- return resolve(ensureDir(paths.wisdomState()), "tags.json");
40
- }
41
-
42
- export function readTagState(): TagState {
43
- const fp = stateFilePath();
44
- if (!existsSync(fp)) {
45
- return { vocabulary: [...DEFAULT_VOCABULARY], suggested: {} };
46
- }
47
- try {
48
- return JSON.parse(readFileSync(fp, "utf-8"));
49
- } catch {
50
- return { vocabulary: [...DEFAULT_VOCABULARY], suggested: {} };
51
- }
52
- }
53
-
54
- export function writeTagState(state: TagState): void {
55
- writeFileSync(stateFilePath(), JSON.stringify(state, null, 2), "utf-8");
56
- }
57
-
58
- /** Get the current vocabulary (for injection into inference prompts). */
59
- export function getVocabulary(): string[] {
60
- return readTagState().vocabulary;
61
- }
62
-
63
- /**
64
- * Record a suggested tag. If it reaches the promotion threshold,
65
- * move it to the vocabulary automatically.
66
- */
67
- export function recordSuggestedTag(tag: string): void {
68
- const state = readTagState();
69
- const normalized = tag.toLowerCase().trim();
70
-
71
- // Already in vocabulary — nothing to do
72
- if (state.vocabulary.includes(normalized)) return;
73
-
74
- // Increment suggestion count
75
- state.suggested[normalized] = (state.suggested[normalized] || 0) + 1;
76
-
77
- // Promote if threshold reached
78
- if (state.suggested[normalized] >= PROMOTION_THRESHOLD) {
79
- state.vocabulary.push(normalized);
80
- delete state.suggested[normalized];
81
- }
82
-
83
- writeTagState(state);
84
- }
85
-
86
- /** Get suggested tags and their counts (for reporting). */
87
- export function getPendingSuggestions(): Record<string, number> {
88
- return readTagState().suggested;
89
- }