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.
- package/package.json +2 -3
- package/src/cli/index.ts +12 -0
- package/src/hooks/handlers/failure.ts +20 -83
- package/src/hooks/handlers/rating.ts +1 -1
- package/src/hooks/handlers/update-check.ts +189 -0
- package/src/hooks/handlers/work-learning.ts +4 -9
- package/src/hooks/lib/context.ts +24 -150
- package/src/hooks/lib/graduation.ts +199 -336
- package/src/hooks/lib/learning-store.ts +265 -0
- package/src/hooks/lib/security.ts +1 -0
- package/src/hooks/lib/stop.ts +1 -6
- package/src/tools/analyze.ts +118 -0
- package/src/hooks/handlers/synthesis.ts +0 -109
- package/src/tools/graduate.ts +0 -79
- package/src/tools/pattern-synthesis.ts +0 -432
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Unified Learning Analysis — graduation + ratings summary in one pipeline.
|
|
3
3
|
*
|
|
4
|
-
* Reads failures and session learnings, detects recurring
|
|
5
|
-
*
|
|
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,
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
14
|
import { resolve } from "node:path";
|
|
14
|
-
import {
|
|
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
|
|
21
|
-
source: string;
|
|
22
|
-
text: string;
|
|
23
|
-
date: string;
|
|
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;
|
|
29
|
-
entries:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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:
|
|
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
|
-
|
|
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]?.
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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 =
|
|
405
|
-
const learnings =
|
|
406
|
-
const all =
|
|
314
|
+
const failures = readFailures(paths.failures());
|
|
315
|
+
const learnings = readLearnings(paths.sessionLearning());
|
|
316
|
+
const all = toAnalysisEntries(failures, learnings);
|
|
407
317
|
|
|
408
318
|
logDebug(
|
|
409
|
-
"
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return result;
|
|
428
|
-
}
|
|
327
|
+
const ratings = summarizeRatings(loadRatings());
|
|
328
|
+
const recommendations = opts.actionable
|
|
329
|
+
? await generateRecommendations(candidates, ratings)
|
|
330
|
+
: [];
|
|
429
331
|
|
|
430
|
-
|
|
332
|
+
const graduated: GraduatedEntry[] = [];
|
|
431
333
|
for (const group of candidates) {
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
}
|