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,483 @@
1
+ /**
2
+ * Wisdom Graduation Pipeline — promotes recurring patterns into permanent wisdom frames.
3
+ *
4
+ * Reads failures and session learnings, detects recurring patterns,
5
+ * and graduates them into wisdom frames with confidence tracking.
6
+ *
7
+ * A pattern qualifies for graduation when it appears 3+ times across different sessions.
8
+ * Confidence starts at 60% and increases by 10% per additional occurrence (capped at 95%).
9
+ * At 85%+, the entry gets the [CRYSTAL: N%] tag and is loaded every session.
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { hasFrontmatter, parse } from "./frontmatter";
15
+ import { logDebug } from "./log";
16
+ import { ensureDir, paths } from "./paths";
17
+
18
+ // ── Types ──
19
+
20
+ interface LearningEntry {
21
+ source: string; // "failure:{slug}" or "learning:{filename}"
22
+ text: string; // context or title+insights
23
+ date: string; // YYYY-MM-DD
24
+ principle: string; // candidate principle from inference
25
+ }
26
+
27
+ interface PatternGroup {
28
+ pattern: string; // representative text
29
+ entries: LearningEntry[];
30
+ domain: string;
31
+ }
32
+
33
+ interface GraduatedEntry {
34
+ pattern: string;
35
+ domain: string;
36
+ confidence: number;
37
+ occurrences: number;
38
+ sources: string[];
39
+ graduatedAt: string;
40
+ }
41
+
42
+ interface GraduationState {
43
+ lastRun: string;
44
+ graduated: GraduatedEntry[];
45
+ }
46
+
47
+ export interface GraduationResult {
48
+ candidates: PatternGroup[];
49
+ graduated: GraduatedEntry[];
50
+ updated: GraduatedEntry[];
51
+ }
52
+
53
+ // ── Domain Classification ──
54
+
55
+ const DOMAIN_MAP: [RegExp, string][] = [
56
+ [/code|test|hook|build|deploy|function|import|type|lint/i, "development"],
57
+ [/commit|git|release|version|tag|branch|push|merge/i, "workflow"],
58
+ [/tone|format|response|verbose|brief|summary|explain/i, "communication"],
59
+ [/install|config|setup|env|path|directory/i, "infrastructure"],
60
+ [/api|endpoint|request|token|auth/i, "integration"],
61
+ ];
62
+
63
+ function classifyDomain(text: string): string {
64
+ for (const [pattern, domain] of DOMAIN_MAP) {
65
+ if (pattern.test(text)) return domain;
66
+ }
67
+ return "general";
68
+ }
69
+
70
+ // ── Pattern Similarity ──
71
+
72
+ const STOP_WORDS = new Set([
73
+ "the",
74
+ "a",
75
+ "an",
76
+ "is",
77
+ "was",
78
+ "are",
79
+ "were",
80
+ "be",
81
+ "been",
82
+ "being",
83
+ "have",
84
+ "has",
85
+ "had",
86
+ "do",
87
+ "does",
88
+ "did",
89
+ "will",
90
+ "would",
91
+ "could",
92
+ "should",
93
+ "may",
94
+ "might",
95
+ "can",
96
+ "shall",
97
+ "to",
98
+ "of",
99
+ "in",
100
+ "for",
101
+ "on",
102
+ "with",
103
+ "at",
104
+ "by",
105
+ "from",
106
+ "as",
107
+ "into",
108
+ "through",
109
+ "during",
110
+ "before",
111
+ "after",
112
+ "and",
113
+ "but",
114
+ "or",
115
+ "nor",
116
+ "not",
117
+ "no",
118
+ "so",
119
+ "if",
120
+ "then",
121
+ "than",
122
+ "that",
123
+ "this",
124
+ "it",
125
+ "its",
126
+ "i",
127
+ "you",
128
+ "he",
129
+ "she",
130
+ "we",
131
+ "they",
132
+ "my",
133
+ "your",
134
+ "his",
135
+ "her",
136
+ "our",
137
+ "their",
138
+ "what",
139
+ "which",
140
+ "who",
141
+ "when",
142
+ "where",
143
+ "how",
144
+ "all",
145
+ "each",
146
+ "both",
147
+ "few",
148
+ "more",
149
+ "most",
150
+ "other",
151
+ "some",
152
+ "such",
153
+ "up",
154
+ "out",
155
+ "about",
156
+ "just",
157
+ "also",
158
+ "very",
159
+ "too",
160
+ "only",
161
+ "own",
162
+ ]);
163
+
164
+ function extractKeywords(text: string): Set<string> {
165
+ return new Set(
166
+ text
167
+ .toLowerCase()
168
+ .replace(/[^a-z0-9\s-]/g, " ")
169
+ .split(/\s+/)
170
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
171
+ );
172
+ }
173
+
174
+ export function similarity(a: string, b: string): number {
175
+ const ka = extractKeywords(a);
176
+ const kb = extractKeywords(b);
177
+ if (ka.size === 0 || kb.size === 0) return 0;
178
+
179
+ let intersection = 0;
180
+ for (const w of ka) {
181
+ if (kb.has(w)) intersection++;
182
+ }
183
+ const union = new Set([...ka, ...kb]).size;
184
+ return union > 0 ? intersection / union : 0;
185
+ }
186
+
187
+ // ── Data Collection ──
188
+
189
+ function collectFailures(): LearningEntry[] {
190
+ const entries: LearningEntry[] = [];
191
+ const failuresDir = paths.failures();
192
+ if (!existsSync(failuresDir)) return entries;
193
+
194
+ try {
195
+ for (const year of readdirSync(failuresDir)) {
196
+ const yearDir = resolve(failuresDir, year);
197
+ for (const month of readdirSync(yearDir)) {
198
+ const monthDir = resolve(yearDir, month);
199
+ for (const slug of readdirSync(monthDir)) {
200
+ let context = "";
201
+ let ts = "";
202
+ let entryPrinciple = "";
203
+
204
+ // Try capture.md (new format)
205
+ const capturePath = resolve(monthDir, slug, "capture.md");
206
+ if (existsSync(capturePath)) {
207
+ try {
208
+ const content = readFileSync(capturePath, "utf-8");
209
+ const { meta } = parse<{
210
+ context?: string;
211
+ ts?: string;
212
+ principle?: string;
213
+ }>(content);
214
+ context = meta.context || "";
215
+ ts = (meta.ts as string) || "";
216
+ entryPrinciple = meta.principle || "";
217
+ } catch {
218
+ /* fallback below */
219
+ }
220
+ }
221
+
222
+ // DEPRECATED: legacy sentiment.json fallback — remove once old failures have capture.md
223
+ if (!context) {
224
+ const sentimentPath = resolve(monthDir, slug, "sentiment.json");
225
+ if (!existsSync(sentimentPath)) continue;
226
+ try {
227
+ const sentiment = JSON.parse(readFileSync(sentimentPath, "utf-8"));
228
+ context = sentiment.context || "";
229
+ ts = sentiment.ts || "";
230
+ } catch {
231
+ continue;
232
+ }
233
+ }
234
+
235
+ if (context.length >= MIN_TEXT_LENGTH) {
236
+ entries.push({
237
+ source: `failure:${slug}`,
238
+ text: context.slice(0, 300),
239
+ date: ts.slice(0, 10),
240
+ principle: entryPrinciple,
241
+ });
242
+ }
243
+ }
244
+ }
245
+ }
246
+ } catch {
247
+ /* non-critical */
248
+ }
249
+
250
+ return entries;
251
+ }
252
+
253
+ function collectLearnings(): LearningEntry[] {
254
+ const entries: LearningEntry[] = [];
255
+ const learningDir = paths.sessionLearning();
256
+ if (!existsSync(learningDir)) return entries;
257
+
258
+ try {
259
+ for (const year of readdirSync(learningDir)) {
260
+ const yearDir = resolve(learningDir, year);
261
+ for (const month of readdirSync(yearDir)) {
262
+ const monthDir = resolve(yearDir, month);
263
+ for (const file of readdirSync(monthDir).filter((f) => f.endsWith(".md"))) {
264
+ try {
265
+ const content = readFileSync(resolve(monthDir, file), "utf-8");
266
+ let title = "";
267
+ let insights = "";
268
+ let entryPrinciple = "";
269
+
270
+ if (hasFrontmatter(content)) {
271
+ // New format
272
+ const { meta, body } = parse<{
273
+ title?: string;
274
+ principle?: string;
275
+ }>(content);
276
+ title = meta.title || "";
277
+ entryPrinciple = meta.principle || "";
278
+ const insightsMatch = body.match(/## Insights\n([\s\S]*?)(?=\n##|$)/);
279
+ insights = insightsMatch?.[1]?.trim() || "";
280
+ } else {
281
+ // DEPRECATED: legacy **Title:** format — remove once old learning files are migrated
282
+ const titleMatch = content.match(/\*\*Title:\*\*\s*(.+)/);
283
+ title = titleMatch?.[1] || "";
284
+ const insightsMatch = content.match(/## Insights\n([\s\S]*?)(?=\n##|$)/);
285
+ insights = insightsMatch?.[1]?.trim() || "";
286
+ }
287
+
288
+ const text = [title, insights].filter(Boolean).join(" ");
289
+ if (text.length >= MIN_TEXT_LENGTH) {
290
+ const dateMatch = file.match(/^(\d{8})/);
291
+ const date = dateMatch
292
+ ? `${dateMatch[1].slice(0, 4)}-${dateMatch[1].slice(4, 6)}-${dateMatch[1].slice(6, 8)}`
293
+ : "";
294
+ entries.push({
295
+ source: `learning:${file}`,
296
+ text: text.slice(0, 300),
297
+ date,
298
+ principle: entryPrinciple,
299
+ });
300
+ }
301
+ } catch {
302
+ /* skip */
303
+ }
304
+ }
305
+ }
306
+ }
307
+ } catch {
308
+ /* non-critical */
309
+ }
310
+
311
+ return entries;
312
+ }
313
+
314
+ // ── Grouping ──
315
+
316
+ export const SIMILARITY_THRESHOLD = 0.35;
317
+ const MIN_OCCURRENCES = 3;
318
+ const MIN_TEXT_LENGTH = 30;
319
+
320
+ /** Filter out entries that aren't actionable as wisdom (questions, greetings, etc.) */
321
+ function isActionable(text: string): boolean {
322
+ const trimmed = text.trim();
323
+ // Skip pure questions
324
+ if (/\?[\s]*$/.test(trimmed)) return false;
325
+ // Skip auto-captured boilerplate
326
+ if (trimmed.includes("*Auto-captured")) return false;
327
+ // Skip very short after cleanup
328
+ if (extractKeywords(trimmed).size < 4) return false;
329
+ return true;
330
+ }
331
+
332
+ function groupPatterns(entries: LearningEntry[]): PatternGroup[] {
333
+ const groups: PatternGroup[] = [];
334
+ const actionable = entries.filter((e) => isActionable(e.text));
335
+
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;
339
+ let matched = false;
340
+ for (const group of groups) {
341
+ const groupText = group.entries[0]?.principle || group.pattern;
342
+ if (similarity(matchText, groupText) >= SIMILARITY_THRESHOLD) {
343
+ group.entries.push(entry);
344
+ matched = true;
345
+ break;
346
+ }
347
+ }
348
+ if (!matched) {
349
+ groups.push({
350
+ pattern: entry.text,
351
+ entries: [entry],
352
+ domain: classifyDomain(entry.text),
353
+ });
354
+ }
355
+ }
356
+
357
+ return groups.filter((g) => g.entries.length >= MIN_OCCURRENCES);
358
+ }
359
+
360
+ // ── State Management ──
361
+
362
+ function stateFilePath(): string {
363
+ return resolve(ensureDir(paths.wisdomState()), "graduated.json");
364
+ }
365
+
366
+ function readState(): GraduationState {
367
+ const fp = stateFilePath();
368
+ if (!existsSync(fp)) return { lastRun: "", graduated: [] };
369
+ try {
370
+ return JSON.parse(readFileSync(fp, "utf-8"));
371
+ } catch {
372
+ return { lastRun: "", graduated: [] };
373
+ }
374
+ }
375
+
376
+ function writeState(state: GraduationState): void {
377
+ writeFileSync(stateFilePath(), JSON.stringify(state, null, 2), "utf-8");
378
+ }
379
+
380
+ // ── Main Graduation Logic ──
381
+
382
+ /**
383
+ * Summarize a pattern group into a concise principle statement.
384
+ * Uses the most common keywords across all entries.
385
+ */
386
+ function synthesizePrinciple(group: PatternGroup): string {
387
+ // Use the shortest entry as the base (likely most concise)
388
+ const sorted = [...group.entries].sort((a, b) => a.text.length - b.text.length);
389
+ let principle = sorted[0].text;
390
+
391
+ // Clean up: take first sentence, cap at 120 chars
392
+ const firstSentence = principle.match(/^[^.!?]+[.!?]?/);
393
+ if (firstSentence) principle = firstSentence[0];
394
+ if (principle.length > 120) {
395
+ principle = `${principle.slice(0, 117)}...`;
396
+ }
397
+
398
+ return principle.trim();
399
+ }
400
+
401
+ export function graduate(): GraduationResult {
402
+ const state = readState();
403
+ const failures = collectFailures();
404
+ const learnings = collectLearnings();
405
+ const all = [...failures, ...learnings];
406
+
407
+ logDebug(
408
+ "graduation",
409
+ `Collected ${failures.length} failures, ${learnings.length} learnings`
410
+ );
411
+
412
+ const candidates = groupPatterns(all);
413
+ const result: GraduationResult = {
414
+ candidates,
415
+ graduated: [],
416
+ updated: [],
417
+ };
418
+
419
+ if (candidates.length === 0) {
420
+ logDebug("graduation", "No patterns with 3+ occurrences found");
421
+ state.lastRun = new Date().toISOString();
422
+ writeState(state);
423
+ return result;
424
+ }
425
+
426
+ // Report candidates — no auto-writing, user decides what to crystallize
427
+ for (const group of candidates) {
428
+ const principle = synthesizePrinciple(group);
429
+ const sources = group.entries.map((e) => e.source);
430
+ const confidence = Math.min(95, 60 + (group.entries.length - MIN_OCCURRENCES) * 10);
431
+
432
+ result.graduated.push({
433
+ pattern: principle,
434
+ domain: group.domain,
435
+ confidence,
436
+ occurrences: group.entries.length,
437
+ sources,
438
+ graduatedAt: new Date().toISOString(),
439
+ });
440
+ }
441
+
442
+ // Update lastRun to prevent re-running immediately
443
+ state.lastRun = new Date().toISOString();
444
+ writeState(state);
445
+
446
+ logDebug(
447
+ "graduation",
448
+ `Found ${result.graduated.length} candidate(s) for manual crystallization`
449
+ );
450
+
451
+ return result;
452
+ }
453
+
454
+ // ── Threshold Check (for Stop hook) ──
455
+
456
+ const GRADUATION_INTERVAL_DAYS = 7;
457
+ const MIN_NEW_ENTRIES = 10;
458
+
459
+ export function shouldRunGraduation(): boolean {
460
+ const state = readState();
461
+
462
+ // Enough time passed since last run?
463
+ let timeThreshold = false;
464
+ if (!state.lastRun) {
465
+ timeThreshold = true;
466
+ } else {
467
+ const daysSince =
468
+ (Date.now() - new Date(state.lastRun).getTime()) / (1000 * 60 * 60 * 24);
469
+ timeThreshold = daysSince >= GRADUATION_INTERVAL_DAYS;
470
+ }
471
+
472
+ // Enough new material?
473
+ const failures = collectFailures();
474
+ const learnings = collectLearnings();
475
+ const graduatedSources = new Set(state.graduated.flatMap((g) => g.sources));
476
+ const newEntries = [...failures, ...learnings].filter(
477
+ (e) => !graduatedSources.has(e.source)
478
+ ).length;
479
+ const entryThreshold = newEntries >= MIN_NEW_ENTRIES;
480
+
481
+ // Run if either condition is met
482
+ return timeThreshold || entryThreshold;
483
+ }
@@ -9,8 +9,8 @@ export const MODEL_PRICING: Record<
9
9
  string,
10
10
  { input: number; output: number; cacheWrite: number; cacheRead: number }
11
11
  > = {
12
- [HAIKU_MODEL]: { input: 1.0, output: 5.0, cacheWrite: 1.25, cacheRead: 0.1 },
13
- "claude-opus-4-6": { input: 5.0, output: 25.0, cacheWrite: 6.25, cacheRead: 0.5 },
14
- "claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.3 },
15
- "claude-sonnet-4-5": { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.3 },
12
+ [HAIKU_MODEL]: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
13
+ "claude-opus-4-6": { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
14
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
15
+ "claude-sonnet-4-5": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
16
16
  };
@@ -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,6 +29,7 @@ export const HOOK_MANAGED_FILES = [
29
29
  "signal-cache.json",
30
30
  "pending-failure.json",
31
31
  "token-usage.jsonl",
32
+ "graduated.json",
32
33
  ];
33
34
 
34
35
  /** Hook-managed directories — AI must not write to or delete from these */
@@ -96,12 +96,13 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
96
96
  writeFileSync(
97
97
  resolve(dir, filename),
98
98
  [
99
- `# ${source === "explicit" ? "Low Rating" : "Implicit Low Rating"}: ${rating}/10`,
100
- `**Title:** ${context.slice(0, 100) || "(low rating)"}`,
101
- `**Date:** ${new Date().toISOString().slice(0, 10)}`,
102
- `**Rating:** ${rating}/10`,
103
- `**Source:** ${source}`,
104
- `**Category:** ${category.toUpperCase()}`,
99
+ "---",
100
+ `title: "${(context.slice(0, 100) || "(low rating)").replace(/"/g, '\\"')}"`,
101
+ `category: "${category}"`,
102
+ `date: "${new Date().toISOString().slice(0, 10)}"`,
103
+ `rating: ${rating}`,
104
+ `source: "${source}"`,
105
+ "---",
105
106
  "",
106
107
  "## Context",
107
108
  context || "*(unavailable)*",