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