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.
@@ -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
  }