@hawon/nexus 0.1.0 → 0.3.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.
Files changed (52) hide show
  1. package/README.md +60 -38
  2. package/dist/cli/index.js +76 -145
  3. package/dist/index.js +15 -26
  4. package/dist/mcp/server.js +61 -32
  5. package/package.json +2 -1
  6. package/scripts/auto-skill.sh +54 -0
  7. package/scripts/auto-sync.sh +11 -0
  8. package/scripts/benchmark.ts +444 -0
  9. package/scripts/scan-tool-result.sh +46 -0
  10. package/src/cli/index.ts +79 -172
  11. package/src/index.ts +17 -29
  12. package/src/mcp/server.ts +67 -41
  13. package/src/memory-engine/index.ts +4 -6
  14. package/src/memory-engine/nexus-memory.test.ts +437 -0
  15. package/src/memory-engine/nexus-memory.ts +631 -0
  16. package/src/memory-engine/semantic.ts +380 -0
  17. package/src/parser/parse.ts +1 -21
  18. package/src/promptguard/advanced-rules.ts +129 -12
  19. package/src/promptguard/entropy.ts +21 -2
  20. package/src/promptguard/evolution/auto-update.ts +16 -6
  21. package/src/promptguard/multilingual-rules.ts +68 -0
  22. package/src/promptguard/rules.ts +87 -2
  23. package/src/promptguard/scanner.test.ts +262 -0
  24. package/src/promptguard/scanner.ts +1 -1
  25. package/src/promptguard/semantic.ts +19 -4
  26. package/src/promptguard/token-analysis.ts +17 -5
  27. package/src/review/analyzer.test.ts +279 -0
  28. package/src/review/analyzer.ts +112 -28
  29. package/src/shared/stop-words.ts +21 -0
  30. package/src/skills/index.ts +11 -27
  31. package/src/skills/memory-skill-engine.ts +1044 -0
  32. package/src/testing/health-check.ts +19 -2
  33. package/src/cost/index.ts +0 -3
  34. package/src/cost/tracker.ts +0 -290
  35. package/src/cost/types.ts +0 -34
  36. package/src/memory-engine/compressor.ts +0 -97
  37. package/src/memory-engine/context-window.ts +0 -113
  38. package/src/memory-engine/store.ts +0 -371
  39. package/src/memory-engine/types.ts +0 -32
  40. package/src/skills/context-engine.ts +0 -863
  41. package/src/skills/extractor.ts +0 -224
  42. package/src/skills/global-context.ts +0 -726
  43. package/src/skills/library.ts +0 -189
  44. package/src/skills/pattern-engine.ts +0 -712
  45. package/src/skills/render-evolved.ts +0 -160
  46. package/src/skills/skill-reconciler.ts +0 -703
  47. package/src/skills/smart-extractor.ts +0 -843
  48. package/src/skills/types.ts +0 -18
  49. package/src/skills/wisdom-extractor.ts +0 -737
  50. package/src/superdev-evolution/index.ts +0 -3
  51. package/src/superdev-evolution/skill-manager.ts +0 -266
  52. package/src/superdev-evolution/types.ts +0 -20
@@ -1,843 +0,0 @@
1
- /**
2
- * Smart Skill Extractor — Context-Aware Pattern Recognition
3
- *
4
- * Unlike the basic extractor that treats every tool-call sequence as a skill,
5
- * this engine understands conversation FLOW:
6
- *
7
- * 1. EPISODE SEGMENTATION
8
- * - Breaks sessions into "task episodes" (one coherent unit of work)
9
- * - Detects episode boundaries: topic change, time gap, transition markers
10
- *
11
- * 2. INTENT CLASSIFICATION
12
- * - Classifies what the user actually wanted (not just what tools ran)
13
- * - "fix X" vs "build X" vs "investigate X" vs "deploy X"
14
- *
15
- * 3. DECISION POINT DETECTION
16
- * - Finds moments where the approach changed:
17
- * - User redirected ("아니 그게 아니라", "no wait", "instead")
18
- * - Claude failed and tried differently
19
- * - User gave feedback that shaped the next steps
20
- * - These decision points ARE the real skill — not the tool calls
21
- *
22
- * 4. FLOW NARRATIVE
23
- * - Builds a narrative: motivation → attempt → feedback → adaptation → result
24
- * - This narrative IS the skill context that makes it reusable
25
- *
26
- * 5. QUALITY GATE
27
- * - Only promotes patterns that are genuinely reusable
28
- * - Filters: enough complexity, clear outcome, reproducible steps
29
- */
30
-
31
- import type { ParsedSession, ParsedMessage, ToolCall } from "../parser/types.js";
32
- import { createHash } from "node:crypto";
33
-
34
- // ─── Types ───────────────────────────────────────────────────────
35
-
36
- export type Episode = {
37
- /** Unique ID. */
38
- id: string;
39
- /** What the user wanted to achieve. */
40
- intent: Intent;
41
- /** Messages in this episode. */
42
- messages: ParsedMessage[];
43
- /** Tool calls made. */
44
- toolCalls: ToolCall[];
45
- /** Files modified. */
46
- files: string[];
47
- /** Decision points (where approach changed). */
48
- decisionPoints: DecisionPoint[];
49
- /** Full narrative of what happened. */
50
- narrative: Narrative;
51
- /** Outcome of the episode. */
52
- outcome: EpisodeOutcome;
53
- /** Start/end timestamps. */
54
- startedAt: string;
55
- endedAt: string;
56
- /** Skill quality score 0-1. Higher = more worth extracting. */
57
- skillQuality: number;
58
- };
59
-
60
- export type Intent = {
61
- /** High-level category. */
62
- category: IntentCategory;
63
- /** Human-readable description. */
64
- description: string;
65
- /** Key entities (files, modules, concepts). */
66
- entities: string[];
67
- /** Original user request. */
68
- originalRequest: string;
69
- };
70
-
71
- export type IntentCategory =
72
- | "fix_bug" // Fix an error, crash, or incorrect behavior
73
- | "build_feature" // Create something new
74
- | "refactor" // Restructure existing code
75
- | "investigate" // Analyze, debug, understand
76
- | "configure" // Setup, install, config
77
- | "deploy" // Deploy, publish, release
78
- | "test" // Write or fix tests
79
- | "security" // Security audit, vulnerability fix
80
- | "review" // Code review, PR review
81
- | "learn" // User asking to understand something
82
- | "misc"; // Doesn't fit other categories
83
-
84
- export type DecisionPoint = {
85
- /** Index in episode messages where the decision occurred. */
86
- messageIndex: number;
87
- /** What triggered the change. */
88
- trigger: DecisionTrigger;
89
- /** What was the approach before. */
90
- before: string;
91
- /** What was the approach after. */
92
- after: string;
93
- /** Why (inferred). */
94
- reason: string;
95
- };
96
-
97
- export type DecisionTrigger =
98
- | "user_redirect" // User explicitly changed direction
99
- | "error_recovery" // Tool/command failed, had to adapt
100
- | "user_feedback" // User gave positive/negative feedback
101
- | "scope_expansion" // "also do X" — task grew
102
- | "scope_reduction" // "just do X" — task narrowed
103
- | "approach_pivot"; // Claude realized a different method was better
104
-
105
- export type Narrative = {
106
- /** Why this task was started. */
107
- motivation: string;
108
- /** What was attempted first. */
109
- initialApproach: string;
110
- /** Key turns (max 5). */
111
- keyTurns: NarrativeTurn[];
112
- /** Final result. */
113
- resolution: string;
114
- /** Lessons learned (what worked, what didn't). */
115
- lessons: string[];
116
- };
117
-
118
- export type NarrativeTurn = {
119
- what: string;
120
- why: string;
121
- outcome: "success" | "failure" | "partial";
122
- };
123
-
124
- export type EpisodeOutcome = {
125
- status: "completed" | "failed" | "abandoned" | "ongoing";
126
- /** Confidence in this assessment. */
127
- confidence: number;
128
- /** User's final reaction if any. */
129
- finalReaction?: string;
130
- };
131
-
132
- export type SmartSkill = {
133
- id: string;
134
- /** Clear, actionable name (e.g., "Fix ESLint errors in TypeScript project"). */
135
- name: string;
136
- /** When to use this skill. */
137
- whenToUse: string;
138
- /** Step-by-step approach (the real value). */
139
- approach: SmartStep[];
140
- /** What to watch out for. */
141
- pitfalls: string[];
142
- /** Tools typically needed. */
143
- tools: string[];
144
- /** Files/patterns typically involved. */
145
- filePatterns: string[];
146
- /** How many times this pattern appeared. */
147
- frequency: number;
148
- /** Quality score. */
149
- quality: number;
150
- /** Source episodes. */
151
- sourceEpisodes: string[];
152
- };
153
-
154
- export type SmartStep = {
155
- order: number;
156
- action: string;
157
- tool?: string;
158
- /** Why this step matters. */
159
- rationale: string;
160
- /** Common mistakes at this step. */
161
- caution?: string;
162
- };
163
-
164
- // ─── Transition/Redirect Markers ─────────────────────────────────
165
-
166
- const TRANSITION_MARKERS_KO = [
167
- "자", "이제", "다음", "그러면", "그럼", "좋아", "오케이", "ㅇㅋ",
168
- "됐고", "됐어", "됐다", "다른거", "다른 거", "그거 말고",
169
- ];
170
-
171
- const TRANSITION_MARKERS_EN = [
172
- "now", "next", "okay", "alright", "moving on", "let's",
173
- "done with that", "forget that", "different",
174
- ];
175
-
176
- const REDIRECT_MARKERS_KO = [
177
- "아니", "아닌데", "그게 아니라", "말고", "대신", "다시",
178
- "잠깐", "멈춰", "스톱", "그만", "아 맞다", "근데",
179
- "그거 말고", "다른 방식", "다르게", "바꿔",
180
- ];
181
-
182
- const REDIRECT_MARKERS_EN = [
183
- "no", "wait", "stop", "actually", "instead", "not that",
184
- "wrong", "different", "change", "scratch that", "never mind",
185
- "hold on", "but", "hmm",
186
- ];
187
-
188
- const POSITIVE_MARKERS = [
189
- "좋아", "완벽", "됐어", "됐다", "고마워", "감사", "잘됐",
190
- "ㅇㅇ", "ㄱㄱ", "오케이", "ㅇㅋ",
191
- "good", "perfect", "great", "thanks", "nice", "works", "awesome",
192
- ];
193
-
194
- const NEGATIVE_MARKERS = [
195
- "안돼", "안 돼", "틀렸", "잘못", "에러", "버그",
196
- "wrong", "error", "fail", "broken", "doesn't work", "bug", "crash",
197
- ];
198
-
199
- const ERROR_PATTERNS = [
200
- /error/i, /Error:/i, /failed/i, /FAIL/i, /exception/i,
201
- /command not found/i, /permission denied/i, /ENOENT/i,
202
- /Cannot find/i, /not found/i, /exit code [1-9]/i,
203
- ];
204
-
205
- // ─── Noise Filters ───────────────────────────────────────────────
206
-
207
- const NOISE_PREFIXES = [
208
- "<task-notification>",
209
- "<system-reminder>",
210
- "<local-command",
211
- "Tool loaded",
212
- "Using Node for alias",
213
- ];
214
-
215
- function isNoiseMessage(msg: ParsedMessage): boolean {
216
- const text = msg.content.trim();
217
- if (text.length === 0) return true;
218
- if (NOISE_PREFIXES.some((p) => text.startsWith(p))) return true;
219
- // System-injected metadata
220
- if (text.startsWith("{") && text.includes('"type"')) return true;
221
- return false;
222
- }
223
-
224
- function isSubstantiveUserMessage(msg: ParsedMessage): boolean {
225
- if (msg.role !== "user") return false;
226
- if (isNoiseMessage(msg)) return false;
227
- // Very short messages that are just acknowledgments
228
- const text = msg.content.trim();
229
- if (text.length <= 3 && /^[ㅇㄱㅎㅋ]+$/.test(text)) return false;
230
- return true;
231
- }
232
-
233
- // ─── Episode Segmentation ────────────────────────────────────────
234
-
235
- /**
236
- * Break a session into coherent task episodes.
237
- *
238
- * Episode boundaries are detected by:
239
- * 1. Time gap > 10 minutes between messages
240
- * 2. Transition markers ("이제", "다음", "now let's")
241
- * 3. Complete topic change (low keyword overlap)
242
- */
243
- export function segmentEpisodes(session: ParsedSession): Episode[] {
244
- const messages = session.messages.filter((m) => !isNoiseMessage(m));
245
- if (messages.length === 0) return [];
246
-
247
- const episodes: Episode[] = [];
248
- let currentMessages: ParsedMessage[] = [];
249
- let episodeCount = 0;
250
-
251
- for (let i = 0; i < messages.length; i++) {
252
- const msg = messages[i];
253
- const prev = i > 0 ? messages[i - 1] : null;
254
-
255
- // Check for episode boundary
256
- let isBoundary = false;
257
-
258
- if (currentMessages.length > 0 && msg.role === "user") {
259
- // Time gap > 10 minutes
260
- if (prev && msg.timestamp && prev.timestamp) {
261
- const gap = new Date(msg.timestamp).getTime() - new Date(prev.timestamp).getTime();
262
- if (gap > 10 * 60 * 1000) isBoundary = true;
263
- }
264
-
265
- // Transition marker
266
- const text = msg.content.toLowerCase().trim();
267
- const firstWords = text.split(/\s+/).slice(0, 3).join(" ");
268
- if (TRANSITION_MARKERS_KO.some((m) => firstWords.includes(m)) ||
269
- TRANSITION_MARKERS_EN.some((m) => firstWords.includes(m))) {
270
- // Only if there's enough content in current episode
271
- if (currentMessages.length >= 4) isBoundary = true;
272
- }
273
-
274
- // Topic change: low keyword overlap with recent messages
275
- if (currentMessages.length >= 6) {
276
- const recentKeywords = extractMessageKeywords(
277
- currentMessages.slice(-4).filter((m) => m.role === "user"),
278
- );
279
- const newKeywords = extractMessageKeywords([msg]);
280
- const overlap = keywordOverlap(recentKeywords, newKeywords);
281
- if (overlap < 0.1 && newKeywords.size > 2) isBoundary = true;
282
- }
283
- }
284
-
285
- if (isBoundary && currentMessages.length >= 2) {
286
- const episode = buildEpisode(currentMessages, session.sessionId, episodeCount++);
287
- if (episode) episodes.push(episode);
288
- currentMessages = [];
289
- }
290
-
291
- currentMessages.push(msg);
292
- }
293
-
294
- // Final episode
295
- if (currentMessages.length >= 2) {
296
- const episode = buildEpisode(currentMessages, session.sessionId, episodeCount);
297
- if (episode) episodes.push(episode);
298
- }
299
-
300
- return episodes;
301
- }
302
-
303
- // ─── Intent Classification ───────────────────────────────────────
304
-
305
- function classifyIntent(messages: ParsedMessage[]): Intent {
306
- const userMessages = messages.filter((m) => m.role === "user" && isSubstantiveUserMessage(m));
307
- if (userMessages.length === 0) {
308
- return {
309
- category: "misc",
310
- description: "Unknown task",
311
- entities: [],
312
- originalRequest: "",
313
- };
314
- }
315
-
316
- const firstRequest = userMessages[0].content;
317
- const allUserText = userMessages.map((m) => m.content).join(" ").toLowerCase();
318
-
319
- // Category detection with weighted keyword matching
320
- const categoryScores: Record<IntentCategory, number> = {
321
- fix_bug: 0, build_feature: 0, refactor: 0, investigate: 0,
322
- configure: 0, deploy: 0, test: 0, security: 0, review: 0,
323
- learn: 0, misc: 0,
324
- };
325
-
326
- const patterns: [IntentCategory, RegExp, number][] = [
327
- // Fix
328
- ["fix_bug", /fix|고치|수정|bug|error|에러|버그|오류|crash|안[되돼]|doesn.t work|broken/i, 3],
329
- ["fix_bug", /해결|resolve|debug|디버그|왜.*안/i, 2],
330
-
331
- // Build
332
- ["build_feature", /만들|생성|create|build|add|추가|implement|구현|새로/i, 3],
333
- ["build_feature", /write|작성|개발|develop/i, 2],
334
-
335
- // Refactor
336
- ["refactor", /refactor|리팩토|정리|clean|개선|improve|optimize|최적화/i, 3],
337
- ["refactor", /restructure|reorganize|simplify/i, 2],
338
-
339
- // Investigate
340
- ["investigate", /분석|analyze|찾아|find|확인|check|search|조사|탐색|스캔/i, 3],
341
- ["investigate", /what.*is|어떻게.*되|뭐가|왜.*이런/i, 2],
342
-
343
- // Configure
344
- ["configure", /설정|setup|install|config|설치|configure|init|초기화/i, 3],
345
- ["configure", /환경|environment|연결|connect|세팅/i, 2],
346
-
347
- // Deploy
348
- ["deploy", /배포|deploy|publish|push|release|올려|npm publish/i, 3],
349
- ["deploy", /upload|ship|pr.*제출|pr.*생성/i, 2],
350
-
351
- // Test
352
- ["test", /테스트|test|검증|verify|validate|확인.*동작/i, 3],
353
-
354
- // Security
355
- ["security", /보안|security|취약점|vulnerability|exploit|해킹|audit|감사/i, 3],
356
- ["security", /injection|xss|csrf|ssrf|인젝션/i, 2],
357
-
358
- // Review
359
- ["review", /리뷰|review|검토|봐봐|확인.*해봐|체크/i, 3],
360
-
361
- // Learn
362
- ["learn", /알려줘|설명|explain|tell me|뭐야|what is|how does|어떻게/i, 2],
363
- ["learn", /이해|understand|가르쳐|teach/i, 2],
364
- ];
365
-
366
- for (const [cat, pattern, weight] of patterns) {
367
- if (pattern.test(allUserText)) {
368
- categoryScores[cat] += weight;
369
- }
370
- }
371
-
372
- // Find highest
373
- let bestCat: IntentCategory = "misc";
374
- let bestScore = 0;
375
- for (const [cat, score] of Object.entries(categoryScores)) {
376
- if (score > bestScore) {
377
- bestScore = score;
378
- bestCat = cat as IntentCategory;
379
- }
380
- }
381
-
382
- // Extract entities (file names, module names, technical terms)
383
- const entities = extractEntities(allUserText);
384
-
385
- // Build description
386
- const categoryLabels: Record<IntentCategory, string> = {
387
- fix_bug: "Fix", build_feature: "Build", refactor: "Refactor",
388
- investigate: "Investigate", configure: "Configure", deploy: "Deploy",
389
- test: "Test", security: "Security audit", review: "Review",
390
- learn: "Learn about", misc: "Task",
391
- };
392
-
393
- const entityStr = entities.slice(0, 3).join(", ");
394
- const description = entityStr
395
- ? `${categoryLabels[bestCat]}: ${entityStr}`
396
- : `${categoryLabels[bestCat]}: ${firstRequest.slice(0, 60)}`;
397
-
398
- return {
399
- category: bestCat,
400
- description,
401
- entities,
402
- originalRequest: firstRequest,
403
- };
404
- }
405
-
406
- function extractEntities(text: string): string[] {
407
- const entities: string[] = [];
408
-
409
- // File paths
410
- const filePaths = text.match(/[\w./\\-]+\.\w{1,5}/g);
411
- if (filePaths) entities.push(...filePaths.slice(0, 3));
412
-
413
- // Backtick-quoted identifiers
414
- const backticks = text.match(/`([^`]+)`/g);
415
- if (backticks) entities.push(...backticks.map((b) => b.replace(/`/g, "")).slice(0, 3));
416
-
417
- // Technical terms (CamelCase, kebab-case)
418
- const techTerms = text.match(/[A-Z][a-z]+[A-Z]\w+|[a-z]+-[a-z]+-[a-z]+/g);
419
- if (techTerms) entities.push(...techTerms.slice(0, 3));
420
-
421
- return [...new Set(entities)].slice(0, 5);
422
- }
423
-
424
- // ─── Decision Point Detection ────────────────────────────────────
425
-
426
- function detectDecisionPoints(messages: ParsedMessage[]): DecisionPoint[] {
427
- const points: DecisionPoint[] = [];
428
-
429
- for (let i = 1; i < messages.length; i++) {
430
- const msg = messages[i];
431
- if (msg.role !== "user") continue;
432
-
433
- const text = msg.content.toLowerCase();
434
- const prev = messages[i - 1];
435
-
436
- // User redirect
437
- if (REDIRECT_MARKERS_KO.some((m) => text.includes(m)) ||
438
- REDIRECT_MARKERS_EN.some((m) => text.includes(m))) {
439
- const prevAction = prev?.toolCalls?.[0]?.name ?? prev?.content.slice(0, 50) ?? "previous approach";
440
- points.push({
441
- messageIndex: i,
442
- trigger: "user_redirect",
443
- before: prevAction,
444
- after: msg.content.slice(0, 80),
445
- reason: `User changed direction: "${msg.content.slice(0, 60)}"`,
446
- });
447
- continue;
448
- }
449
-
450
- // Error recovery: check if previous assistant had errors in tool results
451
- if (prev?.role === "assistant" && prev.toolCalls) {
452
- const hasError = prev.toolCalls.some((tc) =>
453
- tc.result && ERROR_PATTERNS.some((p) => p.test(tc.result ?? "")),
454
- );
455
- if (hasError) {
456
- points.push({
457
- messageIndex: i,
458
- trigger: "error_recovery",
459
- before: prev.toolCalls.map((tc) => tc.name).join("→"),
460
- after: msg.content.slice(0, 80),
461
- reason: "Previous tool call failed, adapting approach",
462
- });
463
- continue;
464
- }
465
- }
466
-
467
- // Scope expansion: "그리고", "also", "추가로"
468
- const expansionMarkers = ["그리고", "추가로", "또", "also", "and also", "additionally"];
469
- if (expansionMarkers.some((m) => text.startsWith(m) || text.includes(` ${m} `))) {
470
- points.push({
471
- messageIndex: i,
472
- trigger: "scope_expansion",
473
- before: "original scope",
474
- after: msg.content.slice(0, 80),
475
- reason: `Scope expanded: "${msg.content.slice(0, 60)}"`,
476
- });
477
- }
478
-
479
- // User feedback (positive or negative)
480
- if (POSITIVE_MARKERS.some((m) => text.includes(m))) {
481
- points.push({
482
- messageIndex: i,
483
- trigger: "user_feedback",
484
- before: "awaiting feedback",
485
- after: "confirmed",
486
- reason: "User confirmed approach works",
487
- });
488
- } else if (NEGATIVE_MARKERS.some((m) => text.includes(m))) {
489
- points.push({
490
- messageIndex: i,
491
- trigger: "user_feedback",
492
- before: "attempted approach",
493
- after: msg.content.slice(0, 80),
494
- reason: `Negative feedback: "${msg.content.slice(0, 60)}"`,
495
- });
496
- }
497
- }
498
-
499
- return points;
500
- }
501
-
502
- // ─── Narrative Builder ───────────────────────────────────────────
503
-
504
- function buildNarrative(messages: ParsedMessage[], decisionPoints: DecisionPoint[]): Narrative {
505
- const userMessages = messages.filter((m) => isSubstantiveUserMessage(m));
506
- const assistantMessages = messages.filter((m) => m.role === "assistant" && m.content.trim());
507
-
508
- // Motivation: what came before the first real request
509
- const motivation = userMessages.length > 0
510
- ? userMessages[0].content.slice(0, 150)
511
- : "Unknown";
512
-
513
- // Initial approach: first assistant response with tools
514
- const firstToolResponse = assistantMessages.find((m) => m.toolCalls && m.toolCalls.length > 0);
515
- const initialApproach = firstToolResponse
516
- ? `Used ${firstToolResponse.toolCalls?.map((tc) => tc.name).join(", ")} — ${firstToolResponse.content.slice(0, 100)}`
517
- : assistantMessages[0]?.content.slice(0, 100) ?? "No approach recorded";
518
-
519
- // Key turns from decision points
520
- const keyTurns: NarrativeTurn[] = decisionPoints.slice(0, 5).map((dp) => ({
521
- what: dp.after.slice(0, 100),
522
- why: dp.reason,
523
- outcome: dp.trigger === "user_feedback" && dp.after === "confirmed" ? "success" as const
524
- : dp.trigger === "error_recovery" ? "failure" as const
525
- : "partial" as const,
526
- }));
527
-
528
- // Resolution: last assistant message
529
- const lastAssistant = [...assistantMessages].reverse().find((m) => m.content.trim().length > 10);
530
- const resolution = lastAssistant?.content.slice(0, 150) ?? "No resolution recorded";
531
-
532
- // Lessons
533
- const lessons: string[] = [];
534
- for (const dp of decisionPoints) {
535
- if (dp.trigger === "error_recovery") {
536
- lessons.push(`Approach "${dp.before}" failed — switched to "${dp.after}"`);
537
- }
538
- if (dp.trigger === "user_redirect") {
539
- lessons.push(`User preferred different approach: ${dp.reason}`);
540
- }
541
- }
542
-
543
- return { motivation, initialApproach, keyTurns, resolution, lessons };
544
- }
545
-
546
- // ─── Episode Builder ─────────────────────────────────────────────
547
-
548
- function buildEpisode(
549
- messages: ParsedMessage[],
550
- sessionId: string,
551
- index: number,
552
- ): Episode | null {
553
- // Must have at least one substantive user message and one assistant response
554
- const hasUser = messages.some((m) => isSubstantiveUserMessage(m));
555
- const hasAssistant = messages.some((m) => m.role === "assistant" && m.content.trim());
556
- if (!hasUser || !hasAssistant) return null;
557
-
558
- const intent = classifyIntent(messages);
559
- const decisionPoints = detectDecisionPoints(messages);
560
-
561
- // Collect tool calls and files
562
- const toolCalls: ToolCall[] = [];
563
- const files: string[] = [];
564
- for (const msg of messages) {
565
- if (msg.toolCalls) {
566
- for (const tc of msg.toolCalls) {
567
- toolCalls.push(tc);
568
- const fp = tc.input["file_path"] ?? tc.input["path"];
569
- if (typeof fp === "string") files.push(fp);
570
- }
571
- }
572
- }
573
-
574
- const narrative = buildNarrative(messages, decisionPoints);
575
- const outcome = inferEpisodeOutcome(messages);
576
-
577
- // Quality score
578
- const skillQuality = computeSkillQuality(
579
- messages, toolCalls, decisionPoints, outcome,
580
- );
581
-
582
- const timestamps = messages
583
- .map((m) => m.timestamp)
584
- .filter(Boolean)
585
- .sort();
586
-
587
- const id = createHash("sha256")
588
- .update(`${sessionId}:${index}:${intent.description}`)
589
- .digest("hex")
590
- .slice(0, 12);
591
-
592
- return {
593
- id,
594
- intent,
595
- messages,
596
- toolCalls,
597
- files: [...new Set(files)],
598
- decisionPoints,
599
- narrative,
600
- outcome,
601
- startedAt: timestamps[0] ?? "",
602
- endedAt: timestamps[timestamps.length - 1] ?? "",
603
- skillQuality,
604
- };
605
- }
606
-
607
- // ─── Outcome Inference ───────────────────────────────────────────
608
-
609
- function inferEpisodeOutcome(messages: ParsedMessage[]): EpisodeOutcome {
610
- const userMessages = messages.filter((m) => isSubstantiveUserMessage(m));
611
- if (userMessages.length === 0) {
612
- return { status: "ongoing", confidence: 0.3 };
613
- }
614
-
615
- const lastUser = userMessages[userMessages.length - 1];
616
- const text = lastUser.content.toLowerCase();
617
-
618
- // Strong success
619
- if (POSITIVE_MARKERS.some((m) => text.includes(m))) {
620
- return { status: "completed", confidence: 0.9, finalReaction: lastUser.content.slice(0, 60) };
621
- }
622
-
623
- // Strong failure
624
- if (NEGATIVE_MARKERS.some((m) => text.includes(m))) {
625
- return { status: "failed", confidence: 0.8, finalReaction: lastUser.content.slice(0, 60) };
626
- }
627
-
628
- // If last message is a new task, previous was probably completed
629
- const lastIsNewTask = TRANSITION_MARKERS_KO.some((m) => text.includes(m)) ||
630
- TRANSITION_MARKERS_EN.some((m) => text.includes(m));
631
- if (lastIsNewTask) {
632
- return { status: "completed", confidence: 0.7 };
633
- }
634
-
635
- // Default: if there were tool calls and no complaints, probably completed
636
- const hasToolCalls = messages.some((m) => m.toolCalls && m.toolCalls.length > 0);
637
- if (hasToolCalls) {
638
- return { status: "completed", confidence: 0.5 };
639
- }
640
-
641
- return { status: "ongoing", confidence: 0.3 };
642
- }
643
-
644
- // ─── Quality Gate ────────────────────────────────────────────────
645
-
646
- function computeSkillQuality(
647
- messages: ParsedMessage[],
648
- toolCalls: ToolCall[],
649
- decisionPoints: DecisionPoint[],
650
- outcome: EpisodeOutcome,
651
- ): number {
652
- let score = 0;
653
-
654
- // Has tool calls (indicates concrete work, not just chat)
655
- if (toolCalls.length >= 2) score += 0.2;
656
- if (toolCalls.length >= 5) score += 0.1;
657
-
658
- // Multiple tool types (more complex task)
659
- const uniqueTools = new Set(toolCalls.map((tc) => tc.name));
660
- if (uniqueTools.size >= 2) score += 0.15;
661
- if (uniqueTools.size >= 3) score += 0.1;
662
-
663
- // Has decision points (shows adaptation — valuable knowledge)
664
- if (decisionPoints.length >= 1) score += 0.15;
665
- if (decisionPoints.length >= 2) score += 0.1;
666
-
667
- // Successful outcome
668
- if (outcome.status === "completed") score += 0.15;
669
- if (outcome.confidence > 0.7) score += 0.05;
670
-
671
- // Not too short, not too long (sweet spot: 4-30 messages)
672
- const msgCount = messages.length;
673
- if (msgCount >= 4 && msgCount <= 30) score += 0.1;
674
- if (msgCount > 30) score -= 0.05; // Too long = probably multiple tasks
675
-
676
- // Has user feedback (validates the approach)
677
- const hasFeedback = decisionPoints.some((dp) => dp.trigger === "user_feedback");
678
- if (hasFeedback) score += 0.1;
679
-
680
- return Math.min(1, Math.max(0, score));
681
- }
682
-
683
- // ─── Smart Skill Generation ─────────────────────────────────────
684
-
685
- /**
686
- * Convert high-quality episodes into actionable skills.
687
- */
688
- export function generateSmartSkills(
689
- episodes: Episode[],
690
- minQuality = 0.3,
691
- ): SmartSkill[] {
692
- const qualified = episodes.filter((ep) => ep.skillQuality >= minQuality);
693
- const skills: SmartSkill[] = [];
694
-
695
- for (const episode of qualified) {
696
- const steps: SmartStep[] = [];
697
- let stepOrder = 1;
698
-
699
- // Build steps from tool calls + decision points
700
- const dpIndices = new Set(episode.decisionPoints.map((dp) => dp.messageIndex));
701
-
702
- for (const msg of episode.messages) {
703
- if (msg.role !== "assistant" || !msg.toolCalls) continue;
704
-
705
- for (const tc of msg.toolCalls) {
706
- const fileArg = tc.input["file_path"] ?? tc.input["path"] ?? tc.input["command"];
707
- const action = fileArg
708
- ? `${tc.name}: ${String(fileArg).slice(0, 80)}`
709
- : tc.name;
710
-
711
- const step: SmartStep = {
712
- order: stepOrder++,
713
- action,
714
- tool: tc.name,
715
- rationale: "", // Will be filled by context
716
- };
717
-
718
- steps.push(step);
719
- }
720
- }
721
-
722
- // Add rationale from decision points
723
- for (const dp of episode.decisionPoints) {
724
- if (dp.trigger === "error_recovery" && steps.length > 0) {
725
- const nearestStep = steps[Math.min(dp.messageIndex, steps.length - 1)];
726
- if (nearestStep) {
727
- nearestStep.caution = dp.reason;
728
- }
729
- }
730
- }
731
-
732
- // Add rationale from narrative
733
- if (steps.length > 0 && episode.narrative.initialApproach) {
734
- steps[0].rationale = `Start here: ${episode.narrative.initialApproach.slice(0, 80)}`;
735
- }
736
-
737
- // Build pitfalls from failed decision points
738
- const pitfalls = episode.decisionPoints
739
- .filter((dp) => dp.trigger === "error_recovery" || dp.trigger === "user_redirect")
740
- .map((dp) => dp.reason)
741
- .slice(0, 5);
742
-
743
- // File patterns
744
- const filePatterns = generalizeFilePaths(episode.files);
745
-
746
- const skill: SmartSkill = {
747
- id: episode.id,
748
- name: episode.intent.description,
749
- whenToUse: episode.narrative.motivation.slice(0, 200),
750
- approach: steps.slice(0, 15), // Cap at 15 steps
751
- pitfalls,
752
- tools: [...new Set(episode.toolCalls.map((tc) => tc.name))],
753
- filePatterns,
754
- frequency: 1,
755
- quality: episode.skillQuality,
756
- sourceEpisodes: [episode.id],
757
- };
758
-
759
- skills.push(skill);
760
- }
761
-
762
- return skills.sort((a, b) => b.quality - a.quality);
763
- }
764
-
765
- // ─── Full Pipeline ───────────────────────────────────────────────
766
-
767
- /**
768
- * Run the full smart extraction pipeline on sessions.
769
- */
770
- export function smartExtract(sessions: ParsedSession[]): {
771
- episodes: Episode[];
772
- skills: SmartSkill[];
773
- } {
774
- const allEpisodes: Episode[] = [];
775
-
776
- for (const session of sessions) {
777
- const episodes = segmentEpisodes(session);
778
- allEpisodes.push(...episodes);
779
- }
780
-
781
- const skills = generateSmartSkills(allEpisodes);
782
-
783
- return { episodes: allEpisodes, skills };
784
- }
785
-
786
- // ─── Helpers ─────────────────────────────────────────────────────
787
-
788
- function extractMessageKeywords(messages: ParsedMessage[]): Set<string> {
789
- const keywords = new Set<string>();
790
- const stopWords = new Set([
791
- "the", "a", "is", "are", "and", "or", "to", "in", "for", "of",
792
- "이", "그", "저", "을", "를", "에", "가", "는", "은", "의",
793
- ]);
794
-
795
- for (const msg of messages) {
796
- const words = msg.content.toLowerCase().split(/\s+/);
797
- for (const w of words) {
798
- if (w.length > 2 && !stopWords.has(w)) keywords.add(w);
799
- }
800
- }
801
- return keywords;
802
- }
803
-
804
- function keywordOverlap(a: Set<string>, b: Set<string>): number {
805
- if (a.size === 0 || b.size === 0) return 0;
806
- const intersection = [...a].filter((w) => b.has(w)).length;
807
- const union = new Set([...a, ...b]).size;
808
- return intersection / union;
809
- }
810
-
811
- function generalizeFilePaths(files: string[]): string[] {
812
- if (files.length === 0) return [];
813
-
814
- // Group by directory
815
- const byDir = new Map<string, string[]>();
816
- for (const f of files) {
817
- const parts = f.split("/");
818
- const dir = parts.slice(0, -1).join("/") || ".";
819
- const ext = f.split(".").pop() ?? "";
820
- byDir.set(dir, [...(byDir.get(dir) ?? []), ext]);
821
- }
822
-
823
- // If 3+ files in same dir with same extension, generalize
824
- const patterns: string[] = [];
825
- for (const [dir, exts] of byDir) {
826
- const extCounts = new Map<string, number>();
827
- for (const ext of exts) {
828
- extCounts.set(ext, (extCounts.get(ext) ?? 0) + 1);
829
- }
830
- for (const [ext, count] of extCounts) {
831
- if (count >= 3) {
832
- patterns.push(`${dir}/**/*.${ext}`);
833
- }
834
- }
835
- }
836
-
837
- // If no patterns, return unique files (max 5)
838
- if (patterns.length === 0) {
839
- return [...new Set(files)].slice(0, 5);
840
- }
841
-
842
- return [...new Set(patterns)];
843
- }