511 lines
16 KiB
C#
Executable File
511 lines
16 KiB
C#
Executable File
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using MarketAlly.AIPlugin;
|
|
|
|
namespace MarketAlly.AIPlugin.Context
|
|
{
|
|
/// <summary>
|
|
/// Plugin for searching through stored context to find relevant information from previous conversations.
|
|
/// Provides intelligent search across conversation history, decisions, and code changes.
|
|
/// </summary>
|
|
[AIPlugin("ContextSearch", "Searches through stored context and conversation history to find relevant information from previous discussions")]
|
|
public class ContextSearchPlugin : IAIPlugin
|
|
{
|
|
[AIParameter("Search query or keywords to find in context", required: true)]
|
|
public string Query { get; set; } = "";
|
|
|
|
[AIParameter("Type of context to search: 'all', 'conversation', 'decision', 'codechange', 'insight', 'milestone'", required: false)]
|
|
public string ContextType { get; set; } = "all";
|
|
|
|
[AIParameter("Project path to search context for", required: false)]
|
|
public string? ProjectPath { get; set; }
|
|
|
|
[AIParameter("Maximum number of results to return", required: false)]
|
|
public int MaxResults { get; set; } = 10;
|
|
|
|
[AIParameter("Search priority: 'all', 'high', 'medium', 'low'", required: false)]
|
|
public string Priority { get; set; } = "all";
|
|
|
|
[AIParameter("Number of days back to search (0 for all time)", required: false)]
|
|
public int DaysBack { get; set; } = 0;
|
|
|
|
[AIParameter("Tags to filter by (comma-separated)", required: false)]
|
|
public string? Tags { get; set; }
|
|
|
|
[AIParameter("Include full content in results (otherwise just summaries)", required: false)]
|
|
public bool IncludeContent { get; set; } = true;
|
|
|
|
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
|
|
{
|
|
["query"] = typeof(string),
|
|
["contextType"] = typeof(string),
|
|
["contexttype"] = typeof(string),
|
|
["projectPath"] = typeof(string),
|
|
["projectpath"] = typeof(string),
|
|
["maxResults"] = typeof(int),
|
|
["maxresults"] = typeof(int),
|
|
["priority"] = typeof(string),
|
|
["daysBack"] = typeof(int),
|
|
["daysback"] = typeof(int),
|
|
["tags"] = typeof(string),
|
|
["includeContent"] = typeof(bool),
|
|
["includecontent"] = typeof(bool)
|
|
};
|
|
|
|
public async Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
|
|
{
|
|
try
|
|
{
|
|
// Validate required parameters
|
|
if (!parameters.ContainsKey("query") || string.IsNullOrWhiteSpace(parameters["query"].ToString()))
|
|
{
|
|
return new AIPluginResult(new ArgumentException("Missing or empty query parameter"),
|
|
"The 'query' parameter is required and cannot be empty");
|
|
}
|
|
|
|
// Extract parameters
|
|
var query = parameters["query"].ToString()!;
|
|
var contextType = parameters.TryGetValue("contextType", out var ct) ? ct.ToString()!.ToLower() : "all";
|
|
var projectPath = parameters.TryGetValue("projectPath", out var pp) ? pp?.ToString() : null;
|
|
var maxResults = parameters.TryGetValue("maxResults", out var mr) ? Convert.ToInt32(mr) : 10;
|
|
var priority = parameters.TryGetValue("priority", out var p) ? p.ToString()!.ToLower() : "all";
|
|
var daysBack = parameters.TryGetValue("daysBack", out var db) ? Convert.ToInt32(db) : 0;
|
|
var tags = parameters.TryGetValue("tags", out var t) ? t?.ToString() : null;
|
|
var includeContent = parameters.TryGetValue("includeContent", out var ic) ? Convert.ToBoolean(ic) : true;
|
|
|
|
// Get storage path
|
|
var storagePath = await GetStoragePathAsync(projectPath);
|
|
|
|
if (!Directory.Exists(storagePath))
|
|
{
|
|
return new AIPluginResult(new
|
|
{
|
|
Results = new List<object>(),
|
|
TotalFound = 0,
|
|
Message = "No context storage found"
|
|
}, "No stored context found. Use ContextStorage plugin to store information first.");
|
|
}
|
|
|
|
// Search through context
|
|
var searchResults = await SearchContextAsync(storagePath, query, contextType, priority, daysBack, tags, maxResults, includeContent);
|
|
|
|
return new AIPluginResult(new
|
|
{
|
|
Query = query,
|
|
Results = searchResults.Results,
|
|
TotalFound = searchResults.TotalFound,
|
|
SearchParameters = new
|
|
{
|
|
ContextType = contextType,
|
|
Priority = priority,
|
|
DaysBack = daysBack,
|
|
Tags = tags,
|
|
MaxResults = maxResults,
|
|
IncludeContent = includeContent
|
|
},
|
|
Message = $"Found {searchResults.TotalFound} relevant context entries"
|
|
}, $"Found {searchResults.TotalFound} context entries matching '{query}'");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new AIPluginResult(ex, "Failed to search context");
|
|
}
|
|
}
|
|
|
|
private async Task<string> GetStoragePathAsync(string? projectPath)
|
|
{
|
|
if (string.IsNullOrEmpty(projectPath))
|
|
{
|
|
projectPath = Directory.GetCurrentDirectory();
|
|
}
|
|
|
|
return Path.Combine(projectPath, ".context");
|
|
}
|
|
|
|
private async Task<SearchResults> SearchContextAsync(string storagePath, string query, string contextType,
|
|
string priority, int daysBack, string? tags, int maxResults, bool includeContent)
|
|
{
|
|
var results = new List<ContextSearchResult>();
|
|
var totalFound = 0;
|
|
|
|
// First, try to use the index for faster searching
|
|
var indexPath = Path.Combine(storagePath, "context-index.json");
|
|
List<ContextIndexEntry> indexEntries = new();
|
|
|
|
if (File.Exists(indexPath))
|
|
{
|
|
try
|
|
{
|
|
var indexJson = await File.ReadAllTextAsync(indexPath);
|
|
var index = JsonSerializer.Deserialize<List<ContextIndexEntry>>(indexJson);
|
|
if (index != null)
|
|
{
|
|
indexEntries = index;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Index corrupted, fall back to file scanning
|
|
}
|
|
}
|
|
|
|
// Filter index entries based on search criteria
|
|
var filteredIndex = FilterIndexEntries(indexEntries, contextType, priority, daysBack, tags);
|
|
|
|
// Search through index entries first
|
|
var indexResults = SearchIndexEntries(filteredIndex, query);
|
|
|
|
// Load full content for matching entries
|
|
var loadedResults = new List<ContextSearchResult>();
|
|
foreach (var indexResult in indexResults.Take(maxResults))
|
|
{
|
|
var fullEntry = await LoadFullContextEntryAsync(storagePath, indexResult.IndexEntry);
|
|
if (fullEntry != null)
|
|
{
|
|
var searchResult = new ContextSearchResult
|
|
{
|
|
Id = fullEntry.Id,
|
|
Type = fullEntry.Type,
|
|
Summary = fullEntry.Summary,
|
|
Content = includeContent ? fullEntry.Content : null,
|
|
Tags = fullEntry.Tags,
|
|
Priority = fullEntry.Priority,
|
|
Timestamp = fullEntry.Timestamp,
|
|
ProjectPath = fullEntry.ProjectPath,
|
|
Relevance = indexResult.Relevance,
|
|
MatchedTerms = indexResult.MatchedTerms,
|
|
Metadata = fullEntry.Metadata
|
|
};
|
|
loadedResults.Add(searchResult);
|
|
}
|
|
}
|
|
|
|
// If index search didn't find enough results, fall back to full file search
|
|
if (loadedResults.Count < maxResults)
|
|
{
|
|
var fileResults = await SearchContextFilesAsync(storagePath, query, contextType, priority, daysBack, tags,
|
|
maxResults - loadedResults.Count, includeContent, loadedResults.Select(r => r.Id).ToHashSet());
|
|
loadedResults.AddRange(fileResults.Results);
|
|
totalFound += fileResults.TotalFound;
|
|
}
|
|
|
|
// Sort by relevance and timestamp
|
|
results = loadedResults.OrderByDescending(r => r.Relevance)
|
|
.ThenByDescending(r => r.Timestamp)
|
|
.Take(maxResults)
|
|
.ToList();
|
|
|
|
return new SearchResults
|
|
{
|
|
Results = results,
|
|
TotalFound = Math.Max(totalFound, results.Count)
|
|
};
|
|
}
|
|
|
|
private List<ContextIndexEntry> FilterIndexEntries(List<ContextIndexEntry> entries, string contextType,
|
|
string priority, int daysBack, string? tags)
|
|
{
|
|
var filtered = entries.AsEnumerable();
|
|
|
|
// Filter by type
|
|
if (contextType != "all")
|
|
{
|
|
filtered = filtered.Where(e => e.Type.Equals(contextType, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// Filter by priority
|
|
if (priority != "all")
|
|
{
|
|
filtered = filtered.Where(e => e.Priority.Equals(priority, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// Filter by date
|
|
if (daysBack > 0)
|
|
{
|
|
var cutoffDate = DateTime.UtcNow.AddDays(-daysBack);
|
|
filtered = filtered.Where(e => e.Timestamp >= cutoffDate);
|
|
}
|
|
|
|
// Filter by tags
|
|
if (!string.IsNullOrEmpty(tags))
|
|
{
|
|
var searchTags = tags.Split(',').Select(t => t.Trim().ToLower()).ToList();
|
|
filtered = filtered.Where(e => e.Tags.Any(tag => searchTags.Contains(tag.ToLower())));
|
|
}
|
|
|
|
return filtered.ToList();
|
|
}
|
|
|
|
private List<IndexSearchResult> SearchIndexEntries(List<ContextIndexEntry> entries, string query)
|
|
{
|
|
var results = new List<IndexSearchResult>();
|
|
var queryTerms = ExtractSearchTerms(query);
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
var relevance = CalculateRelevance(entry, queryTerms);
|
|
if (relevance > 0)
|
|
{
|
|
results.Add(new IndexSearchResult
|
|
{
|
|
IndexEntry = entry,
|
|
Relevance = relevance,
|
|
MatchedTerms = GetMatchedTerms(entry, queryTerms)
|
|
});
|
|
}
|
|
}
|
|
|
|
return results.OrderByDescending(r => r.Relevance).ToList();
|
|
}
|
|
|
|
private async Task<StoredContextEntry?> LoadFullContextEntryAsync(string storagePath, ContextIndexEntry indexEntry)
|
|
{
|
|
try
|
|
{
|
|
var filePath = Path.Combine(storagePath, indexEntry.FileName);
|
|
if (!File.Exists(filePath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var json = await File.ReadAllTextAsync(filePath);
|
|
var entries = JsonSerializer.Deserialize<List<StoredContextEntry>>(json);
|
|
return entries?.FirstOrDefault(e => e.Id == indexEntry.Id);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task<SearchResults> SearchContextFilesAsync(string storagePath, string query, string contextType,
|
|
string priority, int daysBack, string? tags, int maxResults, bool includeContent, HashSet<string> excludeIds)
|
|
{
|
|
var results = new List<ContextSearchResult>();
|
|
var queryTerms = ExtractSearchTerms(query);
|
|
|
|
// Get all context files
|
|
var contextFiles = Directory.GetFiles(storagePath, "context-*.json")
|
|
.Where(f => !Path.GetFileName(f).Equals("context-index.json"))
|
|
.ToList();
|
|
|
|
foreach (var file in contextFiles)
|
|
{
|
|
try
|
|
{
|
|
var json = await File.ReadAllTextAsync(file);
|
|
var entries = JsonSerializer.Deserialize<List<StoredContextEntry>>(json);
|
|
if (entries == null) continue;
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
// Skip if already included
|
|
if (excludeIds.Contains(entry.Id)) continue;
|
|
|
|
// Apply filters
|
|
if (!PassesFilters(entry, contextType, priority, daysBack, tags)) continue;
|
|
|
|
// Calculate relevance
|
|
var relevance = CalculateRelevance(entry, queryTerms);
|
|
if (relevance > 0)
|
|
{
|
|
results.Add(new ContextSearchResult
|
|
{
|
|
Id = entry.Id,
|
|
Type = entry.Type,
|
|
Summary = entry.Summary,
|
|
Content = includeContent ? entry.Content : null,
|
|
Tags = entry.Tags,
|
|
Priority = entry.Priority,
|
|
Timestamp = entry.Timestamp,
|
|
ProjectPath = entry.ProjectPath,
|
|
Relevance = relevance,
|
|
MatchedTerms = GetMatchedTerms(entry, queryTerms),
|
|
Metadata = entry.Metadata
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Skip corrupted files
|
|
}
|
|
}
|
|
|
|
return new SearchResults
|
|
{
|
|
Results = results.OrderByDescending(r => r.Relevance)
|
|
.ThenByDescending(r => r.Timestamp)
|
|
.Take(maxResults)
|
|
.ToList(),
|
|
TotalFound = results.Count
|
|
};
|
|
}
|
|
|
|
private bool PassesFilters(StoredContextEntry entry, string contextType, string priority, int daysBack, string? tags)
|
|
{
|
|
// Type filter
|
|
if (contextType != "all" && !entry.Type.Equals(contextType, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
|
|
// Priority filter
|
|
if (priority != "all" && !entry.Priority.Equals(priority, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
|
|
// Date filter
|
|
if (daysBack > 0 && entry.Timestamp < DateTime.UtcNow.AddDays(-daysBack))
|
|
return false;
|
|
|
|
// Tags filter
|
|
if (!string.IsNullOrEmpty(tags))
|
|
{
|
|
var searchTags = tags.Split(',').Select(t => t.Trim().ToLower()).ToList();
|
|
if (!entry.Tags.Any(tag => searchTags.Contains(tag.ToLower())))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private List<string> ExtractSearchTerms(string query)
|
|
{
|
|
// Extract meaningful terms from the query
|
|
var terms = new List<string>();
|
|
|
|
// Split by common delimiters and clean up
|
|
var rawTerms = Regex.Split(query.ToLower(), @"[\s,;.!?]+")
|
|
.Where(t => t.Length > 2) // Ignore very short terms
|
|
.Where(t => !IsStopWord(t))
|
|
.ToList();
|
|
|
|
terms.AddRange(rawTerms);
|
|
|
|
// Also add the full query for exact phrase matching
|
|
if (query.Length > 5)
|
|
{
|
|
terms.Add(query.ToLower());
|
|
}
|
|
|
|
return terms.Distinct().ToList();
|
|
}
|
|
|
|
private bool IsStopWord(string word)
|
|
{
|
|
var stopWords = new HashSet<string> { "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should", "may", "might", "can", "this", "that", "these", "those" };
|
|
return stopWords.Contains(word);
|
|
}
|
|
|
|
private double CalculateRelevance(ContextIndexEntry entry, List<string> queryTerms)
|
|
{
|
|
return CalculateRelevanceCommon(entry.Summary, entry.Tags, queryTerms);
|
|
}
|
|
|
|
private double CalculateRelevance(StoredContextEntry entry, List<string> queryTerms)
|
|
{
|
|
var summaryRelevance = CalculateRelevanceCommon(entry.Summary, entry.Tags, queryTerms);
|
|
|
|
// Also check content for full entries
|
|
var contentRelevance = 0.0;
|
|
foreach (var term in queryTerms)
|
|
{
|
|
if (entry.Content.Contains(term, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
contentRelevance += 0.3; // Content matches are worth less than summary/tag matches
|
|
}
|
|
}
|
|
|
|
return summaryRelevance + contentRelevance;
|
|
}
|
|
|
|
private double CalculateRelevanceCommon(string summary, List<string> tags, List<string> queryTerms)
|
|
{
|
|
var relevance = 0.0;
|
|
var summaryLower = summary.ToLower();
|
|
var tagsLower = tags.Select(t => t.ToLower()).ToList();
|
|
|
|
foreach (var term in queryTerms)
|
|
{
|
|
// Exact matches in summary are highly relevant
|
|
if (summaryLower.Contains(term))
|
|
{
|
|
relevance += term.Length > 10 ? 2.0 : 1.0; // Longer terms are more significant
|
|
}
|
|
|
|
// Tag matches are also highly relevant
|
|
if (tagsLower.Any(tag => tag.Contains(term)))
|
|
{
|
|
relevance += 1.5;
|
|
}
|
|
}
|
|
|
|
return relevance;
|
|
}
|
|
|
|
private List<string> GetMatchedTerms(ContextIndexEntry entry, List<string> queryTerms)
|
|
{
|
|
return GetMatchedTermsCommon(entry.Summary, entry.Tags, queryTerms);
|
|
}
|
|
|
|
private List<string> GetMatchedTerms(StoredContextEntry entry, List<string> queryTerms)
|
|
{
|
|
var matched = GetMatchedTermsCommon(entry.Summary, entry.Tags, queryTerms);
|
|
|
|
// Also check content
|
|
foreach (var term in queryTerms)
|
|
{
|
|
if (entry.Content.Contains(term, StringComparison.OrdinalIgnoreCase) && !matched.Contains(term))
|
|
{
|
|
matched.Add(term);
|
|
}
|
|
}
|
|
|
|
return matched;
|
|
}
|
|
|
|
private List<string> GetMatchedTermsCommon(string summary, List<string> tags, List<string> queryTerms)
|
|
{
|
|
var matched = new List<string>();
|
|
var summaryLower = summary.ToLower();
|
|
var tagsLower = tags.Select(t => t.ToLower()).ToList();
|
|
|
|
foreach (var term in queryTerms)
|
|
{
|
|
if (summaryLower.Contains(term) || tagsLower.Any(tag => tag.Contains(term)))
|
|
{
|
|
matched.Add(term);
|
|
}
|
|
}
|
|
|
|
return matched;
|
|
}
|
|
}
|
|
|
|
// Supporting classes for search results
|
|
public class SearchResults
|
|
{
|
|
public List<ContextSearchResult> Results { get; set; } = new();
|
|
public int TotalFound { get; set; }
|
|
}
|
|
|
|
public class ContextSearchResult
|
|
{
|
|
public string Id { get; set; } = "";
|
|
public string Type { get; set; } = "";
|
|
public string Summary { get; set; } = "";
|
|
public string? Content { get; set; }
|
|
public List<string> Tags { get; set; } = new();
|
|
public string Priority { get; set; } = "";
|
|
public DateTime Timestamp { get; set; }
|
|
public string ProjectPath { get; set; } = "";
|
|
public double Relevance { get; set; }
|
|
public List<string> MatchedTerms { get; set; } = new();
|
|
public Dictionary<string, object> Metadata { get; set; } = new();
|
|
}
|
|
|
|
public class IndexSearchResult
|
|
{
|
|
public ContextIndexEntry IndexEntry { get; set; } = new();
|
|
public double Relevance { get; set; }
|
|
public List<string> MatchedTerms { get; set; } = new();
|
|
}
|
|
} |