portable-agent-layer 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.4.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,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 {
@@ -171,7 +171,7 @@ function extractKeywords(text: string): Set<string> {
171
171
  );
172
172
  }
173
173
 
174
- function similarity(a: string, b: string): number {
174
+ export function similarity(a: string, b: string): number {
175
175
  const ka = extractKeywords(a);
176
176
  const kb = extractKeywords(b);
177
177
  if (ka.size === 0 || kb.size === 0) return 0;
@@ -199,7 +199,7 @@ function collectFailures(): LearningEntry[] {
199
199
  for (const slug of readdirSync(monthDir)) {
200
200
  let context = "";
201
201
  let ts = "";
202
- let entryTags: string[] = [];
202
+ let entryPrinciple = "";
203
203
 
204
204
  // Try capture.md (new format)
205
205
  const capturePath = resolve(monthDir, slug, "capture.md");
@@ -209,11 +209,11 @@ function collectFailures(): LearningEntry[] {
209
209
  const { meta } = parse<{
210
210
  context?: string;
211
211
  ts?: string;
212
- tags?: string[];
212
+ principle?: string;
213
213
  }>(content);
214
214
  context = meta.context || "";
215
215
  ts = (meta.ts as string) || "";
216
- if (Array.isArray(meta.tags)) entryTags = meta.tags;
216
+ entryPrinciple = meta.principle || "";
217
217
  } catch {
218
218
  /* fallback below */
219
219
  }
@@ -237,7 +237,7 @@ function collectFailures(): LearningEntry[] {
237
237
  source: `failure:${slug}`,
238
238
  text: context.slice(0, 300),
239
239
  date: ts.slice(0, 10),
240
- tags: entryTags,
240
+ principle: entryPrinciple,
241
241
  });
242
242
  }
243
243
  }
@@ -265,16 +265,16 @@ function collectLearnings(): LearningEntry[] {
265
265
  const content = readFileSync(resolve(monthDir, file), "utf-8");
266
266
  let title = "";
267
267
  let insights = "";
268
- let entryTags: string[] = [];
268
+ let entryPrinciple = "";
269
269
 
270
270
  if (hasFrontmatter(content)) {
271
271
  // New format
272
272
  const { meta, body } = parse<{
273
273
  title?: string;
274
- tags?: string[];
274
+ principle?: string;
275
275
  }>(content);
276
276
  title = meta.title || "";
277
- if (Array.isArray(meta.tags)) entryTags = meta.tags;
277
+ entryPrinciple = meta.principle || "";
278
278
  const insightsMatch = body.match(/## Insights\n([\s\S]*?)(?=\n##|$)/);
279
279
  insights = insightsMatch?.[1]?.trim() || "";
280
280
  } else {
@@ -295,7 +295,7 @@ function collectLearnings(): LearningEntry[] {
295
295
  source: `learning:${file}`,
296
296
  text: text.slice(0, 300),
297
297
  date,
298
- tags: entryTags,
298
+ principle: entryPrinciple,
299
299
  });
300
300
  }
301
301
  } catch {
@@ -313,7 +313,7 @@ function collectLearnings(): LearningEntry[] {
313
313
 
314
314
  // ── Grouping ──
315
315
 
316
- const SIMILARITY_THRESHOLD = 0.35;
316
+ export const SIMILARITY_THRESHOLD = 0.35;
317
317
  const MIN_OCCURRENCES = 3;
318
318
  const MIN_TEXT_LENGTH = 30;
319
319
 
@@ -329,33 +329,17 @@ function isActionable(text: string): boolean {
329
329
  return true;
330
330
  }
331
331
 
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
332
  function groupPatterns(entries: LearningEntry[]): PatternGroup[] {
339
333
  const groups: PatternGroup[] = [];
340
334
  const actionable = entries.filter((e) => isActionable(e.text));
341
335
 
342
336
  for (const entry of actionable) {
337
+ // Use principle for matching if available, fall back to raw text
338
+ const matchText = entry.principle || entry.text;
343
339
  let matched = false;
344
340
  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
- ) {
341
+ const groupText = group.entries[0]?.principle || group.pattern;
342
+ if (similarity(matchText, groupText) >= SIMILARITY_THRESHOLD) {
359
343
  group.entries.push(entry);
360
344
  matched = true;
361
345
  break;
@@ -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
+ );
@@ -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,11 +38,16 @@ 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
 
@@ -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
- }