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