diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..a6afbeb96
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,26 @@
+name: Build
+
+on:
+ pull_request:
+ branches: [ main ]
+ push:
+ branches: [ main ]
+
+jobs:
+ build:
+ name: Build Solution
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Run build
+ run: |
+ chmod +x ./build.sh
+ ./build.sh
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 000000000..ad0a11c27
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,36 @@
+name: Test Coverage
+
+on:
+ pull_request:
+ branches: [ main ]
+ push:
+ branches: [ main ]
+
+jobs:
+ coverage:
+ name: Check Test Coverage
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Run coverage check
+ run: |
+ chmod +x ./coverage.sh
+ ./coverage.sh 80
+ env:
+ MIN_COVERAGE: 80
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: TestResults/*/coverage.cobertura.xml
+ if-no-files-found: warn
diff --git a/AGENTS.md b/AGENTS.md
index 921b3b978..edc048659 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,25 @@
see @docs/AGENTS.md
-Ignore the "archived" directory.
\ No newline at end of file
+Ignore the "archived" directory.
+
+# Definition of done
+
+- `format.sh` is passing without errors or warnings
+- `build.sh` is passing without errors or warnings
+- Test coverage is greater than 80% and `coverage.sh` is not failing
+- Problems are not hidden, problems are addressed.
+
+# C# Code Style
+
+- Use .NET 10 and C# 14
+- Always use `this.` prefix
+- Keep magic values and constants in a centralized `Constants.cs` file
+- One class per file, matching the class name with the file name
+- Sort class methods by visibility: public first, private at the end
+- Sort class fields and const by visibility: private, const, props
+- Keep all fields and consts at the top of classes
+- Ensure dirs and paths logic is cross-platform compatible
+- Avoid generic/meaningless names like "Utils" "Common" "Lib"
+- Use plural for Enum names, e.g. "EmbeddingsTypes"
+- Always use explicit visibility
+- Don't use primary constructors
\ No newline at end of file
diff --git a/README.md b/README.md
index f0634ae20..7c90f2b2d 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,15 @@ The previous codebase remains in the repo for reference only.
Both versions exist purely as research projects used to explore new ideas and gather feedback from the community.
+# What’s next
+
+An important aspect of KM² is how we are building the next memory prototype. In parallel, our team is developing [Amplifier](https://github.com/microsoft/amplifier/tree/next), a platform for metacognitive AI engineering. We use Amplifier to build Amplifier itself — and in the same way, we are using Amplifier to build the next generation of Kernel Memory.
+
+KM² will focus on the following areas, which will be documented in more detail when ready:
+- quality of content generated
+- privacy
+- collaboration
+
## Disclaimer
> [!IMPORTANT] > **This is experimental software. _Expect things to break_.**
diff --git a/build.sh b/build.sh
new file mode 100755
index 000000000..a8e9ca2e4
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+set -e
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+cd $ROOT
+
+echo "======================================="
+echo " Building Kernel Memory Solution"
+echo "======================================="
+echo ""
+
+# Clean previous build artifacts
+echo "Cleaning previous build artifacts..."
+dotnet clean --nologo --verbosity quiet
+echo "✓ Clean complete"
+echo ""
+
+# Restore dependencies
+echo "Restoring dependencies..."
+dotnet restore --nologo
+echo "✓ Restore complete"
+echo ""
+
+# Build solution with strict settings
+echo "Building solution..."
+echo ""
+
+# Build with:
+# - TreatWarningsAsErrors: Fail on any warnings (compliance requirement)
+# - EnforceCodeStyleInBuild: Enforce code style during build
+# - NoWarn: Empty (don't suppress any warnings)
+dotnet build \
+ --no-restore \
+ --configuration Release \
+ /p:TreatWarningsAsErrors=true \
+ /p:EnforceCodeStyleInBuild=true \
+ /warnaserror
+
+BUILD_RESULT=$?
+
+echo ""
+
+if [ $BUILD_RESULT -eq 0 ]; then
+ echo "======================================="
+ echo " ✅ Build Successful"
+ echo "======================================="
+ echo ""
+ echo "All projects built successfully with zero warnings."
+ exit 0
+else
+ echo "======================================="
+ echo " ❌ Build Failed"
+ echo "======================================="
+ echo ""
+ echo "Build failed with errors or warnings."
+ echo "Review the output above for details."
+ echo ""
+ echo "Reminder: This project has zero-tolerance for warnings."
+ exit 1
+fi
diff --git a/clean.sh b/clean.sh
new file mode 100755
index 000000000..6661aba3b
--- /dev/null
+++ b/clean.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+set -e
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+cd $ROOT
+
+rm -rf TestResults
+
+rm -rf src/Core/bin
+rm -rf src/Core/obj
+
+rm -rf src/Main/bin
+rm -rf src/Main/obj
diff --git a/coverage.sh b/coverage.sh
new file mode 100755
index 000000000..ab879e3f9
--- /dev/null
+++ b/coverage.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+set -e
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
+cd $ROOT
+
+# Minimum coverage threshold - can be overridden via first argument
+# Default: 80% (as specified in AGENTS.md)
+MIN_COVERAGE=${1:-80}
+
+echo "Running tests with coverage collection..."
+echo ""
+
+# Run tests with coverage using coverlet.collector
+# --collect:"XPlat Code Coverage" enables the collector
+# --results-directory specifies output location
+dotnet test \
+ --collect:"XPlat Code Coverage" \
+ --results-directory ./TestResults
+
+echo ""
+echo "Coverage collection complete!"
+echo ""
+
+# Find the most recent coverage file (coverlet.collector creates it in a GUID subfolder)
+COVERAGE_REPORT=$(find ./TestResults -name "coverage.cobertura.xml" | head -1)
+
+echo "Coverage report location: $COVERAGE_REPORT"
+echo ""
+
+if [ -f "$COVERAGE_REPORT" ]; then
+ # Parse line coverage from cobertura XML
+ LINE_RATE=$(grep -o 'line-rate="[0-9.]*"' "$COVERAGE_REPORT" | head -1 | grep -o '[0-9.]*')
+
+ if [ -n "$LINE_RATE" ]; then
+ # Convert to percentage
+ COVERAGE_PCT=$(awk "BEGIN {printf \"%.2f\", $LINE_RATE * 100}")
+
+ echo "====================================="
+ echo " Test Coverage: ${COVERAGE_PCT}%"
+ echo " Threshold: ${MIN_COVERAGE}%"
+ echo "====================================="
+ echo ""
+
+ # Check if coverage meets threshold
+ MEETS_THRESHOLD=$(awk "BEGIN {print ($COVERAGE_PCT >= $MIN_COVERAGE) ? 1 : 0}")
+
+ if [ "$MEETS_THRESHOLD" -eq 0 ]; then
+ echo "❌ Coverage ${COVERAGE_PCT}% is below minimum threshold of ${MIN_COVERAGE}%"
+ exit 1
+ else
+ echo "✅ Coverage meets minimum threshold"
+ rm -rf TestResults
+ fi
+ else
+ echo "⚠️ Could not parse coverage percentage from report"
+ fi
+else
+ echo "⚠️ Coverage report not found at: $COVERAGE_REPORT"
+fi
diff --git a/mem.slnx b/mem.slnx
index e0995332f..9f0b5fc0f 100644
--- a/mem.slnx
+++ b/mem.slnx
@@ -1,4 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/src/.editorconfig b/src/.editorconfig
index a95dda356..96fcc6217 100644
--- a/src/.editorconfig
+++ b/src/.editorconfig
@@ -378,23 +378,25 @@ dotnet_naming_rule.async_methods_end_in_async.severity = error
# Resharper #
#####################################
-# disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
+# ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
resharper_Condition_Is_Always_True_Or_False_According_To_Nullable_API_Contract_highlighting = none
-
-# disable RedundantTypeArgumentsOfMethod
+# RedundantTypeArgumentsOfMethod
resharper_Redundant_Type_Arguments_Of_Method_highlighting = none
-
-# disable NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
+# NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
resharper_Null_Coalescing_Condition_Is_Always_Not_Null_According_To_API_Contract_highlighting = none
-
-# disable PartialTypeWithSinglePart
+# PartialTypeWithSinglePart
resharper_Partial_Type_With_Single_Part_highlighting = none
-
-# disable RedundantDefaultMemberInitializer
+# RedundantDefaultMemberInitializer
resharper_Redundant_Default_Member_Initializer_highlighting = none
-
-# disable ArrangeTypeModifiers
+# ArrangeTypeModifiers
resharper_Arrange_Type_Modifiers_highlighting = none
-
-# disable ArrangeTypeMemberModifiers
+# ArrangeTypeMemberModifiers
resharper_Arrange_Type_Member_Modifiers_highlighting = none
+# InconsistentNaming
+resharper_Inconsistent_Naming_highlighting = none
+
+
+# CA1056: URI properties should not be strings
+# Suppressed for config classes where string URLs are more practical for JSON serialization
+dotnet_diagnostic.CA1056.severity = none
+
diff --git a/src/Core/Config/AppConfig.cs b/src/Core/Config/AppConfig.cs
new file mode 100644
index 000000000..a2098b9b2
--- /dev/null
+++ b/src/Core/Config/AppConfig.cs
@@ -0,0 +1,79 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Cache;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config;
+
+///
+/// Root configuration for Kernel Memory application
+/// Loaded from ~/.km/config.json or custom path
+///
+public sealed class AppConfig : IValidatable
+{
+ ///
+ /// Named memory nodes (e.g., "personal", "work")
+ /// Key is the node ID, value is the node configuration
+ ///
+ [JsonPropertyName("nodes")]
+ public Dictionary Nodes { get; set; } = new();
+
+ ///
+ /// Optional cache for embeddings to reduce API calls
+ ///
+ [JsonPropertyName("embeddingsCache")]
+ public CacheConfig? EmbeddingsCache { get; set; }
+
+ ///
+ /// Optional cache for LLM responses
+ ///
+ [JsonPropertyName("llmCache")]
+ public CacheConfig? LLMCache { get; set; }
+
+ ///
+ /// Validates the entire configuration tree
+ ///
+ ///
+ public void Validate(string path = "")
+ {
+ if (this.Nodes.Count == 0)
+ {
+ throw new ConfigException("Nodes", "At least one node must be configured");
+ }
+
+ foreach (var (nodeId, nodeConfig) in this.Nodes)
+ {
+ if (string.IsNullOrWhiteSpace(nodeId))
+ {
+ throw new ConfigException("Nodes", "Node ID cannot be empty");
+ }
+
+ nodeConfig.Validate($"Nodes.{nodeId}");
+ }
+
+ this.EmbeddingsCache?.Validate("EmbeddingsCache");
+ this.LLMCache?.Validate("LLMCache");
+ }
+
+ ///
+ /// Creates a default configuration with a single "personal" node
+ /// using local SQLite storage
+ ///
+ public static AppConfig CreateDefault()
+ {
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var kmDir = Path.Combine(homeDir, ".km");
+ var personalNodeDir = Path.Combine(kmDir, "nodes", "personal");
+
+ return new AppConfig
+ {
+ Nodes = new Dictionary
+ {
+ ["personal"] = NodeConfig.CreateDefaultPersonalNode(personalNodeDir)
+ },
+ EmbeddingsCache = CacheConfig.CreateDefaultSqliteCache(
+ Path.Combine(kmDir, "embeddings-cache.db")
+ ),
+ LLMCache = null
+ };
+ }
+}
diff --git a/src/Core/Config/Cache/CacheConfig.cs b/src/Core/Config/Cache/CacheConfig.cs
new file mode 100644
index 000000000..a31305b6c
--- /dev/null
+++ b/src/Core/Config/Cache/CacheConfig.cs
@@ -0,0 +1,88 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Cache;
+
+///
+/// Cache configuration for embeddings or LLM responses
+///
+public sealed class CacheConfig : IValidatable
+{
+ ///
+ /// Allow reading from cache
+ ///
+ [JsonPropertyName("allowRead")]
+ public bool AllowRead { get; set; } = true;
+
+ ///
+ /// Allow writing to cache
+ ///
+ [JsonPropertyName("allowWrite")]
+ public bool AllowWrite { get; set; } = true;
+
+ ///
+ /// Type of cache storage backend
+ ///
+ [JsonPropertyName("type")]
+ public CacheTypes Type { get; set; } = CacheTypes.Sqlite;
+
+ ///
+ /// Path to SQLite database (for Sqlite cache)
+ /// Mutually exclusive with ConnectionString
+ ///
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
+
+ ///
+ /// PostgreSQL connection string (for Postgres cache)
+ /// Mutually exclusive with Path
+ ///
+ [JsonPropertyName("connectionString")]
+ public string? ConnectionString { get; set; }
+
+ ///
+ /// Validates the cache configuration
+ ///
+ ///
+ public void Validate(string path)
+ {
+ var isSqlite = this.Type == CacheTypes.Sqlite;
+ var isPostgres = this.Type == CacheTypes.Postgres;
+ var hasPath = !string.IsNullOrWhiteSpace(this.Path);
+ var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString);
+
+ if (isSqlite && !hasPath)
+ {
+ throw new ConfigException($"{path}.Path", "SQLite cache requires Path");
+ }
+
+ if (isPostgres && !hasConnectionString)
+ {
+ throw new ConfigException($"{path}.ConnectionString",
+ "PostgreSQL cache requires ConnectionString");
+ }
+
+ if (hasPath && hasConnectionString)
+ {
+ throw new ConfigException(path,
+ "Cache: specify either Path (SQLite) or ConnectionString (Postgres), not both");
+ }
+ }
+
+ ///
+ /// Creates a default SQLite cache configuration
+ ///
+ ///
+ internal static CacheConfig CreateDefaultSqliteCache(string path)
+ {
+ return new CacheConfig
+ {
+ AllowRead = true,
+ AllowWrite = true,
+ Type = CacheTypes.Sqlite,
+ Path = path,
+ ConnectionString = null
+ };
+ }
+}
diff --git a/src/Core/Config/ConfigParser.cs b/src/Core/Config/ConfigParser.cs
new file mode 100644
index 000000000..7db74bc76
--- /dev/null
+++ b/src/Core/Config/ConfigParser.cs
@@ -0,0 +1,253 @@
+using System.Text.Json;
+using KernelMemory.Core.Config.Cache;
+using KernelMemory.Core.Config.ContentIndex;
+using KernelMemory.Core.Config.SearchIndex;
+using KernelMemory.Core.Config.Storage;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config;
+
+///
+/// Parses configuration files and returns validated AppConfig instances
+/// Supports JSON with comments and case-insensitive property names
+///
+public static class ConfigParser
+{
+ ///
+ /// JSON serializer options configured for config parsing
+ /// - Case insensitive property names
+ /// - Comments allowed
+ /// - Trailing commas allowed
+ ///
+ private static readonly JsonSerializerOptions s_jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ ///
+ /// Loads configuration from a file, or returns default config if file doesn't exist
+ /// Performs tilde expansion on paths (~/ → home directory)
+ ///
+ /// Path to configuration file
+ /// Validated AppConfig instance
+ /// Thrown when file exists but parsing or validation fails
+ public static AppConfig LoadFromFile(string filePath)
+ {
+ // If file doesn't exist, return default configuration
+ if (!File.Exists(filePath))
+ {
+ return AppConfig.CreateDefault();
+ }
+
+ try
+ {
+ // Read file content
+ var json = File.ReadAllText(filePath);
+
+ // Parse and validate
+ var config = ParseFromString(json);
+
+ // Expand tilde paths
+ ExpandTildePaths(config);
+
+ return config;
+ }
+ catch (ConfigException)
+ {
+ // Re-throw configuration exceptions as-is
+ throw;
+ }
+ catch (JsonException ex)
+ {
+ throw new ConfigException(
+ filePath,
+ $"Failed to parse configuration file: {ex.Message}",
+ ex);
+ }
+ catch (Exception ex)
+ {
+ throw new ConfigException(
+ filePath,
+ $"Error reading configuration file: {ex.Message}",
+ ex);
+ }
+ }
+
+ ///
+ /// Parses configuration from a JSON string
+ ///
+ /// JSON configuration string
+ /// Validated AppConfig instance
+ /// Thrown when parsing or validation fails
+ public static AppConfig ParseFromString(string json)
+ {
+ try
+ {
+ var config = JsonSerializer.Deserialize(json, s_jsonOptions);
+
+ if (config == null)
+ {
+ throw new ConfigException(
+ "root",
+ "Failed to deserialize configuration: result was null");
+ }
+
+ // Validate the configuration
+ config.Validate();
+
+ return config;
+ }
+ catch (ConfigException)
+ {
+ // Re-throw configuration exceptions as-is
+ throw;
+ }
+ catch (JsonException ex)
+ {
+ throw new ConfigException(
+ "root",
+ $"Failed to parse configuration: {ex.Message}",
+ ex);
+ }
+ }
+
+ ///
+ /// Expands tilde (~) in paths to the user's home directory
+ /// Recursively processes all path properties in the configuration
+ ///
+ ///
+ private static void ExpandTildePaths(AppConfig config)
+ {
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ foreach (var (_, nodeConfig) in config.Nodes)
+ {
+ // Expand paths in ContentIndex
+ ExpandTildeInContentIndex(nodeConfig.ContentIndex, homeDir);
+
+ // Expand paths in FileStorage
+ if (nodeConfig.FileStorage != null)
+ {
+ ExpandTildeInStorage(nodeConfig.FileStorage, homeDir);
+ }
+
+ // Expand paths in RepoStorage
+ if (nodeConfig.RepoStorage != null)
+ {
+ ExpandTildeInStorage(nodeConfig.RepoStorage, homeDir);
+ }
+
+ // Expand paths in SearchIndexes
+ foreach (var searchIndex in nodeConfig.SearchIndexes)
+ {
+ ExpandTildeInSearchIndex(searchIndex, homeDir);
+ }
+ }
+
+ // Expand paths in EmbeddingsCache
+ if (config.EmbeddingsCache != null)
+ {
+ ExpandTildeInCache(config.EmbeddingsCache, homeDir);
+ }
+
+ // Expand paths in LLMCache
+ if (config.LLMCache != null)
+ {
+ ExpandTildeInCache(config.LLMCache, homeDir);
+ }
+ }
+
+ ///
+ /// Expands tilde in ContentIndex paths
+ ///
+ ///
+ ///
+ private static void ExpandTildeInContentIndex(ContentIndexConfig config, string homeDir)
+ {
+ if (config is SqliteContentIndexConfig sqliteConfig)
+ {
+ sqliteConfig.Path = ExpandTilde(sqliteConfig.Path, homeDir);
+ }
+ }
+
+ ///
+ /// Expands tilde in Storage paths
+ ///
+ ///
+ ///
+ private static void ExpandTildeInStorage(StorageConfig config, string homeDir)
+ {
+ if (config is DiskStorageConfig diskConfig)
+ {
+ diskConfig.Path = ExpandTilde(diskConfig.Path, homeDir);
+ }
+ }
+
+ ///
+ /// Expands tilde in SearchIndex paths
+ ///
+ ///
+ ///
+ private static void ExpandTildeInSearchIndex(SearchIndexConfig config, string homeDir)
+ {
+ switch (config)
+ {
+ case FtsSearchIndexConfig ftsConfig when ftsConfig.Path != null:
+ ftsConfig.Path = ExpandTilde(ftsConfig.Path, homeDir);
+ break;
+ case VectorSearchIndexConfig vectorConfig when vectorConfig.Path != null:
+ vectorConfig.Path = ExpandTilde(vectorConfig.Path, homeDir);
+ break;
+ case GraphSearchIndexConfig graphConfig when graphConfig.Path != null:
+ graphConfig.Path = ExpandTilde(graphConfig.Path, homeDir);
+ break;
+ }
+ }
+
+ ///
+ /// Expands tilde in Cache paths
+ ///
+ ///
+ ///
+ private static void ExpandTildeInCache(CacheConfig config, string homeDir)
+ {
+ if (config.Path != null)
+ {
+ config.Path = ExpandTilde(config.Path, homeDir);
+ }
+ }
+
+ ///
+ /// Expands tilde (~/ or ~\) at the start of a path to the home directory.
+ /// Cross-platform: handles both forward slash (Unix/macOS) and backslash (Windows).
+ ///
+ ///
+ ///
+ private static string ExpandTilde(string path, string homeDir)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return path;
+ }
+
+ // Handle just ~ (home directory)
+ if (path == "~")
+ {
+ return homeDir;
+ }
+
+ // Handle ~/ or ~\ (home directory with path separator)
+ // Cross-platform: works with both / (Unix) and \ (Windows)
+ if (path.Length >= 2 && path[0] == '~' &&
+ (path[1] == Path.DirectorySeparatorChar ||
+ path[1] == Path.AltDirectorySeparatorChar))
+ {
+ return Path.Combine(homeDir, path.Substring(2));
+ }
+
+ return path;
+ }
+}
diff --git a/src/Core/Config/ContentIndex/ContentIndexConfig.cs b/src/Core/Config/ContentIndex/ContentIndexConfig.cs
new file mode 100644
index 000000000..10b6df46e
--- /dev/null
+++ b/src/Core/Config/ContentIndex/ContentIndexConfig.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.ContentIndex;
+
+///
+/// Base class for content index configurations
+/// Content index is the source of truth, backed by Entity Framework
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
+[JsonDerivedType(typeof(SqliteContentIndexConfig), typeDiscriminator: "sqlite")]
+[JsonDerivedType(typeof(PostgresContentIndexConfig), typeDiscriminator: "postgres")]
+public abstract class ContentIndexConfig : IValidatable
+{
+ ///
+ /// Type of content index
+ ///
+ [JsonIgnore]
+ public abstract ContentIndexTypes Type { get; }
+
+ ///
+ /// Validates the content index configuration
+ ///
+ ///
+ public abstract void Validate(string path);
+}
diff --git a/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs b/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs
new file mode 100644
index 000000000..811703b0c
--- /dev/null
+++ b/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs
@@ -0,0 +1,32 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.ContentIndex;
+
+///
+/// PostgreSQL content index configuration
+///
+public sealed class PostgresContentIndexConfig : ContentIndexConfig
+{
+ ///
+ [JsonIgnore]
+ public override ContentIndexTypes Type => ContentIndexTypes.Postgres;
+
+ ///
+ /// PostgreSQL connection string
+ /// Can be overridden by Aspire environment variables
+ ///
+ [JsonPropertyName("connectionString")]
+ public string ConnectionString { get; set; } = string.Empty;
+
+ ///
+ public override void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.ConnectionString))
+ {
+ throw new ConfigException($"{path}.ConnectionString",
+ "PostgreSQL connection string is required");
+ }
+ }
+}
diff --git a/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs b/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs
new file mode 100644
index 000000000..90e9d1831
--- /dev/null
+++ b/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs
@@ -0,0 +1,32 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.ContentIndex;
+
+///
+/// SQLite content index configuration
+///
+public sealed class SqliteContentIndexConfig : ContentIndexConfig
+{
+ ///
+ [JsonIgnore]
+ public override ContentIndexTypes Type => ContentIndexTypes.Sqlite;
+
+ ///
+ /// Path to SQLite database file (supports tilde expansion)
+ ///
+ [JsonPropertyName("path")]
+ public string Path { get; set; } = string.Empty;
+
+ ///
+ public override void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.Path))
+ {
+ throw new ConfigException($"{path}.Path", "SQLite path is required");
+ }
+
+ // Path will be expanded and validated by the config loader
+ }
+}
diff --git a/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs b/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs
new file mode 100644
index 000000000..5bf10ec3d
--- /dev/null
+++ b/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs
@@ -0,0 +1,84 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Embeddings;
+
+///
+/// Azure OpenAI embeddings provider configuration
+///
+public sealed class AzureOpenAIEmbeddingsConfig : EmbeddingsConfig
+{
+ ///
+ [JsonIgnore]
+ public override EmbeddingsTypes Type => EmbeddingsTypes.AzureOpenAI;
+
+ ///
+ /// Model name (e.g., "text-embedding-ada-002")
+ ///
+ [JsonPropertyName("model")]
+ public string Model { get; set; } = string.Empty;
+
+ ///
+ /// Azure OpenAI endpoint (e.g., "https://myservice.openai.azure.com/")
+ ///
+ [JsonPropertyName("endpoint")]
+ public string Endpoint { get; set; } = string.Empty;
+
+ ///
+ /// Azure OpenAI API key (optional if using managed identity)
+ ///
+ [JsonPropertyName("apiKey")]
+ public string? ApiKey { get; set; }
+
+ ///
+ /// Deployment name in Azure OpenAI
+ ///
+ [JsonPropertyName("deployment")]
+ public string Deployment { get; set; } = string.Empty;
+
+ ///
+ /// Use Azure Managed Identity for authentication
+ ///
+ [JsonPropertyName("useManagedIdentity")]
+ public bool UseManagedIdentity { get; set; }
+
+ ///
+ public override void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.Model))
+ {
+ throw new ConfigException($"{path}.Model", "Model name is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(this.Endpoint))
+ {
+ throw new ConfigException($"{path}.Endpoint", "Azure OpenAI endpoint is required");
+ }
+
+ if (!Uri.TryCreate(this.Endpoint, UriKind.Absolute, out _))
+ {
+ throw new ConfigException($"{path}.Endpoint",
+ $"Invalid Azure OpenAI endpoint: {this.Endpoint}");
+ }
+
+ if (string.IsNullOrWhiteSpace(this.Deployment))
+ {
+ throw new ConfigException($"{path}.Deployment", "Deployment name is required");
+ }
+
+ var hasApiKey = !string.IsNullOrWhiteSpace(this.ApiKey);
+
+ if (!hasApiKey && !this.UseManagedIdentity)
+ {
+ throw new ConfigException(path,
+ "Azure OpenAI requires either ApiKey or UseManagedIdentity");
+ }
+
+ if (hasApiKey && this.UseManagedIdentity)
+ {
+ throw new ConfigException(path,
+ "Azure OpenAI: specify either ApiKey or UseManagedIdentity, not both");
+ }
+ }
+}
diff --git a/src/Core/Config/Embeddings/EmbeddingsConfig.cs b/src/Core/Config/Embeddings/EmbeddingsConfig.cs
new file mode 100644
index 000000000..88f205252
--- /dev/null
+++ b/src/Core/Config/Embeddings/EmbeddingsConfig.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Embeddings;
+
+///
+/// Base class for embeddings provider configurations
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
+[JsonDerivedType(typeof(OllamaEmbeddingsConfig), typeDiscriminator: "ollama")]
+[JsonDerivedType(typeof(OpenAIEmbeddingsConfig), typeDiscriminator: "openai")]
+[JsonDerivedType(typeof(AzureOpenAIEmbeddingsConfig), typeDiscriminator: "azureOpenAI")]
+public abstract class EmbeddingsConfig : IValidatable
+{
+ ///
+ /// Type of embeddings provider
+ ///
+ [JsonIgnore]
+ public abstract EmbeddingsTypes Type { get; }
+
+ ///
+ /// Validates the embeddings configuration
+ ///
+ ///
+ public abstract void Validate(string path);
+}
diff --git a/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs b/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs
new file mode 100644
index 000000000..2fcb36dad
--- /dev/null
+++ b/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs
@@ -0,0 +1,47 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Embeddings;
+
+///
+/// Ollama embeddings provider configuration
+///
+public sealed class OllamaEmbeddingsConfig : EmbeddingsConfig
+{
+ ///
+ [JsonIgnore]
+ public override EmbeddingsTypes Type => EmbeddingsTypes.Ollama;
+
+ ///
+ /// Ollama model name (e.g., "nomic-embed-text", "mxbai-embed-large")
+ ///
+ [JsonPropertyName("model")]
+ public string Model { get; set; } = string.Empty;
+
+ ///
+ /// Ollama base URL (e.g., "http://localhost:11434")
+ ///
+ [JsonPropertyName("baseUrl")]
+ public string BaseUrl { get; set; } = "http://localhost:11434";
+
+ ///
+ public override void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.Model))
+ {
+ throw new ConfigException($"{path}.Model", "Ollama model name is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(this.BaseUrl))
+ {
+ throw new ConfigException($"{path}.BaseUrl", "Ollama base URL is required");
+ }
+
+ if (!Uri.TryCreate(this.BaseUrl, UriKind.Absolute, out _))
+ {
+ throw new ConfigException($"{path}.BaseUrl",
+ $"Invalid Ollama base URL: {this.BaseUrl}");
+ }
+ }
+}
diff --git a/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs b/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs
new file mode 100644
index 000000000..9f73966fc
--- /dev/null
+++ b/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Embeddings;
+
+///
+/// OpenAI embeddings provider configuration
+///
+public sealed class OpenAIEmbeddingsConfig : EmbeddingsConfig
+{
+ ///
+ [JsonIgnore]
+ public override EmbeddingsTypes Type => EmbeddingsTypes.OpenAI;
+
+ ///
+ /// OpenAI model name (e.g., "text-embedding-ada-002", "text-embedding-3-small")
+ ///
+ [JsonPropertyName("model")]
+ public string Model { get; set; } = string.Empty;
+
+ ///
+ /// OpenAI API key
+ ///
+ [JsonPropertyName("apiKey")]
+ public string ApiKey { get; set; } = string.Empty;
+
+ ///
+ /// Optional custom base URL (for OpenAI-compatible APIs)
+ ///
+ [JsonPropertyName("baseUrl")]
+ public string? BaseUrl { get; set; }
+
+ ///
+ public override void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.Model))
+ {
+ throw new ConfigException($"{path}.Model", "OpenAI model name is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(this.ApiKey))
+ {
+ throw new ConfigException($"{path}.ApiKey", "OpenAI API key is required");
+ }
+
+ if (!string.IsNullOrWhiteSpace(this.BaseUrl) &&
+ !Uri.TryCreate(this.BaseUrl, UriKind.Absolute, out _))
+ {
+ throw new ConfigException($"{path}.BaseUrl",
+ $"Invalid OpenAI base URL: {this.BaseUrl}");
+ }
+ }
+}
diff --git a/src/Core/Config/Enums/CacheTypes.cs b/src/Core/Config/Enums/CacheTypes.cs
new file mode 100644
index 000000000..501b51049
--- /dev/null
+++ b/src/Core/Config/Enums/CacheTypes.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Type of cache storage backend
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum CacheTypes
+{
+ /// SQLite database cache
+ Sqlite,
+
+ /// PostgreSQL database cache
+ Postgres
+}
diff --git a/src/Core/Config/Enums/ContentIndexTypes.cs b/src/Core/Config/Enums/ContentIndexTypes.cs
new file mode 100644
index 000000000..4780f84af
--- /dev/null
+++ b/src/Core/Config/Enums/ContentIndexTypes.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Type of Entity Framework backed content index
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ContentIndexTypes
+{
+ /// SQLite database for local/single-user scenarios
+ Sqlite,
+
+ /// PostgreSQL database for multi-user/production scenarios
+ Postgres
+}
diff --git a/src/Core/Config/Enums/EmbeddingsTypes.cs b/src/Core/Config/Enums/EmbeddingsTypes.cs
new file mode 100644
index 000000000..5421d6313
--- /dev/null
+++ b/src/Core/Config/Enums/EmbeddingsTypes.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Type of embeddings provider
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum EmbeddingsTypes
+{
+ /// Local Ollama instance
+ Ollama,
+
+ /// OpenAI API
+ OpenAI,
+
+ /// Azure OpenAI Service
+ AzureOpenAI
+}
diff --git a/src/Core/Config/Enums/NodeAccessLevels.cs b/src/Core/Config/Enums/NodeAccessLevels.cs
new file mode 100644
index 000000000..ba0183c1d
--- /dev/null
+++ b/src/Core/Config/Enums/NodeAccessLevels.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Defines the access level for a memory node
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum NodeAccessLevels
+{
+ /// Full read/write access
+ Full,
+
+ /// Read-only access, no modifications allowed
+ ReadOnly,
+
+ /// Write-only access, typically for ingestion pipelines
+ WriteOnly,
+
+ /// Node is disabled and not accessible
+ Disabled
+}
diff --git a/src/Core/Config/Enums/SearchIndexTypes.cs b/src/Core/Config/Enums/SearchIndexTypes.cs
new file mode 100644
index 000000000..abfaf9ca1
--- /dev/null
+++ b/src/Core/Config/Enums/SearchIndexTypes.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Type of search index for content retrieval
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum SearchIndexTypes
+{
+ /// SQLite Full-Text Search (FTS5)
+ SqliteFTS,
+
+ /// PostgreSQL Full-Text Search
+ PostgresFTS,
+
+ /// SQLite with vector extensions (sqlite-vec)
+ SqliteVector,
+
+ /// PostgreSQL with pgvector extension
+ PostgresVector,
+
+ /// Graph-based semantic search
+ Graph
+}
diff --git a/src/Core/Config/Enums/StorageTypes.cs b/src/Core/Config/Enums/StorageTypes.cs
new file mode 100644
index 000000000..d32177b17
--- /dev/null
+++ b/src/Core/Config/Enums/StorageTypes.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Type of binary storage backend
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum StorageTypes
+{
+ /// Local filesystem storage
+ Disk,
+
+ /// Azure Blob Storage
+ AzureBlobs
+}
diff --git a/src/Core/Config/Enums/VectorMetrics.cs b/src/Core/Config/Enums/VectorMetrics.cs
new file mode 100644
index 000000000..5b84c00fb
--- /dev/null
+++ b/src/Core/Config/Enums/VectorMetrics.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace KernelMemory.Core.Config.Enums;
+
+///
+/// Distance/similarity metric for vector search
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum VectorMetrics
+{
+ /// Cosine similarity (normalized dot product)
+ Cosine,
+
+ /// Euclidean distance (L2)
+ L2,
+
+ /// Inner product (dot product)
+ InnerProduct,
+
+ /// Manhattan distance (L1)
+ L1
+}
diff --git a/src/Core/Config/NodeConfig.cs b/src/Core/Config/NodeConfig.cs
new file mode 100644
index 000000000..53497eaed
--- /dev/null
+++ b/src/Core/Config/NodeConfig.cs
@@ -0,0 +1,113 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.ContentIndex;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.SearchIndex;
+using KernelMemory.Core.Config.Storage;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config;
+
+///
+/// Configuration for a single memory node
+///
+public sealed class NodeConfig : IValidatable
+{
+ ///
+ /// Unique identifier for this node
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Access level for this node
+ ///
+ [JsonPropertyName("access")]
+ public NodeAccessLevels Access { get; set; } = NodeAccessLevels.Full;
+
+ ///
+ /// Content index (source of truth) - REQUIRED
+ /// Stores metadata, cached content, and ingestion state
+ ///
+ [JsonPropertyName("contentIndex")]
+ public ContentIndexConfig ContentIndex { get; set; } = null!;
+
+ ///
+ /// Optional file storage for binary files
+ ///
+ [JsonPropertyName("fileStorage")]
+ public StorageConfig? FileStorage { get; set; }
+
+ ///
+ /// Optional repository storage for git repositories
+ ///
+ [JsonPropertyName("repoStorage")]
+ public StorageConfig? RepoStorage { get; set; }
+
+ ///
+ /// Search indexes for content retrieval
+ /// Multiple indexes can be used for different search strategies
+ ///
+ [JsonPropertyName("searchIndexes")]
+ public List SearchIndexes { get; set; } = new();
+
+ ///
+ /// Validates the node configuration
+ ///
+ ///
+ public void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.Id))
+ {
+ throw new ConfigException(path, "Node ID is required");
+ }
+
+ if (this.ContentIndex == null)
+ {
+ throw new ConfigException($"{path}.ContentIndex", "ContentIndex is required");
+ }
+
+ this.ContentIndex.Validate($"{path}.ContentIndex");
+ this.FileStorage?.Validate($"{path}.FileStorage");
+ this.RepoStorage?.Validate($"{path}.RepoStorage");
+
+ for (int i = 0; i < this.SearchIndexes.Count; i++)
+ {
+ this.SearchIndexes[i].Validate($"{path}.SearchIndexes[{i}]");
+ }
+ }
+
+ ///
+ /// Creates a default "personal" node configuration
+ ///
+ ///
+ internal static NodeConfig CreateDefaultPersonalNode(string nodeDir)
+ {
+ return new NodeConfig
+ {
+ Id = "personal",
+ Access = NodeAccessLevels.Full,
+ ContentIndex = new SqliteContentIndexConfig
+ {
+ Path = Path.Combine(nodeDir, "content.db")
+ },
+ FileStorage = null,
+ RepoStorage = null,
+ SearchIndexes = new List
+ {
+ new FtsSearchIndexConfig
+ {
+ Type = SearchIndexTypes.SqliteFTS,
+ Path = Path.Combine(nodeDir, "fts.db"),
+ EnableStemming = true
+ },
+ new VectorSearchIndexConfig
+ {
+ Type = SearchIndexTypes.SqliteVector,
+ Path = Path.Combine(nodeDir, "vectors.db"),
+ Dimensions = 768,
+ Metric = VectorMetrics.Cosine
+ }
+ }
+ };
+ }
+}
diff --git a/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs b/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs
new file mode 100644
index 000000000..95b10b7f0
--- /dev/null
+++ b/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs
@@ -0,0 +1,59 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.SearchIndex;
+
+///
+/// Full-Text Search index configuration (SQLite FTS5 or PostgreSQL)
+///
+public sealed class FtsSearchIndexConfig : SearchIndexConfig
+{
+ ///
+ /// Path to SQLite database (for SqliteFTS)
+ /// Mutually exclusive with ConnectionString
+ ///
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
+
+ ///
+ /// PostgreSQL connection string (for PostgresFTS)
+ /// Mutually exclusive with Path
+ ///
+ [JsonPropertyName("connectionString")]
+ public string? ConnectionString { get; set; }
+
+ ///
+ /// Enable stemming for better search results
+ ///
+ [JsonPropertyName("enableStemming")]
+ public bool EnableStemming { get; set; } = true;
+
+ ///
+ public override void Validate(string path)
+ {
+ this.Embeddings?.Validate($"{path}.Embeddings");
+
+ var isSqlite = this.Type == SearchIndexTypes.SqliteFTS;
+ var isPostgres = this.Type == SearchIndexTypes.PostgresFTS;
+ var hasPath = !string.IsNullOrWhiteSpace(this.Path);
+ var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString);
+
+ if (isSqlite && !hasPath)
+ {
+ throw new ConfigException($"{path}.Path", "SQLite FTS requires Path");
+ }
+
+ if (isPostgres && !hasConnectionString)
+ {
+ throw new ConfigException($"{path}.ConnectionString",
+ "PostgreSQL FTS requires ConnectionString");
+ }
+
+ if (hasPath && hasConnectionString)
+ {
+ throw new ConfigException(path,
+ "FTS index: specify either Path (SQLite) or ConnectionString (Postgres), not both");
+ }
+ }
+}
diff --git a/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs b/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs
new file mode 100644
index 000000000..272788cfb
--- /dev/null
+++ b/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs
@@ -0,0 +1,46 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.SearchIndex;
+
+///
+/// Graph-based search index configuration
+/// Uses recursive CTEs for SQLite or Apache AGE for PostgreSQL
+///
+public sealed class GraphSearchIndexConfig : SearchIndexConfig
+{
+ ///
+ /// Path to SQLite database (for SQLite graph)
+ /// Mutually exclusive with ConnectionString
+ ///
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
+
+ ///
+ /// PostgreSQL connection string (for Apache AGE graph)
+ /// Mutually exclusive with Path
+ ///
+ [JsonPropertyName("connectionString")]
+ public string? ConnectionString { get; set; }
+
+ ///
+ public override void Validate(string path)
+ {
+ this.Embeddings?.Validate($"{path}.Embeddings");
+
+ var hasPath = !string.IsNullOrWhiteSpace(this.Path);
+ var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString);
+
+ if (!hasPath && !hasConnectionString)
+ {
+ throw new ConfigException(path,
+ "Graph index requires either Path (SQLite) or ConnectionString (Postgres)");
+ }
+
+ if (hasPath && hasConnectionString)
+ {
+ throw new ConfigException(path,
+ "Graph index: specify either Path or ConnectionString, not both");
+ }
+ }
+}
diff --git a/src/Core/Config/SearchIndex/SearchIndexConfig.cs b/src/Core/Config/SearchIndex/SearchIndexConfig.cs
new file mode 100644
index 000000000..a939116e3
--- /dev/null
+++ b/src/Core/Config/SearchIndex/SearchIndexConfig.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Embeddings;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.SearchIndex;
+
+///
+/// Base class for search index configurations
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
+[JsonDerivedType(typeof(FtsSearchIndexConfig), typeDiscriminator: "fts")]
+[JsonDerivedType(typeof(VectorSearchIndexConfig), typeDiscriminator: "vector")]
+[JsonDerivedType(typeof(GraphSearchIndexConfig), typeDiscriminator: "graph")]
+public abstract class SearchIndexConfig : IValidatable
+{
+ ///
+ /// Type of search index
+ ///
+ [JsonIgnore]
+ public SearchIndexTypes Type { get; set; }
+
+ ///
+ /// Optional embeddings configuration for this index
+ /// Overrides node-level or global embeddings config
+ ///
+ [JsonPropertyName("embeddings")]
+ public EmbeddingsConfig? Embeddings { get; set; }
+
+ ///
+ /// Validates the search index configuration
+ ///
+ ///
+ public abstract void Validate(string path);
+}
diff --git a/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs b/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs
new file mode 100644
index 000000000..bd6f23baf
--- /dev/null
+++ b/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs
@@ -0,0 +1,80 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.SearchIndex;
+
+///
+/// Vector search index configuration (SQLite with sqlite-vec or PostgreSQL with pgvector)
+///
+public sealed class VectorSearchIndexConfig : SearchIndexConfig
+{
+ ///
+ /// Path to SQLite database (for SqliteVector)
+ /// Mutually exclusive with ConnectionString
+ ///
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
+
+ ///
+ /// PostgreSQL connection string (for PostgresVector)
+ /// Mutually exclusive with Path
+ ///
+ [JsonPropertyName("connectionString")]
+ public string? ConnectionString { get; set; }
+
+ ///
+ /// Vector dimensions (must match embeddings model)
+ /// Common values: 384 (MiniLM), 768 (BERT), 1536 (OpenAI ada-002), 3072 (OpenAI ada-003)
+ ///
+ [JsonPropertyName("dimensions")]
+ public int Dimensions { get; set; } = 768;
+
+ ///
+ /// Distance/similarity metric for vector comparison
+ ///
+ [JsonPropertyName("metric")]
+ public VectorMetrics Metric { get; set; } = VectorMetrics.Cosine;
+
+ ///
+ public override void Validate(string path)
+ {
+ this.Embeddings?.Validate($"{path}.Embeddings");
+
+ var isSqlite = this.Type == SearchIndexTypes.SqliteVector;
+ var isPostgres = this.Type == SearchIndexTypes.PostgresVector;
+ var hasPath = !string.IsNullOrWhiteSpace(this.Path);
+ var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString);
+
+ if (isSqlite && !hasPath)
+ {
+ throw new ConfigException($"{path}.Path", "SQLite vector index requires Path");
+ }
+
+ if (isPostgres && !hasConnectionString)
+ {
+ throw new ConfigException($"{path}.ConnectionString",
+ "PostgreSQL vector index requires ConnectionString");
+ }
+
+ if (hasPath && hasConnectionString)
+ {
+ throw new ConfigException(path,
+ "Vector index: specify either Path (SQLite) or ConnectionString (Postgres), not both");
+ }
+
+ if (this.Dimensions <= 0)
+ {
+ throw new ConfigException($"{path}.Dimensions",
+ $"Vector dimensions must be positive (got {this.Dimensions})");
+ }
+
+ // Common dimensions check (warning, not error)
+ var commonDimensions = new[] { 384, 768, 1024, 1536, 3072 };
+ if (!commonDimensions.Contains(this.Dimensions))
+ {
+ // Log warning: uncommon dimension size
+ // This is acceptable, just informational
+ }
+ }
+}
diff --git a/src/Core/Config/Storage/AzureBlobStorageConfig.cs b/src/Core/Config/Storage/AzureBlobStorageConfig.cs
new file mode 100644
index 000000000..e02590fd8
--- /dev/null
+++ b/src/Core/Config/Storage/AzureBlobStorageConfig.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Storage;
+
+///
+/// Azure Blob Storage configuration
+///
+public sealed class AzureBlobStorageConfig : StorageConfig
+{
+ ///
+ [JsonIgnore]
+ public override StorageTypes Type => StorageTypes.AzureBlobs;
+
+ ///
+ /// Azure Storage connection string
+ ///
+ [JsonPropertyName("connectionString")]
+ public string? ConnectionString { get; set; }
+
+ ///
+ /// Azure Storage API key (alternative to connection string)
+ ///
+ [JsonPropertyName("apiKey")]
+ public string? ApiKey { get; set; }
+
+ ///
+ /// Use Azure Managed Identity for authentication
+ ///
+ [JsonPropertyName("useManagedIdentity")]
+ public bool UseManagedIdentity { get; set; }
+
+ ///
+ public override void Validate(string path)
+ {
+ var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString);
+ var hasApiKey = !string.IsNullOrWhiteSpace(this.ApiKey);
+
+ if (!hasConnectionString && !hasApiKey && !this.UseManagedIdentity)
+ {
+ throw new ConfigException(path,
+ "Azure Blob storage requires one of: ConnectionString, ApiKey, or UseManagedIdentity");
+ }
+
+ if ((hasConnectionString ? 1 : 0) + (hasApiKey ? 1 : 0) + (this.UseManagedIdentity ? 1 : 0) > 1)
+ {
+ throw new ConfigException(path,
+ "Azure Blob storage: specify only one authentication method");
+ }
+ }
+}
diff --git a/src/Core/Config/Storage/DiskStorageConfig.cs b/src/Core/Config/Storage/DiskStorageConfig.cs
new file mode 100644
index 000000000..d4e7a900f
--- /dev/null
+++ b/src/Core/Config/Storage/DiskStorageConfig.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Storage;
+
+///
+/// Local disk storage configuration
+///
+public sealed class DiskStorageConfig : StorageConfig
+{
+ ///
+ [JsonIgnore]
+ public override StorageTypes Type => StorageTypes.Disk;
+
+ ///
+ /// Path to storage directory (supports tilde expansion)
+ ///
+ [JsonPropertyName("path")]
+ public string Path { get; set; } = string.Empty;
+
+ ///
+ public override void Validate(string path)
+ {
+ if (string.IsNullOrWhiteSpace(this.Path))
+ {
+ throw new ConfigException($"{path}.Path", "Disk storage path is required");
+ }
+ }
+}
diff --git a/src/Core/Config/Storage/StorageConfig.cs b/src/Core/Config/Storage/StorageConfig.cs
new file mode 100644
index 000000000..69b5ac9e9
--- /dev/null
+++ b/src/Core/Config/Storage/StorageConfig.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.Validation;
+
+namespace KernelMemory.Core.Config.Storage;
+
+///
+/// Base class for storage configurations (files, repositories)
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
+[JsonDerivedType(typeof(DiskStorageConfig), typeDiscriminator: "disk")]
+[JsonDerivedType(typeof(AzureBlobStorageConfig), typeDiscriminator: "azureBlobs")]
+public abstract class StorageConfig : IValidatable
+{
+ ///
+ /// Type of storage backend
+ ///
+ [JsonIgnore]
+ public abstract StorageTypes Type { get; }
+
+ ///
+ /// Validates the storage configuration
+ ///
+ ///
+ public abstract void Validate(string path);
+}
diff --git a/src/Core/Config/Validation/ConfigException.cs b/src/Core/Config/Validation/ConfigException.cs
new file mode 100644
index 000000000..8c4ccbb6c
--- /dev/null
+++ b/src/Core/Config/Validation/ConfigException.cs
@@ -0,0 +1,50 @@
+namespace KernelMemory.Core.Config.Validation;
+
+///
+/// Exception thrown when configuration validation fails
+///
+public class ConfigException : Exception
+{
+ ///
+ /// JSON path where the error occurred
+ ///
+ public string ConfigPath { get; }
+
+ ///
+ /// Creates a new configuration exception
+ ///
+ /// JSON path (e.g., "Nodes.personal.ContentIndex.Path")
+ /// Human-readable error message
+ public ConfigException(string configPath, string message)
+ : base($"Configuration error at '{configPath}': {message}")
+ {
+ this.ConfigPath = configPath;
+ }
+
+ ///
+ /// Creates a new configuration exception with inner exception
+ ///
+ /// JSON path
+ /// Human-readable error message
+ /// Underlying exception
+ public ConfigException(string configPath, string message, Exception innerException)
+ : base($"Configuration error at '{configPath}': {message}", innerException)
+ {
+ this.ConfigPath = configPath;
+ }
+
+ public ConfigException() : base()
+ {
+ this.ConfigPath = string.Empty;
+ }
+
+ public ConfigException(string? message) : base(message)
+ {
+ this.ConfigPath = string.Empty;
+ }
+
+ public ConfigException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ this.ConfigPath = string.Empty;
+ }
+}
diff --git a/src/Core/Config/Validation/IValidatable.cs b/src/Core/Config/Validation/IValidatable.cs
new file mode 100644
index 000000000..55ea06d87
--- /dev/null
+++ b/src/Core/Config/Validation/IValidatable.cs
@@ -0,0 +1,14 @@
+namespace KernelMemory.Core.Config.Validation;
+
+///
+/// Interface for configuration objects that can validate themselves
+///
+public interface IValidatable
+{
+ ///
+ /// Validates the configuration object
+ ///
+ /// JSON path for error reporting (e.g., "Nodes.personal.ContentIndex")
+ /// Thrown when validation fails
+ void Validate(string path);
+}
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 237d66167..3a5c20e56 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -1,9 +1,9 @@
+ KernelMemory.Core
+ KernelMemory.Core
net10.0
- enable
- enable
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index f6fe17bb0..62761c246 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -5,7 +5,7 @@
0.100.0-alpha
- 14
+ latest
enable
@@ -26,10 +26,6 @@
portable
-
- $([System.IO.Path]::GetDirectoryName($([MSBuild]::GetPathOfFileAbove('LICENSE', '$(MSBuildThisFileDirectory)'))))/
-
-
@@ -138,6 +134,10 @@
true
+
+ $([System.IO.Path]::GetDirectoryName($([MSBuild]::GetPathOfFileAbove('LICENSE', '$(MSBuildThisFileDirectory)'))))/
+
+
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index fe5b712be..fc4481b00 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -14,7 +14,6 @@
-
diff --git a/src/Main/Main.csproj b/src/Main/Main.csproj
index ba1dc76ab..c577398a8 100644
--- a/src/Main/Main.csproj
+++ b/src/Main/Main.csproj
@@ -1,11 +1,10 @@
- Exe
+ KernelMemory.Main
+ KernelMemory.Main
net10.0
- latest
- enable
- enable
+ Exe
true
true
diff --git a/src/Main/Program.cs b/src/Main/Program.cs
index c860d1f6d..b779b96bb 100644
--- a/src/Main/Program.cs
+++ b/src/Main/Program.cs
@@ -1,4 +1,4 @@
-namespace Main;
+namespace KernelMemory.Main;
sealed class Program
{
diff --git a/tests/Core.Tests/Config/AppConfigTests.cs b/tests/Core.Tests/Config/AppConfigTests.cs
new file mode 100644
index 000000000..d455109de
--- /dev/null
+++ b/tests/Core.Tests/Config/AppConfigTests.cs
@@ -0,0 +1,208 @@
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.Cache;
+using KernelMemory.Core.Config.ContentIndex;
+using KernelMemory.Core.Config.Enums;
+using KernelMemory.Core.Config.SearchIndex;
+using KernelMemory.Core.Config.Validation;
+
+namespace Core.Tests.Config;
+
+///
+/// Tests for AppConfig validation and default configuration
+///
+public class AppConfigTests
+{
+ [Fact]
+ public void CreateDefault_ShouldCreateValidConfiguration()
+ {
+ // Act
+ var config = AppConfig.CreateDefault();
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.Single(config.Nodes);
+ Assert.True(config.Nodes.ContainsKey("personal"));
+ Assert.NotNull(config.EmbeddingsCache);
+ Assert.Null(config.LLMCache);
+
+ // Verify personal node structure
+ var personalNode = config.Nodes["personal"];
+ Assert.Equal("personal", personalNode.Id);
+ Assert.Equal(NodeAccessLevels.Full, personalNode.Access);
+ Assert.NotNull(personalNode.ContentIndex);
+ Assert.IsType(personalNode.ContentIndex);
+ Assert.Null(personalNode.FileStorage);
+ Assert.Null(personalNode.RepoStorage);
+ Assert.Equal(2, personalNode.SearchIndexes.Count);
+
+ // Verify search indexes
+ Assert.IsType(personalNode.SearchIndexes[0]);
+ Assert.IsType(personalNode.SearchIndexes[1]);
+
+ var ftsIndex = (FtsSearchIndexConfig)personalNode.SearchIndexes[0];
+ Assert.Equal(SearchIndexTypes.SqliteFTS, ftsIndex.Type);
+ Assert.True(ftsIndex.EnableStemming);
+ Assert.NotNull(ftsIndex.Path);
+ Assert.Contains("fts.db", ftsIndex.Path);
+
+ var vectorIndex = (VectorSearchIndexConfig)personalNode.SearchIndexes[1];
+ Assert.Equal(SearchIndexTypes.SqliteVector, vectorIndex.Type);
+ Assert.Equal(768, vectorIndex.Dimensions);
+ Assert.Equal(VectorMetrics.Cosine, vectorIndex.Metric);
+ Assert.NotNull(vectorIndex.Path);
+ Assert.Contains("vectors.db", vectorIndex.Path);
+
+ // Verify embeddings cache
+ Assert.Equal(CacheTypes.Sqlite, config.EmbeddingsCache.Type);
+ Assert.True(config.EmbeddingsCache.AllowRead);
+ Assert.True(config.EmbeddingsCache.AllowWrite);
+ Assert.NotNull(config.EmbeddingsCache.Path);
+ Assert.Contains("embeddings-cache.db", config.EmbeddingsCache.Path);
+ }
+
+ [Fact]
+ public void Validate_WithValidConfig_ShouldNotThrow()
+ {
+ // Arrange
+ var config = AppConfig.CreateDefault();
+
+ // Act & Assert - should not throw
+ config.Validate();
+ }
+
+ [Fact]
+ public void Validate_WithNoNodes_ShouldThrowConfigException()
+ {
+ // Arrange
+ var config = new AppConfig
+ {
+ Nodes = new Dictionary()
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => config.Validate());
+ Assert.Equal("Nodes", exception.ConfigPath);
+ Assert.Contains("At least one node must be configured", exception.Message);
+ }
+
+ [Fact]
+ public void Validate_WithEmptyNodeId_ShouldThrowConfigException()
+ {
+ // Arrange
+ var config = new AppConfig
+ {
+ Nodes = new Dictionary
+ {
+ [""] = new NodeConfig
+ {
+ Id = "test",
+ ContentIndex = new SqliteContentIndexConfig { Path = "test.db" }
+ }
+ }
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => config.Validate());
+ Assert.Equal("Nodes", exception.ConfigPath);
+ Assert.Contains("Node ID cannot be empty", exception.Message);
+ }
+
+ [Fact]
+ public void Validate_WithInvalidNodeConfig_ShouldThrowConfigException()
+ {
+ // Arrange
+ var config = new AppConfig
+ {
+ Nodes = new Dictionary
+ {
+ ["test"] = new NodeConfig
+ {
+ Id = "", // Invalid: empty ID
+ ContentIndex = new SqliteContentIndexConfig { Path = "test.db" }
+ }
+ }
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => config.Validate());
+ Assert.Equal("Nodes.test", exception.ConfigPath);
+ Assert.Contains("Node ID is required", exception.Message);
+ }
+
+ [Fact]
+ public void Validate_WithInvalidEmbeddingsCache_ShouldThrowConfigException()
+ {
+ // Arrange
+ var config = AppConfig.CreateDefault();
+ config.EmbeddingsCache = new CacheConfig
+ {
+ Type = CacheTypes.Sqlite,
+ Path = null // Invalid: sqlite needs path
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => config.Validate());
+ Assert.Equal("EmbeddingsCache.Path", exception.ConfigPath);
+ Assert.Contains("SQLite cache requires Path", exception.Message);
+ }
+
+ [Fact]
+ public void Validate_WithInvalidLLMCache_ShouldThrowConfigException()
+ {
+ // Arrange
+ var config = AppConfig.CreateDefault();
+ config.LLMCache = new CacheConfig
+ {
+ Type = CacheTypes.Postgres,
+ ConnectionString = null // Invalid: postgres needs connection string
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => config.Validate());
+ Assert.Equal("LLMCache.ConnectionString", exception.ConfigPath);
+ Assert.Contains("PostgreSQL cache requires ConnectionString", exception.Message);
+ }
+
+ [Fact]
+ public void Validate_WithMultipleNodes_ShouldValidateAll()
+ {
+ // Arrange
+ var config = AppConfig.CreateDefault();
+ config.Nodes["work"] = new NodeConfig
+ {
+ Id = "work",
+ Access = NodeAccessLevels.ReadOnly,
+ ContentIndex = new SqliteContentIndexConfig { Path = "work.db" }
+ };
+
+ // Act & Assert - should not throw
+ config.Validate();
+
+ // Verify both nodes
+ Assert.Equal(2, config.Nodes.Count);
+ Assert.True(config.Nodes.ContainsKey("personal"));
+ Assert.True(config.Nodes.ContainsKey("work"));
+ }
+
+ [Fact]
+ public void Validate_PropagatesPathInErrors()
+ {
+ // Arrange
+ var config = new AppConfig
+ {
+ Nodes = new Dictionary
+ {
+ ["mynode"] = new NodeConfig
+ {
+ Id = "mynode",
+ ContentIndex = new SqliteContentIndexConfig { Path = "" } // Invalid: empty path
+ }
+ }
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => config.Validate());
+ Assert.Equal("Nodes.mynode.ContentIndex.Path", exception.ConfigPath);
+ Assert.Contains("SQLite path is required", exception.Message);
+ }
+}
diff --git a/tests/Core.Tests/Config/ConfigParserTests.cs b/tests/Core.Tests/Config/ConfigParserTests.cs
new file mode 100644
index 000000000..d6e6e53bd
--- /dev/null
+++ b/tests/Core.Tests/Config/ConfigParserTests.cs
@@ -0,0 +1,365 @@
+using System.Text.Json;
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.Validation;
+
+namespace Core.Tests.Config;
+
+///
+/// Tests for ConfigParser - loading and parsing configuration files
+///
+public class ConfigParserTests
+{
+ [Fact]
+ public void LoadFromFile_WhenFileMissing_ShouldReturnDefaultConfig()
+ {
+ // Arrange
+ var nonExistentPath = Path.Combine(Path.GetTempPath(), $"nonexistent-{Guid.NewGuid()}.json");
+
+ // Act
+ var config = ConfigParser.LoadFromFile(nonExistentPath);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.Single(config.Nodes);
+ Assert.True(config.Nodes.ContainsKey("personal"));
+ Assert.NotNull(config.EmbeddingsCache);
+ }
+
+ [Fact]
+ public void LoadFromFile_WithValidJson_ShouldReturnParsedConfig()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""mynode"": {
+ ""id"": ""mynode"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.Single(config.Nodes);
+ Assert.True(config.Nodes.ContainsKey("mynode"));
+ Assert.Equal("mynode", config.Nodes["mynode"].Id);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithInvalidJson_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var invalidJson = "{ invalid json here }";
+
+ try
+ {
+ File.WriteAllText(tempFile, invalidJson);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Failed to parse configuration", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithValidationErrors_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {}
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Equal("Nodes", exception.ConfigPath);
+ Assert.Contains("At least one node must be configured", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithTildeInPath_ShouldExpandToHomeDirectory()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""~/.km/test.db""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ var contentIndex = (KernelMemory.Core.Config.ContentIndex.SqliteContentIndexConfig)config.Nodes["test"].ContentIndex;
+ Assert.NotNull(contentIndex.Path);
+ Assert.DoesNotContain("~", contentIndex.Path);
+ Assert.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), contentIndex.Path);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithCommentsInJson_ShouldParseSuccessfully()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var jsonWithComments = @"{
+ // This is a comment
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ /* Multi-line
+ comment */
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, jsonWithComments);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.Single(config.Nodes);
+ Assert.True(config.Nodes.ContainsKey("test"));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithCaseInsensitiveProperties_ShouldParseSuccessfully()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""Nodes"": {
+ ""test"": {
+ ""Id"": ""test"",
+ ""Access"": ""Full"",
+ ""ContentIndex"": {
+ ""$type"": ""sqlite"",
+ ""Path"": ""test.db""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.Single(config.Nodes);
+ Assert.Equal("test", config.Nodes["test"].Id);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void ParseFromString_WithValidJson_ShouldReturnParsedConfig()
+ {
+ // Arrange
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ }
+ }
+ }
+ }";
+
+ // Act
+ var config = ConfigParser.ParseFromString(json);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.Single(config.Nodes);
+ Assert.Equal("test", config.Nodes["test"].Id);
+ }
+
+ [Fact]
+ public void ParseFromString_WithInvalidJson_ShouldThrowConfigException()
+ {
+ // Arrange
+ var invalidJson = "{ invalid json }";
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.ParseFromString(invalidJson));
+ Assert.Contains("Failed to parse configuration", exception.Message);
+ }
+
+ [Fact]
+ public void LoadFromFile_WithCacheTildeExpansion_ShouldExpandPaths()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ }
+ }
+ },
+ ""embeddingsCache"": {
+ ""type"": ""Sqlite"",
+ ""path"": ""~/embeddings-cache.db""
+ },
+ ""llmCache"": {
+ ""type"": ""Sqlite"",
+ ""path"": ""~/llm-cache.db""
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ Assert.NotNull(config.EmbeddingsCache);
+ Assert.NotNull(config.LLMCache);
+ Assert.StartsWith(homeDir, config.EmbeddingsCache.Path!);
+ Assert.StartsWith(homeDir, config.LLMCache.Path!);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithCacheBothPathAndConnectionString_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ }
+ }
+ },
+ ""embeddingsCache"": {
+ ""type"": ""Sqlite"",
+ ""path"": ""cache.db"",
+ ""connectionString"": ""Host=localhost""
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("specify either Path", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+}
diff --git a/tests/Core.Tests/Config/ContentIndexConfigTests.cs b/tests/Core.Tests/Config/ContentIndexConfigTests.cs
new file mode 100644
index 000000000..108e351b1
--- /dev/null
+++ b/tests/Core.Tests/Config/ContentIndexConfigTests.cs
@@ -0,0 +1,85 @@
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.Validation;
+
+namespace Core.Tests.Config;
+
+///
+/// Tests for Content Index configuration validation
+///
+public class ContentIndexConfigTests
+{
+ [Fact]
+ public void LoadFromFile_WithPostgresContentIndex_ShouldValidate()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""postgres"",
+ ""connectionString"": ""Host=localhost;Database=testdb;Username=test;Password=test""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ var node = config.Nodes["test"];
+ var postgresIndex = Assert.IsType(node.ContentIndex);
+ Assert.Equal("Host=localhost;Database=testdb;Username=test;Password=test", postgresIndex.ConnectionString);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithPostgresContentIndexMissingConnectionString_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""postgres"",
+ ""connectionString"": """"
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("PostgreSQL connection string is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+}
diff --git a/tests/Core.Tests/Config/EmbeddingsConfigTests.cs b/tests/Core.Tests/Config/EmbeddingsConfigTests.cs
new file mode 100644
index 000000000..f97564bfc
--- /dev/null
+++ b/tests/Core.Tests/Config/EmbeddingsConfigTests.cs
@@ -0,0 +1,522 @@
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.Validation;
+
+namespace Core.Tests.Config;
+
+///
+/// Tests for Embeddings configuration validation
+///
+public class EmbeddingsConfigTests
+{
+ [Fact]
+ public void LoadFromFile_WithOllamaEmbeddings_ShouldValidate()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 384,
+ ""embeddings"": {
+ ""$type"": ""ollama"",
+ ""model"": ""all-minilm"",
+ ""baseUrl"": ""http://localhost:11434""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ var searchIndex = config.Nodes["test"].SearchIndexes[0];
+ Assert.NotNull(searchIndex.Embeddings);
+ var ollamaConfig = Assert.IsType(searchIndex.Embeddings);
+ Assert.Equal("all-minilm", ollamaConfig.Model);
+ Assert.Equal("http://localhost:11434", ollamaConfig.BaseUrl);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithOpenAIEmbeddings_ShouldValidate()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""openai"",
+ ""model"": ""text-embedding-ada-002"",
+ ""apiKey"": ""test-key"",
+ ""baseUrl"": ""https://api.openai.com/v1""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ var searchIndex = config.Nodes["test"].SearchIndexes[0];
+ Assert.NotNull(searchIndex.Embeddings);
+ var openaiConfig = Assert.IsType(searchIndex.Embeddings);
+ Assert.Equal("text-embedding-ada-002", openaiConfig.Model);
+ Assert.Equal("test-key", openaiConfig.ApiKey);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithOllamaEmbeddingsMissingModel_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 384,
+ ""embeddings"": {
+ ""$type"": ""ollama"",
+ ""model"": """",
+ ""baseUrl"": ""http://localhost:11434""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Ollama model name is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithOllamaEmbeddingsMissingBaseUrl_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 384,
+ ""embeddings"": {
+ ""$type"": ""ollama"",
+ ""model"": ""all-minilm"",
+ ""baseUrl"": """"
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Ollama base URL is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithOpenAIEmbeddingsMissingApiKey_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""openai"",
+ ""model"": ""text-embedding-ada-002"",
+ ""apiKey"": """"
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("OpenAI API key is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureOpenAIEmbeddingsMissingModel_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""azureOpenAI"",
+ ""model"": """",
+ ""endpoint"": ""https://test.openai.azure.com"",
+ ""deployment"": ""test-deployment"",
+ ""apiKey"": ""test-key""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Model name is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureOpenAIEmbeddingsMissingEndpoint_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""azureOpenAI"",
+ ""model"": ""text-embedding-ada-002"",
+ ""endpoint"": """",
+ ""deployment"": ""test-deployment"",
+ ""apiKey"": ""test-key""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Azure OpenAI endpoint is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureOpenAIEmbeddingsMissingDeployment_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""azureOpenAI"",
+ ""model"": ""text-embedding-ada-002"",
+ ""endpoint"": ""https://test.openai.azure.com"",
+ ""deployment"": """",
+ ""apiKey"": ""test-key""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Deployment name is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureOpenAIEmbeddingsNoAuth_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""azureOpenAI"",
+ ""model"": ""text-embedding-ada-002"",
+ ""endpoint"": ""https://test.openai.azure.com"",
+ ""deployment"": ""test-deployment""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("requires either ApiKey or UseManagedIdentity", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureOpenAIEmbeddings_ShouldValidate()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 1536,
+ ""embeddings"": {
+ ""$type"": ""azureOpenAI"",
+ ""model"": ""text-embedding-ada-002"",
+ ""endpoint"": ""https://test.openai.azure.com"",
+ ""deployment"": ""text-embedding-ada-002"",
+ ""apiKey"": ""test-key""
+ }
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ var searchIndex = config.Nodes["test"].SearchIndexes[0];
+ Assert.NotNull(searchIndex.Embeddings);
+ var azureConfig = Assert.IsType(searchIndex.Embeddings);
+ Assert.Equal("text-embedding-ada-002", azureConfig.Model);
+ Assert.Equal("https://test.openai.azure.com", azureConfig.Endpoint);
+ Assert.Equal("text-embedding-ada-002", azureConfig.Deployment);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+}
diff --git a/tests/Core.Tests/Config/SearchIndexConfigTests.cs b/tests/Core.Tests/Config/SearchIndexConfigTests.cs
new file mode 100644
index 000000000..5d849524e
--- /dev/null
+++ b/tests/Core.Tests/Config/SearchIndexConfigTests.cs
@@ -0,0 +1,183 @@
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.Validation;
+
+namespace Core.Tests.Config;
+
+///
+/// Tests for Search Index configuration validation
+///
+public class SearchIndexConfigTests
+{
+ [Fact]
+ public void LoadFromFile_WithGraphSearchIndex_ShouldExpandTildePath()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""graph"",
+ ""path"": ""~/graph-index.db""
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ var node = config.Nodes["test"];
+ Assert.Single(node.SearchIndexes);
+ var graphIndex = Assert.IsType(node.SearchIndexes[0]);
+
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ Assert.StartsWith(homeDir, graphIndex.Path);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithFtsSearchIndexBothPathAndConnection_ShouldThrowConfigException()
+ {
+ // Test: FTS with both Path and ConnectionString
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""fts"",
+ ""type"": ""SqliteFTS"",
+ ""path"": ""fts.db"",
+ ""connectionString"": ""Host=localhost""
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("specify either Path", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithVectorSearchIndexBothPathAndConnection_ShouldThrowConfigException()
+ {
+ // Test: Vector with both Path and ConnectionString
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""connectionString"": ""Host=localhost"",
+ ""dimensions"": 384
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("specify either Path", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithVectorSearchIndexInvalidDimensions_ShouldThrowConfigException()
+ {
+ // Test: Vector with invalid Dimensions
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""searchIndexes"": [
+ {
+ ""$type"": ""vector"",
+ ""type"": ""SqliteVector"",
+ ""path"": ""vector.db"",
+ ""dimensions"": 0
+ }
+ ]
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("must be positive", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+}
diff --git a/tests/Core.Tests/Config/StorageConfigTests.cs b/tests/Core.Tests/Config/StorageConfigTests.cs
new file mode 100644
index 000000000..616b9ac38
--- /dev/null
+++ b/tests/Core.Tests/Config/StorageConfigTests.cs
@@ -0,0 +1,267 @@
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.Validation;
+
+namespace Core.Tests.Config;
+
+///
+/// Tests for Storage configuration validation and parsing
+///
+public class StorageConfigTests
+{
+ [Fact]
+ public void LoadFromFile_WithDiskStorage_ShouldExpandTildePath()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""fileStorage"": {
+ ""$type"": ""disk"",
+ ""path"": ""~/test-files""
+ },
+ ""repoStorage"": {
+ ""$type"": ""disk"",
+ ""path"": ""~/test-repos""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ var node = config.Nodes["test"];
+ Assert.NotNull(node.FileStorage);
+ Assert.NotNull(node.RepoStorage);
+
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var fileStorage = Assert.IsType(node.FileStorage);
+ var repoStorage = Assert.IsType(node.RepoStorage);
+ Assert.StartsWith(homeDir, fileStorage.Path);
+ Assert.StartsWith(homeDir, repoStorage.Path);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithDiskStorageMissingPath_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""fileStorage"": {
+ ""$type"": ""disk"",
+ ""path"": """"
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Disk storage path is required", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureBlobStorageConnectionString_ShouldValidate()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""fileStorage"": {
+ ""$type"": ""azureBlobs"",
+ ""connectionString"": ""DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key;EndpointSuffix=core.windows.net""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.NotNull(config.Nodes["test"].FileStorage);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureBlobStorageApiKey_ShouldValidate()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""fileStorage"": {
+ ""$type"": ""azureBlobs"",
+ ""account"": ""testaccount"",
+ ""apiKey"": ""test-api-key""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act
+ var config = ConfigParser.LoadFromFile(tempFile);
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.NotNull(config.Nodes["test"].FileStorage);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureBlobStorageNoAuth_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""fileStorage"": {
+ ""$type"": ""azureBlobs"",
+ ""account"": ""testaccount""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("Azure Blob storage requires one of", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_WithAzureBlobStorageMultipleAuth_ShouldThrowConfigException()
+ {
+ // Arrange
+ var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json");
+ var json = @"{
+ ""nodes"": {
+ ""test"": {
+ ""id"": ""test"",
+ ""access"": ""Full"",
+ ""contentIndex"": {
+ ""$type"": ""sqlite"",
+ ""path"": ""test.db""
+ },
+ ""fileStorage"": {
+ ""$type"": ""azureBlobs"",
+ ""connectionString"": ""DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key"",
+ ""apiKey"": ""test-api-key""
+ }
+ }
+ }
+ }";
+
+ try
+ {
+ File.WriteAllText(tempFile, json);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile));
+ Assert.Contains("specify only one authentication method", exception.Message);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+}
diff --git a/tests/Core.Tests/Core.Tests.csproj b/tests/Core.Tests/Core.Tests.csproj
new file mode 100644
index 000000000..cd434d381
--- /dev/null
+++ b/tests/Core.Tests/Core.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+
+ false
+
+
+ $(NoWarn);CA1707;CA1307
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
new file mode 100644
index 000000000..6afe069d9
--- /dev/null
+++ b/tests/Directory.Build.props
@@ -0,0 +1,90 @@
+
+
+
+ false
+
+
+ latest
+
+
+ enable
+
+
+ enable
+
+
+ LatestMajor
+
+
+
+ true
+ full
+
+
+
+ portable
+
+
+
+
+
+ <_Parameter1>false
+
+
+
+
+
+
+
+
+
+ true
+ true
+ All
+ latest
+
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props
new file mode 100644
index 000000000..d47ecb08e
--- /dev/null
+++ b/tests/Directory.Packages.props
@@ -0,0 +1,24 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file