wispy-cli 2.7.9 → 2.7.11
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/bin/wispy.mjs +293 -0
- package/core/config.mjs +29 -11
- package/core/features.mjs +225 -0
- package/core/loop-detector.mjs +183 -0
- package/core/memory.mjs +255 -0
- package/core/secrets.mjs +251 -0
- package/core/session.mjs +4 -1
- package/core/tts.mjs +194 -0
- package/lib/jsonl-emitter.mjs +101 -0
- package/package.json +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/loop-detector.mjs — Tool-call loop detection for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Detects four patterns:
|
|
5
|
+
* 1. Exact repeat: same tool + same args hash 3+ times in window
|
|
6
|
+
* 2. Oscillation: A→B→A→B pattern (alternating between two calls)
|
|
7
|
+
* 3. No progress: 5+ tool calls where result hash doesn't change
|
|
8
|
+
* 4. Error loop: same tool fails 3+ times in a row
|
|
9
|
+
*
|
|
10
|
+
* v1.0.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute a short hash of any value.
|
|
18
|
+
*/
|
|
19
|
+
function hashValue(value) {
|
|
20
|
+
if (value === null || value === undefined) return "null";
|
|
21
|
+
let str;
|
|
22
|
+
try {
|
|
23
|
+
str = JSON.stringify(value);
|
|
24
|
+
} catch {
|
|
25
|
+
str = String(value);
|
|
26
|
+
}
|
|
27
|
+
return createHash("sha1").update(str).digest("hex").slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class LoopDetector extends EventEmitter {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} options
|
|
33
|
+
* @param {number} options.windowSize - Look at last N calls (default 10)
|
|
34
|
+
* @param {number} options.maxRepeats - Same call N times = loop (default 3)
|
|
35
|
+
* @param {number} options.maxNoProgress - N calls with no new output = stuck (default 5)
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
super();
|
|
39
|
+
this.windowSize = options.windowSize ?? 10;
|
|
40
|
+
this.maxRepeats = options.maxRepeats ?? 3;
|
|
41
|
+
this.maxNoProgress = options.maxNoProgress ?? 5;
|
|
42
|
+
|
|
43
|
+
// Array of { tool, argsHash, resultHash, isError, timestamp }
|
|
44
|
+
this._history = [];
|
|
45
|
+
|
|
46
|
+
// How many warnings have been emitted (to decide force-break)
|
|
47
|
+
this._warningCount = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record a tool call result.
|
|
52
|
+
* @param {string} toolName
|
|
53
|
+
* @param {object} args
|
|
54
|
+
* @param {any} result - The result (or Error/string on failure)
|
|
55
|
+
*/
|
|
56
|
+
record(toolName, args, result) {
|
|
57
|
+
const argsHash = hashValue(args);
|
|
58
|
+
const isError = (result && typeof result === "object" && result.success === false)
|
|
59
|
+
|| (result instanceof Error);
|
|
60
|
+
|
|
61
|
+
// Use error message as result hash if error, so errors are deduplicated
|
|
62
|
+
const resultForHash = isError
|
|
63
|
+
? (result?.error ?? result?.message ?? "error")
|
|
64
|
+
: result;
|
|
65
|
+
|
|
66
|
+
const resultHash = hashValue(resultForHash);
|
|
67
|
+
|
|
68
|
+
this._history.push({
|
|
69
|
+
tool: toolName,
|
|
70
|
+
argsHash,
|
|
71
|
+
resultHash,
|
|
72
|
+
isError,
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Keep only the window
|
|
77
|
+
if (this._history.length > this.windowSize) {
|
|
78
|
+
this._history = this._history.slice(-this.windowSize);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if the agent is in a loop.
|
|
84
|
+
* @returns {{ looping: boolean, reason?: string, suggestion?: string, warningCount: number }}
|
|
85
|
+
*/
|
|
86
|
+
check() {
|
|
87
|
+
const history = this._history;
|
|
88
|
+
if (history.length < 2) return { looping: false, warningCount: this._warningCount };
|
|
89
|
+
|
|
90
|
+
// ── 1. Exact repeat: same tool + same args hash N+ times in window ────────
|
|
91
|
+
const callKey = (e) => `${e.tool}:${e.argsHash}`;
|
|
92
|
+
const counts = new Map();
|
|
93
|
+
for (const entry of history) {
|
|
94
|
+
const key = callKey(entry);
|
|
95
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
for (const [key, count] of counts) {
|
|
98
|
+
if (count >= this.maxRepeats) {
|
|
99
|
+
const [tool] = key.split(":");
|
|
100
|
+
this._warningCount++;
|
|
101
|
+
return {
|
|
102
|
+
looping: true,
|
|
103
|
+
reason: `exact_repeat`,
|
|
104
|
+
tool,
|
|
105
|
+
count,
|
|
106
|
+
warningCount: this._warningCount,
|
|
107
|
+
suggestion: `Loop detected: you've called ${tool} with the same arguments ${count} times. Try a different approach or verify the outcome before retrying.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 2. Oscillation: A→B→A→B pattern ─────────────────────────────────────
|
|
113
|
+
if (history.length >= 4) {
|
|
114
|
+
const last4 = history.slice(-4).map(callKey);
|
|
115
|
+
// A→B→A→B
|
|
116
|
+
if (last4[0] === last4[2] && last4[1] === last4[3] && last4[0] !== last4[1]) {
|
|
117
|
+
const toolA = history[history.length - 4].tool;
|
|
118
|
+
const toolB = history[history.length - 3].tool;
|
|
119
|
+
this._warningCount++;
|
|
120
|
+
return {
|
|
121
|
+
looping: true,
|
|
122
|
+
reason: `oscillation`,
|
|
123
|
+
tool: `${toolA}↔${toolB}`,
|
|
124
|
+
warningCount: this._warningCount,
|
|
125
|
+
suggestion: `Loop detected: oscillating between ${toolA} and ${toolB}. These calls aren't making progress. Try a different strategy.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── 3. No progress: N consecutive calls with identical result hash ────────
|
|
131
|
+
if (history.length >= this.maxNoProgress) {
|
|
132
|
+
const recent = history.slice(-this.maxNoProgress);
|
|
133
|
+
const firstHash = recent[0].resultHash;
|
|
134
|
+
// All results are the same AND none are "null" (null = tool produced nothing)
|
|
135
|
+
if (firstHash !== "null" && recent.every(e => e.resultHash === firstHash)) {
|
|
136
|
+
const tool = recent[recent.length - 1].tool;
|
|
137
|
+
this._warningCount++;
|
|
138
|
+
return {
|
|
139
|
+
looping: true,
|
|
140
|
+
reason: `no_progress`,
|
|
141
|
+
tool,
|
|
142
|
+
count: this.maxNoProgress,
|
|
143
|
+
warningCount: this._warningCount,
|
|
144
|
+
suggestion: `Loop detected: ${this.maxNoProgress} consecutive tool calls produced identical results. The agent appears to be stuck — try a different approach.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 4. Error loop: same tool fails 3+ times in a row ─────────────────────
|
|
150
|
+
if (history.length >= 3) {
|
|
151
|
+
const recent = history.slice(-3);
|
|
152
|
+
if (recent.every(e => e.isError && e.tool === recent[0].tool)) {
|
|
153
|
+
const tool = recent[0].tool;
|
|
154
|
+
this._warningCount++;
|
|
155
|
+
return {
|
|
156
|
+
looping: true,
|
|
157
|
+
reason: `error_loop`,
|
|
158
|
+
tool,
|
|
159
|
+
count: 3,
|
|
160
|
+
warningCount: this._warningCount,
|
|
161
|
+
suggestion: `Loop detected: ${tool} has failed 3 times in a row. Stop retrying and try a different approach or report the error.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { looping: false, warningCount: this._warningCount };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reset the detector state (e.g., after user intervention or successful progress).
|
|
171
|
+
*/
|
|
172
|
+
reset() {
|
|
173
|
+
this._history = [];
|
|
174
|
+
this._warningCount = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Returns the number of recorded entries.
|
|
179
|
+
*/
|
|
180
|
+
get size() {
|
|
181
|
+
return this._history.length;
|
|
182
|
+
}
|
|
183
|
+
}
|
package/core/memory.mjs
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* - daily/YYYY-MM-DD.md — daily logs
|
|
7
7
|
* - projects/<name>.md — project-specific memory
|
|
8
8
|
* - user.md — user preferences/info
|
|
9
|
+
*
|
|
10
|
+
* v2.8+ enhancements:
|
|
11
|
+
* - getRelevantMemories() — keyword + recency scoring for context injection
|
|
12
|
+
* - autoExtractFacts() — auto-flush important facts from conversation
|
|
13
|
+
* - Fuzzy search with recency + frequency weighting
|
|
14
|
+
* - Access frequency tracking
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import path from "node:path";
|
|
@@ -13,10 +19,14 @@ import { readFile, writeFile, appendFile, mkdir, readdir, unlink, stat } from "n
|
|
|
13
19
|
|
|
14
20
|
const MAX_SEARCH_RESULTS = 20;
|
|
15
21
|
const MAX_SNIPPET_CHARS = 200;
|
|
22
|
+
// How many messages must accumulate before autoExtractFacts triggers
|
|
23
|
+
const AUTO_EXTRACT_INTERVAL = 10;
|
|
16
24
|
|
|
17
25
|
export class MemoryManager {
|
|
18
26
|
constructor(wispyDir) {
|
|
19
27
|
this.memoryDir = path.join(wispyDir, "memory");
|
|
28
|
+
// Frequency tracking: key → access count (in-memory only, resets per session)
|
|
29
|
+
this._accessFrequency = new Map();
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/**
|
|
@@ -88,6 +98,8 @@ export class MemoryManager {
|
|
|
88
98
|
const filePath = this._keyToPath(key);
|
|
89
99
|
try {
|
|
90
100
|
const content = await readFile(filePath, "utf8");
|
|
101
|
+
// Track access frequency
|
|
102
|
+
this._accessFrequency.set(key, (this._accessFrequency.get(key) ?? 0) + 1);
|
|
91
103
|
return { key, content, path: filePath };
|
|
92
104
|
} catch {
|
|
93
105
|
return null;
|
|
@@ -281,4 +293,247 @@ ${recentUserMsgs.slice(0, 2000)}`;
|
|
|
281
293
|
return null;
|
|
282
294
|
}
|
|
283
295
|
}
|
|
296
|
+
|
|
297
|
+
// ── v2.8 Enhanced Memory Methods ────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get top N most relevant memories for a given user message.
|
|
301
|
+
* Uses keyword matching + recency scoring + frequency weighting.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} userMessage
|
|
304
|
+
* @param {number} limit - max memories to return (default 3)
|
|
305
|
+
* @returns {Promise<string|null>} formatted system message injection or null
|
|
306
|
+
*/
|
|
307
|
+
async getRelevantMemories(userMessage, limit = 3) {
|
|
308
|
+
if (!userMessage?.trim()) return null;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
await this._ensureDir();
|
|
312
|
+
const terms = this._extractKeywords(userMessage);
|
|
313
|
+
if (terms.length === 0) return null;
|
|
314
|
+
|
|
315
|
+
// Collect all memory files with metadata
|
|
316
|
+
const allKeys = await this.list();
|
|
317
|
+
if (allKeys.length === 0) return null;
|
|
318
|
+
|
|
319
|
+
const scored = [];
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
|
|
322
|
+
for (const item of allKeys) {
|
|
323
|
+
const content = await readFile(item.path, "utf8").catch(() => "");
|
|
324
|
+
if (!content.trim()) continue;
|
|
325
|
+
|
|
326
|
+
// 1. Keyword match score
|
|
327
|
+
const lower = content.toLowerCase();
|
|
328
|
+
let matchScore = 0;
|
|
329
|
+
for (const term of terms) {
|
|
330
|
+
// Count occurrences for stronger weighting
|
|
331
|
+
const regex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
|
|
332
|
+
const matches = (lower.match(regex) ?? []).length;
|
|
333
|
+
matchScore += matches;
|
|
334
|
+
}
|
|
335
|
+
if (matchScore === 0) continue;
|
|
336
|
+
|
|
337
|
+
// 2. Recency score (0–1, decays over 30 days)
|
|
338
|
+
const ageDays = item.updatedAt
|
|
339
|
+
? (now - new Date(item.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
|
|
340
|
+
: 30;
|
|
341
|
+
const recencyScore = Math.max(0, 1 - ageDays / 30);
|
|
342
|
+
|
|
343
|
+
// 3. Frequency score (often-accessed memories rank higher)
|
|
344
|
+
const freq = this._accessFrequency.get(item.key) ?? 0;
|
|
345
|
+
const freqScore = Math.min(freq / 10, 1); // cap at 10 accesses = 1.0
|
|
346
|
+
|
|
347
|
+
// Combined score
|
|
348
|
+
const totalScore = matchScore * 2 + recencyScore * 1.5 + freqScore;
|
|
349
|
+
|
|
350
|
+
scored.push({ key: item.key, content, score: totalScore });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (scored.length === 0) return null;
|
|
354
|
+
|
|
355
|
+
// Sort by score descending, take top N
|
|
356
|
+
scored.sort((a, b) => b.score - a.score);
|
|
357
|
+
const top = scored.slice(0, limit);
|
|
358
|
+
|
|
359
|
+
// Format as system message injection
|
|
360
|
+
const parts = top.map(m => {
|
|
361
|
+
const snippet = m.content.trim().slice(0, 500);
|
|
362
|
+
return `### memory:${m.key}\n${snippet}`;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return parts.join("\n\n");
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Auto-extract important facts from conversation and save to memory.
|
|
373
|
+
* Triggered every AUTO_EXTRACT_INTERVAL messages.
|
|
374
|
+
*
|
|
375
|
+
* @param {Array<{role,content}>} messages - conversation history
|
|
376
|
+
* @param {Function} aiCall - async (prompt) => string
|
|
377
|
+
* @returns {Promise<string|null>} extracted facts or null
|
|
378
|
+
*/
|
|
379
|
+
async autoExtractFacts(messages, aiCall) {
|
|
380
|
+
if (!messages?.length || !aiCall) return null;
|
|
381
|
+
|
|
382
|
+
// Only run every N messages to avoid excessive AI calls
|
|
383
|
+
const userMsgs = messages.filter(m => m.role === "user");
|
|
384
|
+
if (userMsgs.length % AUTO_EXTRACT_INTERVAL !== 0) return null;
|
|
385
|
+
|
|
386
|
+
const recentMsgs = messages.slice(-AUTO_EXTRACT_INTERVAL * 2);
|
|
387
|
+
const conversationText = recentMsgs
|
|
388
|
+
.map(m => `${m.role === "user" ? "User" : "Assistant"}: ${
|
|
389
|
+
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
|
390
|
+
}`)
|
|
391
|
+
.join("\n\n");
|
|
392
|
+
|
|
393
|
+
if (!conversationText.trim()) return null;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const prompt = `Analyze this conversation and extract key facts, preferences, or important information worth remembering for future sessions. Be specific and concise. Format as bullet points. If nothing important, reply "nothing".
|
|
397
|
+
|
|
398
|
+
Conversation:
|
|
399
|
+
${conversationText.slice(0, 3000)}
|
|
400
|
+
|
|
401
|
+
Examples of things to extract:
|
|
402
|
+
- User preferences (tools, languages, styles)
|
|
403
|
+
- Project names and goals
|
|
404
|
+
- Personal context (name, location, role)
|
|
405
|
+
- Decisions made
|
|
406
|
+
- Important deadlines or dates`;
|
|
407
|
+
|
|
408
|
+
const result = await aiCall(prompt);
|
|
409
|
+
if (!result || result.toLowerCase().trim() === "nothing") return null;
|
|
410
|
+
|
|
411
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
412
|
+
|
|
413
|
+
// Deduplicate: check if very similar content already exists
|
|
414
|
+
const existingDaily = await this.get(`daily/${today}`);
|
|
415
|
+
if (existingDaily?.content?.includes(result.slice(0, 50))) return null;
|
|
416
|
+
|
|
417
|
+
await this.append(`daily/${today}`, result.slice(0, 800));
|
|
418
|
+
return result;
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Fuzzy search across all memory files.
|
|
426
|
+
* Extends base search() with recency and frequency weighting.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} query
|
|
429
|
+
* @param {object} opts
|
|
430
|
+
* @param {number} [opts.limit]
|
|
431
|
+
* @param {boolean} [opts.fuzzy] - enable fuzzy matching (default true)
|
|
432
|
+
* @returns {Promise<Array>}
|
|
433
|
+
*/
|
|
434
|
+
async searchEnhanced(query, opts = {}) {
|
|
435
|
+
if (!query?.trim()) return [];
|
|
436
|
+
await this._ensureDir();
|
|
437
|
+
|
|
438
|
+
const terms = this._extractKeywords(query);
|
|
439
|
+
if (terms.length === 0) return [];
|
|
440
|
+
|
|
441
|
+
const results = [];
|
|
442
|
+
await this._searchDirEnhanced(this.memoryDir, this.memoryDir, terms, results, opts.fuzzy !== false);
|
|
443
|
+
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
|
|
446
|
+
// Apply recency and frequency weighting to results
|
|
447
|
+
for (const r of results) {
|
|
448
|
+
try {
|
|
449
|
+
const fileStat = await stat(r.path).catch(() => null);
|
|
450
|
+
const ageDays = fileStat?.mtime
|
|
451
|
+
? (now - fileStat.mtime.getTime()) / (1000 * 60 * 60 * 24)
|
|
452
|
+
: 30;
|
|
453
|
+
const recencyBonus = Math.max(0, 1 - ageDays / 30) * 2;
|
|
454
|
+
const freqBonus = Math.min((this._accessFrequency.get(r.key) ?? 0) / 5, 1);
|
|
455
|
+
r.matchCount = r.matchCount + recencyBonus + freqBonus;
|
|
456
|
+
} catch { /* ignore stat errors */ }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
results.sort((a, b) => b.matchCount - a.matchCount);
|
|
460
|
+
return results.slice(0, opts.limit ?? MAX_SEARCH_RESULTS);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async _searchDirEnhanced(baseDir, dir, terms, results, fuzzy = true) {
|
|
464
|
+
let entries;
|
|
465
|
+
try {
|
|
466
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
467
|
+
} catch {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
const fullPath = path.join(dir, entry.name);
|
|
472
|
+
if (entry.isDirectory()) {
|
|
473
|
+
await this._searchDirEnhanced(baseDir, fullPath, terms, results, fuzzy);
|
|
474
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
475
|
+
const content = await readFile(fullPath, "utf8").catch(() => "");
|
|
476
|
+
const lines = content.split("\n");
|
|
477
|
+
const key = path.relative(baseDir, fullPath).replace(/\.md$/, "");
|
|
478
|
+
|
|
479
|
+
let matchCount = 0;
|
|
480
|
+
const matchingLines = [];
|
|
481
|
+
|
|
482
|
+
for (let i = 0; i < lines.length; i++) {
|
|
483
|
+
const line = lines[i];
|
|
484
|
+
const lower = line.toLowerCase();
|
|
485
|
+
|
|
486
|
+
let lineMatches = terms.filter(t => lower.includes(t)).length;
|
|
487
|
+
|
|
488
|
+
// Fuzzy: also check for partial matches (e.g. "proj" matches "project")
|
|
489
|
+
if (fuzzy && lineMatches === 0) {
|
|
490
|
+
lineMatches = terms.filter(t =>
|
|
491
|
+
t.length >= 4 && lower.split(/\s+/).some(word => word.startsWith(t.slice(0, -1)))
|
|
492
|
+
).length * 0.5; // partial credit
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (lineMatches > 0) {
|
|
496
|
+
matchCount += lineMatches;
|
|
497
|
+
matchingLines.push({
|
|
498
|
+
lineNumber: i + 1,
|
|
499
|
+
text: line.slice(0, MAX_SNIPPET_CHARS),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (matchCount > 0) {
|
|
505
|
+
results.push({
|
|
506
|
+
key,
|
|
507
|
+
path: fullPath,
|
|
508
|
+
matchCount,
|
|
509
|
+
snippets: matchingLines.slice(0, 5),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Extract meaningful keywords from a string for search/scoring.
|
|
518
|
+
* Filters out common stop words.
|
|
519
|
+
*/
|
|
520
|
+
_extractKeywords(text) {
|
|
521
|
+
const STOP_WORDS = new Set([
|
|
522
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been",
|
|
523
|
+
"have", "has", "had", "do", "does", "did", "will", "would",
|
|
524
|
+
"could", "should", "may", "might", "can", "shall",
|
|
525
|
+
"i", "you", "he", "she", "it", "we", "they", "me", "him", "her",
|
|
526
|
+
"us", "them", "my", "your", "his", "its", "our", "their",
|
|
527
|
+
"this", "that", "these", "those", "what", "which", "who",
|
|
528
|
+
"how", "when", "where", "why", "and", "or", "but", "not",
|
|
529
|
+
"in", "on", "at", "to", "for", "of", "with", "by", "from",
|
|
530
|
+
"up", "out", "as", "if", "then", "than", "so", "just",
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
return text
|
|
534
|
+
.toLowerCase()
|
|
535
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
536
|
+
.split(/\s+/)
|
|
537
|
+
.filter(w => w.length >= 3 && !STOP_WORDS.has(w));
|
|
538
|
+
}
|
|
284
539
|
}
|