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.
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Learning Store — single collection layer for reading failures and session learnings.
3
+ *
4
+ * Both context.ts (session injection) and graduation.ts (pattern detection) read from
5
+ * the same directories. This module provides a shared, deduplicated API for both.
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { parse } from "./frontmatter";
11
+
12
+ // ── Types ──
13
+
14
+ export interface FailureEntry {
15
+ slug: string;
16
+ rating: number;
17
+ context: string;
18
+ principle: string;
19
+ date: string;
20
+ ts: string;
21
+ }
22
+
23
+ export interface LearningEntry {
24
+ filename: string;
25
+ title: string;
26
+ category: string;
27
+ principle: string;
28
+ date: string;
29
+ insights: string;
30
+ }
31
+
32
+ // ── Shared Directory Walker ──
33
+
34
+ /**
35
+ * Walk year/month directory structure in reverse chronological order.
36
+ * Yields entries from the innermost directory (month level).
37
+ */
38
+ function* walkMonthDirs(baseDir: string): Generator<{ monthDir: string }> {
39
+ if (!existsSync(baseDir)) return;
40
+ try {
41
+ for (const year of readdirSync(baseDir).sort().reverse()) {
42
+ const yearDir = resolve(baseDir, year);
43
+ try {
44
+ for (const month of readdirSync(yearDir).sort().reverse()) {
45
+ yield { monthDir: resolve(yearDir, month) };
46
+ }
47
+ } catch {
48
+ /* skip invalid year dirs */
49
+ }
50
+ }
51
+ } catch {
52
+ /* non-critical */
53
+ }
54
+ }
55
+
56
+ // ── Failures ──
57
+
58
+ export function readFailures(baseDir: string, limit?: number): FailureEntry[] {
59
+ const entries: FailureEntry[] = [];
60
+
61
+ for (const { monthDir } of walkMonthDirs(baseDir)) {
62
+ try {
63
+ for (const slug of readdirSync(monthDir).sort().reverse()) {
64
+ const capturePath = resolve(monthDir, slug, "capture.md");
65
+ if (!existsSync(capturePath)) continue;
66
+
67
+ try {
68
+ const content = readFileSync(capturePath, "utf-8");
69
+ const { meta } = parse<{
70
+ rating?: number;
71
+ context?: string;
72
+ principle?: string;
73
+ date?: string;
74
+ ts?: string;
75
+ slug?: string;
76
+ }>(content);
77
+
78
+ if (!meta.context) continue;
79
+
80
+ entries.push({
81
+ slug: meta.slug || slug,
82
+ rating: meta.rating ?? 0,
83
+ context: meta.context,
84
+ principle: meta.principle || "",
85
+ date: meta.date || (meta.ts ? String(meta.ts).slice(0, 10) : ""),
86
+ ts: meta.ts ? String(meta.ts) : "",
87
+ });
88
+
89
+ if (limit && entries.length >= limit) return entries;
90
+ } catch {
91
+ /* skip malformed */
92
+ }
93
+ }
94
+ } catch {
95
+ /* skip invalid month dirs */
96
+ }
97
+ }
98
+
99
+ return entries;
100
+ }
101
+
102
+ // ── Learnings ──
103
+
104
+ export function readLearnings(baseDir: string, limit?: number): LearningEntry[] {
105
+ const entries: LearningEntry[] = [];
106
+
107
+ for (const { monthDir } of walkMonthDirs(baseDir)) {
108
+ try {
109
+ const files = readdirSync(monthDir)
110
+ .filter((f) => f.endsWith(".md"))
111
+ .sort()
112
+ .reverse();
113
+
114
+ for (const file of files) {
115
+ try {
116
+ const content = readFileSync(resolve(monthDir, file), "utf-8");
117
+ const { meta, body } = parse<{
118
+ title?: string;
119
+ category?: string;
120
+ principle?: string;
121
+ date?: string;
122
+ }>(content);
123
+
124
+ if (!meta.title) continue;
125
+
126
+ const insightsMatch = body.match(/## Insights\n([\s\S]*?)(?=\n##|$)/);
127
+
128
+ entries.push({
129
+ filename: file,
130
+ title: meta.title,
131
+ category: meta.category || "algorithm",
132
+ principle: meta.principle || "",
133
+ date: meta.date || "",
134
+ insights: insightsMatch?.[1]?.trim() || "",
135
+ });
136
+
137
+ if (limit && entries.length >= limit) return entries;
138
+ } catch {
139
+ /* skip malformed */
140
+ }
141
+ }
142
+ } catch {
143
+ /* skip invalid month dirs */
144
+ }
145
+ }
146
+
147
+ return entries;
148
+ }
149
+
150
+ // ── Text Similarity (Jaccard on keywords) ──
151
+
152
+ const STOP_WORDS = new Set([
153
+ "the",
154
+ "a",
155
+ "an",
156
+ "is",
157
+ "was",
158
+ "are",
159
+ "were",
160
+ "be",
161
+ "been",
162
+ "being",
163
+ "have",
164
+ "has",
165
+ "had",
166
+ "do",
167
+ "does",
168
+ "did",
169
+ "will",
170
+ "would",
171
+ "could",
172
+ "should",
173
+ "may",
174
+ "might",
175
+ "can",
176
+ "shall",
177
+ "to",
178
+ "of",
179
+ "in",
180
+ "for",
181
+ "on",
182
+ "with",
183
+ "at",
184
+ "by",
185
+ "from",
186
+ "as",
187
+ "into",
188
+ "through",
189
+ "during",
190
+ "before",
191
+ "after",
192
+ "and",
193
+ "but",
194
+ "or",
195
+ "nor",
196
+ "not",
197
+ "no",
198
+ "so",
199
+ "if",
200
+ "then",
201
+ "than",
202
+ "that",
203
+ "this",
204
+ "it",
205
+ "its",
206
+ "i",
207
+ "you",
208
+ "he",
209
+ "she",
210
+ "we",
211
+ "they",
212
+ "my",
213
+ "your",
214
+ "his",
215
+ "her",
216
+ "our",
217
+ "their",
218
+ "what",
219
+ "which",
220
+ "who",
221
+ "when",
222
+ "where",
223
+ "how",
224
+ "all",
225
+ "each",
226
+ "both",
227
+ "few",
228
+ "more",
229
+ "most",
230
+ "other",
231
+ "some",
232
+ "such",
233
+ "up",
234
+ "out",
235
+ "about",
236
+ "just",
237
+ "also",
238
+ "very",
239
+ "too",
240
+ "only",
241
+ "own",
242
+ ]);
243
+
244
+ export function extractKeywords(text: string): Set<string> {
245
+ return new Set(
246
+ text
247
+ .toLowerCase()
248
+ .replace(/[^a-z0-9\s-]/g, " ")
249
+ .split(/\s+/)
250
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
251
+ );
252
+ }
253
+
254
+ export function similarity(a: string, b: string): number {
255
+ const ka = extractKeywords(a);
256
+ const kb = extractKeywords(b);
257
+ if (ka.size === 0 || kb.size === 0) return 0;
258
+
259
+ let intersection = 0;
260
+ for (const w of ka) {
261
+ if (kb.has(w)) intersection++;
262
+ }
263
+ const union = new Set([...ka, ...kb]).size;
264
+ return union > 0 ? intersection / union : 0;
265
+ }
@@ -30,6 +30,7 @@ export const HOOK_MANAGED_FILES = [
30
30
  "pending-failure.json",
31
31
  "token-usage.jsonl",
32
32
  "graduated.json",
33
+ "update-available.json",
33
34
  ];
34
35
 
35
36
  /** Hook-managed directories — AI must not write to or delete from these */
@@ -8,7 +8,6 @@ import { resolve } from "node:path";
8
8
  import { autoBackup } from "../handlers/backup";
9
9
  import { captureFailure } from "../handlers/failure";
10
10
  import { captureRelationship } from "../handlers/relationship";
11
- import { checkSynthesisTrigger } from "../handlers/synthesis";
12
11
  import { resetTab } from "../handlers/tab";
13
12
  import { updateCounts } from "../handlers/update-counts";
14
13
  import { captureWorkLearning } from "../handlers/work-learning";
@@ -44,7 +43,6 @@ export async function runStopHandlers(
44
43
  checkPendingFailure(transcript),
45
44
  updateCounts(),
46
45
  autoBackup(),
47
- checkSynthesisTrigger(),
48
46
  ]);
49
47
 
50
48
  const handlerNames = [
@@ -55,7 +53,6 @@ export async function runStopHandlers(
55
53
  "pending-failure",
56
54
  "update-counts",
57
55
  "backup",
58
- "synthesis",
59
56
  ];
60
57
  for (let i = 0; i < results.length; i++) {
61
58
  const r = results[i];
@@ -145,9 +142,7 @@ async function checkPendingFailure(transcript: string): Promise<void> {
145
142
  pending.rating,
146
143
  pending.context,
147
144
  transcript,
148
- pending.detailedContext,
149
- pending.responsePreview,
150
- pending.userPreview
145
+ pending.detailedContext
151
146
  );
152
147
  } catch {
153
148
  // Non-critical
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unified Learning Analysis — graduation patterns + ratings summary.
4
+ *
5
+ * Reads failures and session learnings, finds recurring patterns,
6
+ * summarizes ratings, and generates recommendations.
7
+ *
8
+ * Usage: bun run tool:analyze
9
+ */
10
+
11
+ import { parseArgs } from "node:util";
12
+ import { analyze } from "../hooks/lib/graduation";
13
+
14
+ const { values } = parseArgs({
15
+ args: Bun.argv.slice(2),
16
+ options: {
17
+ help: { type: "boolean", short: "h" },
18
+ actionable: { type: "boolean", short: "a" },
19
+ },
20
+ });
21
+
22
+ if (values.help) {
23
+ console.log(`
24
+ PAL Learning Analysis — unified graduation + ratings report
25
+
26
+ Reads all captured failures (rating ≤3) and session learnings,
27
+ groups recurring patterns via Jaccard similarity on context text,
28
+ and summarizes rating trends.
29
+
30
+ Sections:
31
+ Ratings Overall average, low/high counts
32
+ Graduation Patterns with 3+ occurrences → ready to crystallize
33
+ Emerging Patterns with 2 occurrences → one more to graduate
34
+
35
+ Flags:
36
+ --actionable, -a Generate actionable recommendations via Haiku inference
37
+
38
+ To crystallize a graduated pattern, add it to the target wisdom frame:
39
+ - Your principle here [CRYSTAL: 85%]
40
+
41
+ Usage: bun run tool:analyze [--actionable] [--help]
42
+ `);
43
+ process.exit(0);
44
+ }
45
+
46
+ const result = await analyze({ actionable: values.actionable });
47
+
48
+ const hasPatterns = result.candidates.length > 0 || result.emerging.length > 0;
49
+ const hasRatings = result.ratings !== null;
50
+
51
+ if (!hasPatterns && !hasRatings) {
52
+ console.log("\n No patterns or ratings data found.\n");
53
+ process.exit(0);
54
+ }
55
+
56
+ // ── Ratings Summary ──
57
+
58
+ if (result.ratings) {
59
+ const r = result.ratings;
60
+ console.log(`\n Ratings: ${r.average.toFixed(1)}/10 avg (${r.total} total)`);
61
+ console.log(` Low (≤4): ${r.low.count} | High (≥7): ${r.high.count}`);
62
+ }
63
+
64
+ // ── Graduation Candidates ──
65
+
66
+ if (result.candidates.length > 0) {
67
+ console.log(
68
+ `\n Graduation Report — ${result.candidates.length} pattern(s) detected\n`
69
+ );
70
+ console.log(" ─────────────────────────────────────────────────\n");
71
+
72
+ for (const candidate of result.candidates) {
73
+ console.log(` [${candidate.domain}] ${candidate.entries.length}x occurrences`);
74
+ console.log("");
75
+
76
+ for (const entry of candidate.entries) {
77
+ const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
78
+ console.log(
79
+ ` ${entry.date || "unknown"} [${sourceType}] ${entry.text.slice(0, 100)}`
80
+ );
81
+ }
82
+
83
+ console.log("");
84
+ console.log(" Target frame:", `memory/wisdom/frames/${candidate.domain}.md`);
85
+ console.log(" ─────────────────────────────────────────────────\n");
86
+ }
87
+ }
88
+
89
+ // ── Emerging Patterns ──
90
+
91
+ if (result.emerging.length > 0) {
92
+ console.log(` Emerging (2x — one more to graduate)\n`);
93
+ for (const group of result.emerging) {
94
+ console.log(` [${group.domain}] ${group.entries.length}x`);
95
+ for (const entry of group.entries) {
96
+ const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
97
+ console.log(
98
+ ` ${entry.date || "unknown"} [${sourceType}] ${entry.text.slice(0, 80)}`
99
+ );
100
+ }
101
+ console.log("");
102
+ }
103
+ }
104
+
105
+ // ── Recommendations ──
106
+
107
+ if (result.recommendations.length > 0) {
108
+ console.log(" Recommendations:\n");
109
+ for (const rec of result.recommendations) {
110
+ console.log(` ${rec}`);
111
+ }
112
+ console.log("");
113
+ }
114
+
115
+ if (result.candidates.length > 0) {
116
+ console.log(" To crystallize: add a line to the wisdom frame file.");
117
+ console.log(" Format: - Your principle here [CRYSTAL: 85%]\n");
118
+ }
@@ -1,109 +0,0 @@
1
- /**
2
- * Auto-trigger pattern synthesis on Stop when conditions are met:
3
- * - 7+ days since last synthesis report
4
- * - 20+ new ratings since last synthesis
5
- */
6
-
7
- import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { resolve } from "node:path";
9
- import { logDebug } from "../lib/log";
10
- import { palPkg, paths } from "../lib/paths";
11
-
12
- const MIN_DAYS_BETWEEN = 7;
13
- const MIN_NEW_RATINGS = 20;
14
-
15
- function getLastSynthesisDate(): Date | null {
16
- try {
17
- const synthDir = paths.synthesis();
18
- if (!existsSync(synthDir)) return null;
19
-
20
- const months = readdirSync(synthDir).sort().reverse();
21
- for (const month of months) {
22
- const monthDir = resolve(synthDir, month);
23
- const files = readdirSync(monthDir)
24
- .filter((f) => f.endsWith(".md"))
25
- .sort()
26
- .reverse();
27
- if (files.length > 0) {
28
- // Filename: YYYY-MM-DD_period-patterns.md
29
- const dateStr = files[0].slice(0, 10);
30
- const date = new Date(dateStr);
31
- if (!Number.isNaN(date.getTime())) return date;
32
- }
33
- }
34
- } catch {
35
- /* ignore */
36
- }
37
- return null;
38
- }
39
-
40
- function countRatingsSince(since: Date | null): number {
41
- try {
42
- const ratingsFile = resolve(paths.signals(), "ratings.jsonl");
43
- if (!existsSync(ratingsFile)) return 0;
44
-
45
- const lines = readFileSync(ratingsFile, "utf-8").trim().split("\n");
46
- if (!since) return lines.length;
47
-
48
- let count = 0;
49
- for (const line of lines) {
50
- try {
51
- const entry = JSON.parse(line) as { ts?: string };
52
- if (entry.ts && new Date(entry.ts) > since) count++;
53
- } catch {
54
- /* skip bad lines */
55
- }
56
- }
57
- return count;
58
- } catch {
59
- return 0;
60
- }
61
- }
62
-
63
- export async function checkSynthesisTrigger(): Promise<void> {
64
- const lastDate = getLastSynthesisDate();
65
- const now = new Date();
66
-
67
- // Check days since last synthesis
68
- if (lastDate) {
69
- const daysSince = (now.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24);
70
- if (daysSince < MIN_DAYS_BETWEEN) {
71
- logDebug(
72
- "synthesis",
73
- `Skipping: only ${daysSince.toFixed(1)} days since last report`
74
- );
75
- return;
76
- }
77
- }
78
-
79
- // Check new rating count
80
- const newRatings = countRatingsSince(lastDate);
81
- if (newRatings < MIN_NEW_RATINGS) {
82
- logDebug(
83
- "synthesis",
84
- `Skipping: only ${newRatings} new ratings (need ${MIN_NEW_RATINGS})`
85
- );
86
- return;
87
- }
88
-
89
- logDebug(
90
- "synthesis",
91
- `Triggering: ${newRatings} new ratings, ${lastDate ? `last report: ${lastDate.toISOString().slice(0, 10)}` : "no previous report"}`
92
- );
93
-
94
- // Spawn synthesis as a detached process so it doesn't block the Stop handler
95
- try {
96
- const repoDir = palPkg();
97
- const proc = Bun.spawn(["bun", "run", "tool:patterns"], {
98
- cwd: repoDir,
99
- stdout: "ignore",
100
- stderr: "ignore",
101
- stdin: "ignore",
102
- });
103
- // Don't await — let it run in background
104
- proc.unref();
105
- logDebug("synthesis", "Spawned pattern synthesis in background");
106
- } catch (err) {
107
- logDebug("synthesis", `Failed to spawn: ${err}`);
108
- }
109
- }
@@ -1,79 +0,0 @@
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 && result.emerging.length === 0) {
17
- console.log("\n No recurring patterns found.\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
- 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
- }