agentshield-sdk 7.4.0 → 10.0.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 (57) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -21
  3. package/README.md +30 -37
  4. package/bin/agentshield-audit +51 -0
  5. package/package.json +7 -9
  6. package/src/adaptive.js +330 -330
  7. package/src/agent-intent.js +807 -0
  8. package/src/alert-tuning.js +480 -480
  9. package/src/audit-streaming.js +1 -1
  10. package/src/badges.js +196 -196
  11. package/src/behavioral-dna.js +12 -0
  12. package/src/canary.js +2 -3
  13. package/src/certification.js +563 -563
  14. package/src/circuit-breaker.js +2 -2
  15. package/src/confused-deputy.js +4 -0
  16. package/src/conversation.js +494 -494
  17. package/src/cross-turn.js +649 -0
  18. package/src/ctf.js +462 -462
  19. package/src/detector-core.js +71 -152
  20. package/src/document-scanner.js +795 -795
  21. package/src/drift-monitor.js +344 -0
  22. package/src/encoding.js +429 -429
  23. package/src/ensemble.js +523 -0
  24. package/src/enterprise.js +405 -405
  25. package/src/flight-recorder.js +2 -0
  26. package/src/i18n-patterns.js +523 -523
  27. package/src/index.js +19 -0
  28. package/src/main.js +79 -6
  29. package/src/mcp-guard.js +974 -0
  30. package/src/micro-model.js +762 -0
  31. package/src/ml-detector.js +316 -0
  32. package/src/model-finetuning.js +884 -884
  33. package/src/multimodal.js +296 -296
  34. package/src/nist-mapping.js +2 -2
  35. package/src/observability.js +330 -330
  36. package/src/openclaw.js +450 -450
  37. package/src/otel.js +544 -544
  38. package/src/owasp-2025.js +1 -1
  39. package/src/owasp-agentic.js +420 -0
  40. package/src/persistent-learning.js +677 -0
  41. package/src/plugin-marketplace.js +628 -628
  42. package/src/plugin-system.js +349 -349
  43. package/src/policy-extended.js +635 -635
  44. package/src/policy.js +443 -443
  45. package/src/prompt-leakage.js +2 -2
  46. package/src/real-attack-datasets.js +2 -2
  47. package/src/redteam-cli.js +439 -0
  48. package/src/self-training.js +772 -0
  49. package/src/smart-config.js +812 -0
  50. package/src/supply-chain-scanner.js +691 -0
  51. package/src/testing.js +5 -1
  52. package/src/threat-encyclopedia.js +629 -629
  53. package/src/threat-intel-network.js +1017 -1017
  54. package/src/token-analysis.js +467 -467
  55. package/src/tool-output-validator.js +354 -354
  56. package/src/watermark.js +1 -2
  57. package/types/index.d.ts +660 -0
@@ -0,0 +1,677 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield — Persistent Learning + Feedback API (v8.0)
5
+ *
6
+ * Makes detection smarter over time by persisting learned patterns to disk
7
+ * and accepting structured user feedback. Enhances the LearningLoop from
8
+ * adaptive-defense.js with disk persistence, pattern decay, and a dedicated
9
+ * FeedbackCollector that bridges operator input into the learning pipeline.
10
+ *
11
+ * - PersistentLearningLoop: disk-backed pattern learning with decay & promotion
12
+ * - FeedbackCollector: structured FP/FN feedback → learning loop integration
13
+ *
14
+ * Zero external dependencies. All processing runs locally.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ const LOG_PREFIX = '[Agent Shield]';
22
+
23
+ // =========================================================================
24
+ // Helpers
25
+ // =========================================================================
26
+
27
+ /**
28
+ * Generate a unique ID. Uses crypto.randomUUID() when available,
29
+ * falls back to timestamp + random hex.
30
+ * @returns {string}
31
+ */
32
+ function generateId() {
33
+ if (typeof crypto.randomUUID === 'function') {
34
+ return crypto.randomUUID();
35
+ }
36
+ const ts = Date.now().toString(36);
37
+ const rand = Math.random().toString(16).slice(2, 10);
38
+ return `${ts}-${rand}`;
39
+ }
40
+
41
+ /**
42
+ * SHA-256 hash truncated to 16 hex chars.
43
+ * @param {string} text
44
+ * @returns {string}
45
+ */
46
+ function hashText(text) {
47
+ return crypto.createHash('sha256').update(text).digest('hex').substring(0, 16);
48
+ }
49
+
50
+ /** Injection-related keywords used to filter n-grams. */
51
+ const INJECTION_KEYWORDS = [
52
+ 'ignore', 'forget', 'disregard', 'override', 'bypass', 'disable',
53
+ 'system', 'prompt', 'reveal', 'output', 'instructions', 'previous',
54
+ 'jailbreak', 'sudo', 'admin', 'execute', 'inject', 'extract',
55
+ 'exfiltrate', 'delete', 'drop', 'curl', 'fetch', 'eval',
56
+ 'pretend', 'roleplay', 'act', 'imagine', 'hypothetically'
57
+ ];
58
+
59
+ /**
60
+ * Extract n-grams (3-5 words) from text, filtered to those containing
61
+ * injection-related keywords.
62
+ * @param {string} text
63
+ * @returns {string[]}
64
+ */
65
+ function extractNgrams(text) {
66
+ const lower = text.toLowerCase().replace(/[^\w\s]/g, ' ');
67
+ const words = lower.split(/\s+/).filter(w => w.length > 1);
68
+ const ngrams = [];
69
+
70
+ for (let n = 3; n <= 5; n++) {
71
+ for (let i = 0; i <= words.length - n; i++) {
72
+ const gram = words.slice(i, i + n).join(' ');
73
+ const hasKeyword = INJECTION_KEYWORDS.some(kw => gram.includes(kw));
74
+ if (hasKeyword) {
75
+ ngrams.push(gram);
76
+ }
77
+ }
78
+ }
79
+
80
+ return ngrams;
81
+ }
82
+
83
+ // =========================================================================
84
+ // 1. PersistentLearningLoop
85
+ // =========================================================================
86
+
87
+ /**
88
+ * Disk-backed learning loop that extracts signature patterns from observed
89
+ * attacks, promotes them after repeated sightings, and persists everything
90
+ * to JSON on disk. Patterns decay over time if not re-observed.
91
+ */
92
+ class PersistentLearningLoop {
93
+ /**
94
+ * @param {object} [config]
95
+ * @param {boolean} [config.persist=false] - Write patterns to disk
96
+ * @param {string} [config.persistPath='./.agentshield/learned-patterns.json'] - File path
97
+ * @param {number} [config.promotionThreshold=3] - Hits before pattern is promoted
98
+ * @param {number} [config.maxPatterns=500] - Max active patterns
99
+ * @param {number} [config.decayMs=604800000] - Pattern decay time (7 days default)
100
+ * @param {number} [config.maxFalsePositives=3] - FP reports before revocation
101
+ */
102
+ constructor(config = {}) {
103
+ this._persist = config.persist === true;
104
+ this._persistPath = config.persistPath || './.agentshield/learned-patterns.json';
105
+ this._promotionThreshold = config.promotionThreshold || 3;
106
+ this._maxPatterns = config.maxPatterns || 500;
107
+ this._decayMs = config.decayMs || 604800000; // 7 days
108
+ this._maxFalsePositives = config.maxFalsePositives || 3;
109
+
110
+ /** @type {Map<string, object>} sigHash → candidate */
111
+ this._candidates = new Map();
112
+ /** @type {Map<string, object>} patternId → promoted pattern */
113
+ this._promoted = new Map();
114
+
115
+ this._stats = {
116
+ attacksIngested: 0,
117
+ candidatesCreated: 0,
118
+ patternsPromoted: 0,
119
+ patternsRevoked: 0,
120
+ falsePositivesReported: 0,
121
+ saves: 0,
122
+ loads: 0
123
+ };
124
+
125
+ // Auto-load from disk if persistence is enabled
126
+ if (this._persist) {
127
+ this.load();
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Ingest an attack that was detected by other means.
133
+ * Extracts signature patterns and adds to candidate pool.
134
+ * @param {string} text - The attack text
135
+ * @param {object} [meta] - { category, source, severity }
136
+ * @returns {object} { candidates: number, signatures: string[] }
137
+ */
138
+ ingest(text, meta = {}) {
139
+ if (!text || typeof text !== 'string') {
140
+ return { candidates: 0, signatures: [] };
141
+ }
142
+
143
+ this._stats.attacksIngested++;
144
+ const ngrams = extractNgrams(text);
145
+ const signatures = [];
146
+ let promotedAny = false;
147
+
148
+ for (const gram of ngrams) {
149
+ const sigHash = hashText(gram);
150
+ const existing = this._candidates.get(sigHash);
151
+
152
+ if (existing) {
153
+ existing.hitCount++;
154
+ existing.lastSeen = Date.now();
155
+ if (meta.category && !existing.categories.includes(meta.category)) {
156
+ existing.categories.push(meta.category);
157
+ }
158
+
159
+ // Promote if threshold reached and not already promoted
160
+ if (existing.hitCount >= this._promotionThreshold &&
161
+ !existing.promoted &&
162
+ this._promoted.size < this._maxPatterns) {
163
+ existing.promoted = true;
164
+ const patternId = `PL_${sigHash.substring(0, 12)}`;
165
+ const confidence = Math.min(1.0, 0.5 + (existing.hitCount * 0.05));
166
+ this._promoted.set(patternId, {
167
+ patternId,
168
+ signature: gram,
169
+ sigHash,
170
+ categories: [...existing.categories],
171
+ confidence,
172
+ hitCount: existing.hitCount,
173
+ fpCount: 0,
174
+ source: meta.source || 'persistent_learning',
175
+ severity: meta.severity || 'medium',
176
+ promotedAt: Date.now(),
177
+ lastSeen: Date.now(),
178
+ active: true
179
+ });
180
+ this._stats.patternsPromoted++;
181
+ promotedAny = true;
182
+ console.log(`${LOG_PREFIX} Pattern promoted: "${gram}" (${patternId})`);
183
+ }
184
+ } else {
185
+ // New candidate
186
+ this._candidates.set(sigHash, {
187
+ signature: gram,
188
+ sigHash,
189
+ hitCount: 1,
190
+ categories: meta.category ? [meta.category] : [],
191
+ firstSeen: Date.now(),
192
+ lastSeen: Date.now(),
193
+ promoted: false
194
+ });
195
+ this._stats.candidatesCreated++;
196
+ }
197
+
198
+ signatures.push(gram);
199
+ }
200
+
201
+ // Auto-save after promotion
202
+ if (promotedAny && this._persist) {
203
+ this.save();
204
+ }
205
+
206
+ return { candidates: ngrams.length, signatures };
207
+ }
208
+
209
+ /**
210
+ * Check text against learned patterns.
211
+ * @param {string} text
212
+ * @returns {object} { matches: Array<{ pattern, source: 'learned', confidence }>, count: number }
213
+ */
214
+ check(text) {
215
+ if (!text || typeof text !== 'string') {
216
+ return { matches: [], count: 0 };
217
+ }
218
+
219
+ const lower = text.toLowerCase();
220
+ const matches = [];
221
+
222
+ for (const [_patternId, pattern] of this._promoted) {
223
+ if (!pattern.active) continue;
224
+ if (lower.includes(pattern.signature.toLowerCase())) {
225
+ pattern.lastSeen = Date.now();
226
+ pattern.hitCount++;
227
+ matches.push({
228
+ patternId: pattern.patternId,
229
+ pattern: pattern.signature,
230
+ source: 'learned',
231
+ confidence: pattern.confidence,
232
+ categories: pattern.categories,
233
+ severity: pattern.severity
234
+ });
235
+ }
236
+ }
237
+
238
+ return { matches, count: matches.length };
239
+ }
240
+
241
+ /**
242
+ * Report a false positive on a learned pattern.
243
+ * @param {string} patternId
244
+ * @returns {object} { revoked: boolean, fpCount: number, remaining: number }
245
+ */
246
+ reportFalsePositive(patternId) {
247
+ const pattern = this._promoted.get(patternId);
248
+ if (!pattern) {
249
+ return { revoked: false, fpCount: 0, remaining: 0 };
250
+ }
251
+
252
+ pattern.fpCount++;
253
+ this._stats.falsePositivesReported++;
254
+ let revoked = false;
255
+
256
+ if (pattern.fpCount >= this._maxFalsePositives) {
257
+ pattern.active = false;
258
+ revoked = true;
259
+ this._stats.patternsRevoked++;
260
+ console.log(`${LOG_PREFIX} Pattern revoked due to false positives: ${patternId}`);
261
+
262
+ if (this._persist) {
263
+ this.save();
264
+ }
265
+ }
266
+
267
+ const remaining = [...this._promoted.values()].filter(p => p.active).length;
268
+ return { revoked, fpCount: pattern.fpCount, remaining };
269
+ }
270
+
271
+ /**
272
+ * Save learned patterns to disk (if persist=true).
273
+ * Uses atomic write: write to .tmp then rename.
274
+ * @returns {boolean} success
275
+ */
276
+ save() {
277
+ if (!this._persist) {
278
+ return false;
279
+ }
280
+
281
+ try {
282
+ // Run decay before saving
283
+ this.decay();
284
+
285
+ const dir = path.dirname(this._persistPath);
286
+ fs.mkdirSync(dir, { recursive: true });
287
+
288
+ const data = this.export();
289
+ const json = JSON.stringify(data, null, 2);
290
+ const tmpPath = this._persistPath + '.tmp';
291
+
292
+ fs.writeFileSync(tmpPath, json, 'utf8');
293
+ fs.renameSync(tmpPath, this._persistPath);
294
+
295
+ this._stats.saves++;
296
+ console.log(`${LOG_PREFIX} Saved ${data.patterns.length} patterns to ${this._persistPath}`);
297
+ return true;
298
+ } catch (err) {
299
+ console.error(`${LOG_PREFIX} Failed to save patterns: ${err.message}`);
300
+ return false;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Load learned patterns from disk.
306
+ * @returns {boolean} success
307
+ */
308
+ load() {
309
+ try {
310
+ if (!fs.existsSync(this._persistPath)) {
311
+ return false;
312
+ }
313
+
314
+ const raw = fs.readFileSync(this._persistPath, 'utf8');
315
+ const data = JSON.parse(raw);
316
+ this.import(data);
317
+ this._stats.loads++;
318
+ console.log(`${LOG_PREFIX} Loaded patterns from ${this._persistPath}`);
319
+ return true;
320
+ } catch (err) {
321
+ console.error(`${LOG_PREFIX} Failed to load patterns: ${err.message}`);
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Export patterns as JSON (regardless of persist setting).
328
+ * @returns {object} { version, timestamp, patterns, candidates, stats }
329
+ */
330
+ export() {
331
+ const patterns = [];
332
+ for (const [_id, p] of this._promoted) {
333
+ patterns.push({ ...p });
334
+ }
335
+
336
+ const candidates = [];
337
+ for (const [_hash, c] of this._candidates) {
338
+ candidates.push({ ...c });
339
+ }
340
+
341
+ return {
342
+ version: '8.0',
343
+ timestamp: new Date().toISOString(),
344
+ patterns,
345
+ candidates,
346
+ stats: { ...this._stats }
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Import patterns from JSON.
352
+ * @param {object} data
353
+ * @returns {number} imported count
354
+ */
355
+ import(data) {
356
+ if (!data || typeof data !== 'object') {
357
+ return 0;
358
+ }
359
+
360
+ let imported = 0;
361
+
362
+ // Import promoted patterns
363
+ if (Array.isArray(data.patterns)) {
364
+ for (const p of data.patterns) {
365
+ if (!p.patternId || !p.signature) continue;
366
+ if (this._promoted.has(p.patternId)) continue;
367
+ if (this._promoted.size >= this._maxPatterns) break;
368
+
369
+ this._promoted.set(p.patternId, {
370
+ patternId: p.patternId,
371
+ signature: p.signature,
372
+ sigHash: p.sigHash || hashText(p.signature),
373
+ categories: p.categories || [],
374
+ confidence: p.confidence || 0.75,
375
+ hitCount: p.hitCount || 0,
376
+ fpCount: p.fpCount || 0,
377
+ source: p.source || 'imported',
378
+ severity: p.severity || 'medium',
379
+ promotedAt: p.promotedAt || Date.now(),
380
+ lastSeen: p.lastSeen || Date.now(),
381
+ active: p.active !== false
382
+ });
383
+ imported++;
384
+ }
385
+ }
386
+
387
+ // Import candidates
388
+ if (Array.isArray(data.candidates)) {
389
+ for (const c of data.candidates) {
390
+ if (!c.signature || !c.sigHash) continue;
391
+ if (this._candidates.has(c.sigHash)) continue;
392
+
393
+ this._candidates.set(c.sigHash, {
394
+ signature: c.signature,
395
+ sigHash: c.sigHash,
396
+ hitCount: c.hitCount || 1,
397
+ categories: c.categories || [],
398
+ firstSeen: c.firstSeen || Date.now(),
399
+ lastSeen: c.lastSeen || Date.now(),
400
+ promoted: c.promoted || false
401
+ });
402
+ }
403
+ }
404
+
405
+ return imported;
406
+ }
407
+
408
+ /**
409
+ * Decay old patterns that haven't been seen recently.
410
+ * Removes patterns where Date.now() - lastSeen > decayMs.
411
+ * @returns {number} patterns removed
412
+ */
413
+ decay() {
414
+ const now = Date.now();
415
+ let removed = 0;
416
+
417
+ // Decay promoted patterns
418
+ for (const [patternId, pattern] of this._promoted) {
419
+ if (now - pattern.lastSeen > this._decayMs) {
420
+ this._promoted.delete(patternId);
421
+ removed++;
422
+ }
423
+ }
424
+
425
+ // Decay candidates
426
+ for (const [sigHash, candidate] of this._candidates) {
427
+ if (now - candidate.lastSeen > this._decayMs) {
428
+ this._candidates.delete(sigHash);
429
+ }
430
+ }
431
+
432
+ if (removed > 0) {
433
+ console.log(`${LOG_PREFIX} Decayed ${removed} stale patterns`);
434
+ }
435
+
436
+ return removed;
437
+ }
438
+
439
+ /**
440
+ * Get learning statistics.
441
+ * @returns {object}
442
+ */
443
+ getStats() {
444
+ const activePatterns = [...this._promoted.values()].filter(p => p.active).length;
445
+ const revokedPatterns = [...this._promoted.values()].filter(p => !p.active).length;
446
+ return {
447
+ ...this._stats,
448
+ activePatterns,
449
+ revokedPatterns,
450
+ candidates: this._candidates.size,
451
+ totalPromoted: this._promoted.size
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Get all active patterns.
457
+ * @returns {Array<object>}
458
+ */
459
+ getActivePatterns() {
460
+ const patterns = [];
461
+ for (const [_id, p] of this._promoted) {
462
+ if (p.active) patterns.push({ ...p });
463
+ }
464
+ return patterns;
465
+ }
466
+ }
467
+
468
+ // =========================================================================
469
+ // 2. FeedbackCollector
470
+ // =========================================================================
471
+
472
+ /**
473
+ * Collects user feedback (false positives / false negatives) and feeds them
474
+ * into the PersistentLearningLoop. Tracks all feedback with IDs for audit.
475
+ */
476
+ class FeedbackCollector {
477
+ /**
478
+ * @param {object} [config]
479
+ * @param {boolean} [config.autoRetrain=true] - Auto retrain after enough feedback
480
+ * @param {number} [config.maxPending=100] - Max pending reviews
481
+ * @param {number} [config.cooldownMs=5000] - Min time between retrains
482
+ * @param {PersistentLearningLoop} [config.learningLoop] - Connected learning loop
483
+ */
484
+ constructor(config = {}) {
485
+ this._autoRetrain = config.autoRetrain !== false;
486
+ this._maxPending = config.maxPending || 100;
487
+ this._cooldownMs = config.cooldownMs || 5000;
488
+ this._learningLoop = config.learningLoop || null;
489
+
490
+ /** @type {Array<object>} */
491
+ this._pending = [];
492
+ /** @type {Array<object>} */
493
+ this._processed = [];
494
+
495
+ this._lastRetrainAt = 0;
496
+
497
+ this._stats = {
498
+ falsePositives: 0,
499
+ falseNegatives: 0,
500
+ totalProcessed: 0,
501
+ patternsAdded: 0,
502
+ patternsRevoked: 0,
503
+ retrainCount: 0
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Report a false positive — something was flagged that shouldn't have been.
509
+ * @param {string} text - The text that was incorrectly flagged
510
+ * @param {object} [meta] - { scanId, category, patternId, reason }
511
+ * @returns {object} { id: string, status: 'recorded', pendingCount: number }
512
+ */
513
+ reportFalsePositive(text, meta = {}) {
514
+ const id = `fp_${generateId()}`;
515
+
516
+ const entry = {
517
+ id,
518
+ type: 'false_positive',
519
+ text: typeof text === 'string' ? text.substring(0, 2000) : '',
520
+ meta: { ...meta },
521
+ timestamp: new Date().toISOString(),
522
+ status: 'pending'
523
+ };
524
+
525
+ this._pending.push(entry);
526
+ this._stats.falsePositives++;
527
+
528
+ // Enforce max pending
529
+ while (this._pending.length > this._maxPending) {
530
+ this._pending.shift();
531
+ }
532
+
533
+ console.log(`${LOG_PREFIX} False positive reported: ${id}`);
534
+
535
+ return { id, status: 'recorded', pendingCount: this._pending.length };
536
+ }
537
+
538
+ /**
539
+ * Report a false negative — something should have been caught but wasn't.
540
+ * @param {string} text - The text that should have been detected
541
+ * @param {object} [meta] - { expectedCategory, severity, source }
542
+ * @returns {object} { id: string, status: 'recorded', pendingCount: number }
543
+ */
544
+ reportFalseNegative(text, meta = {}) {
545
+ const id = `fn_${generateId()}`;
546
+
547
+ const entry = {
548
+ id,
549
+ type: 'false_negative',
550
+ text: typeof text === 'string' ? text.substring(0, 2000) : '',
551
+ meta: { ...meta },
552
+ timestamp: new Date().toISOString(),
553
+ status: 'pending'
554
+ };
555
+
556
+ this._pending.push(entry);
557
+ this._stats.falseNegatives++;
558
+
559
+ // Enforce max pending
560
+ while (this._pending.length > this._maxPending) {
561
+ this._pending.shift();
562
+ }
563
+
564
+ console.log(`${LOG_PREFIX} False negative reported: ${id}`);
565
+
566
+ return { id, status: 'recorded', pendingCount: this._pending.length };
567
+ }
568
+
569
+ /**
570
+ * Get pending feedback that hasn't been processed.
571
+ * @returns {Array<object>}
572
+ */
573
+ getPending() {
574
+ return this._pending.filter(e => e.status === 'pending');
575
+ }
576
+
577
+ /**
578
+ * Process all pending feedback:
579
+ * - FPs: report to learning loop for potential revocation
580
+ * - FNs: ingest into learning loop for pattern generation
581
+ * - If autoRetrain: trigger retrain event
582
+ * @returns {object} { processed: number, patternsAdded: number, patternsRevoked: number, retrainTriggered: boolean }
583
+ */
584
+ process() {
585
+ const pending = this.getPending();
586
+ let patternsAdded = 0;
587
+ let patternsRevoked = 0;
588
+
589
+ for (const entry of pending) {
590
+ entry.status = 'processed';
591
+ entry.processedAt = new Date().toISOString();
592
+
593
+ if (entry.type === 'false_positive') {
594
+ // Report to learning loop for potential revocation
595
+ if (this._learningLoop && entry.meta.patternId) {
596
+ const result = this._learningLoop.reportFalsePositive(entry.meta.patternId);
597
+ if (result.revoked) {
598
+ patternsRevoked++;
599
+ }
600
+ entry.result = result;
601
+ }
602
+ } else if (entry.type === 'false_negative') {
603
+ // Ingest into learning loop for pattern generation
604
+ if (this._learningLoop) {
605
+ const result = this._learningLoop.ingest(entry.text, {
606
+ category: entry.meta.expectedCategory || 'unknown',
607
+ source: 'feedback',
608
+ severity: entry.meta.severity || 'medium'
609
+ });
610
+ patternsAdded += result.candidates;
611
+ entry.result = result;
612
+ }
613
+ }
614
+
615
+ this._processed.push(entry);
616
+ this._stats.totalProcessed++;
617
+ }
618
+
619
+ this._stats.patternsAdded += patternsAdded;
620
+ this._stats.patternsRevoked += patternsRevoked;
621
+
622
+ // Check if retrain should be triggered
623
+ let retrainTriggered = false;
624
+ const now = Date.now();
625
+ if (this._autoRetrain && pending.length > 0 && (now - this._lastRetrainAt) >= this._cooldownMs) {
626
+ this._lastRetrainAt = now;
627
+ this._stats.retrainCount++;
628
+ retrainTriggered = true;
629
+ console.log(`${LOG_PREFIX} Retrain triggered after processing ${pending.length} feedback items`);
630
+ }
631
+
632
+ // Remove processed entries from pending
633
+ this._pending = this._pending.filter(e => e.status === 'pending');
634
+
635
+ return {
636
+ processed: pending.length,
637
+ patternsAdded,
638
+ patternsRevoked,
639
+ retrainTriggered
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Get feedback stats.
645
+ * @returns {object}
646
+ */
647
+ getStats() {
648
+ return {
649
+ ...this._stats,
650
+ pendingCount: this._pending.filter(e => e.status === 'pending').length,
651
+ processedCount: this._processed.length
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Export all feedback data.
657
+ * @returns {object}
658
+ */
659
+ export() {
660
+ return {
661
+ version: '8.0',
662
+ timestamp: new Date().toISOString(),
663
+ pending: this._pending.map(e => ({ ...e })),
664
+ processed: this._processed.map(e => ({ ...e })),
665
+ stats: this.getStats()
666
+ };
667
+ }
668
+ }
669
+
670
+ // =========================================================================
671
+ // Exports
672
+ // =========================================================================
673
+
674
+ module.exports = {
675
+ PersistentLearningLoop,
676
+ FeedbackCollector
677
+ };