Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
- Follow @docs/AGENTS.md instructions without exceptions
- Ignore the "archived" directory
- During development don't EVER write to my personal ~/.km dir
- For any task with multiple steps, create a todo list FIRST, then execute.
- Avoid destructive operations like "get reset", "git clean", etc.

Expand Down
2 changes: 1 addition & 1 deletion docs
Submodule docs updated from 6c3214 to 556529
21 changes: 14 additions & 7 deletions src/Core/Config/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,24 +57,31 @@ public void Validate(string path = "")

/// <summary>
/// Creates a default configuration with a single "personal" node
/// using local SQLite storage
/// using local SQLite storage in the user's home directory
/// </summary>
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 CreateDefault(kmDir);
}

/// <summary>
/// Creates a default configuration with a single "personal" node
/// using local SQLite storage in the specified base directory
/// </summary>
/// <param name="baseDir">Base directory for data storage</param>
public static AppConfig CreateDefault(string baseDir)
{
var personalNodeDir = Path.Combine(baseDir, "nodes", "personal");

return new AppConfig
{
Nodes = new Dictionary<string, NodeConfig>
{
["personal"] = NodeConfig.CreateDefaultPersonalNode(personalNodeDir)
},
EmbeddingsCache = CacheConfig.CreateDefaultSqliteCache(
Path.Combine(kmDir, "embeddings-cache.db")
),
LLMCache = null
}
// EmbeddingsCache and LLMCache intentionally omitted - add when features are implemented
};
}
}
75 changes: 70 additions & 5 deletions src/Core/Config/ConfigParser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using KernelMemory.Core.Config.Cache;
using KernelMemory.Core.Config.ContentIndex;
using KernelMemory.Core.Config.SearchIndex;
Expand All @@ -19,28 +20,55 @@ public static class ConfigParser
/// - Case insensitive property names
/// - Comments allowed
/// - Trailing commas allowed
/// - Supports polymorphic deserialization
/// </summary>
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};

/// <summary>
/// Loads configuration from a file, or returns default config if file doesn't exist
/// JSON serializer options for writing config files
/// - Indented formatting
/// - Camel case property names
/// - Omit null values
/// </summary>
private static readonly JsonSerializerOptions s_writeJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};

/// <summary>
/// Loads configuration from a file, or creates default config if file doesn't exist.
/// The config file is always ensured to exist on disk after loading.
/// Performs tilde expansion on paths (~/ → home directory)
/// </summary>
/// <param name="filePath">Path to configuration file</param>
/// <returns>Validated AppConfig instance</returns>
/// <exception cref="ConfigException">Thrown when file exists but parsing or validation fails</exception>
public static AppConfig LoadFromFile(string filePath)
{
// If file doesn't exist, return default configuration
AppConfig config;

// If file doesn't exist, create default configuration relative to config file location
if (!File.Exists(filePath))
{
return AppConfig.CreateDefault();
var configDir = Path.GetDirectoryName(filePath);
var baseDir = string.IsNullOrEmpty(configDir) ? "." : configDir;

// Create default config relative to config file location
config = AppConfig.CreateDefault(baseDir);

// Write the config file
WriteConfigFile(filePath, config);

return config;
}

try
Expand All @@ -49,11 +77,14 @@ public static AppConfig LoadFromFile(string filePath)
var json = File.ReadAllText(filePath);

// Parse and validate
var config = ParseFromString(json);
config = ParseFromString(json);

// Expand tilde paths
ExpandTildePaths(config);

// Always ensure the config file exists (recreate if deleted between load and save)
WriteConfigFileIfMissing(filePath, config);

return config;
}
catch (ConfigException)
Expand Down Expand Up @@ -251,4 +282,38 @@ private static string ExpandTilde(string path, string homeDir)

return path;
}
/// <summary>
/// Writes the config file to disk if it doesn't exist.
/// Used to ensure config file is always present after any operation.
/// </summary>
/// <param name="filePath">Path to configuration file</param>
/// <param name="config">Configuration to write</param>
private static void WriteConfigFileIfMissing(string filePath, AppConfig config)
{
if (File.Exists(filePath))
{
return;
}

WriteConfigFile(filePath, config);
}

/// <summary>
/// Writes the config file to disk, creating directories if needed.
/// </summary>
/// <param name="filePath">Path to configuration file</param>
/// <param name="config">Configuration to write</param>
private static void WriteConfigFile(string filePath, AppConfig config)
{
// Create the directory if needed
var configDir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir))
{
Directory.CreateDirectory(configDir);
}

// Write the config file
var json = System.Text.Json.JsonSerializer.Serialize(config, s_writeJsonOptions);
File.WriteAllText(filePath, json);
}
}
2 changes: 1 addition & 1 deletion src/Core/Config/ContentIndex/ContentIndexConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace KernelMemory.Core.Config.ContentIndex;
/// Base class for content index configurations
/// Content index is the source of truth, backed by Entity Framework
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(SqliteContentIndexConfig), typeDiscriminator: "sqlite")]
[JsonDerivedType(typeof(PostgresContentIndexConfig), typeDiscriminator: "postgres")]
public abstract class ContentIndexConfig : IValidatable
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Config/Embeddings/EmbeddingsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace KernelMemory.Core.Config.Embeddings;
/// <summary>
/// Base class for embeddings provider configurations
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(OllamaEmbeddingsConfig), typeDiscriminator: "ollama")]
[JsonDerivedType(typeof(OpenAIEmbeddingsConfig), typeDiscriminator: "openai")]
[JsonDerivedType(typeof(AzureOpenAIEmbeddingsConfig), typeDiscriminator: "azureOpenAI")]
Expand Down
22 changes: 15 additions & 7 deletions src/Core/Config/NodeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,24 @@ public void Validate(string path)
this.FileStorage?.Validate($"{path}.FileStorage");
this.RepoStorage?.Validate($"{path}.RepoStorage");

// Validate search indexes
for (int i = 0; i < this.SearchIndexes.Count; i++)
{
this.SearchIndexes[i].Validate($"{path}.SearchIndexes[{i}]");
}

// Ensure all search index IDs are unique within this node
var duplicateIds = this.SearchIndexes
.GroupBy(idx => idx.Id)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();

if (duplicateIds.Count > 0)
{
throw new ConfigException($"{path}.SearchIndexes",
$"Duplicate search index IDs found: {string.Join(", ", duplicateIds)}. Each search index must have a unique ID within a node.");
}
}

/// <summary>
Expand All @@ -97,16 +111,10 @@ internal static NodeConfig CreateDefaultPersonalNode(string nodeDir)
{
new FtsSearchIndexConfig
{
Id = "sqlite-fts",
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
}
}
};
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public sealed class FtsSearchIndexConfig : SearchIndexConfig
/// <inheritdoc />
public override void Validate(string path)
{
// Validate ID is provided
if (string.IsNullOrWhiteSpace(this.Id))
{
throw new ConfigException($"{path}.Id", "Search index ID is required");
}

this.Embeddings?.Validate($"{path}.Embeddings");

var isSqlite = this.Type == SearchIndexTypes.SqliteFTS;
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public sealed class GraphSearchIndexConfig : SearchIndexConfig
/// <inheritdoc />
public override void Validate(string path)
{
// Validate ID is provided
if (string.IsNullOrWhiteSpace(this.Id))
{
throw new ConfigException($"{path}.Id", "Search index ID is required");
}

this.Embeddings?.Validate($"{path}.Embeddings");

var hasPath = !string.IsNullOrWhiteSpace(this.Path);
Expand Down
14 changes: 11 additions & 3 deletions src/Core/Config/SearchIndex/SearchIndexConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ namespace KernelMemory.Core.Config.SearchIndex;
/// <summary>
/// Base class for search index configurations
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(FtsSearchIndexConfig), typeDiscriminator: "fts")]
[JsonDerivedType(typeof(VectorSearchIndexConfig), typeDiscriminator: "vector")]
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(FtsSearchIndexConfig), typeDiscriminator: "sqliteFTS")]
[JsonDerivedType(typeof(VectorSearchIndexConfig), typeDiscriminator: "sqliteVector")]
[JsonDerivedType(typeof(GraphSearchIndexConfig), typeDiscriminator: "graph")]
public abstract class SearchIndexConfig : IValidatable
{
/// <summary>
/// Unique identifier for this search index instance.
/// Must be unique within a node's SearchIndexes array.
/// Used to identify index instances in operations pipeline (stable across config changes).
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;

/// <summary>
/// Type of search index
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public sealed class VectorSearchIndexConfig : SearchIndexConfig
/// <inheritdoc />
public override void Validate(string path)
{
// Validate ID is provided
if (string.IsNullOrWhiteSpace(this.Id))
{
throw new ConfigException($"{path}.Id", "Search index ID is required");
}

this.Embeddings?.Validate($"{path}.Embeddings");

var isSqlite = this.Type == SearchIndexTypes.SqliteVector;
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Config/Storage/StorageConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace KernelMemory.Core.Config.Storage;
/// <summary>
/// Base class for storage configurations (files, repositories)
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(DiskStorageConfig), typeDiscriminator: "disk")]
[JsonDerivedType(typeof(AzureBlobStorageConfig), typeDiscriminator: "azureBlobs")]
public abstract class StorageConfig : IValidatable
Expand Down
25 changes: 25 additions & 0 deletions src/Core/Search/FtsMatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Search;

/// <summary>
/// Represents a match from a full-text search query.
/// </summary>
public sealed class FtsMatch
{
/// <summary>
/// The content ID that matched the search query.
/// </summary>
public required string ContentId { get; init; }

/// <summary>
/// The relevance score (higher is more relevant).
/// FTS5 rank is negative (closer to 0 is better), so we negate it.
/// </summary>
public required double Score { get; init; }

/// <summary>
/// A snippet of the matched text with highlights.
/// Highlights are marked with configurable markers (default: no markers).
/// </summary>
public required string Snippet { get; init; }
}
19 changes: 19 additions & 0 deletions src/Core/Search/IFtsIndex.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Search;

/// <summary>
/// Interface for Full-Text Search index operations.
/// Extends ISearchIndex with search-specific capabilities.
/// Implementations include SQLite FTS5 and PostgreSQL FTS.
/// </summary>
public interface IFtsIndex : ISearchIndex
{
/// <summary>
/// Searches the full-text index for matching content.
/// </summary>
/// <param name="query">The search query (FTS5 syntax supported).</param>
/// <param name="limit">Maximum number of results to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matches ordered by relevance (highest score first).</returns>
Task<IReadOnlyList<FtsMatch>> SearchAsync(string query, int limit = 10, CancellationToken cancellationToken = default);
}
32 changes: 32 additions & 0 deletions src/Core/Search/ISearchIndex.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Search;

/// <summary>
/// Base interface for all search index types.
/// Implementations include FTS, vector search, graph search, etc.
/// </summary>
public interface ISearchIndex
{
/// <summary>
/// Updates this index when content is created or updated.
/// </summary>
/// <param name="contentId">The content ID to index.</param>
/// <param name="text">The text content to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task IndexAsync(string contentId, string text, CancellationToken cancellationToken = default);

/// <summary>
/// Removes content from this index.
/// Idempotent - no error if content doesn't exist.
/// </summary>
/// <param name="contentId">The content ID to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RemoveAsync(string contentId, CancellationToken cancellationToken = default);

/// <summary>
/// Clears all entries from this index.
/// Used for rebuilding from content storage.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task ClearAsync(CancellationToken cancellationToken = default);
}
Loading
Loading