@memberjunction/ai-agents 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +4 -0
  2. package/dist/agent-context-injector.d.ts +63 -23
  3. package/dist/agent-context-injector.d.ts.map +1 -1
  4. package/dist/agent-context-injector.js +205 -55
  5. package/dist/agent-context-injector.js.map +1 -1
  6. package/dist/base-agent.d.ts +36 -2
  7. package/dist/base-agent.d.ts.map +1 -1
  8. package/dist/base-agent.js +189 -13
  9. package/dist/base-agent.js.map +1 -1
  10. package/dist/memory-manager-agent.d.ts +47 -0
  11. package/dist/memory-manager-agent.d.ts.map +1 -1
  12. package/dist/memory-manager-agent.js +389 -51
  13. package/dist/memory-manager-agent.js.map +1 -1
  14. package/package.json +16 -18
  15. package/dist/__tests__/action-changes.test.d.ts +0 -15
  16. package/dist/__tests__/action-changes.test.d.ts.map +0 -1
  17. package/dist/__tests__/action-changes.test.js +0 -394
  18. package/dist/__tests__/action-changes.test.js.map +0 -1
  19. package/dist/__tests__/agent-memory-features.test.d.ts +0 -123
  20. package/dist/__tests__/agent-memory-features.test.d.ts.map +0 -1
  21. package/dist/__tests__/agent-memory-features.test.js +0 -596
  22. package/dist/__tests__/agent-memory-features.test.js.map +0 -1
  23. package/dist/__tests__/agent-type-prompt-params.test.d.ts +0 -14
  24. package/dist/__tests__/agent-type-prompt-params.test.d.ts.map +0 -1
  25. package/dist/__tests__/agent-type-prompt-params.test.js +0 -583
  26. package/dist/__tests__/agent-type-prompt-params.test.js.map +0 -1
  27. package/dist/__tests__/chat-handling-option.test.d.ts +0 -54
  28. package/dist/__tests__/chat-handling-option.test.d.ts.map +0 -1
  29. package/dist/__tests__/chat-handling-option.test.js +0 -222
  30. 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
- try {
352
- parsedResult = JSON.parse(result.result);
353
- if (this._verbose) {
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: sparseCandidates.length,
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 sparseCandidates) {
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
- try {
567
- parsedResult = JSON.parse(extractResult.result);
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
- if (extracted.mergeWithExistingId) {
695
- const existingNote = await md.GetEntityObject('AI Agent Notes', contextUser);
696
- if (await existingNote.Load(extracted.mergeWithExistingId)) {
697
- // Update existing note
698
- existingNote.Note = extracted.content;
699
- existingNote.Type = extracted.type;
700
- existingNote.AgentNoteTypeID = aiNoteTypeId;
701
- if (await existingNote.Save()) {
702
- merged++;
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
- failed++;
758
+ LogStatus(`Memory Manager: Merge target ${mergeTargetId} not found, skipping revocation`);
706
759
  }
707
760
  }
708
- else {
709
- failed++;
710
- }
761
+ createNewNote = true;
711
762
  }
712
- else {
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 USER-specific memories by default
740
- const scopeLevel = extracted.scopeLevel || 'contact';
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 === 'organization' && sourceRun?.PrimaryScopeEntityID) {
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 || 'contact'; // Default to most specific
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 === 'organization') {
843
- // Org-level example - primary scope only, no secondary
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 (contact level) - inherit full scope
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
- // Refresh AIEngine memory cache if any new memories were created
1005
- // This ensures subsequent agent runs can find the new notes/examples via semantic search
1006
- if (notesCreated > 0 || examplesCreated > 0) {
1007
- LogStatus(`Memory Manager: Refreshing AIEngine memory cache...`);
1008
- await AIEngine.Instance.Config(true, params.contextUser);
1009
- LogStatus(`Memory Manager: Memory cache refreshed`);
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 };