@memberjunction/ai-agents 4.2.0 → 4.3.1
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/README.md +4 -0
- package/dist/agent-context-injector.d.ts +63 -23
- package/dist/agent-context-injector.d.ts.map +1 -1
- package/dist/agent-context-injector.js +205 -55
- package/dist/agent-context-injector.js.map +1 -1
- package/dist/base-agent.d.ts +36 -2
- package/dist/base-agent.d.ts.map +1 -1
- package/dist/base-agent.js +189 -13
- package/dist/base-agent.js.map +1 -1
- package/dist/memory-manager-agent.d.ts +47 -0
- package/dist/memory-manager-agent.d.ts.map +1 -1
- package/dist/memory-manager-agent.js +389 -51
- package/dist/memory-manager-agent.js.map +1 -1
- package/package.json +16 -18
- package/dist/__tests__/action-changes.test.d.ts +0 -15
- package/dist/__tests__/action-changes.test.d.ts.map +0 -1
- package/dist/__tests__/action-changes.test.js +0 -394
- package/dist/__tests__/action-changes.test.js.map +0 -1
- package/dist/__tests__/agent-memory-features.test.d.ts +0 -123
- package/dist/__tests__/agent-memory-features.test.d.ts.map +0 -1
- package/dist/__tests__/agent-memory-features.test.js +0 -596
- package/dist/__tests__/agent-memory-features.test.js.map +0 -1
- package/dist/__tests__/agent-type-prompt-params.test.d.ts +0 -14
- package/dist/__tests__/agent-type-prompt-params.test.d.ts.map +0 -1
- package/dist/__tests__/agent-type-prompt-params.test.js +0 -583
- package/dist/__tests__/agent-type-prompt-params.test.js.map +0 -1
- package/dist/__tests__/chat-handling-option.test.d.ts +0 -54
- package/dist/__tests__/chat-handling-option.test.d.ts.map +0 -1
- package/dist/__tests__/chat-handling-option.test.js +0 -222
- package/dist/__tests__/chat-handling-option.test.js.map +0 -1
|
@@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import { BaseAgent } from './base-agent.js';
|
|
8
|
-
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
+
import { RegisterClass, CleanAndParseJSON } from '@memberjunction/global';
|
|
9
9
|
import { Metadata, RunView, RunQuery, LogError, LogStatus } from '@memberjunction/core';
|
|
10
10
|
import { AIPromptRunner } from '@memberjunction/ai-prompts';
|
|
11
11
|
import { AIPromptParams } from '@memberjunction/ai-core-plus';
|
|
@@ -21,6 +21,23 @@ const EXTRACTION_CONFIG = {
|
|
|
21
21
|
minContentLength: 10, // Minimum note content length (lowered from 20 to allow short names)
|
|
22
22
|
cooldownHours: 24 // Don't re-extract from same conversation within 24h
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Configuration for note consolidation.
|
|
26
|
+
* Consolidation finds clusters of similar notes and synthesizes them into single comprehensive notes.
|
|
27
|
+
*/
|
|
28
|
+
const CONSOLIDATION_CONFIG = {
|
|
29
|
+
/**
|
|
30
|
+
* How often to run consolidation:
|
|
31
|
+
* - 'disabled': Do not run consolidation
|
|
32
|
+
* - 'every-run': Run on every memory manager execution
|
|
33
|
+
* - 'hourly': Run once per hour (based on last run time)
|
|
34
|
+
* - 'daily': Run once per day
|
|
35
|
+
* - number: Run every N executions (e.g., 4 = every 4th run)
|
|
36
|
+
*/
|
|
37
|
+
frequency: 'disabled',
|
|
38
|
+
minClusterSize: 3, // Minimum notes in a cluster to trigger consolidation
|
|
39
|
+
similarityThreshold: 0.60 // Semantic similarity threshold — 60% captures topically related notes while excluding dissimilar ones
|
|
40
|
+
};
|
|
24
41
|
/**
|
|
25
42
|
* Memory Manager Agent - automatically extracts notes and examples from conversations.
|
|
26
43
|
* This agent runs on a schedule (every 15 minutes) and analyzes high-quality conversations
|
|
@@ -326,6 +343,7 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
326
343
|
params.prompt = prompt;
|
|
327
344
|
params.data = promptData;
|
|
328
345
|
params.contextUser = contextUser;
|
|
346
|
+
params.attemptJSONRepair = true;
|
|
329
347
|
const result = await runner.ExecutePrompt(params);
|
|
330
348
|
// Finalize Step 3 after extraction
|
|
331
349
|
await this.FinalizeRunStep(step3, result.success, {
|
|
@@ -346,27 +364,37 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
346
364
|
return [];
|
|
347
365
|
}
|
|
348
366
|
// Parse result if it's a string (AI sometimes returns JSON as string)
|
|
367
|
+
// Some models (e.g., Gemini) wrap JSON in ```json fences
|
|
349
368
|
let parsedResult;
|
|
350
369
|
if (typeof result.result === 'string') {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
LogStatus(`Memory Manager: Parsed string result into object with ${parsedResult.notes?.length || 0} notes`);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
catch (e) {
|
|
358
|
-
LogError('Failed to parse extraction result as JSON:', e);
|
|
370
|
+
const parsed = CleanAndParseJSON(result.result, true);
|
|
371
|
+
if (!parsed) {
|
|
372
|
+
LogError('Failed to parse extraction result as JSON');
|
|
359
373
|
return [];
|
|
360
374
|
}
|
|
375
|
+
parsedResult = parsed;
|
|
376
|
+
if (this._verbose) {
|
|
377
|
+
LogStatus(`Memory Manager: Parsed string result into object with ${parsedResult.notes?.length || 0} notes`);
|
|
378
|
+
}
|
|
361
379
|
}
|
|
362
380
|
else {
|
|
363
381
|
parsedResult = result.result;
|
|
364
382
|
}
|
|
383
|
+
// Normalize singular mergeWithExistingId → plural mergeWithExistingIds
|
|
384
|
+
// The LLM still outputs the singular field; we convert it here.
|
|
385
|
+
// Notes are plain objects from JSON.parse, so the runtime cast is safe.
|
|
386
|
+
if (parsedResult.notes) {
|
|
387
|
+
for (const note of parsedResult.notes) {
|
|
388
|
+
if (note.mergeWithExistingId && !note.mergeWithExistingIds) {
|
|
389
|
+
note.mergeWithExistingIds = [note.mergeWithExistingId];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
365
393
|
// Log raw notes before filtering (for debugging)
|
|
366
394
|
if (this._verbose && parsedResult.notes) {
|
|
367
395
|
LogStatus(`Memory Manager: LLM returned ${parsedResult.notes.length} raw notes before filtering`);
|
|
368
396
|
for (const note of parsedResult.notes) {
|
|
369
|
-
LogStatus(`Memory Manager: Raw note: [${note.type}] "${note.content}" (confidence: ${note.confidence})`);
|
|
397
|
+
LogStatus(`Memory Manager: Raw note: [${note.type}] "${note.content}" (confidence: ${note.confidence})${note.mergeWithExistingIds?.length ? ` mergeWith: ${note.mergeWithExistingIds.join(', ')}` : ''}`);
|
|
370
398
|
}
|
|
371
399
|
}
|
|
372
400
|
// Filter by confidence threshold (use EXTRACTION_CONFIG)
|
|
@@ -414,9 +442,14 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
414
442
|
if (this._verbose) {
|
|
415
443
|
LogStatus(`Memory Manager: ${sparseCandidates.length} candidates after sparsity filter`);
|
|
416
444
|
}
|
|
445
|
+
// Collapse merge candidates with identical content into single candidates
|
|
446
|
+
const collapsedCandidates = this.collapseMergeCandidates(sparseCandidates);
|
|
447
|
+
if (this._verbose && collapsedCandidates.length < sparseCandidates.length) {
|
|
448
|
+
LogStatus(`Memory Manager: Collapsed ${sparseCandidates.length} candidates to ${collapsedCandidates.length} after merging duplicate merge targets`);
|
|
449
|
+
}
|
|
417
450
|
// Step 4: Apply deduplication (summary step)
|
|
418
451
|
const step4 = await this.CreateRunStep('Decision', 'Deduplicate Note Candidates', {
|
|
419
|
-
candidateCount:
|
|
452
|
+
candidateCount: collapsedCandidates.length,
|
|
420
453
|
existingNoteCount: existingNotes.length
|
|
421
454
|
});
|
|
422
455
|
const approvedNotes = [];
|
|
@@ -424,7 +457,7 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
424
457
|
let dedupeLlmCallCount = 0;
|
|
425
458
|
// Find deduplication prompt
|
|
426
459
|
const dedupePrompt = AIEngine.Instance.Prompts.find(p => p.Name === 'Memory Manager - Deduplicate Note' && p.Category === 'MJ: System');
|
|
427
|
-
for (const candidate of
|
|
460
|
+
for (const candidate of collapsedCandidates) {
|
|
428
461
|
// Check global max notes per run
|
|
429
462
|
if (approvedNotes.length >= EXTRACTION_CONFIG.maxNotesPerRun) {
|
|
430
463
|
if (this._verbose) {
|
|
@@ -432,6 +465,16 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
432
465
|
}
|
|
433
466
|
break;
|
|
434
467
|
}
|
|
468
|
+
// Notes with mergeWithExistingIds are intentional replacements —
|
|
469
|
+
// the extraction LLM already determined this is a contradiction.
|
|
470
|
+
// Dedup would reject them as "too similar" to the note they're replacing.
|
|
471
|
+
if (candidate.mergeWithExistingIds?.length) {
|
|
472
|
+
approvedNotes.push(candidate);
|
|
473
|
+
if (this._verbose) {
|
|
474
|
+
LogStatus(`Memory Manager: Auto-approved merge note targeting ${candidate.mergeWithExistingIds.join(', ')}: "${candidate.content.substring(0, 50)}..."`);
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
435
478
|
// Step 1: Check for exact content match FIRST (case-insensitive, trimmed)
|
|
436
479
|
const normalizedContent = candidate.content.toLowerCase().trim();
|
|
437
480
|
const exactMatch = existingNotes.find(n => n.Note && n.Note.toLowerCase().trim() === normalizedContent);
|
|
@@ -442,7 +485,9 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
442
485
|
}
|
|
443
486
|
continue;
|
|
444
487
|
}
|
|
445
|
-
// Step 2: Find similar existing notes using semantic search with tighter threshold
|
|
488
|
+
// Step 2: Find similar existing notes using semantic search with tighter threshold.
|
|
489
|
+
// Cross-user dedup for org/global notes works naturally because those notes have
|
|
490
|
+
// UserID=null, so FindSimilarAgentNotes won't filter them out for any user.
|
|
446
491
|
const similarNotes = await AIEngine.Instance.FindSimilarAgentNotes(candidate.content, candidate.agentId, candidate.userId, candidate.companyId, 10, // Top 10 similar (increased from 5)
|
|
447
492
|
0.85 // 85% similarity threshold (increased from 70%)
|
|
448
493
|
);
|
|
@@ -462,6 +507,7 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
462
507
|
}))
|
|
463
508
|
};
|
|
464
509
|
dedupeParams.contextUser = contextUser;
|
|
510
|
+
dedupeParams.attemptJSONRepair = true;
|
|
465
511
|
const dedupeResult = await runner.ExecutePrompt(dedupeParams);
|
|
466
512
|
dedupeLlmCallCount++;
|
|
467
513
|
if (dedupeResult.success && dedupeResult.result && dedupeResult.result.shouldAdd) {
|
|
@@ -552,6 +598,7 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
552
598
|
extractParams.prompt = extractPrompt;
|
|
553
599
|
extractParams.data = { qaPairs };
|
|
554
600
|
extractParams.contextUser = contextUser;
|
|
601
|
+
extractParams.attemptJSONRepair = true;
|
|
555
602
|
const extractResult = await runner.ExecutePrompt(extractParams);
|
|
556
603
|
if (!extractResult.success || !extractResult.result) {
|
|
557
604
|
await this.FinalizeRunStep(step5, false, {
|
|
@@ -561,15 +608,15 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
561
608
|
return [];
|
|
562
609
|
}
|
|
563
610
|
// Parse result if it's a string (AI sometimes returns JSON as string)
|
|
611
|
+
// Some models (e.g., Gemini) wrap JSON in ```json fences
|
|
564
612
|
let parsedResult;
|
|
565
613
|
if (typeof extractResult.result === 'string') {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
catch (e) {
|
|
570
|
-
LogError('Failed to parse example extraction result as JSON:', e);
|
|
614
|
+
const parsed = CleanAndParseJSON(extractResult.result, true);
|
|
615
|
+
if (!parsed) {
|
|
616
|
+
LogError('Failed to parse example extraction result as JSON');
|
|
571
617
|
return [];
|
|
572
618
|
}
|
|
619
|
+
parsedResult = parsed;
|
|
573
620
|
}
|
|
574
621
|
else {
|
|
575
622
|
parsedResult = extractResult.result;
|
|
@@ -612,6 +659,7 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
612
659
|
}))
|
|
613
660
|
};
|
|
614
661
|
dedupeParams.contextUser = contextUser;
|
|
662
|
+
dedupeParams.attemptJSONRepair = true;
|
|
615
663
|
const dedupeResult = await runner.ExecutePrompt(dedupeParams);
|
|
616
664
|
exampleDedupeLlmCallCount++;
|
|
617
665
|
if (dedupeResult.success && dedupeResult.result && dedupeResult.result.shouldAdd) {
|
|
@@ -690,26 +738,29 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
690
738
|
}
|
|
691
739
|
sourceRun = runCache.get(extracted.sourceAgentRunId) || null;
|
|
692
740
|
}
|
|
693
|
-
// Check if we should merge with existing
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
existingNote.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
741
|
+
// Check if we should merge with existing (revoke all targets, create one replacement)
|
|
742
|
+
let createNewNote = !extracted.mergeWithExistingIds?.length;
|
|
743
|
+
if (extracted.mergeWithExistingIds?.length) {
|
|
744
|
+
for (const mergeTargetId of extracted.mergeWithExistingIds) {
|
|
745
|
+
const existingNote = await md.GetEntityObject('AI Agent Notes', contextUser);
|
|
746
|
+
if (await existingNote.Load(mergeTargetId)) {
|
|
747
|
+
existingNote.Status = 'Revoked';
|
|
748
|
+
existingNote.Comments = `Superseded: contradiction detected, replaced by new note from conversation ${extracted.sourceConversationId || 'unknown'}`;
|
|
749
|
+
if (await existingNote.Save()) {
|
|
750
|
+
merged++;
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
LogError(`Memory Manager: Failed to revoke note ${existingNote.ID} during merge`);
|
|
754
|
+
failed++;
|
|
755
|
+
}
|
|
703
756
|
}
|
|
704
757
|
else {
|
|
705
|
-
|
|
758
|
+
LogStatus(`Memory Manager: Merge target ${mergeTargetId} not found, skipping revocation`);
|
|
706
759
|
}
|
|
707
760
|
}
|
|
708
|
-
|
|
709
|
-
failed++;
|
|
710
|
-
}
|
|
761
|
+
createNewNote = true;
|
|
711
762
|
}
|
|
712
|
-
|
|
763
|
+
if (createNewNote) {
|
|
713
764
|
// Create new note
|
|
714
765
|
const note = await md.GetEntityObject('AI Agent Notes', contextUser);
|
|
715
766
|
// Only use valid UUIDs, filter out placeholders like "user-uuid-here"
|
|
@@ -724,7 +775,6 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
724
775
|
agentId = sourceRun.AgentID;
|
|
725
776
|
}
|
|
726
777
|
note.AgentID = agentId;
|
|
727
|
-
note.UserID = isValidUUID(extracted.userId) ? extracted.userId : null;
|
|
728
778
|
note.CompanyID = isValidUUID(extracted.companyId) ? extracted.companyId : null;
|
|
729
779
|
note.AgentNoteTypeID = aiNoteTypeId; // "AI" type for AI-generated notes
|
|
730
780
|
note.Type = extracted.type; // Category: Preference, Constraint, Context, Issue, Example
|
|
@@ -736,14 +786,14 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
736
786
|
// Only use if it's a valid UUID (LLM now sees message IDs in the prompt)
|
|
737
787
|
note.SourceConversationDetailID = isValidUUID(extracted.sourceConversationDetailId) ? extracted.sourceConversationDetailId : null;
|
|
738
788
|
note.SourceAIAgentRunID = extracted.sourceAgentRunId || null;
|
|
739
|
-
// Apply scope: Lean towards
|
|
740
|
-
const scopeLevel = extracted.scopeLevel || '
|
|
789
|
+
// Apply scope: Lean towards user-specific memories by default
|
|
790
|
+
const scopeLevel = extracted.scopeLevel || 'user';
|
|
741
791
|
if (scopeLevel === 'global') {
|
|
742
792
|
note.PrimaryScopeEntityID = null;
|
|
743
793
|
note.PrimaryScopeRecordID = null;
|
|
744
794
|
note.SecondaryScopes = null;
|
|
745
795
|
}
|
|
746
|
-
else if (scopeLevel === '
|
|
796
|
+
else if (scopeLevel === 'company' && sourceRun?.PrimaryScopeEntityID) {
|
|
747
797
|
note.PrimaryScopeEntityID = sourceRun.PrimaryScopeEntityID;
|
|
748
798
|
note.PrimaryScopeRecordID = sourceRun.PrimaryScopeRecordID;
|
|
749
799
|
note.SecondaryScopes = null;
|
|
@@ -753,6 +803,12 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
753
803
|
note.PrimaryScopeRecordID = sourceRun.PrimaryScopeRecordID;
|
|
754
804
|
note.SecondaryScopes = sourceRun.SecondaryScopes;
|
|
755
805
|
}
|
|
806
|
+
// UserID: Only set for user-scoped notes. Company/global notes belong to the
|
|
807
|
+
// company, not a specific user. This also enables cross-user dedup — FindSimilarAgentNotes
|
|
808
|
+
// filters by userId, so null UserID makes company notes visible to all users.
|
|
809
|
+
note.UserID = scopeLevel === 'user'
|
|
810
|
+
? (isValidUUID(extracted.userId) ? extracted.userId : null)
|
|
811
|
+
: null;
|
|
756
812
|
const saveResult = await note.Save();
|
|
757
813
|
if (saveResult) {
|
|
758
814
|
created++;
|
|
@@ -776,6 +832,270 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
776
832
|
});
|
|
777
833
|
return created + merged;
|
|
778
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* Collapse merge candidates with identical content into a single candidate
|
|
837
|
+
* carrying all merge target IDs. This prevents duplicate active notes when
|
|
838
|
+
* a contradiction invalidates multiple existing notes (e.g., "I hate pizza"
|
|
839
|
+
* replacing both a pepperoni note and a mushrooms note).
|
|
840
|
+
*/
|
|
841
|
+
collapseMergeCandidates(candidates) {
|
|
842
|
+
const mergeGroups = new Map();
|
|
843
|
+
const result = [];
|
|
844
|
+
for (const candidate of candidates) {
|
|
845
|
+
if (candidate.mergeWithExistingIds?.length) {
|
|
846
|
+
const key = candidate.content.toLowerCase().trim();
|
|
847
|
+
const existing = mergeGroups.get(key);
|
|
848
|
+
if (existing) {
|
|
849
|
+
existing.mergeWithExistingIds.push(...candidate.mergeWithExistingIds);
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
mergeGroups.set(key, candidate);
|
|
853
|
+
result.push(candidate);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
result.push(candidate);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return result;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Determine whether consolidation should run based on CONSOLIDATION_CONFIG.frequency.
|
|
864
|
+
* Uses AIAgentRun history instead of static in-memory state so that timing
|
|
865
|
+
* survives process restarts and works correctly across clustered instances.
|
|
866
|
+
*
|
|
867
|
+
* Supports:
|
|
868
|
+
* - 'every-run': Always run
|
|
869
|
+
* - 'hourly': Run if 1+ hour since last completed MM run
|
|
870
|
+
* - 'daily': Run if 24+ hours since last completed MM run
|
|
871
|
+
* - number: Run every N completed MM runs
|
|
872
|
+
*/
|
|
873
|
+
async shouldRunConsolidation(agentId, contextUser) {
|
|
874
|
+
const freq = CONSOLIDATION_CONFIG.frequency;
|
|
875
|
+
if (freq === 'disabled') {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
if (freq === 'every-run') {
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
if (freq === 'hourly' || freq === 'daily') {
|
|
882
|
+
const thresholdHours = freq === 'hourly' ? 1 : 24;
|
|
883
|
+
const lastRun = await this.GetLastRunTime(agentId, contextUser);
|
|
884
|
+
if (!lastRun) {
|
|
885
|
+
return true; // First run — consolidate
|
|
886
|
+
}
|
|
887
|
+
const hoursSinceLast = (Date.now() - lastRun.getTime()) / (1000 * 60 * 60);
|
|
888
|
+
return hoursSinceLast >= thresholdHours;
|
|
889
|
+
}
|
|
890
|
+
// Numeric frequency: run every N completed executions
|
|
891
|
+
if (typeof freq === 'number') {
|
|
892
|
+
const rv = new RunView();
|
|
893
|
+
const countResult = await rv.RunView({
|
|
894
|
+
EntityName: 'MJ: AI Agent Runs',
|
|
895
|
+
ExtraFilter: `AgentID='${agentId}' AND Status='Completed'`,
|
|
896
|
+
ResultType: 'count_only'
|
|
897
|
+
}, contextUser);
|
|
898
|
+
const completedCount = countResult.Success ? (countResult.TotalRowCount || 0) : 0;
|
|
899
|
+
return completedCount > 0 && completedCount % freq === 0;
|
|
900
|
+
}
|
|
901
|
+
// Default: run every time
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Consolidate related notes into single, comprehensive notes.
|
|
906
|
+
* This method finds clusters of similar notes and synthesizes them.
|
|
907
|
+
*
|
|
908
|
+
* @param agentId Optional - consolidate notes for a specific agent only
|
|
909
|
+
* @param contextUser The context user for database operations
|
|
910
|
+
* @returns Number of notes consolidated (archived)
|
|
911
|
+
*/
|
|
912
|
+
async consolidateRelatedNotes(agentId, contextUser) {
|
|
913
|
+
const allNotes = AIEngine.Instance.AgentNotes;
|
|
914
|
+
// Filter to active, auto-generated notes (optionally for specific agent)
|
|
915
|
+
const activeNotes = allNotes.filter(n => n.Status === 'Active' &&
|
|
916
|
+
n.IsAutoGenerated &&
|
|
917
|
+
(agentId === null || n.AgentID === agentId));
|
|
918
|
+
if (activeNotes.length < CONSOLIDATION_CONFIG.minClusterSize) {
|
|
919
|
+
if (this._verbose) {
|
|
920
|
+
LogStatus(`Memory Manager: Only ${activeNotes.length} active notes - need at least ${CONSOLIDATION_CONFIG.minClusterSize} to consolidate`);
|
|
921
|
+
}
|
|
922
|
+
return { consolidated: 0, archived: 0 };
|
|
923
|
+
}
|
|
924
|
+
if (this._verbose) {
|
|
925
|
+
LogStatus(`Memory Manager: Analyzing ${activeNotes.length} active notes for consolidation`);
|
|
926
|
+
}
|
|
927
|
+
const clusters = await this.findConsolidationClusters(activeNotes);
|
|
928
|
+
if (clusters.length === 0) {
|
|
929
|
+
if (this._verbose) {
|
|
930
|
+
LogStatus(`Memory Manager: No clusters found with ${CONSOLIDATION_CONFIG.minClusterSize}+ similar notes`);
|
|
931
|
+
}
|
|
932
|
+
return { consolidated: 0, archived: 0 };
|
|
933
|
+
}
|
|
934
|
+
if (this._verbose) {
|
|
935
|
+
LogStatus(`Memory Manager: Found ${clusters.length} clusters to consolidate`);
|
|
936
|
+
}
|
|
937
|
+
// Find consolidation prompt
|
|
938
|
+
const consolidatePrompt = AIEngine.Instance.Prompts.find(p => p.Name === 'Memory Manager - Consolidate Notes' && p.Category === 'MJ: System');
|
|
939
|
+
if (!consolidatePrompt) {
|
|
940
|
+
LogError('Memory Manager: Consolidation prompt not found');
|
|
941
|
+
return { consolidated: 0, archived: 0 };
|
|
942
|
+
}
|
|
943
|
+
const aiNoteTypeId = AIEngine.Instance.AgenteNoteTypeIDByName('AI');
|
|
944
|
+
if (!aiNoteTypeId) {
|
|
945
|
+
LogError('Memory Manager: Could not find "AI" note type');
|
|
946
|
+
return { consolidated: 0, archived: 0 };
|
|
947
|
+
}
|
|
948
|
+
let consolidated = 0;
|
|
949
|
+
let archived = 0;
|
|
950
|
+
const runner = new AIPromptRunner();
|
|
951
|
+
const md = new Metadata();
|
|
952
|
+
for (const cluster of clusters) {
|
|
953
|
+
try {
|
|
954
|
+
const result = await this.processConsolidationCluster(cluster, consolidatePrompt, aiNoteTypeId, runner, md, contextUser);
|
|
955
|
+
consolidated += result.consolidated;
|
|
956
|
+
archived += result.archived;
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
LogError('Memory Manager: Exception during consolidation:', error);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return { consolidated, archived };
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Find clusters of semantically similar notes suitable for consolidation.
|
|
966
|
+
* Each cluster contains notes that exceed the similarity threshold and meet the minimum cluster size.
|
|
967
|
+
*/
|
|
968
|
+
async findConsolidationClusters(activeNotes) {
|
|
969
|
+
const clusters = [];
|
|
970
|
+
const processedIds = new Set();
|
|
971
|
+
for (const note of activeNotes) {
|
|
972
|
+
if (processedIds.has(note.ID)) {
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
const similarNotes = await AIEngine.Instance.FindSimilarAgentNotes(note.Note || '', note.AgentID, note.UserID, note.CompanyID, 10, CONSOLIDATION_CONFIG.similarityThreshold);
|
|
976
|
+
const cluster = [note];
|
|
977
|
+
processedIds.add(note.ID);
|
|
978
|
+
for (const similar of similarNotes) {
|
|
979
|
+
if (!processedIds.has(similar.note.ID)) {
|
|
980
|
+
cluster.push(similar.note);
|
|
981
|
+
processedIds.add(similar.note.ID);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (cluster.length >= CONSOLIDATION_CONFIG.minClusterSize) {
|
|
985
|
+
clusters.push(cluster);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return clusters;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Process a single consolidation cluster: run the LLM prompt, create the consolidated note,
|
|
992
|
+
* and revoke source notes.
|
|
993
|
+
*/
|
|
994
|
+
async processConsolidationCluster(cluster, consolidatePrompt, aiNoteTypeId, runner, md, contextUser) {
|
|
995
|
+
const promptData = {
|
|
996
|
+
notesToConsolidate: cluster.map(n => ({
|
|
997
|
+
id: n.ID,
|
|
998
|
+
type: n.Type,
|
|
999
|
+
content: n.Note,
|
|
1000
|
+
createdAt: n.__mj_CreatedAt,
|
|
1001
|
+
accessCount: n.AccessCount,
|
|
1002
|
+
agentId: n.AgentID,
|
|
1003
|
+
userId: n.UserID,
|
|
1004
|
+
companyId: n.CompanyID
|
|
1005
|
+
}))
|
|
1006
|
+
};
|
|
1007
|
+
const params = new AIPromptParams();
|
|
1008
|
+
params.prompt = consolidatePrompt;
|
|
1009
|
+
params.data = promptData;
|
|
1010
|
+
params.contextUser = contextUser;
|
|
1011
|
+
params.attemptJSONRepair = true;
|
|
1012
|
+
const result = await runner.ExecutePrompt(params);
|
|
1013
|
+
if (!result.success || !result.result) {
|
|
1014
|
+
LogError(`Memory Manager: Consolidation prompt failed for cluster: ${result.errorMessage}`);
|
|
1015
|
+
return { consolidated: 0, archived: 0 };
|
|
1016
|
+
}
|
|
1017
|
+
// Parse result if string (some models wrap JSON in ```json fences)
|
|
1018
|
+
let parsedResult = result.result;
|
|
1019
|
+
if (typeof result.result === 'string') {
|
|
1020
|
+
const parsed = CleanAndParseJSON(result.result, true);
|
|
1021
|
+
if (!parsed) {
|
|
1022
|
+
LogError('Memory Manager: Failed to parse consolidation result');
|
|
1023
|
+
return { consolidated: 0, archived: 0 };
|
|
1024
|
+
}
|
|
1025
|
+
parsedResult = parsed;
|
|
1026
|
+
}
|
|
1027
|
+
if (!parsedResult.shouldConsolidate) {
|
|
1028
|
+
if (this._verbose) {
|
|
1029
|
+
LogStatus(`Memory Manager: Skipping cluster consolidation - ${parsedResult.reason}`);
|
|
1030
|
+
}
|
|
1031
|
+
return { consolidated: 0, archived: 0 };
|
|
1032
|
+
}
|
|
1033
|
+
// Create consolidated note
|
|
1034
|
+
const consolidatedNoteData = parsedResult.consolidatedNote;
|
|
1035
|
+
const newNote = await md.GetEntityObject('AI Agent Notes', contextUser);
|
|
1036
|
+
const templateNote = cluster[0];
|
|
1037
|
+
newNote.AgentID = templateNote.AgentID;
|
|
1038
|
+
newNote.UserID = templateNote.UserID;
|
|
1039
|
+
newNote.CompanyID = templateNote.CompanyID;
|
|
1040
|
+
newNote.AgentNoteTypeID = aiNoteTypeId;
|
|
1041
|
+
newNote.Type = consolidatedNoteData.type;
|
|
1042
|
+
newNote.Note = consolidatedNoteData.content;
|
|
1043
|
+
newNote.IsAutoGenerated = true;
|
|
1044
|
+
newNote.Status = 'Active';
|
|
1045
|
+
newNote.AccessCount = cluster.reduce((sum, n) => sum + (n.AccessCount || 0), 0);
|
|
1046
|
+
newNote.Comments = `Consolidated from ${cluster.length} notes: ${parsedResult.reason}`;
|
|
1047
|
+
this.applyScopeToConsolidatedNote(newNote, templateNote, consolidatedNoteData.scopeLevel);
|
|
1048
|
+
const saveResult = await newNote.Save();
|
|
1049
|
+
if (!saveResult) {
|
|
1050
|
+
LogError(`Memory Manager: Failed to save consolidated note: ${JSON.stringify(newNote.LatestResult)}`);
|
|
1051
|
+
return { consolidated: 0, archived: 0 };
|
|
1052
|
+
}
|
|
1053
|
+
// Revoke source notes in parallel
|
|
1054
|
+
// TODO: When a ConsolidatedIntoNoteID FK column is added to AI Agent Notes,
|
|
1055
|
+
// set it here so decomposition can properly restore source notes.
|
|
1056
|
+
const revokeResults = await Promise.all(cluster.map(async (sourceNote) => {
|
|
1057
|
+
const noteToRevoke = await md.GetEntityObject('AI Agent Notes', contextUser);
|
|
1058
|
+
if (await noteToRevoke.Load(sourceNote.ID)) {
|
|
1059
|
+
noteToRevoke.Status = 'Revoked';
|
|
1060
|
+
noteToRevoke.Comments = 'Revoked during note consolidation';
|
|
1061
|
+
if (await noteToRevoke.Save()) {
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
LogError(`Memory Manager: Failed to revoke source note ${sourceNote.ID}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return false;
|
|
1069
|
+
}));
|
|
1070
|
+
const archived = revokeResults.filter(Boolean).length;
|
|
1071
|
+
if (this._verbose) {
|
|
1072
|
+
LogStatus(`Memory Manager: Consolidated ${cluster.length} notes into: "${consolidatedNoteData.content.substring(0, 50)}..."`);
|
|
1073
|
+
}
|
|
1074
|
+
return { consolidated: 1, archived };
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Apply scope fields to a consolidated note based on the LLM's scope recommendation.
|
|
1078
|
+
* Mirrors the scoping logic in CreateNoteRecords.
|
|
1079
|
+
*/
|
|
1080
|
+
applyScopeToConsolidatedNote(newNote, templateNote, scopeLevel) {
|
|
1081
|
+
const level = scopeLevel || 'user';
|
|
1082
|
+
if (level === 'global') {
|
|
1083
|
+
newNote.PrimaryScopeEntityID = null;
|
|
1084
|
+
newNote.PrimaryScopeRecordID = null;
|
|
1085
|
+
newNote.SecondaryScopes = null;
|
|
1086
|
+
}
|
|
1087
|
+
else if (level === 'company' && templateNote.PrimaryScopeEntityID) {
|
|
1088
|
+
newNote.PrimaryScopeEntityID = templateNote.PrimaryScopeEntityID;
|
|
1089
|
+
newNote.PrimaryScopeRecordID = templateNote.PrimaryScopeRecordID;
|
|
1090
|
+
newNote.SecondaryScopes = null;
|
|
1091
|
+
}
|
|
1092
|
+
else {
|
|
1093
|
+
// user (default) — inherit full scope
|
|
1094
|
+
newNote.PrimaryScopeEntityID = templateNote.PrimaryScopeEntityID;
|
|
1095
|
+
newNote.PrimaryScopeRecordID = templateNote.PrimaryScopeRecordID;
|
|
1096
|
+
newNote.SecondaryScopes = templateNote.SecondaryScopes;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
779
1099
|
/**
|
|
780
1100
|
* Create example records from extracted data.
|
|
781
1101
|
* Inherits scope from source agent run and applies scopeLevel to determine scope specificity.
|
|
@@ -832,21 +1152,21 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
832
1152
|
example.SourceAIAgentRunID = extracted.sourceAgentRunId || null;
|
|
833
1153
|
// Apply scope from source agent run based on scopeLevel hint
|
|
834
1154
|
if (sourceRun && sourceRun.PrimaryScopeEntityID) {
|
|
835
|
-
const scopeLevel = extracted.scopeLevel || '
|
|
1155
|
+
const scopeLevel = extracted.scopeLevel || 'user'; // Default to most specific
|
|
836
1156
|
if (scopeLevel === 'global') {
|
|
837
1157
|
// Global example - no scope fields set
|
|
838
1158
|
example.PrimaryScopeEntityID = null;
|
|
839
1159
|
example.PrimaryScopeRecordID = null;
|
|
840
1160
|
example.SecondaryScopes = null;
|
|
841
1161
|
}
|
|
842
|
-
else if (scopeLevel === '
|
|
843
|
-
//
|
|
1162
|
+
else if (scopeLevel === 'company') {
|
|
1163
|
+
// Company-level example - primary scope only, no secondary
|
|
844
1164
|
example.PrimaryScopeEntityID = sourceRun.PrimaryScopeEntityID;
|
|
845
1165
|
example.PrimaryScopeRecordID = sourceRun.PrimaryScopeRecordID;
|
|
846
1166
|
example.SecondaryScopes = null;
|
|
847
1167
|
}
|
|
848
1168
|
else {
|
|
849
|
-
// Fully-scoped example (
|
|
1169
|
+
// Fully-scoped example (user level) - inherit full scope
|
|
850
1170
|
example.PrimaryScopeEntityID = sourceRun.PrimaryScopeEntityID;
|
|
851
1171
|
example.PrimaryScopeRecordID = sourceRun.PrimaryScopeRecordID;
|
|
852
1172
|
example.SecondaryScopes = sourceRun.SecondaryScopes;
|
|
@@ -878,8 +1198,8 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
878
1198
|
*/
|
|
879
1199
|
async executeAgentInternal(params, config) {
|
|
880
1200
|
try {
|
|
881
|
-
// Use verbose flag from agent execution params
|
|
882
|
-
this._verbose = params.verbose ?? false;
|
|
1201
|
+
// Use verbose flag from agent execution params or data payload (UI passes it via data)
|
|
1202
|
+
this._verbose = params.verbose ?? params.data?.verbose ?? false;
|
|
883
1203
|
// Initialize observability state for this run
|
|
884
1204
|
this._agentRunID = this.AgentRun?.ID || null;
|
|
885
1205
|
this._stepCounter = 0;
|
|
@@ -941,6 +1261,12 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
941
1261
|
};
|
|
942
1262
|
return { finalStep, stepCount: 1 };
|
|
943
1263
|
}
|
|
1264
|
+
// Initialization guard: ensure vector services exist for semantic dedup.
|
|
1265
|
+
// If no notes existed at server startup, _noteVectorService is null and
|
|
1266
|
+
// AddOrUpdateSingleNoteEmbedding will throw when saving new notes.
|
|
1267
|
+
// The two post-creation/post-consolidation Config(true) calls were removed
|
|
1268
|
+
// because entity save hooks now update vectors incrementally.
|
|
1269
|
+
await AIEngine.Instance.Config(true, params.contextUser);
|
|
944
1270
|
// Extract notes from conversations (with rating context)
|
|
945
1271
|
const extractedNotes = await this.ExtractNotesFromConversations(conversations, params.contextUser);
|
|
946
1272
|
if (this._verbose) {
|
|
@@ -1001,23 +1327,35 @@ let MemoryManagerAgent = class MemoryManagerAgent extends BaseAgent {
|
|
|
1001
1327
|
const notesCreated = await this.CreateNoteRecords(extractedNotes, params.contextUser);
|
|
1002
1328
|
const examplesCreated = await this.CreateExampleRecords(extractedExamples, params.contextUser);
|
|
1003
1329
|
LogStatus(`Memory Manager: Created ${notesCreated} notes and ${examplesCreated} examples`);
|
|
1004
|
-
//
|
|
1005
|
-
// This
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
LogStatus(`Memory Manager:
|
|
1330
|
+
// Step 9: Consolidate related notes
|
|
1331
|
+
// This finds clusters of similar notes and synthesizes them into single comprehensive notes
|
|
1332
|
+
let consolidatedCount = 0;
|
|
1333
|
+
let archivedCount = 0;
|
|
1334
|
+
if (await this.shouldRunConsolidation(params.agent.ID, params.contextUser)) {
|
|
1335
|
+
LogStatus(`Memory Manager: Running note consolidation...`);
|
|
1336
|
+
const consolidationResult = await this.consolidateRelatedNotes(null, params.contextUser);
|
|
1337
|
+
consolidatedCount = consolidationResult.consolidated;
|
|
1338
|
+
archivedCount = consolidationResult.archived;
|
|
1339
|
+
if (consolidatedCount > 0 || archivedCount > 0) {
|
|
1340
|
+
LogStatus(`Memory Manager: Consolidated ${consolidatedCount} note clusters, archived ${archivedCount} source notes`);
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
LogStatus(`Memory Manager: No notes to consolidate`);
|
|
1344
|
+
}
|
|
1010
1345
|
}
|
|
1346
|
+
const consolidationSummary = consolidatedCount > 0 ? ` Consolidated ${consolidatedCount} clusters (${archivedCount} notes archived).` : '';
|
|
1011
1347
|
const finalStep = {
|
|
1012
1348
|
terminate: true,
|
|
1013
1349
|
step: 'Success',
|
|
1014
|
-
message: `Processed ${conversations.length} conversations (${totalMessages} messages) and ${agentRuns.length} agent runs. Created ${notesCreated} notes and ${examplesCreated} examples
|
|
1350
|
+
message: `Processed ${conversations.length} conversations (${totalMessages} messages) and ${agentRuns.length} agent runs. Created ${notesCreated} notes and ${examplesCreated} examples.${consolidationSummary}`,
|
|
1015
1351
|
newPayload: {
|
|
1016
1352
|
notesCreated,
|
|
1017
1353
|
examplesCreated,
|
|
1018
1354
|
conversationsProcessed: conversations.length,
|
|
1019
1355
|
messagesProcessed: totalMessages,
|
|
1020
|
-
agentRunsProcessed: agentRuns.length
|
|
1356
|
+
agentRunsProcessed: agentRuns.length,
|
|
1357
|
+
notesConsolidated: consolidatedCount,
|
|
1358
|
+
notesArchived: archivedCount
|
|
1021
1359
|
}
|
|
1022
1360
|
};
|
|
1023
1361
|
return { finalStep, stepCount: 1 };
|