Merge pull request 'Add typed memory items and multi-embedding search' (#2) from feature/category-specific-items into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -8,8 +8,9 @@ The project is intentionally small and operationally explicit: everything local
|
||||
|
||||
- Stores memories inside explicit projects.
|
||||
- Supports four categories: `requirement`, `location`, `todo`, and `note`.
|
||||
- Finds memories with hybrid semantic and keyword search.
|
||||
- Updates embeddings whenever memory content changes.
|
||||
- Finds memories with hybrid semantic and keyword search across one to four query texts.
|
||||
- Stores one default embedding per memory and optional additional embeddings for targeted discoverability.
|
||||
- Updates the default embedding whenever memory content changes.
|
||||
- Soft-deletes items so accidental deletes are recoverable at the database level.
|
||||
- Exposes everything through HTTP MCP tools.
|
||||
- Provides an optional Blazor web UI for browsing and testing memories.
|
||||
@@ -102,12 +103,22 @@ All local operations are script-first.
|
||||
| --- | --- |
|
||||
| `CreateProject` | Create an explicit project. |
|
||||
| `ListProjects` | List projects. |
|
||||
| `AddItem` | Add a memory item to an existing project. |
|
||||
| `AddRequirement` | Add a requirement with required statement and context fields. |
|
||||
| `AddLocation` | Add a location with required item and place fields. |
|
||||
| `AddTodo` | Add a todo with required task and priority fields. |
|
||||
| `AddNote` | Add a note with required subject and body fields. |
|
||||
| `FindItems` | Search memory items. |
|
||||
| `UpdateItem` | Update an item and regenerate embeddings when needed. |
|
||||
| `ListItemEmbeddings` | List default and additional embeddings for an item. |
|
||||
| `AddItemEmbedding` | Add an additional discoverability embedding. |
|
||||
| `UpdateItemEmbedding` | Update an additional embedding. |
|
||||
| `DeleteItemEmbedding` | Delete an additional embedding. |
|
||||
| `UpdateRequirement` | Update a requirement item. |
|
||||
| `UpdateLocation` | Update a location item. |
|
||||
| `UpdateTodo` | Update a todo item. |
|
||||
| `UpdateNote` | Update a note item. |
|
||||
| `DeleteItem` | Soft-delete an item. |
|
||||
|
||||
There is no default project. `AddItem`, `UpdateItem`, and `DeleteItem` require an existing project. `FindItems` requires a project unless `searchAllProjects` is set to `true`.
|
||||
There is no default project. Add, update, and delete tools require an existing project. `FindItems` requires a project unless `searchAllProjects` is set to `true`.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+33
-18
@@ -61,9 +61,9 @@ Fields:
|
||||
|
||||
### Memory Item
|
||||
|
||||
Memory items belong to exactly one project.
|
||||
Memory items belong to exactly one project. EF Core maps them with table-per-type inheritance: shared fields live in `memory_items`, and each category has a subtype table with required category-specific columns.
|
||||
|
||||
Fields:
|
||||
Base fields:
|
||||
|
||||
- `id`
|
||||
- `project_id`
|
||||
@@ -71,34 +71,49 @@ Fields:
|
||||
- `title`
|
||||
- `content`
|
||||
- `tags`
|
||||
- `metadata`
|
||||
- `status`
|
||||
- `embedding`
|
||||
- `embedding_model`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `deleted_at`
|
||||
|
||||
The `embedding` column is a `vector(384)` pgvector column.
|
||||
### Memory Item Embedding
|
||||
|
||||
Embeddings are stored separately from items so a large item can expose additional targeted discoverability text without changing its canonical description.
|
||||
|
||||
Fields:
|
||||
|
||||
- `id`
|
||||
- `memory_item_id`
|
||||
- `position`
|
||||
- `label`
|
||||
- `text`
|
||||
- `embedding`
|
||||
- `embedding_model`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
The `embedding` column is a `vector(384)` pgvector column. Position `0` is the generated default embedding. Additional embeddings use position `1` and above.
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Use |
|
||||
| --- | --- |
|
||||
| `requirement` | Project requirements, decisions, constraints. |
|
||||
| `location` | Where something is stored or placed. |
|
||||
| `todo` | Action items. |
|
||||
| `note` | Ideas and general memories. |
|
||||
| Category | Table | Required fields |
|
||||
| --- | --- | --- |
|
||||
| `requirement` | `requirement_memory_items` | `statement`, `context` |
|
||||
| `location` | `location_memory_items` | `item_name`, `place` |
|
||||
| `todo` | `todo_memory_items` | `task`, `priority` |
|
||||
| `note` | `note_memory_items` | `subject`, `body` |
|
||||
|
||||
## Search
|
||||
|
||||
Search is hybrid:
|
||||
|
||||
- PostgreSQL full-text ranking over `title` and `content`
|
||||
- pgvector cosine similarity over embeddings
|
||||
- pgvector cosine similarity over all embeddings linked to each item
|
||||
- Project and category filters
|
||||
- Recency as a secondary sort
|
||||
|
||||
`FindItems` accepts one to four query texts. Cortex embeds each query, scores every query against every item embedding, and returns each item once using its best matching query/embedding pair.
|
||||
|
||||
Normal search requires a project. Global search requires `searchAllProjects = true`.
|
||||
|
||||
## Embeddings
|
||||
@@ -109,17 +124,17 @@ The embedding model is:
|
||||
BAAI/bge-small-en-v1.5
|
||||
```
|
||||
|
||||
The model produces 384-dimensional vectors. That dimension is part of the database schema, so changing models later requires a planned migration or a second embedding column.
|
||||
The model produces 384-dimensional vectors. That dimension is part of the embedding table schema, so changing models later requires a planned re-embedding migration or a parallel embedding table/column strategy.
|
||||
|
||||
Embeddings are generated for:
|
||||
|
||||
- new items
|
||||
- title changes
|
||||
- content changes
|
||||
- category changes
|
||||
- category-specific field changes
|
||||
- tag changes
|
||||
|
||||
Metadata and status changes do not regenerate embeddings.
|
||||
Additional embeddings are generated when they are added or when their text changes. Additional embedding text is only for discoverability; the item still has one canonical title/content description.
|
||||
|
||||
Status changes do not regenerate embeddings.
|
||||
|
||||
## Deletes
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ The current model produces 384-dimensional vectors. If the new model has a diffe
|
||||
- indexes
|
||||
- docs
|
||||
|
||||
Changing dimensions for existing data requires either a re-embedding migration or a new embedding column.
|
||||
Changing dimensions for existing data requires either a re-embedding migration or a new embedding table/column strategy.
|
||||
|
||||
## Local Docker Config
|
||||
|
||||
|
||||
+139
-62
@@ -22,113 +22,190 @@ Arguments:
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | string | yes | Human-readable project name. |
|
||||
|
||||
Result:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Cortex",
|
||||
"slug": "cortex",
|
||||
"createdAt": "2026-07-08T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## ListProjects
|
||||
|
||||
Lists projects.
|
||||
|
||||
Arguments: none.
|
||||
|
||||
Result:
|
||||
## AddRequirement
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Cortex",
|
||||
"slug": "cortex",
|
||||
"createdAt": "2026-07-08T12:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## AddItem
|
||||
|
||||
Adds a memory item to an existing project.
|
||||
|
||||
Arguments:
|
||||
Adds a requirement, decision, or constraint.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `category` | string | yes | `requirement`, `location`, `todo`, or `note`. |
|
||||
| `title` | string | yes | Short title. |
|
||||
| `content` | string | yes | Full memory content. |
|
||||
| `statement` | string | yes | Requirement, decision, or constraint statement. |
|
||||
| `context` | string | yes | Context explaining why it matters. |
|
||||
| `tags` | string array | no | Optional tags. |
|
||||
| `status` | string | no | Optional status. Defaults to `active`. |
|
||||
|
||||
## AddLocation
|
||||
|
||||
Adds where a concrete thing is located.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `itemName` | string | yes | Object, file, or resource being located. |
|
||||
| `place` | string | yes | Place where the item is located. |
|
||||
| `details` | string | no | Extra location details. |
|
||||
| `tags` | string array | no | Optional tags. |
|
||||
| `metadataJson` | string | no | Optional JSON object encoded as a string. |
|
||||
| `status` | string | no | Optional status. Defaults to `active`. |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "cortex",
|
||||
"category": "location",
|
||||
"title": "USB-C charging brick",
|
||||
"content": "The USB-C charging brick is in the blue backpack.",
|
||||
"tags": ["charging", "backpack"],
|
||||
"metadataJson": "{\"object\":\"charging brick\",\"place\":\"blue backpack\"}"
|
||||
"project": "home",
|
||||
"itemName": "USB-C charging brick",
|
||||
"place": "blue backpack",
|
||||
"tags": ["charging", "backpack"]
|
||||
}
|
||||
```
|
||||
|
||||
## AddTodo
|
||||
|
||||
Adds an action item.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `task` | string | yes | Action to complete. |
|
||||
| `priority` | string | yes | Priority such as `low`, `normal`, `high`, or `urgent`. |
|
||||
| `dueAt` | timestamp | no | Optional due timestamp. |
|
||||
| `details` | string | no | Extra task details. |
|
||||
| `tags` | string array | no | Optional tags. |
|
||||
| `status` | string | no | Optional status. Defaults to `active`. |
|
||||
|
||||
## AddNote
|
||||
|
||||
Adds a general note or idea.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `subject` | string | yes | Note subject. |
|
||||
| `body` | string | yes | Note body. |
|
||||
| `tags` | string array | no | Optional tags. |
|
||||
| `status` | string | no | Optional status. Defaults to `active`. |
|
||||
|
||||
## FindItems
|
||||
|
||||
Finds memories with hybrid search.
|
||||
|
||||
Arguments:
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `queries` | string array | yes | One to four natural-language search queries. Each query is embedded and searched separately. |
|
||||
| `project` | string | required unless global | Project name or slug. |
|
||||
| `searchAllProjects` | boolean | no | Set `true` for global search. |
|
||||
| `category` | string | no | Optional category filter: `requirement`, `location`, `todo`, or `note`. |
|
||||
| `limit` | integer | no | Result limit from 1 to 50. Defaults to 10. |
|
||||
|
||||
Results are distinct items. If an item has multiple embeddings, Cortex returns the best matching embedding/query pair with `matchedQueryIndex`, `matchedEmbeddingPosition`, and `matchedEmbeddingLabel`.
|
||||
|
||||
## ListItemEmbeddings
|
||||
|
||||
Lists the default and additional embeddings for an item.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `query` | string | yes | Natural-language search query. |
|
||||
| `project` | string | required unless global | Project name or slug. |
|
||||
| `searchAllProjects` | boolean | no | Set `true` for global search. |
|
||||
| `category` | string | no | Optional category filter. |
|
||||
| `limit` | integer | no | Result limit from 1 to 50. Defaults to 10. |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `itemId` | GUID | yes | Item id from a previous result. |
|
||||
|
||||
Example:
|
||||
## AddItemEmbedding
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "where is my charging brick",
|
||||
"project": "home",
|
||||
"category": "location",
|
||||
"limit": 5
|
||||
}
|
||||
```
|
||||
Adds an additional discoverability embedding. This does not change the item's canonical title/content.
|
||||
|
||||
## UpdateItem
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `itemId` | GUID | yes | Item id from a previous result. |
|
||||
| `text` | string | yes | Aspect-specific text to embed. |
|
||||
| `label` | string | no | Short label for what the embedding targets. |
|
||||
|
||||
Updates an existing item. If title, content, category, or tags change, Cortex regenerates the embedding.
|
||||
## UpdateItemEmbedding
|
||||
|
||||
Arguments:
|
||||
Updates an additional embedding. The generated default embedding at position `0` cannot be edited directly.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `itemId` | GUID | yes | Item id from a previous result. |
|
||||
| `embeddingId` | GUID | yes | Embedding id from `ListItemEmbeddings`. |
|
||||
| `label` | string | no | Replacement label. Pass an empty string to clear. |
|
||||
| `text` | string | no | Replacement embedding text. |
|
||||
|
||||
## DeleteItemEmbedding
|
||||
|
||||
Deletes an additional embedding. The default embedding at position `0` cannot be deleted.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `itemId` | GUID | yes | Item id from a previous result. |
|
||||
| `embeddingId` | GUID | yes | Embedding id from `ListItemEmbeddings`. |
|
||||
|
||||
## UpdateRequirement
|
||||
|
||||
Updates an existing requirement item.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `id` | GUID | yes | Item id from a previous result. |
|
||||
| `category` | string | no | Replacement category. |
|
||||
| `title` | string | no | Replacement title. |
|
||||
| `content` | string | no | Replacement content. |
|
||||
| `statement` | string | no | Replacement statement. |
|
||||
| `context` | string | no | Replacement context. |
|
||||
| `tags` | string array | no | Replacement tag list. |
|
||||
| `status` | string | no | Replacement status. |
|
||||
|
||||
## UpdateLocation
|
||||
|
||||
Updates an existing location item.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `id` | GUID | yes | Item id from a previous result. |
|
||||
| `itemName` | string | no | Replacement item name. |
|
||||
| `place` | string | no | Replacement place. |
|
||||
| `details` | string | no | Replacement details. Pass an empty string to clear. |
|
||||
| `tags` | string array | no | Replacement tag list. |
|
||||
| `status` | string | no | Replacement status. |
|
||||
|
||||
## UpdateTodo
|
||||
|
||||
Updates an existing todo item.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `id` | GUID | yes | Item id from a previous result. |
|
||||
| `task` | string | no | Replacement task. |
|
||||
| `priority` | string | no | Replacement priority. |
|
||||
| `dueAt` | timestamp | no | Replacement due timestamp. |
|
||||
| `clearDueAt` | boolean | no | Set `true` to clear the due timestamp. |
|
||||
| `details` | string | no | Replacement details. Pass an empty string to clear. |
|
||||
| `tags` | string array | no | Replacement tag list. |
|
||||
| `status` | string | no | Replacement status. |
|
||||
|
||||
## UpdateNote
|
||||
|
||||
Updates an existing note item.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
| `id` | GUID | yes | Item id from a previous result. |
|
||||
| `subject` | string | no | Replacement subject. |
|
||||
| `body` | string | no | Replacement body. |
|
||||
| `tags` | string array | no | Replacement tag list. |
|
||||
| `metadataJson` | string | no | Replacement JSON metadata string. |
|
||||
| `status` | string | no | Replacement status. |
|
||||
|
||||
## DeleteItem
|
||||
|
||||
Soft-deletes an item.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `project` | string | yes | Existing project name or slug. |
|
||||
|
||||
@@ -50,9 +50,9 @@ Check logs:
|
||||
.\scripts\logs.ps1 -Service app -Follow
|
||||
```
|
||||
|
||||
## AddItem Fails with Missing Project
|
||||
## Add Tool Fails with Missing Project
|
||||
|
||||
This is expected when the project does not exist. Create the project first with `CreateProject`.
|
||||
This is expected when the project does not exist. Create the project first with `CreateProject`, then use the category-specific add tool.
|
||||
|
||||
Cortex intentionally has no default project.
|
||||
|
||||
|
||||
+3
-2
@@ -55,11 +55,12 @@ Stop:
|
||||
|
||||
- Create projects.
|
||||
- Browse project memory counts.
|
||||
- Add memories in `requirement`, `location`, `todo`, and `note` categories.
|
||||
- Search within one project or all projects.
|
||||
- Add memories through category-specific forms for `requirement`, `location`, `todo`, and `note`.
|
||||
- Search with one to four query texts within one project or all projects.
|
||||
- Filter recent memories by category.
|
||||
- Include or hide soft-deleted items.
|
||||
- Edit active memories.
|
||||
- Add and delete additional discoverability embeddings from the edit modal.
|
||||
- Soft-delete active memories.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,51 +27,191 @@ public static class CortexTools
|
||||
return memory.ListProjectsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "AddItem", UseStructuredContent = true, Destructive = false)]
|
||||
[Description("Add a memory item to an existing explicit project.")]
|
||||
public static Task<MemoryItemDto> AddItem(
|
||||
[McpServerTool(Name = "AddRequirement", UseStructuredContent = true, Destructive = false)]
|
||||
[Description("Add a project requirement, decision, or constraint to an existing explicit project.")]
|
||||
public static Task<MemoryItemDto> AddRequirement(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug. The project must already exist.")] string project,
|
||||
[Description("One of: requirement, location, todo, note.")] string category,
|
||||
[Description("Short item title.")] string title,
|
||||
[Description("Full item content to remember.")] string content,
|
||||
[Description("Required requirement, decision, or constraint statement.")] string statement,
|
||||
[Description("Required context explaining why this requirement matters.")] string context,
|
||||
[Description("Optional tags for filtering and recall.")] string[]? tags = null,
|
||||
[Description("Optional JSON object with category-specific fields.")] string? metadataJson = null,
|
||||
[Description("Optional item status. Defaults to active.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.AddItemAsync(new AddMemoryItemRequest(project, category, title, content, tags, metadataJson, status), cancellationToken);
|
||||
return memory.AddRequirementAsync(new AddRequirementRequest(project, statement, context, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "AddLocation", UseStructuredContent = true, Destructive = false)]
|
||||
[Description("Add where a concrete thing is located in an existing explicit project.")]
|
||||
public static Task<MemoryItemDto> AddLocation(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug. The project must already exist.")] string project,
|
||||
[Description("Required name of the object, file, or resource being located.")] string itemName,
|
||||
[Description("Required place where the item is located.")] string place,
|
||||
[Description("Optional extra location details.")] string? details = null,
|
||||
[Description("Optional tags for filtering and recall.")] string[]? tags = null,
|
||||
[Description("Optional item status. Defaults to active.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.AddLocationAsync(new AddLocationRequest(project, itemName, place, details, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "AddTodo", UseStructuredContent = true, Destructive = false)]
|
||||
[Description("Add an action item to an existing explicit project.")]
|
||||
public static Task<MemoryItemDto> AddTodo(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug. The project must already exist.")] string project,
|
||||
[Description("Required action to complete.")] string task,
|
||||
[Description("Required priority such as low, normal, high, or urgent.")] string priority,
|
||||
[Description("Optional due timestamp.")] DateTimeOffset? dueAt = null,
|
||||
[Description("Optional extra task details.")] string? details = null,
|
||||
[Description("Optional tags for filtering and recall.")] string[]? tags = null,
|
||||
[Description("Optional item status. Defaults to active.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.AddTodoAsync(new AddTodoRequest(project, task, priority, dueAt, details, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "AddNote", UseStructuredContent = true, Destructive = false)]
|
||||
[Description("Add a general note or idea to an existing explicit project.")]
|
||||
public static Task<MemoryItemDto> AddNote(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug. The project must already exist.")] string project,
|
||||
[Description("Required note subject.")] string subject,
|
||||
[Description("Required note body.")] string body,
|
||||
[Description("Optional tags for filtering and recall.")] string[]? tags = null,
|
||||
[Description("Optional item status. Defaults to active.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.AddNoteAsync(new AddNoteRequest(project, subject, body, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "FindItems", UseStructuredContent = true, ReadOnly = true, Destructive = false)]
|
||||
[Description("Find memory items with hybrid semantic and keyword search.")]
|
||||
public static Task<IReadOnlyList<SearchResultDto>> FindItems(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Natural language search query.")] string query,
|
||||
[Description("One to four natural language search queries. Each query is embedded and searched separately.")] string[] queries,
|
||||
[Description("Project name or slug. Required unless searchAllProjects is true.")] string? project = null,
|
||||
[Description("Set true to search across all projects.")] bool searchAllProjects = false,
|
||||
[Description("Optional category filter: requirement, location, todo, note.")] string? category = null,
|
||||
[Description("Maximum number of results, from 1 to 50.")] int limit = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.FindItemsAsync(new FindMemoryItemsRequest(query, project, searchAllProjects, category, limit), cancellationToken);
|
||||
return memory.FindItemsAsync(new FindMemoryItemsRequest(queries, project, searchAllProjects, category, limit), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "UpdateItem", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Update an existing memory item. Content-related changes automatically regenerate the embedding.")]
|
||||
public static Task<MemoryItemDto> UpdateItem(
|
||||
[McpServerTool(Name = "ListItemEmbeddings", UseStructuredContent = true, ReadOnly = true, Destructive = false)]
|
||||
[Description("List default and additional embeddings for an existing memory item.")]
|
||||
public static Task<IReadOnlyList<MemoryItemEmbeddingDto>> ListItemEmbeddings(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid itemId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.ListItemEmbeddingsAsync(new ListItemEmbeddingsRequest(project, itemId), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "AddItemEmbedding", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Add an additional discoverability embedding to an existing memory item. The canonical item content is unchanged.")]
|
||||
public static Task<MemoryItemEmbeddingDto> AddItemEmbedding(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid itemId,
|
||||
[Description("Required text to embed for this specific discoverability aspect.")] string text,
|
||||
[Description("Optional short label for what this embedding targets.")] string? label = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.AddItemEmbeddingAsync(new AddItemEmbeddingRequest(project, itemId, label, text), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "UpdateItemEmbedding", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Update an additional item embedding. The default embedding cannot be edited directly.")]
|
||||
public static Task<MemoryItemEmbeddingDto> UpdateItemEmbedding(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid itemId,
|
||||
[Description("Embedding id from ListItemEmbeddings.")] Guid embeddingId,
|
||||
[Description("Replacement label. Pass an empty string to clear.")] string? label = null,
|
||||
[Description("Replacement text to embed.")] string? text = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.UpdateItemEmbeddingAsync(new UpdateItemEmbeddingRequest(project, itemId, embeddingId, label, text), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "DeleteItemEmbedding", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Delete an additional item embedding. The default embedding cannot be deleted.")]
|
||||
public static Task<MemoryItemEmbeddingDto> DeleteItemEmbedding(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid itemId,
|
||||
[Description("Embedding id from ListItemEmbeddings.")] Guid embeddingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.DeleteItemEmbeddingAsync(new DeleteItemEmbeddingRequest(project, itemId, embeddingId), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "UpdateRequirement", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Update an existing requirement item. Content-related changes automatically regenerate the embedding.")]
|
||||
public static Task<MemoryItemDto> UpdateRequirement(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid id,
|
||||
[Description("Optional new category: requirement, location, todo, note.")] string? category = null,
|
||||
[Description("Optional new title.")] string? title = null,
|
||||
[Description("Optional new content.")] string? content = null,
|
||||
[Description("Replacement requirement, decision, or constraint statement.")] string? statement = null,
|
||||
[Description("Replacement context explaining why this requirement matters.")] string? context = null,
|
||||
[Description("Optional replacement tag list.")] string[]? tags = null,
|
||||
[Description("Optional replacement JSON metadata object.")] string? metadataJson = null,
|
||||
[Description("Optional new status.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.UpdateItemAsync(new UpdateMemoryItemRequest(project, id, category, title, content, tags, metadataJson, status), cancellationToken);
|
||||
return memory.UpdateRequirementAsync(new UpdateRequirementRequest(project, id, statement, context, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "UpdateLocation", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Update an existing location item. Content-related changes automatically regenerate the embedding.")]
|
||||
public static Task<MemoryItemDto> UpdateLocation(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid id,
|
||||
[Description("Replacement object, file, or resource name.")] string? itemName = null,
|
||||
[Description("Replacement place where the item is located.")] string? place = null,
|
||||
[Description("Replacement extra location details. Pass an empty string to clear.")] string? details = null,
|
||||
[Description("Optional replacement tag list.")] string[]? tags = null,
|
||||
[Description("Optional new status.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.UpdateLocationAsync(new UpdateLocationRequest(project, id, itemName, place, details, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "UpdateTodo", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Update an existing todo item. Content-related changes automatically regenerate the embedding.")]
|
||||
public static Task<MemoryItemDto> UpdateTodo(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid id,
|
||||
[Description("Replacement action to complete.")] string? task = null,
|
||||
[Description("Replacement priority.")] string? priority = null,
|
||||
[Description("Replacement due timestamp.")] DateTimeOffset? dueAt = null,
|
||||
[Description("Set true to clear the due timestamp.")] bool clearDueAt = false,
|
||||
[Description("Replacement extra task details. Pass an empty string to clear.")] string? details = null,
|
||||
[Description("Optional replacement tag list.")] string[]? tags = null,
|
||||
[Description("Optional new status.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.UpdateTodoAsync(new UpdateTodoRequest(project, id, task, priority, dueAt, clearDueAt, details, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "UpdateNote", UseStructuredContent = true, Destructive = true)]
|
||||
[Description("Update an existing note item. Content-related changes automatically regenerate the embedding.")]
|
||||
public static Task<MemoryItemDto> UpdateNote(
|
||||
ICortexMemoryService memory,
|
||||
[Description("Existing project name or slug.")] string project,
|
||||
[Description("Memory item id from find results.")] Guid id,
|
||||
[Description("Replacement note subject.")] string? subject = null,
|
||||
[Description("Replacement note body.")] string? body = null,
|
||||
[Description("Optional replacement tag list.")] string[]? tags = null,
|
||||
[Description("Optional new status.")] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return memory.UpdateNoteAsync(new UpdateNoteRequest(project, id, subject, body, tags, status), cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "DeleteItem", UseStructuredContent = true, Destructive = true)]
|
||||
|
||||
@@ -11,11 +11,12 @@ public sealed record MemoryItemDto(
|
||||
string Title,
|
||||
string Content,
|
||||
string[] Tags,
|
||||
string MetadataJson,
|
||||
string Status,
|
||||
string? EmbeddingModel,
|
||||
string? DefaultEmbeddingModel,
|
||||
int EmbeddingCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
DateTimeOffset UpdatedAt,
|
||||
MemoryItemDetailsDto Details);
|
||||
|
||||
public sealed record SearchResultDto(
|
||||
Guid Id,
|
||||
@@ -24,7 +25,33 @@ public sealed record SearchResultDto(
|
||||
string Title,
|
||||
string Content,
|
||||
string[] Tags,
|
||||
string MetadataJson,
|
||||
string Status,
|
||||
double Score,
|
||||
int MatchedQueryIndex,
|
||||
int MatchedEmbeddingPosition,
|
||||
string? MatchedEmbeddingLabel,
|
||||
DateTimeOffset UpdatedAt,
|
||||
MemoryItemDetailsDto Details);
|
||||
|
||||
public sealed record MemoryItemEmbeddingDto(
|
||||
Guid Id,
|
||||
Guid MemoryItemId,
|
||||
int Position,
|
||||
string? Label,
|
||||
string Text,
|
||||
string? EmbeddingModel,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record MemoryItemDetailsDto(
|
||||
string? RequirementStatement = null,
|
||||
string? RequirementContext = null,
|
||||
string? LocationItemName = null,
|
||||
string? LocationPlace = null,
|
||||
string? LocationDetails = null,
|
||||
string? TodoTask = null,
|
||||
string? TodoPriority = null,
|
||||
DateTimeOffset? TodoDueAt = null,
|
||||
string? TodoDetails = null,
|
||||
string? NoteSubject = null,
|
||||
string? NoteBody = null);
|
||||
|
||||
@@ -7,6 +7,11 @@ public sealed class CortexDbContext(DbContextOptions<CortexDbContext> options) :
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<MemoryItem> MemoryItems => Set<MemoryItem>();
|
||||
public DbSet<MemoryItemEmbedding> MemoryItemEmbeddings => Set<MemoryItemEmbedding>();
|
||||
public DbSet<RequirementMemoryItem> RequirementMemoryItems => Set<RequirementMemoryItem>();
|
||||
public DbSet<LocationMemoryItem> LocationMemoryItems => Set<LocationMemoryItem>();
|
||||
public DbSet<TodoMemoryItem> TodoMemoryItems => Set<TodoMemoryItem>();
|
||||
public DbSet<NoteMemoryItem> NoteMemoryItems => Set<NoteMemoryItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -34,9 +39,7 @@ public sealed class CortexDbContext(DbContextOptions<CortexDbContext> options) :
|
||||
entity.Property(item => item.Title).HasColumnName("title").HasMaxLength(240).IsRequired();
|
||||
entity.Property(item => item.Content).HasColumnName("content").IsRequired();
|
||||
entity.Property(item => item.Tags).HasColumnName("tags").HasColumnType("text[]");
|
||||
entity.Property(item => item.MetadataJson).HasColumnName("metadata").HasColumnType("jsonb");
|
||||
entity.Property(item => item.Status).HasColumnName("status").HasMaxLength(40);
|
||||
entity.Property(item => item.EmbeddingModel).HasColumnName("embedding_model").HasMaxLength(120);
|
||||
entity.Property(item => item.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(item => item.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.Property(item => item.DeletedAt).HasColumnName("deleted_at");
|
||||
@@ -44,5 +47,57 @@ public sealed class CortexDbContext(DbContextOptions<CortexDbContext> options) :
|
||||
entity.HasIndex(item => new { item.ProjectId, item.Category });
|
||||
entity.HasIndex(item => item.DeletedAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MemoryItemEmbedding>(entity =>
|
||||
{
|
||||
entity.ToTable("memory_item_embeddings", table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_memory_item_embeddings_position_non_negative", "position >= 0");
|
||||
});
|
||||
entity.HasKey(embedding => embedding.Id);
|
||||
entity.Property(embedding => embedding.Id).HasColumnName("id");
|
||||
entity.Property(embedding => embedding.MemoryItemId).HasColumnName("memory_item_id");
|
||||
entity.Property(embedding => embedding.Position).HasColumnName("position");
|
||||
entity.Property(embedding => embedding.Label).HasColumnName("label").HasMaxLength(120);
|
||||
entity.Property(embedding => embedding.Text).HasColumnName("text").IsRequired();
|
||||
entity.Property(embedding => embedding.EmbeddingModel).HasColumnName("embedding_model").HasMaxLength(120);
|
||||
entity.Property(embedding => embedding.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(embedding => embedding.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.HasOne(embedding => embedding.MemoryItem)
|
||||
.WithMany(item => item.Embeddings)
|
||||
.HasForeignKey(embedding => embedding.MemoryItemId);
|
||||
entity.HasIndex(embedding => new { embedding.MemoryItemId, embedding.Position }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RequirementMemoryItem>(entity =>
|
||||
{
|
||||
entity.ToTable("requirement_memory_items");
|
||||
entity.Property(item => item.Statement).HasColumnName("statement").IsRequired();
|
||||
entity.Property(item => item.Context).HasColumnName("context").IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LocationMemoryItem>(entity =>
|
||||
{
|
||||
entity.ToTable("location_memory_items");
|
||||
entity.Property(item => item.ItemName).HasColumnName("item_name").HasMaxLength(240).IsRequired();
|
||||
entity.Property(item => item.Place).HasColumnName("place").HasMaxLength(400).IsRequired();
|
||||
entity.Property(item => item.Details).HasColumnName("details");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TodoMemoryItem>(entity =>
|
||||
{
|
||||
entity.ToTable("todo_memory_items");
|
||||
entity.Property(item => item.Task).HasColumnName("task").HasMaxLength(240).IsRequired();
|
||||
entity.Property(item => item.Priority).HasColumnName("priority").HasMaxLength(40).IsRequired();
|
||||
entity.Property(item => item.DueAt).HasColumnName("due_at");
|
||||
entity.Property(item => item.Details).HasColumnName("details");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<NoteMemoryItem>(entity =>
|
||||
{
|
||||
entity.ToTable("note_memory_items");
|
||||
entity.Property(item => item.Subject).HasColumnName("subject").HasMaxLength(240).IsRequired();
|
||||
entity.Property(item => item.Body).HasColumnName("body").IsRequired();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Cortex.Core.Data;
|
||||
|
||||
public sealed class CortexDbContextFactory : IDesignTimeDbContextFactory<CortexDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=54329;Database=cortex;Username=cortex;Password=cortex_dev_password;GSS Encryption Mode=Disable";
|
||||
|
||||
public CortexDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Cortex")
|
||||
?? Environment.GetEnvironmentVariable("CORTEX_CONNECTION_STRING")
|
||||
?? DefaultConnectionString;
|
||||
|
||||
var options = new DbContextOptionsBuilder<CortexDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new CortexDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -28,25 +28,21 @@ public static class SeedData
|
||||
return;
|
||||
}
|
||||
|
||||
await memory.AddItemAsync(
|
||||
new AddMemoryItemRequest(
|
||||
await memory.AddRequirementAsync(
|
||||
new AddRequirementRequest(
|
||||
"cortex",
|
||||
"requirement",
|
||||
"Cortex runs as an HTTP MCP server",
|
||||
"The Cortex MCP server is a .NET 10 ASP.NET Core app hosted by Kestrel and deployed locally with Docker.",
|
||||
["mcp", "dotnet", "docker"],
|
||||
"""{"source":"seed"}""",
|
||||
"active"),
|
||||
cancellationToken);
|
||||
|
||||
await memory.AddItemAsync(
|
||||
new AddMemoryItemRequest(
|
||||
await memory.AddNoteAsync(
|
||||
new AddNoteRequest(
|
||||
"cortex",
|
||||
"note",
|
||||
"Embedding model choice",
|
||||
"Use BAAI/bge-small-en-v1.5 through a local Hugging Face Text Embeddings Inference container. The vector size is 384.",
|
||||
["embeddings", "pgvector"],
|
||||
"""{"source":"seed"}""",
|
||||
"active"),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
namespace Cortex.Core.Domain;
|
||||
|
||||
public sealed class MemoryItem : ITimestampedEntity
|
||||
public abstract class MemoryItem : ITimestampedEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public MemoryCategory Category { get; set; }
|
||||
public MemoryCategory Category { get; protected set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public string[] Tags { get; set; } = [];
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? EmbeddingModel { get; set; }
|
||||
public ICollection<MemoryItemEmbedding> Embeddings { get; set; } = [];
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MemoryItemEmbedding : ITimestampedEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid MemoryItemId { get; set; }
|
||||
public MemoryItem? MemoryItem { get; set; }
|
||||
public int Position { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public required string Text { get; set; }
|
||||
public string? EmbeddingModel { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RequirementMemoryItem : MemoryItem
|
||||
{
|
||||
public RequirementMemoryItem()
|
||||
{
|
||||
Category = MemoryCategory.Requirement;
|
||||
}
|
||||
|
||||
public required string Statement { get; set; }
|
||||
public required string Context { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LocationMemoryItem : MemoryItem
|
||||
{
|
||||
public LocationMemoryItem()
|
||||
{
|
||||
Category = MemoryCategory.Location;
|
||||
}
|
||||
|
||||
public required string ItemName { get; set; }
|
||||
public required string Place { get; set; }
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TodoMemoryItem : MemoryItem
|
||||
{
|
||||
public TodoMemoryItem()
|
||||
{
|
||||
Category = MemoryCategory.Todo;
|
||||
}
|
||||
|
||||
public required string Task { get; set; }
|
||||
public required string Priority { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
public sealed class NoteMemoryItem : MemoryItem
|
||||
{
|
||||
public NoteMemoryItem()
|
||||
{
|
||||
Category = MemoryCategory.Note;
|
||||
}
|
||||
|
||||
public required string Subject { get; set; }
|
||||
public required string Body { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Cortex.Core.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cortex.Core.Migrations;
|
||||
|
||||
[DbContext(typeof(CortexDbContext))]
|
||||
[Migration("20260711153000_AddCategorySpecificMemoryItems")]
|
||||
public partial class AddCategorySpecificMemoryItems : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "requirement_memory_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
statement = table.Column<string>(type: "text", nullable: false),
|
||||
context = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_requirement_memory_items", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_requirement_memory_items_memory_items_id",
|
||||
column: x => x.id,
|
||||
principalTable: "memory_items",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "location_memory_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
item_name = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
place = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
|
||||
details = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_location_memory_items", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_location_memory_items_memory_items_id",
|
||||
column: x => x.id,
|
||||
principalTable: "memory_items",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "todo_memory_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
task = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
priority = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
due_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
details = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_todo_memory_items", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_todo_memory_items_memory_items_id",
|
||||
column: x => x.id,
|
||||
principalTable: "memory_items",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "note_memory_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
subject = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
body = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_note_memory_items", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_note_memory_items_memory_items_id",
|
||||
column: x => x.id,
|
||||
principalTable: "memory_items",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO requirement_memory_items (id, statement, context)
|
||||
SELECT id, title, content
|
||||
FROM memory_items
|
||||
WHERE category = 'Requirement';
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO location_memory_items (id, item_name, place, details)
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(NULLIF(metadata->>'object', ''), title),
|
||||
COALESCE(NULLIF(metadata->>'place', ''), content),
|
||||
NULLIF(content, '')
|
||||
FROM memory_items
|
||||
WHERE category = 'Location';
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO todo_memory_items (id, task, priority, details)
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
COALESCE(NULLIF(metadata->>'priority', ''), 'normal'),
|
||||
NULLIF(content, '')
|
||||
FROM memory_items
|
||||
WHERE category = 'Todo';
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO note_memory_items (id, subject, body)
|
||||
SELECT id, title, content
|
||||
FROM memory_items
|
||||
WHERE category = 'Note';
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "metadata",
|
||||
table: "memory_items");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "metadata",
|
||||
table: "memory_items",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValue: "{}");
|
||||
|
||||
migrationBuilder.DropTable(name: "location_memory_items");
|
||||
migrationBuilder.DropTable(name: "note_memory_items");
|
||||
migrationBuilder.DropTable(name: "requirement_memory_items");
|
||||
migrationBuilder.DropTable(name: "todo_memory_items");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Cortex.Core.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cortex.Core.Migrations;
|
||||
|
||||
[DbContext(typeof(CortexDbContext))]
|
||||
[Migration("20260711164500_AddMemoryItemEmbeddings")]
|
||||
public partial class AddMemoryItemEmbeddings : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "memory_item_embeddings",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
memory_item_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
position = table.Column<int>(type: "integer", nullable: false),
|
||||
label = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
text = table.Column<string>(type: "text", nullable: false),
|
||||
embedding_model = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_memory_item_embeddings", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_memory_item_embeddings_memory_items_memory_item_id",
|
||||
column: x => x.memory_item_id,
|
||||
principalTable: "memory_items",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.Sql("ALTER TABLE memory_item_embeddings ADD COLUMN embedding vector(384);");
|
||||
migrationBuilder.Sql("ALTER TABLE memory_item_embeddings ADD CONSTRAINT ck_memory_item_embeddings_position_non_negative CHECK (position >= 0);");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_memory_item_embeddings_memory_item_id_position",
|
||||
table: "memory_item_embeddings",
|
||||
columns: ["memory_item_id", "position"],
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO memory_item_embeddings (
|
||||
id,
|
||||
memory_item_id,
|
||||
position,
|
||||
label,
|
||||
text,
|
||||
embedding_model,
|
||||
created_at,
|
||||
updated_at)
|
||||
SELECT
|
||||
id,
|
||||
id,
|
||||
0,
|
||||
NULL,
|
||||
category || E'\n' || title || E'\n' || content || E'\nTags: ' || array_to_string(tags, ', '),
|
||||
embedding_model,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM memory_items;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE memory_item_embeddings mie
|
||||
SET embedding = mi.embedding
|
||||
FROM memory_items mi
|
||||
WHERE mie.memory_item_id = mi.id
|
||||
AND mie.position = 0;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("CREATE INDEX ix_memory_item_embeddings_embedding_hnsw ON memory_item_embeddings USING hnsw (embedding vector_cosine_ops);");
|
||||
migrationBuilder.Sql("DROP INDEX IF EXISTS ix_memory_items_embedding_hnsw;");
|
||||
migrationBuilder.Sql("ALTER TABLE memory_items DROP COLUMN embedding;");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "embedding_model",
|
||||
table: "memory_items");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "embedding_model",
|
||||
table: "memory_items",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql("ALTER TABLE memory_items ADD COLUMN embedding vector(384);");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE memory_items mi
|
||||
SET embedding_model = mie.embedding_model,
|
||||
embedding = mie.embedding
|
||||
FROM memory_item_embeddings mie
|
||||
WHERE mie.memory_item_id = mi.id
|
||||
AND mie.position = 0;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("CREATE INDEX ix_memory_items_embedding_hnsw ON memory_items USING hnsw (embedding vector_cosine_ops);");
|
||||
migrationBuilder.DropTable(name: "memory_item_embeddings");
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,327 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Cortex.Core.Data;
|
||||
using Cortex.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cortex.Core.Migrations;
|
||||
|
||||
[DbContext(typeof(CortexDbContext))]
|
||||
public sealed class CortexDbContextModelSnapshot : ModelSnapshot
|
||||
namespace Cortex.Core.Migrations
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
[DbContext(typeof(CortexDbContext))]
|
||||
partial class CortexDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasPostgresExtension("vector");
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.Project", entity =>
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
entity.Property<Guid>("Id").HasColumnName("id").HasColumnType("uuid");
|
||||
entity.Property<DateTimeOffset>("CreatedAt").HasColumnName("created_at").HasColumnType("timestamp with time zone");
|
||||
entity.Property<string>("Name").IsRequired().HasMaxLength(160).HasColumnName("name").HasColumnType("character varying(160)");
|
||||
entity.Property<string>("Slug").IsRequired().HasMaxLength(180).HasColumnName("slug").HasColumnType("character varying(180)");
|
||||
entity.Property<DateTimeOffset>("UpdatedAt").HasColumnName("updated_at").HasColumnType("timestamp with time zone");
|
||||
entity.HasKey("Id");
|
||||
entity.HasIndex("Slug").IsUnique();
|
||||
entity.ToTable("projects");
|
||||
});
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.MemoryItem", entity =>
|
||||
{
|
||||
entity.Property<Guid>("Id").HasColumnName("id").HasColumnType("uuid");
|
||||
entity.Property<MemoryCategory>("Category").HasColumnName("category").HasMaxLength(40).HasConversion<string>();
|
||||
entity.Property<string>("Content").IsRequired().HasColumnName("content").HasColumnType("text");
|
||||
entity.Property<DateTimeOffset>("CreatedAt").HasColumnName("created_at").HasColumnType("timestamp with time zone");
|
||||
entity.Property<DateTimeOffset?>("DeletedAt").HasColumnName("deleted_at").HasColumnType("timestamp with time zone");
|
||||
entity.Property<string>("EmbeddingModel").HasColumnName("embedding_model").HasMaxLength(120).HasColumnType("character varying(120)");
|
||||
entity.Property<string>("MetadataJson").IsRequired().HasColumnName("metadata").HasColumnType("jsonb");
|
||||
entity.Property<Guid>("ProjectId").HasColumnName("project_id").HasColumnType("uuid");
|
||||
entity.Property<string>("Status").IsRequired().HasColumnName("status").HasMaxLength(40).HasColumnType("character varying(40)");
|
||||
entity.Property<string[]>("Tags").IsRequired().HasColumnName("tags").HasColumnType("text[]");
|
||||
entity.Property<string>("Title").IsRequired().HasColumnName("title").HasMaxLength(240).HasColumnType("character varying(240)");
|
||||
entity.Property<DateTimeOffset>("UpdatedAt").HasColumnName("updated_at").HasColumnType("timestamp with time zone");
|
||||
entity.HasKey("Id");
|
||||
entity.HasIndex("DeletedAt");
|
||||
entity.HasIndex("ProjectId", "Category");
|
||||
entity.HasOne("Cortex.Core.Domain.Project", "Project").WithMany("Items").HasForeignKey("ProjectId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
entity.Navigation("Project");
|
||||
entity.ToTable("memory_items");
|
||||
});
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.Project", entity =>
|
||||
{
|
||||
entity.Navigation("Items");
|
||||
});
|
||||
modelBuilder.Entity("Cortex.Core.Domain.MemoryItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("category");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeletedAt");
|
||||
|
||||
b.HasIndex("ProjectId", "Category");
|
||||
|
||||
b.ToTable("memory_items", (string)null);
|
||||
|
||||
b.UseTptMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.MemoryItemEmbedding", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EmbeddingModel")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)")
|
||||
.HasColumnName("embedding_model");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)")
|
||||
.HasColumnName("label");
|
||||
|
||||
b.Property<Guid>("MemoryItemId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("memory_item_id");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("position");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MemoryItemId", "Position")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("memory_item_embeddings", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_memory_item_embeddings_position_non_negative", "position >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(180)
|
||||
.HasColumnType("character varying(180)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.LocationMemoryItem", b =>
|
||||
{
|
||||
b.HasBaseType("Cortex.Core.Domain.MemoryItem");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<string>("ItemName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)")
|
||||
.HasColumnName("item_name");
|
||||
|
||||
b.Property<string>("Place")
|
||||
.IsRequired()
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("character varying(400)")
|
||||
.HasColumnName("place");
|
||||
|
||||
b.ToTable("location_memory_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.NoteMemoryItem", b =>
|
||||
{
|
||||
b.HasBaseType("Cortex.Core.Domain.MemoryItem");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("body");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.ToTable("note_memory_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.RequirementMemoryItem", b =>
|
||||
{
|
||||
b.HasBaseType("Cortex.Core.Domain.MemoryItem");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("context");
|
||||
|
||||
b.Property<string>("Statement")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("statement");
|
||||
|
||||
b.ToTable("requirement_memory_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.TodoMemoryItem", b =>
|
||||
{
|
||||
b.HasBaseType("Cortex.Core.Domain.MemoryItem");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("due_at");
|
||||
|
||||
b.Property<string>("Priority")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("Task")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)")
|
||||
.HasColumnName("task");
|
||||
|
||||
b.ToTable("todo_memory_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.MemoryItem", b =>
|
||||
{
|
||||
b.HasOne("Cortex.Core.Domain.Project", "Project")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.MemoryItemEmbedding", b =>
|
||||
{
|
||||
b.HasOne("Cortex.Core.Domain.MemoryItem", "MemoryItem")
|
||||
.WithMany("Embeddings")
|
||||
.HasForeignKey("MemoryItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MemoryItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.LocationMemoryItem", b =>
|
||||
{
|
||||
b.HasOne("Cortex.Core.Domain.MemoryItem", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Cortex.Core.Domain.LocationMemoryItem", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.NoteMemoryItem", b =>
|
||||
{
|
||||
b.HasOne("Cortex.Core.Domain.MemoryItem", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Cortex.Core.Domain.NoteMemoryItem", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.RequirementMemoryItem", b =>
|
||||
{
|
||||
b.HasOne("Cortex.Core.Domain.MemoryItem", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Cortex.Core.Domain.RequirementMemoryItem", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.TodoMemoryItem", b =>
|
||||
{
|
||||
b.HasOne("Cortex.Core.Domain.MemoryItem", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Cortex.Core.Domain.TodoMemoryItem", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.MemoryItem", b =>
|
||||
{
|
||||
b.Navigation("Embeddings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cortex.Core.Domain.Project", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using System.Data.Common;
|
||||
using Cortex.Core.Contracts;
|
||||
using Cortex.Core.Data;
|
||||
using Cortex.Core.Domain;
|
||||
@@ -18,6 +18,8 @@ public sealed class CortexMemoryService(
|
||||
IOptions<CortexOptions> options,
|
||||
IConfiguration configuration) : ICortexMemoryService
|
||||
{
|
||||
private const int MaxTitleLength = 240;
|
||||
private const int MaxSearchQueries = 4;
|
||||
private readonly CortexOptions _options = options.Value;
|
||||
private readonly string _connectionString = configuration.GetConnectionString("Cortex")
|
||||
?? throw new InvalidOperationException("Connection string 'Cortex' is missing.");
|
||||
@@ -59,32 +61,93 @@ public sealed class CortexMemoryService(
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> AddItemAsync(AddMemoryItemRequest request, CancellationToken cancellationToken)
|
||||
public async Task<MemoryItemDto> AddRequirementAsync(AddRequirementRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var category = ParseCategory(request.Category);
|
||||
var metadataJson = NormalizeMetadata(request.MetadataJson);
|
||||
var tags = NormalizeTags(request.Tags);
|
||||
var statement = Required(request.Statement, nameof(request.Statement));
|
||||
var context = Required(request.Context, nameof(request.Context));
|
||||
|
||||
var item = new MemoryItem
|
||||
var item = new RequirementMemoryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
Category = category,
|
||||
Title = request.Title.Trim(),
|
||||
Content = request.Content.Trim(),
|
||||
Tags = tags,
|
||||
MetadataJson = metadataJson,
|
||||
Status = string.IsNullOrWhiteSpace(request.Status) ? "active" : request.Status.Trim(),
|
||||
EmbeddingModel = _options.EmbeddingModel
|
||||
Statement = statement,
|
||||
Context = context,
|
||||
Title = TitleFrom(statement),
|
||||
Content = context,
|
||||
Tags = NormalizeTags(request.Tags),
|
||||
Status = NormalizeStatus(request.Status)
|
||||
};
|
||||
|
||||
db.MemoryItems.Add(item);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await UpdateEmbeddingAsync(item, cancellationToken);
|
||||
return await AddAndEmbedAsync(item, project, cancellationToken);
|
||||
}
|
||||
|
||||
item.Project = project;
|
||||
return ToDto(item);
|
||||
public async Task<MemoryItemDto> AddLocationAsync(AddLocationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var itemName = Required(request.ItemName, nameof(request.ItemName));
|
||||
var place = Required(request.Place, nameof(request.Place));
|
||||
var details = Optional(request.Details);
|
||||
|
||||
var item = new LocationMemoryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
ItemName = itemName,
|
||||
Place = place,
|
||||
Details = details,
|
||||
Title = TitleFrom(itemName),
|
||||
Content = BuildLocationContent(itemName, place, details),
|
||||
Tags = NormalizeTags(request.Tags),
|
||||
Status = NormalizeStatus(request.Status)
|
||||
};
|
||||
|
||||
return await AddAndEmbedAsync(item, project, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> AddTodoAsync(AddTodoRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var task = Required(request.Task, nameof(request.Task));
|
||||
var priority = Required(request.Priority, nameof(request.Priority));
|
||||
var details = Optional(request.Details);
|
||||
|
||||
var item = new TodoMemoryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
Task = task,
|
||||
Priority = priority,
|
||||
DueAt = request.DueAt,
|
||||
Details = details,
|
||||
Title = TitleFrom(task),
|
||||
Content = BuildTodoContent(task, priority, request.DueAt, details),
|
||||
Tags = NormalizeTags(request.Tags),
|
||||
Status = NormalizeStatus(request.Status)
|
||||
};
|
||||
|
||||
return await AddAndEmbedAsync(item, project, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> AddNoteAsync(AddNoteRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var subject = Required(request.Subject, nameof(request.Subject));
|
||||
var body = Required(request.Body, nameof(request.Body));
|
||||
|
||||
var item = new NoteMemoryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
Subject = subject,
|
||||
Body = body,
|
||||
Title = TitleFrom(subject),
|
||||
Content = body,
|
||||
Tags = NormalizeTags(request.Tags),
|
||||
Status = NormalizeStatus(request.Status)
|
||||
};
|
||||
|
||||
return await AddAndEmbedAsync(item, project, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchResultDto>> FindItemsAsync(FindMemoryItemsRequest request, CancellationToken cancellationToken)
|
||||
@@ -94,44 +157,109 @@ public sealed class CortexMemoryService(
|
||||
throw new ArgumentException("Project is required unless search_all_projects is true.", nameof(request));
|
||||
}
|
||||
|
||||
var queries = NormalizeQueries(request.Queries);
|
||||
var limit = Math.Clamp(request.Limit, 1, 50);
|
||||
var queryEmbedding = VectorLiteral.From(await embeddings.EmbedAsync(request.Query, cancellationToken));
|
||||
var projectSlug = string.IsNullOrWhiteSpace(request.Project) ? null : Slug.From(request.Project);
|
||||
var category = string.IsNullOrWhiteSpace(request.Category) ? null : ParseCategory(request.Category).ToString();
|
||||
var queryRows = string.Join(", ", queries.Select((_, index) => $"(@query{index}, @embedding{index}, {index})"));
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
command.CommandText = $"""
|
||||
WITH query_inputs(query_text, embedding_text, query_index) AS (
|
||||
VALUES {queryRows}
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
mi.id,
|
||||
p.slug AS project_slug,
|
||||
mi.category,
|
||||
mi.title,
|
||||
mi.content,
|
||||
mi.tags,
|
||||
mi.status,
|
||||
(
|
||||
COALESCE(ts_rank_cd(to_tsvector('simple', mi.title || ' ' || mi.content), plainto_tsquery('simple', qi.query_text)), 0) * 0.35
|
||||
+ CASE
|
||||
WHEN mie.embedding IS NULL THEN 0
|
||||
ELSE (1 - (mie.embedding <=> CAST(qi.embedding_text AS vector))) * 0.65
|
||||
END
|
||||
)::double precision AS score,
|
||||
qi.query_index,
|
||||
mie.position AS embedding_position,
|
||||
mie.label AS embedding_label,
|
||||
mi.updated_at,
|
||||
req.statement,
|
||||
req.context,
|
||||
loc.item_name,
|
||||
loc.place,
|
||||
loc.details,
|
||||
todo.task,
|
||||
todo.priority,
|
||||
todo.due_at,
|
||||
todo.details AS todo_details,
|
||||
note.subject,
|
||||
note.body
|
||||
FROM memory_items mi
|
||||
INNER JOIN projects p ON p.id = mi.project_id
|
||||
INNER JOIN memory_item_embeddings mie ON mie.memory_item_id = mi.id
|
||||
CROSS JOIN query_inputs qi
|
||||
LEFT JOIN requirement_memory_items req ON req.id = mi.id
|
||||
LEFT JOIN location_memory_items loc ON loc.id = mi.id
|
||||
LEFT JOIN todo_memory_items todo ON todo.id = mi.id
|
||||
LEFT JOIN note_memory_items note ON note.id = mi.id
|
||||
WHERE mi.deleted_at IS NULL
|
||||
AND (@search_all_projects OR p.slug = @project_slug)
|
||||
AND (@category IS NULL OR mi.category = @category)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY id
|
||||
ORDER BY score DESC, embedding_position ASC, query_index ASC
|
||||
) AS match_rank
|
||||
FROM scored
|
||||
)
|
||||
SELECT
|
||||
mi.id,
|
||||
p.slug AS project_slug,
|
||||
mi.category,
|
||||
mi.title,
|
||||
mi.content,
|
||||
mi.tags,
|
||||
mi.metadata::text,
|
||||
mi.status,
|
||||
(
|
||||
COALESCE(ts_rank_cd(to_tsvector('simple', mi.title || ' ' || mi.content), plainto_tsquery('simple', @query)), 0) * 0.35
|
||||
+ CASE
|
||||
WHEN mi.embedding IS NULL THEN 0
|
||||
ELSE (1 - (mi.embedding <=> CAST(@embedding AS vector))) * 0.65
|
||||
END
|
||||
)::double precision AS score,
|
||||
mi.updated_at
|
||||
FROM memory_items mi
|
||||
INNER JOIN projects p ON p.id = mi.project_id
|
||||
WHERE mi.deleted_at IS NULL
|
||||
AND (@search_all_projects OR p.slug = @project_slug)
|
||||
AND (@category IS NULL OR mi.category = @category)
|
||||
ORDER BY score DESC, mi.updated_at DESC
|
||||
id,
|
||||
project_slug,
|
||||
category,
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
status,
|
||||
score,
|
||||
query_index,
|
||||
embedding_position,
|
||||
embedding_label,
|
||||
updated_at,
|
||||
statement,
|
||||
context,
|
||||
item_name,
|
||||
place,
|
||||
details,
|
||||
task,
|
||||
priority,
|
||||
due_at,
|
||||
todo_details,
|
||||
subject,
|
||||
body
|
||||
FROM ranked
|
||||
WHERE match_rank = 1
|
||||
ORDER BY score DESC, updated_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
command.Parameters.Add("query", NpgsqlDbType.Text).Value = request.Query;
|
||||
command.Parameters.Add("embedding", NpgsqlDbType.Text).Value = queryEmbedding;
|
||||
for (var index = 0; index < queries.Length; index++)
|
||||
{
|
||||
command.Parameters.Add($"query{index}", NpgsqlDbType.Text).Value = queries[index];
|
||||
command.Parameters.Add($"embedding{index}", NpgsqlDbType.Text).Value =
|
||||
VectorLiteral.From(await embeddings.EmbedAsync(queries[index], cancellationToken));
|
||||
}
|
||||
|
||||
command.Parameters.Add("search_all_projects", NpgsqlDbType.Boolean).Value = request.SearchAllProjects;
|
||||
command.Parameters.Add("project_slug", NpgsqlDbType.Text).Value = (object?)projectSlug ?? DBNull.Value;
|
||||
command.Parameters.Add("category", NpgsqlDbType.Text).Value = (object?)category ?? DBNull.Value;
|
||||
@@ -150,81 +278,285 @@ public sealed class CortexMemoryService(
|
||||
reader.GetString(4),
|
||||
reader.GetFieldValue<string[]>(5),
|
||||
reader.GetString(6),
|
||||
reader.GetString(7),
|
||||
reader.GetDouble(8),
|
||||
reader.GetFieldValue<DateTimeOffset>(9)));
|
||||
reader.GetDouble(7),
|
||||
reader.GetInt32(8),
|
||||
reader.GetInt32(9),
|
||||
NullableString(reader, 10),
|
||||
reader.GetFieldValue<DateTimeOffset>(11),
|
||||
new MemoryItemDetailsDto(
|
||||
NullableString(reader, 12),
|
||||
NullableString(reader, 13),
|
||||
NullableString(reader, 14),
|
||||
NullableString(reader, 15),
|
||||
NullableString(reader, 16),
|
||||
NullableString(reader, 17),
|
||||
NullableString(reader, 18),
|
||||
NullableDateTimeOffset(reader, 19),
|
||||
NullableString(reader, 20),
|
||||
NullableString(reader, 21),
|
||||
NullableString(reader, 22))));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> UpdateItemAsync(UpdateMemoryItemRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<MemoryItemEmbeddingDto>> ListItemEmbeddingsAsync(
|
||||
ListItemEmbeddingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var item = await FindActiveItemAsync(project.Id, request.Id, cancellationToken);
|
||||
_ = await FindActiveItemAsync<MemoryItem>(project.Id, request.ItemId, cancellationToken);
|
||||
|
||||
return await db.MemoryItemEmbeddings
|
||||
.Where(embedding => embedding.MemoryItemId == request.ItemId)
|
||||
.OrderBy(embedding => embedding.Position)
|
||||
.Select(embedding => ToDto(embedding))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemEmbeddingDto> AddItemEmbeddingAsync(
|
||||
AddItemEmbeddingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
_ = await FindActiveItemAsync<MemoryItem>(project.Id, request.ItemId, cancellationToken);
|
||||
var text = Required(request.Text, nameof(request.Text));
|
||||
|
||||
var maxPosition = await db.MemoryItemEmbeddings
|
||||
.Where(embedding => embedding.MemoryItemId == request.ItemId)
|
||||
.Select(embedding => (int?)embedding.Position)
|
||||
.MaxAsync(cancellationToken) ?? 0;
|
||||
|
||||
var embedding = new MemoryItemEmbedding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MemoryItemId = request.ItemId,
|
||||
Position = Math.Max(1, maxPosition + 1),
|
||||
Label = Optional(request.Label),
|
||||
Text = text,
|
||||
EmbeddingModel = _options.EmbeddingModel
|
||||
};
|
||||
|
||||
db.MemoryItemEmbeddings.Add(embedding);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await UpdateEmbeddingVectorAsync(embedding, text, cancellationToken);
|
||||
|
||||
return ToDto(embedding);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemEmbeddingDto> UpdateItemEmbeddingAsync(
|
||||
UpdateItemEmbeddingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
_ = await FindActiveItemAsync<MemoryItem>(project.Id, request.ItemId, cancellationToken);
|
||||
var embedding = await FindItemEmbeddingAsync(request.ItemId, request.EmbeddingId, cancellationToken);
|
||||
|
||||
if (embedding.Position == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The default embedding is generated from the item and cannot be edited directly.");
|
||||
}
|
||||
|
||||
var shouldRegenerateEmbedding = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Category))
|
||||
if (request.Label is not null)
|
||||
{
|
||||
item.Category = ParseCategory(request.Category);
|
||||
embedding.Label = Optional(request.Label);
|
||||
}
|
||||
|
||||
if (request.Text is not null)
|
||||
{
|
||||
embedding.Text = Required(request.Text, nameof(request.Text));
|
||||
embedding.EmbeddingModel = _options.EmbeddingModel;
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
item.Title = request.Title.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
item.Content = request.Content.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (request.Tags is not null)
|
||||
{
|
||||
item.Tags = NormalizeTags(request.Tags);
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (request.MetadataJson is not null)
|
||||
{
|
||||
item.MetadataJson = NormalizeMetadata(request.MetadataJson);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
item.Status = request.Status.Trim();
|
||||
}
|
||||
|
||||
if (shouldRegenerateEmbedding)
|
||||
{
|
||||
item.EmbeddingModel = _options.EmbeddingModel;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (shouldRegenerateEmbedding)
|
||||
{
|
||||
await UpdateEmbeddingAsync(item, cancellationToken);
|
||||
await UpdateEmbeddingVectorAsync(embedding, embedding.Text, cancellationToken);
|
||||
}
|
||||
|
||||
item.Project = project;
|
||||
return ToDto(item);
|
||||
return ToDto(embedding);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemEmbeddingDto> DeleteItemEmbeddingAsync(
|
||||
DeleteItemEmbeddingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
_ = await FindActiveItemAsync<MemoryItem>(project.Id, request.ItemId, cancellationToken);
|
||||
var embedding = await FindItemEmbeddingAsync(request.ItemId, request.EmbeddingId, cancellationToken);
|
||||
|
||||
if (embedding.Position == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The default embedding cannot be deleted.");
|
||||
}
|
||||
|
||||
db.MemoryItemEmbeddings.Remove(embedding);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(embedding);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> UpdateRequirementAsync(UpdateRequirementRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var item = await FindActiveItemAsync<RequirementMemoryItem>(project.Id, request.Id, cancellationToken);
|
||||
var shouldRegenerateEmbedding = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Statement))
|
||||
{
|
||||
item.Statement = request.Statement.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Context))
|
||||
{
|
||||
item.Context = request.Context.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
item.Title = TitleFrom(item.Statement);
|
||||
item.Content = item.Context;
|
||||
shouldRegenerateEmbedding |= ApplyCommonUpdates(item, request.Tags, request.Status);
|
||||
|
||||
return await SaveUpdatedAsync(item, project, shouldRegenerateEmbedding, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> UpdateLocationAsync(UpdateLocationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var item = await FindActiveItemAsync<LocationMemoryItem>(project.Id, request.Id, cancellationToken);
|
||||
var shouldRegenerateEmbedding = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ItemName))
|
||||
{
|
||||
item.ItemName = request.ItemName.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Place))
|
||||
{
|
||||
item.Place = request.Place.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (request.Details is not null)
|
||||
{
|
||||
item.Details = Optional(request.Details);
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
item.Title = TitleFrom(item.ItemName);
|
||||
item.Content = BuildLocationContent(item.ItemName, item.Place, item.Details);
|
||||
shouldRegenerateEmbedding |= ApplyCommonUpdates(item, request.Tags, request.Status);
|
||||
|
||||
return await SaveUpdatedAsync(item, project, shouldRegenerateEmbedding, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> UpdateTodoAsync(UpdateTodoRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var item = await FindActiveItemAsync<TodoMemoryItem>(project.Id, request.Id, cancellationToken);
|
||||
var shouldRegenerateEmbedding = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Task))
|
||||
{
|
||||
item.Task = request.Task.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||
{
|
||||
item.Priority = request.Priority.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (request.ClearDueAt)
|
||||
{
|
||||
item.DueAt = null;
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
else if (request.DueAt is not null)
|
||||
{
|
||||
item.DueAt = request.DueAt;
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (request.Details is not null)
|
||||
{
|
||||
item.Details = Optional(request.Details);
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
item.Title = TitleFrom(item.Task);
|
||||
item.Content = BuildTodoContent(item.Task, item.Priority, item.DueAt, item.Details);
|
||||
shouldRegenerateEmbedding |= ApplyCommonUpdates(item, request.Tags, request.Status);
|
||||
|
||||
return await SaveUpdatedAsync(item, project, shouldRegenerateEmbedding, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> UpdateNoteAsync(UpdateNoteRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var item = await FindActiveItemAsync<NoteMemoryItem>(project.Id, request.Id, cancellationToken);
|
||||
var shouldRegenerateEmbedding = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Subject))
|
||||
{
|
||||
item.Subject = request.Subject.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Body))
|
||||
{
|
||||
item.Body = request.Body.Trim();
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
item.Title = TitleFrom(item.Subject);
|
||||
item.Content = item.Body;
|
||||
shouldRegenerateEmbedding |= ApplyCommonUpdates(item, request.Tags, request.Status);
|
||||
|
||||
return await SaveUpdatedAsync(item, project, shouldRegenerateEmbedding, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryItemDto> DeleteItemAsync(DeleteMemoryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await FindProjectAsync(request.Project, cancellationToken);
|
||||
var item = await FindActiveItemAsync(project.Id, request.Id, cancellationToken);
|
||||
var item = await FindActiveItemAsync<MemoryItem>(project.Id, request.Id, cancellationToken);
|
||||
|
||||
item.DeletedAt = DateTimeOffset.UtcNow;
|
||||
item.Status = "deleted";
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
item.Project = project;
|
||||
return ToDto(item);
|
||||
return await ToDtoAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<MemoryItemDto> AddAndEmbedAsync<T>(T item, Project project, CancellationToken cancellationToken)
|
||||
where T : MemoryItem
|
||||
{
|
||||
db.Set<T>().Add(item);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await UpsertDefaultEmbeddingAsync(item, cancellationToken);
|
||||
|
||||
item.Project = project;
|
||||
return await ToDtoAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<MemoryItemDto> SaveUpdatedAsync(MemoryItem item, Project project, bool shouldRegenerateEmbedding, CancellationToken cancellationToken)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (shouldRegenerateEmbedding)
|
||||
{
|
||||
await UpsertDefaultEmbeddingAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
item.Project = project;
|
||||
return await ToDtoAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Project> FindProjectAsync(string projectNameOrSlug, CancellationToken cancellationToken)
|
||||
@@ -240,18 +572,58 @@ public sealed class CortexMemoryService(
|
||||
return project;
|
||||
}
|
||||
|
||||
private async Task<MemoryItem> FindActiveItemAsync(Guid projectId, Guid itemId, CancellationToken cancellationToken)
|
||||
private async Task<T> FindActiveItemAsync<T>(Guid projectId, Guid itemId, CancellationToken cancellationToken)
|
||||
where T : MemoryItem
|
||||
{
|
||||
var item = await db.MemoryItems.SingleOrDefaultAsync(
|
||||
var item = await db.Set<T>().SingleOrDefaultAsync(
|
||||
memory => memory.ProjectId == projectId && memory.Id == itemId && memory.DeletedAt == null,
|
||||
cancellationToken);
|
||||
|
||||
return item ?? throw new InvalidOperationException($"Memory item '{itemId}' does not exist in that project.");
|
||||
return item ?? throw new InvalidOperationException($"Memory item '{itemId}' does not exist in that project or category.");
|
||||
}
|
||||
|
||||
private async Task UpdateEmbeddingAsync(MemoryItem item, CancellationToken cancellationToken)
|
||||
private async Task<MemoryItemEmbedding> FindItemEmbeddingAsync(Guid itemId, Guid embeddingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var embedding = await db.MemoryItemEmbeddings.SingleOrDefaultAsync(
|
||||
candidate => candidate.MemoryItemId == itemId && candidate.Id == embeddingId,
|
||||
cancellationToken);
|
||||
|
||||
return embedding ?? throw new InvalidOperationException($"Embedding '{embeddingId}' does not exist for that item.");
|
||||
}
|
||||
|
||||
private async Task<MemoryItemEmbedding> UpsertDefaultEmbeddingAsync(MemoryItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var text = GetEmbeddingText(item);
|
||||
var embedding = await db.MemoryItemEmbeddings.SingleOrDefaultAsync(
|
||||
candidate => candidate.MemoryItemId == item.Id && candidate.Position == 0,
|
||||
cancellationToken);
|
||||
|
||||
if (embedding is null)
|
||||
{
|
||||
embedding = new MemoryItemEmbedding
|
||||
{
|
||||
Id = item.Id,
|
||||
MemoryItemId = item.Id,
|
||||
Position = 0,
|
||||
Text = text,
|
||||
EmbeddingModel = _options.EmbeddingModel
|
||||
};
|
||||
|
||||
db.MemoryItemEmbeddings.Add(embedding);
|
||||
}
|
||||
else
|
||||
{
|
||||
embedding.Text = text;
|
||||
embedding.EmbeddingModel = _options.EmbeddingModel;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await UpdateEmbeddingVectorAsync(embedding, text, cancellationToken);
|
||||
return embedding;
|
||||
}
|
||||
|
||||
private async Task UpdateEmbeddingVectorAsync(MemoryItemEmbedding embedding, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var text = $"{item.Category}\n{item.Title}\n{item.Content}\nTags: {string.Join(", ", item.Tags)}";
|
||||
var vector = VectorLiteral.From(await embeddings.EmbedAsync(text, cancellationToken));
|
||||
var updatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -260,7 +632,7 @@ public sealed class CortexMemoryService(
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE memory_items
|
||||
UPDATE memory_item_embeddings
|
||||
SET embedding = CAST(@embedding AS vector),
|
||||
embedding_model = @embedding_model,
|
||||
updated_at = @updated_at
|
||||
@@ -270,10 +642,11 @@ public sealed class CortexMemoryService(
|
||||
command.Parameters.AddWithValue("embedding", vector);
|
||||
command.Parameters.AddWithValue("embedding_model", _options.EmbeddingModel);
|
||||
command.Parameters.AddWithValue("updated_at", updatedAt);
|
||||
command.Parameters.AddWithValue("id", item.Id);
|
||||
command.Parameters.AddWithValue("id", embedding.Id);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
item.UpdatedAt = updatedAt;
|
||||
embedding.EmbeddingModel = _options.EmbeddingModel;
|
||||
embedding.UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
private static MemoryCategory ParseCategory(string category)
|
||||
@@ -291,6 +664,44 @@ public sealed class CortexMemoryService(
|
||||
throw new ArgumentException("Category must be one of: requirement, location, todo, note.", nameof(category));
|
||||
}
|
||||
|
||||
private static string[] NormalizeQueries(string[]? queries)
|
||||
{
|
||||
var normalized = queries?
|
||||
.Select(query => query.Trim())
|
||||
.Where(query => query.Length > 0)
|
||||
.ToArray() ?? [];
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one query is required.", nameof(queries));
|
||||
}
|
||||
|
||||
if (normalized.Length > MaxSearchQueries)
|
||||
{
|
||||
throw new ArgumentException($"FindItems supports at most {MaxSearchQueries} queries.", nameof(queries));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool ApplyCommonUpdates(MemoryItem item, string[]? tags, string? status)
|
||||
{
|
||||
var shouldRegenerateEmbedding = false;
|
||||
|
||||
if (tags is not null)
|
||||
{
|
||||
item.Tags = NormalizeTags(tags);
|
||||
shouldRegenerateEmbedding = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
item.Status = status.Trim();
|
||||
}
|
||||
|
||||
return shouldRegenerateEmbedding;
|
||||
}
|
||||
|
||||
private static string[] NormalizeTags(string[]? tags)
|
||||
{
|
||||
return tags?
|
||||
@@ -300,15 +711,67 @@ public sealed class CortexMemoryService(
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private static string NormalizeMetadata(string? metadataJson)
|
||||
private static string NormalizeStatus(string? status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(metadataJson))
|
||||
return string.IsNullOrWhiteSpace(status) ? "active" : status.Trim();
|
||||
}
|
||||
|
||||
private static string Required(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "{}";
|
||||
throw new ArgumentException($"{name} is required.", name);
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(metadataJson);
|
||||
return document.RootElement.GetRawText();
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? Optional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string TitleFrom(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length <= MaxTitleLength ? trimmed : $"{trimmed[..(MaxTitleLength - 3)]}...";
|
||||
}
|
||||
|
||||
private static string BuildLocationContent(string itemName, string place, string? details)
|
||||
{
|
||||
var content = $"{itemName} is in {place}.";
|
||||
return string.IsNullOrWhiteSpace(details) ? content : $"{content}\n{details}";
|
||||
}
|
||||
|
||||
private static string BuildTodoContent(string task, string priority, DateTimeOffset? dueAt, string? details)
|
||||
{
|
||||
var due = dueAt is null ? null : $"\nDue: {dueAt.Value:O}";
|
||||
var body = $"Task: {task}\nPriority: {priority}{due}";
|
||||
return string.IsNullOrWhiteSpace(details) ? body : $"{body}\n{details}";
|
||||
}
|
||||
|
||||
private static string GetEmbeddingText(MemoryItem item)
|
||||
{
|
||||
var details = item switch
|
||||
{
|
||||
RequirementMemoryItem requirement => $"Statement: {requirement.Statement}\nContext: {requirement.Context}",
|
||||
LocationMemoryItem location => $"Item: {location.ItemName}\nPlace: {location.Place}\nDetails: {location.Details}",
|
||||
TodoMemoryItem todo => $"Task: {todo.Task}\nPriority: {todo.Priority}\nDue: {todo.DueAt:O}\nDetails: {todo.Details}",
|
||||
NoteMemoryItem note => $"Subject: {note.Subject}\nBody: {note.Body}",
|
||||
_ => item.Content
|
||||
};
|
||||
|
||||
return $"{item.Category}\n{item.Title}\n{item.Content}\n{details}\nTags: {string.Join(", ", item.Tags)}";
|
||||
}
|
||||
|
||||
private static string? NullableString(DbDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? NullableDateTimeOffset(DbDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal);
|
||||
}
|
||||
|
||||
private static ProjectDto ToDto(Project project)
|
||||
@@ -316,8 +779,13 @@ public sealed class CortexMemoryService(
|
||||
return new ProjectDto(project.Id, project.Name, project.Slug, project.CreatedAt);
|
||||
}
|
||||
|
||||
private static MemoryItemDto ToDto(MemoryItem item)
|
||||
private async Task<MemoryItemDto> ToDtoAsync(MemoryItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var embeddings = await db.MemoryItemEmbeddings
|
||||
.Where(embedding => embedding.MemoryItemId == item.Id)
|
||||
.Select(embedding => new { embedding.Position, embedding.EmbeddingModel })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new MemoryItemDto(
|
||||
item.Id,
|
||||
item.Project?.Slug ?? string.Empty,
|
||||
@@ -325,10 +793,47 @@ public sealed class CortexMemoryService(
|
||||
item.Title,
|
||||
item.Content,
|
||||
item.Tags,
|
||||
item.MetadataJson,
|
||||
item.Status,
|
||||
item.EmbeddingModel,
|
||||
embeddings.SingleOrDefault(embedding => embedding.Position == 0)?.EmbeddingModel,
|
||||
embeddings.Count,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
item.UpdatedAt,
|
||||
DetailsFrom(item));
|
||||
}
|
||||
|
||||
private static MemoryItemEmbeddingDto ToDto(MemoryItemEmbedding embedding)
|
||||
{
|
||||
return new MemoryItemEmbeddingDto(
|
||||
embedding.Id,
|
||||
embedding.MemoryItemId,
|
||||
embedding.Position,
|
||||
embedding.Label,
|
||||
embedding.Text,
|
||||
embedding.EmbeddingModel,
|
||||
embedding.CreatedAt,
|
||||
embedding.UpdatedAt);
|
||||
}
|
||||
|
||||
private static MemoryItemDetailsDto DetailsFrom(MemoryItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
RequirementMemoryItem requirement => new MemoryItemDetailsDto(
|
||||
RequirementStatement: requirement.Statement,
|
||||
RequirementContext: requirement.Context),
|
||||
LocationMemoryItem location => new MemoryItemDetailsDto(
|
||||
LocationItemName: location.ItemName,
|
||||
LocationPlace: location.Place,
|
||||
LocationDetails: location.Details),
|
||||
TodoMemoryItem todo => new MemoryItemDetailsDto(
|
||||
TodoTask: todo.Task,
|
||||
TodoPriority: todo.Priority,
|
||||
TodoDueAt: todo.DueAt,
|
||||
TodoDetails: todo.Details),
|
||||
NoteMemoryItem note => new MemoryItemDetailsDto(
|
||||
NoteSubject: note.Subject,
|
||||
NoteBody: note.Body),
|
||||
_ => new MemoryItemDetailsDto()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,36 +6,120 @@ public interface ICortexMemoryService
|
||||
{
|
||||
Task<ProjectDto> CreateProjectAsync(string name, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ProjectDto>> ListProjectsAsync(CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> AddItemAsync(AddMemoryItemRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> AddRequirementAsync(AddRequirementRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> AddLocationAsync(AddLocationRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> AddTodoAsync(AddTodoRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> AddNoteAsync(AddNoteRequest request, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<SearchResultDto>> FindItemsAsync(FindMemoryItemsRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> UpdateItemAsync(UpdateMemoryItemRequest request, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MemoryItemEmbeddingDto>> ListItemEmbeddingsAsync(ListItemEmbeddingsRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemEmbeddingDto> AddItemEmbeddingAsync(AddItemEmbeddingRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemEmbeddingDto> UpdateItemEmbeddingAsync(UpdateItemEmbeddingRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemEmbeddingDto> DeleteItemEmbeddingAsync(DeleteItemEmbeddingRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> UpdateRequirementAsync(UpdateRequirementRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> UpdateLocationAsync(UpdateLocationRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> UpdateTodoAsync(UpdateTodoRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> UpdateNoteAsync(UpdateNoteRequest request, CancellationToken cancellationToken);
|
||||
Task<MemoryItemDto> DeleteItemAsync(DeleteMemoryItemRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AddMemoryItemRequest(
|
||||
public sealed record AddRequirementRequest(
|
||||
string Project,
|
||||
string Category,
|
||||
string Title,
|
||||
string Content,
|
||||
string Statement,
|
||||
string Context,
|
||||
string[]? Tags,
|
||||
string? Status);
|
||||
|
||||
public sealed record AddLocationRequest(
|
||||
string Project,
|
||||
string ItemName,
|
||||
string Place,
|
||||
string? Details,
|
||||
string[]? Tags,
|
||||
string? Status);
|
||||
|
||||
public sealed record AddTodoRequest(
|
||||
string Project,
|
||||
string Task,
|
||||
string Priority,
|
||||
DateTimeOffset? DueAt,
|
||||
string? Details,
|
||||
string[]? Tags,
|
||||
string? Status);
|
||||
|
||||
public sealed record AddNoteRequest(
|
||||
string Project,
|
||||
string Subject,
|
||||
string Body,
|
||||
string[]? Tags,
|
||||
string? MetadataJson,
|
||||
string? Status);
|
||||
|
||||
public sealed record FindMemoryItemsRequest(
|
||||
string Query,
|
||||
string[] Queries,
|
||||
string? Project,
|
||||
bool SearchAllProjects,
|
||||
string? Category,
|
||||
int Limit);
|
||||
int Limit)
|
||||
{
|
||||
public FindMemoryItemsRequest(string query, string? project, bool searchAllProjects, string? category, int limit)
|
||||
: this([query], project, searchAllProjects, category, limit)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateMemoryItemRequest(
|
||||
public sealed record ListItemEmbeddingsRequest(string Project, Guid ItemId);
|
||||
|
||||
public sealed record AddItemEmbeddingRequest(
|
||||
string Project,
|
||||
Guid ItemId,
|
||||
string? Label,
|
||||
string Text);
|
||||
|
||||
public sealed record UpdateItemEmbeddingRequest(
|
||||
string Project,
|
||||
Guid ItemId,
|
||||
Guid EmbeddingId,
|
||||
string? Label,
|
||||
string? Text);
|
||||
|
||||
public sealed record DeleteItemEmbeddingRequest(
|
||||
string Project,
|
||||
Guid ItemId,
|
||||
Guid EmbeddingId);
|
||||
|
||||
public sealed record UpdateRequirementRequest(
|
||||
string Project,
|
||||
Guid Id,
|
||||
string? Category,
|
||||
string? Title,
|
||||
string? Content,
|
||||
string? Statement,
|
||||
string? Context,
|
||||
string[]? Tags,
|
||||
string? Status);
|
||||
|
||||
public sealed record UpdateLocationRequest(
|
||||
string Project,
|
||||
Guid Id,
|
||||
string? ItemName,
|
||||
string? Place,
|
||||
string? Details,
|
||||
string[]? Tags,
|
||||
string? Status);
|
||||
|
||||
public sealed record UpdateTodoRequest(
|
||||
string Project,
|
||||
Guid Id,
|
||||
string? Task,
|
||||
string? Priority,
|
||||
DateTimeOffset? DueAt,
|
||||
bool ClearDueAt,
|
||||
string? Details,
|
||||
string[]? Tags,
|
||||
string? Status);
|
||||
|
||||
public sealed record UpdateNoteRequest(
|
||||
string Project,
|
||||
Guid Id,
|
||||
string? Subject,
|
||||
string? Body,
|
||||
string[]? Tags,
|
||||
string? MetadataJson,
|
||||
string? Status);
|
||||
|
||||
public sealed record DeleteMemoryItemRequest(string Project, Guid Id);
|
||||
|
||||
@@ -61,46 +61,92 @@
|
||||
}
|
||||
|
||||
<section class="command-band">
|
||||
<form class="panel add-panel" @onsubmit="AddItemAsync" @onsubmit:preventDefault>
|
||||
<form class="panel add-panel" @onsubmit="AddMemoryAsync" @onsubmit:preventDefault>
|
||||
<div class="panel-header">
|
||||
<h3>Add memory</h3>
|
||||
<span>@(SelectedProjectRequired ? "Choose a project first" : selectedProjectSlug)</span>
|
||||
</div>
|
||||
|
||||
<fieldset disabled="@SelectedProjectRequired">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Category
|
||||
<select @bind="addCategory">
|
||||
@foreach (var category in Categories)
|
||||
{
|
||||
<option value="@category">@category</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Title
|
||||
<input @bind="addTitle" placeholder="USB-C charging brick" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Content
|
||||
<textarea @bind="addContent" rows="5" placeholder="The USB-C charging brick is in the blue backpack."></textarea>
|
||||
Category
|
||||
<select @bind="addCategory">
|
||||
@foreach (var category in Categories)
|
||||
{
|
||||
<option value="@category">@category</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Tags
|
||||
<input @bind="addTags" placeholder="charging, backpack" />
|
||||
</label>
|
||||
@switch (addCategory)
|
||||
{
|
||||
case "requirement":
|
||||
<label>
|
||||
Statement
|
||||
<input @bind="addRequirementStatement" placeholder="Cortex runs as an HTTP MCP server" />
|
||||
</label>
|
||||
<label>
|
||||
Context
|
||||
<textarea @bind="addRequirementContext" rows="5" placeholder="Kestrel hosts the MCP endpoint for local clients."></textarea>
|
||||
</label>
|
||||
break;
|
||||
case "location":
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Item
|
||||
<input @bind="addLocationItemName" placeholder="USB-C charging brick" />
|
||||
</label>
|
||||
<label>
|
||||
Place
|
||||
<input @bind="addLocationPlace" placeholder="Blue backpack" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Details
|
||||
<textarea @bind="addLocationDetails" rows="4" placeholder="Inside the front pocket."></textarea>
|
||||
</label>
|
||||
break;
|
||||
case "todo":
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Task
|
||||
<input @bind="addTodoTask" placeholder="Buy shelf brackets" />
|
||||
</label>
|
||||
<label>
|
||||
Priority
|
||||
<select @bind="addTodoPriority">
|
||||
@foreach (var priority in Priorities)
|
||||
{
|
||||
<option value="@priority">@priority</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Due
|
||||
<input @bind="addTodoDueAt" placeholder="2026-07-15T10:00:00+02:00" />
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea @bind="addTodoDetails" rows="4" placeholder="Use black metal brackets."></textarea>
|
||||
</label>
|
||||
break;
|
||||
default:
|
||||
<label>
|
||||
Subject
|
||||
<input @bind="addNoteSubject" placeholder="Shelf idea" />
|
||||
</label>
|
||||
<label>
|
||||
Body
|
||||
<textarea @bind="addNoteBody" rows="5" placeholder="Use oak shelves for the reading nook."></textarea>
|
||||
</label>
|
||||
break;
|
||||
}
|
||||
|
||||
<label>
|
||||
Metadata JSON
|
||||
<input @bind="addMetadata" placeholder="{"source":"web"}" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Tags
|
||||
<input @bind="addTags" placeholder="charging, backpack" />
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Add</button>
|
||||
@@ -119,8 +165,8 @@
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Query
|
||||
<input @bind="searchQuery" placeholder="where is the charging brick" />
|
||||
Queries
|
||||
<textarea @bind="searchQueries" rows="4" placeholder="where is the charging brick USB-C power adapter location"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="form-grid">
|
||||
@@ -163,10 +209,12 @@
|
||||
<div class="card-meta">
|
||||
<span>@result.ProjectSlug</span>
|
||||
<span>@result.Category</span>
|
||||
<span>q@(result.MatchedQueryIndex + 1)</span>
|
||||
<span>e@result.MatchedEmbeddingPosition</span>
|
||||
<span>@result.Score.ToString("0.000")</span>
|
||||
</div>
|
||||
<h4>@result.Title</h4>
|
||||
<p>@result.Content</p>
|
||||
<p>@DetailSummary(result.Category, result.Details)</p>
|
||||
<div class="tag-row">
|
||||
@foreach (var tag in result.Tags)
|
||||
{
|
||||
@@ -174,7 +222,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button type="button" class="secondary" @onclick="() => BeginEdit(result)">Edit</button>
|
||||
<button type="button" class="secondary" @onclick="() => BeginEditAsync(result)">Edit</button>
|
||||
<button type="button" class="danger" @onclick="() => DeleteItemAsync(result.ProjectSlug, result.Id)">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -219,10 +267,11 @@
|
||||
<div class="card-meta">
|
||||
<span>@item.ProjectSlug</span>
|
||||
<span>@item.Category</span>
|
||||
<span>@item.EmbeddingCount emb</span>
|
||||
<span>@item.UpdatedAt.LocalDateTime.ToString("g")</span>
|
||||
</div>
|
||||
<h4>@item.Title</h4>
|
||||
<p>@item.Content</p>
|
||||
<p>@DetailSummary(item.Category, item.Details)</p>
|
||||
<div class="tag-row">
|
||||
@foreach (var tag in item.Tags)
|
||||
{
|
||||
@@ -230,7 +279,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button type="button" class="secondary" disabled="@(item.DeletedAt is not null)" @onclick="() => BeginEdit(item)">Edit</button>
|
||||
<button type="button" class="secondary" disabled="@(item.DeletedAt is not null)" @onclick="() => BeginEditAsync(item)">Edit</button>
|
||||
<button type="button" class="danger" disabled="@(item.DeletedAt is not null)" @onclick="() => DeleteItemAsync(item.ProjectSlug, item.Id)">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -251,38 +300,112 @@
|
||||
</div>
|
||||
|
||||
<form class="stack" @onsubmit="SaveEditAsync" @onsubmit:preventDefault>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Category
|
||||
<select @bind="editCategory">
|
||||
@foreach (var category in Categories)
|
||||
{
|
||||
<option value="@category">@category</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Title
|
||||
<input @bind="editTitle" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Content
|
||||
<textarea rows="7" @bind="editContent"></textarea>
|
||||
Category
|
||||
<input value="@editCategory" disabled />
|
||||
</label>
|
||||
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Tags
|
||||
<input @bind="editTags" />
|
||||
</label>
|
||||
<label>
|
||||
Metadata JSON
|
||||
<input @bind="editMetadata" />
|
||||
</label>
|
||||
</div>
|
||||
@switch (editCategory)
|
||||
{
|
||||
case "requirement":
|
||||
<label>
|
||||
Statement
|
||||
<input @bind="editRequirementStatement" />
|
||||
</label>
|
||||
<label>
|
||||
Context
|
||||
<textarea rows="6" @bind="editRequirementContext"></textarea>
|
||||
</label>
|
||||
break;
|
||||
case "location":
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Item
|
||||
<input @bind="editLocationItemName" />
|
||||
</label>
|
||||
<label>
|
||||
Place
|
||||
<input @bind="editLocationPlace" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Details
|
||||
<textarea rows="5" @bind="editLocationDetails"></textarea>
|
||||
</label>
|
||||
break;
|
||||
case "todo":
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Task
|
||||
<input @bind="editTodoTask" />
|
||||
</label>
|
||||
<label>
|
||||
Priority
|
||||
<select @bind="editTodoPriority">
|
||||
@foreach (var priority in Priorities)
|
||||
{
|
||||
<option value="@priority">@priority</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Due
|
||||
<input @bind="editTodoDueAt" placeholder="2026-07-15T10:00:00+02:00" />
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea rows="5" @bind="editTodoDetails"></textarea>
|
||||
</label>
|
||||
break;
|
||||
default:
|
||||
<label>
|
||||
Subject
|
||||
<input @bind="editNoteSubject" />
|
||||
</label>
|
||||
<label>
|
||||
Body
|
||||
<textarea rows="6" @bind="editNoteBody"></textarea>
|
||||
</label>
|
||||
break;
|
||||
}
|
||||
|
||||
<label>
|
||||
Tags
|
||||
<input @bind="editTags" />
|
||||
</label>
|
||||
|
||||
<section class="stack">
|
||||
<div class="panel-header">
|
||||
<h3>Embeddings</h3>
|
||||
<span>@editEmbeddings.Count total</span>
|
||||
</div>
|
||||
|
||||
@foreach (var embedding in editEmbeddings)
|
||||
{
|
||||
<div class="project-row">
|
||||
<span title="@embedding.Text">
|
||||
@embedding.Position: @(string.IsNullOrWhiteSpace(embedding.Label) ? "default" : embedding.Label)
|
||||
</span>
|
||||
@if (embedding.Position > 0)
|
||||
{
|
||||
<button type="button" class="danger" @onclick="() => DeleteEmbeddingAsync(embedding.Id)">Delete</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Label
|
||||
<input @bind="newEmbeddingLabel" placeholder="constraints" />
|
||||
</label>
|
||||
<label>
|
||||
Text
|
||||
<input @bind="newEmbeddingText" placeholder="Aspect-specific discoverability text" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="secondary" @onclick="AddEmbeddingAsync">Add embedding</button>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
@@ -295,6 +418,7 @@
|
||||
|
||||
@code {
|
||||
private static readonly string[] Categories = ["requirement", "location", "todo", "note"];
|
||||
private static readonly string[] Priorities = ["normal", "low", "high", "urgent"];
|
||||
|
||||
private IReadOnlyList<ProjectSummary> projects = [];
|
||||
private IReadOnlyList<MemoryListItem> recentItems = [];
|
||||
@@ -303,11 +427,19 @@
|
||||
private string? selectedProjectSlug;
|
||||
private string newProjectName = string.Empty;
|
||||
private string addCategory = "note";
|
||||
private string addTitle = string.Empty;
|
||||
private string addContent = string.Empty;
|
||||
private string addRequirementStatement = string.Empty;
|
||||
private string addRequirementContext = string.Empty;
|
||||
private string addLocationItemName = string.Empty;
|
||||
private string addLocationPlace = string.Empty;
|
||||
private string addLocationDetails = string.Empty;
|
||||
private string addTodoTask = string.Empty;
|
||||
private string addTodoPriority = "normal";
|
||||
private string addTodoDueAt = string.Empty;
|
||||
private string addTodoDetails = string.Empty;
|
||||
private string addNoteSubject = string.Empty;
|
||||
private string addNoteBody = string.Empty;
|
||||
private string addTags = string.Empty;
|
||||
private string addMetadata = """{"source":"web"}""";
|
||||
private string searchQuery = string.Empty;
|
||||
private string searchQueries = string.Empty;
|
||||
private string searchCategory = string.Empty;
|
||||
private bool searchAllProjects;
|
||||
private int searchLimit = 10;
|
||||
@@ -317,10 +449,21 @@
|
||||
private bool isError;
|
||||
private EditState? editing;
|
||||
private string editCategory = "note";
|
||||
private string editTitle = string.Empty;
|
||||
private string editContent = string.Empty;
|
||||
private string editRequirementStatement = string.Empty;
|
||||
private string editRequirementContext = string.Empty;
|
||||
private string editLocationItemName = string.Empty;
|
||||
private string editLocationPlace = string.Empty;
|
||||
private string editLocationDetails = string.Empty;
|
||||
private string editTodoTask = string.Empty;
|
||||
private string editTodoPriority = "normal";
|
||||
private string editTodoDueAt = string.Empty;
|
||||
private string editTodoDetails = string.Empty;
|
||||
private string editNoteSubject = string.Empty;
|
||||
private string editNoteBody = string.Empty;
|
||||
private string editTags = string.Empty;
|
||||
private string editMetadata = "{}";
|
||||
private IReadOnlyList<MemoryItemEmbeddingDto> editEmbeddings = [];
|
||||
private string newEmbeddingLabel = string.Empty;
|
||||
private string newEmbeddingText = string.Empty;
|
||||
|
||||
private bool SelectedProjectRequired => string.IsNullOrWhiteSpace(selectedProjectSlug);
|
||||
private string CurrentScopeTitle => string.IsNullOrWhiteSpace(selectedProjectSlug) ? "All projects" : selectedProjectSlug;
|
||||
@@ -369,7 +512,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
private async Task AddMemoryAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedProjectSlug))
|
||||
{
|
||||
@@ -379,16 +522,21 @@
|
||||
|
||||
await GuardedAsync(async () =>
|
||||
{
|
||||
var item = await Memory.AddItemAsync(
|
||||
new AddMemoryItemRequest(
|
||||
selectedProjectSlug,
|
||||
addCategory,
|
||||
addTitle,
|
||||
addContent,
|
||||
ParseTags(addTags),
|
||||
addMetadata,
|
||||
"active"),
|
||||
CancellationToken.None);
|
||||
var item = addCategory switch
|
||||
{
|
||||
"requirement" => await Memory.AddRequirementAsync(
|
||||
new AddRequirementRequest(selectedProjectSlug, addRequirementStatement, addRequirementContext, ParseTags(addTags), "active"),
|
||||
CancellationToken.None),
|
||||
"location" => await Memory.AddLocationAsync(
|
||||
new AddLocationRequest(selectedProjectSlug, addLocationItemName, addLocationPlace, addLocationDetails, ParseTags(addTags), "active"),
|
||||
CancellationToken.None),
|
||||
"todo" => await Memory.AddTodoAsync(
|
||||
new AddTodoRequest(selectedProjectSlug, addTodoTask, addTodoPriority, ParseDueAt(addTodoDueAt), addTodoDetails, ParseTags(addTags), "active"),
|
||||
CancellationToken.None),
|
||||
_ => await Memory.AddNoteAsync(
|
||||
new AddNoteRequest(selectedProjectSlug, addNoteSubject, addNoteBody, ParseTags(addTags), "active"),
|
||||
CancellationToken.None)
|
||||
};
|
||||
|
||||
message = $"Added {item.Title}.";
|
||||
ClearAddForm();
|
||||
@@ -408,7 +556,7 @@
|
||||
{
|
||||
searchResults = await Memory.FindItemsAsync(
|
||||
new FindMemoryItemsRequest(
|
||||
searchQuery,
|
||||
ParseSearchQueries(searchQueries),
|
||||
selectedProjectSlug,
|
||||
searchAllProjects,
|
||||
string.IsNullOrWhiteSpace(searchCategory) ? null : searchCategory,
|
||||
@@ -442,17 +590,21 @@
|
||||
|
||||
await GuardedAsync(async () =>
|
||||
{
|
||||
var updated = await Memory.UpdateItemAsync(
|
||||
new UpdateMemoryItemRequest(
|
||||
editing.ProjectSlug,
|
||||
editing.Id,
|
||||
editCategory,
|
||||
editTitle,
|
||||
editContent,
|
||||
ParseTags(editTags),
|
||||
editMetadata,
|
||||
"active"),
|
||||
CancellationToken.None);
|
||||
var updated = editCategory switch
|
||||
{
|
||||
"requirement" => await Memory.UpdateRequirementAsync(
|
||||
new UpdateRequirementRequest(editing.ProjectSlug, editing.Id, editRequirementStatement, editRequirementContext, ParseTags(editTags), "active"),
|
||||
CancellationToken.None),
|
||||
"location" => await Memory.UpdateLocationAsync(
|
||||
new UpdateLocationRequest(editing.ProjectSlug, editing.Id, editLocationItemName, editLocationPlace, editLocationDetails, ParseTags(editTags), "active"),
|
||||
CancellationToken.None),
|
||||
"todo" => await Memory.UpdateTodoAsync(
|
||||
new UpdateTodoRequest(editing.ProjectSlug, editing.Id, editTodoTask, editTodoPriority, ParseDueAt(editTodoDueAt), string.IsNullOrWhiteSpace(editTodoDueAt), editTodoDetails, ParseTags(editTags), "active"),
|
||||
CancellationToken.None),
|
||||
_ => await Memory.UpdateNoteAsync(
|
||||
new UpdateNoteRequest(editing.ProjectSlug, editing.Id, editNoteSubject, editNoteBody, ParseTags(editTags), "active"),
|
||||
CancellationToken.None)
|
||||
};
|
||||
|
||||
message = $"Updated {updated.Title}.";
|
||||
editing = null;
|
||||
@@ -471,47 +623,118 @@
|
||||
await LoadRecentAsync();
|
||||
}
|
||||
|
||||
private void BeginEdit(SearchResultDto item)
|
||||
private async Task BeginEditAsync(SearchResultDto item)
|
||||
{
|
||||
editing = new EditState(item.ProjectSlug, item.Id);
|
||||
editCategory = item.Category.ToString().ToLowerInvariant();
|
||||
editTitle = item.Title;
|
||||
editContent = item.Content;
|
||||
editTags = string.Join(", ", item.Tags);
|
||||
editMetadata = item.MetadataJson;
|
||||
LoadEditFields(item.Details, item.Tags);
|
||||
await LoadEditEmbeddingsAsync();
|
||||
}
|
||||
|
||||
private void BeginEdit(MemoryListItem item)
|
||||
private async Task BeginEditAsync(MemoryListItem item)
|
||||
{
|
||||
editing = new EditState(item.ProjectSlug, item.Id);
|
||||
editCategory = item.Category.ToString().ToLowerInvariant();
|
||||
editTitle = item.Title;
|
||||
editContent = item.Content;
|
||||
editTags = string.Join(", ", item.Tags);
|
||||
editMetadata = item.MetadataJson;
|
||||
LoadEditFields(item.Details, item.Tags);
|
||||
await LoadEditEmbeddingsAsync();
|
||||
}
|
||||
|
||||
private void LoadEditFields(MemoryItemDetailsDto details, string[] tags)
|
||||
{
|
||||
editRequirementStatement = details.RequirementStatement ?? string.Empty;
|
||||
editRequirementContext = details.RequirementContext ?? string.Empty;
|
||||
editLocationItemName = details.LocationItemName ?? string.Empty;
|
||||
editLocationPlace = details.LocationPlace ?? string.Empty;
|
||||
editLocationDetails = details.LocationDetails ?? string.Empty;
|
||||
editTodoTask = details.TodoTask ?? string.Empty;
|
||||
editTodoPriority = string.IsNullOrWhiteSpace(details.TodoPriority) ? "normal" : details.TodoPriority;
|
||||
editTodoDueAt = details.TodoDueAt?.ToString("O") ?? string.Empty;
|
||||
editTodoDetails = details.TodoDetails ?? string.Empty;
|
||||
editNoteSubject = details.NoteSubject ?? string.Empty;
|
||||
editNoteBody = details.NoteBody ?? string.Empty;
|
||||
editTags = string.Join(", ", tags);
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
editing = null;
|
||||
editEmbeddings = [];
|
||||
newEmbeddingLabel = string.Empty;
|
||||
newEmbeddingText = string.Empty;
|
||||
}
|
||||
|
||||
private void ClearAddForm()
|
||||
{
|
||||
addCategory = "note";
|
||||
addTitle = string.Empty;
|
||||
addContent = string.Empty;
|
||||
addRequirementStatement = string.Empty;
|
||||
addRequirementContext = string.Empty;
|
||||
addLocationItemName = string.Empty;
|
||||
addLocationPlace = string.Empty;
|
||||
addLocationDetails = string.Empty;
|
||||
addTodoTask = string.Empty;
|
||||
addTodoPriority = "normal";
|
||||
addTodoDueAt = string.Empty;
|
||||
addTodoDetails = string.Empty;
|
||||
addNoteSubject = string.Empty;
|
||||
addNoteBody = string.Empty;
|
||||
addTags = string.Empty;
|
||||
addMetadata = """{"source":"web"}""";
|
||||
}
|
||||
|
||||
private void ClearSearch()
|
||||
{
|
||||
searchQuery = string.Empty;
|
||||
searchQueries = string.Empty;
|
||||
searchCategory = string.Empty;
|
||||
searchResults = [];
|
||||
}
|
||||
|
||||
private async Task LoadEditEmbeddingsAsync()
|
||||
{
|
||||
if (editing is null)
|
||||
{
|
||||
editEmbeddings = [];
|
||||
return;
|
||||
}
|
||||
|
||||
editEmbeddings = await Memory.ListItemEmbeddingsAsync(
|
||||
new ListItemEmbeddingsRequest(editing.ProjectSlug, editing.Id),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AddEmbeddingAsync()
|
||||
{
|
||||
if (editing is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await GuardedAsync(async () =>
|
||||
{
|
||||
_ = await Memory.AddItemEmbeddingAsync(
|
||||
new AddItemEmbeddingRequest(editing.ProjectSlug, editing.Id, newEmbeddingLabel, newEmbeddingText),
|
||||
CancellationToken.None);
|
||||
newEmbeddingLabel = string.Empty;
|
||||
newEmbeddingText = string.Empty;
|
||||
await LoadEditEmbeddingsAsync();
|
||||
await LoadRecentAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DeleteEmbeddingAsync(Guid embeddingId)
|
||||
{
|
||||
if (editing is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await GuardedAsync(async () =>
|
||||
{
|
||||
_ = await Memory.DeleteItemEmbeddingAsync(
|
||||
new DeleteItemEmbeddingRequest(editing.ProjectSlug, editing.Id, embeddingId),
|
||||
CancellationToken.None);
|
||||
await LoadEditEmbeddingsAsync();
|
||||
await LoadRecentAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task GuardedAsync(Func<Task> work)
|
||||
{
|
||||
try
|
||||
@@ -539,6 +762,25 @@
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string[] ParseSearchQueries(string queries)
|
||||
{
|
||||
return queries
|
||||
.Split(['\r', '\n'], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDueAt(string dueAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dueAt))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(dueAt, out var parsed)
|
||||
? parsed
|
||||
: throw new ArgumentException("Due must be a valid timestamp.");
|
||||
}
|
||||
|
||||
private static MemoryCategory? ParseOptionalCategory(string category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
@@ -549,5 +791,17 @@
|
||||
return Enum.TryParse<MemoryCategory>(category, ignoreCase: true, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static string DetailSummary(MemoryCategory category, MemoryItemDetailsDto details)
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
MemoryCategory.Requirement => details.RequirementContext ?? details.RequirementStatement ?? string.Empty,
|
||||
MemoryCategory.Location => $"{details.LocationItemName} -> {details.LocationPlace}".Trim(),
|
||||
MemoryCategory.Todo => $"{details.TodoTask} [{details.TodoPriority}]".Trim(),
|
||||
MemoryCategory.Note => details.NoteBody ?? details.NoteSubject ?? string.Empty,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record EditState(string ProjectSlug, Guid Id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cortex.Core.Data;
|
||||
using Cortex.Core.Contracts;
|
||||
using Cortex.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -47,22 +48,68 @@ public sealed class CortexUiQueries(CortexDbContext db)
|
||||
query = query.Where(item => item.Category == category);
|
||||
}
|
||||
|
||||
return await query
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.Select(item => new MemoryListItem(
|
||||
.ToListAsync(cancellationToken);
|
||||
var itemIds = items.Select(item => item.Id).ToArray();
|
||||
var embeddingSummaries = await db.MemoryItemEmbeddings
|
||||
.Where(embedding => itemIds.Contains(embedding.MemoryItemId))
|
||||
.GroupBy(embedding => embedding.MemoryItemId)
|
||||
.Select(group => new
|
||||
{
|
||||
ItemId = group.Key,
|
||||
Count = group.Count(),
|
||||
DefaultModel = group
|
||||
.Where(embedding => embedding.Position == 0)
|
||||
.Select(embedding => embedding.EmbeddingModel)
|
||||
.SingleOrDefault()
|
||||
})
|
||||
.ToDictionaryAsync(summary => summary.ItemId, cancellationToken);
|
||||
|
||||
return items
|
||||
.Select(item =>
|
||||
{
|
||||
embeddingSummaries.TryGetValue(item.Id, out var embeddingSummary);
|
||||
return new MemoryListItem(
|
||||
item.Id,
|
||||
item.Project!.Slug,
|
||||
item.Category,
|
||||
item.Title,
|
||||
item.Content,
|
||||
item.Tags,
|
||||
item.MetadataJson,
|
||||
item.Status,
|
||||
embeddingSummary?.DefaultModel,
|
||||
embeddingSummary?.Count ?? 0,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
item.DeletedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
item.DeletedAt,
|
||||
DetailsFrom(item));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static MemoryItemDetailsDto DetailsFrom(MemoryItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
RequirementMemoryItem requirement => new MemoryItemDetailsDto(
|
||||
RequirementStatement: requirement.Statement,
|
||||
RequirementContext: requirement.Context),
|
||||
LocationMemoryItem location => new MemoryItemDetailsDto(
|
||||
LocationItemName: location.ItemName,
|
||||
LocationPlace: location.Place,
|
||||
LocationDetails: location.Details),
|
||||
TodoMemoryItem todo => new MemoryItemDetailsDto(
|
||||
TodoTask: todo.Task,
|
||||
TodoPriority: todo.Priority,
|
||||
TodoDueAt: todo.DueAt,
|
||||
TodoDetails: todo.Details),
|
||||
NoteMemoryItem note => new MemoryItemDetailsDto(
|
||||
NoteSubject: note.Subject,
|
||||
NoteBody: note.Body),
|
||||
_ => new MemoryItemDetailsDto()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +128,10 @@ public sealed record MemoryListItem(
|
||||
string Title,
|
||||
string Content,
|
||||
string[] Tags,
|
||||
string MetadataJson,
|
||||
string Status,
|
||||
string? DefaultEmbeddingModel,
|
||||
int EmbeddingCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset? DeletedAt);
|
||||
DateTimeOffset? DeletedAt,
|
||||
MemoryItemDetailsDto Details);
|
||||
|
||||
@@ -15,11 +15,12 @@ public sealed record MemoryItemDto(
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("content")] string Content,
|
||||
[property: JsonPropertyName("tags")] string[] Tags,
|
||||
[property: JsonPropertyName("metadataJson")] string MetadataJson,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("embeddingModel")] string? EmbeddingModel,
|
||||
[property: JsonPropertyName("defaultEmbeddingModel")] string? DefaultEmbeddingModel,
|
||||
[property: JsonPropertyName("embeddingCount")] int EmbeddingCount,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset UpdatedAt);
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset UpdatedAt,
|
||||
[property: JsonPropertyName("details")] MemoryItemDetailsDto Details);
|
||||
|
||||
public sealed record SearchResultDto(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
@@ -28,7 +29,33 @@ public sealed record SearchResultDto(
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("content")] string Content,
|
||||
[property: JsonPropertyName("tags")] string[] Tags,
|
||||
[property: JsonPropertyName("metadataJson")] string MetadataJson,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("score")] double Score,
|
||||
[property: JsonPropertyName("matchedQueryIndex")] int MatchedQueryIndex,
|
||||
[property: JsonPropertyName("matchedEmbeddingPosition")] int MatchedEmbeddingPosition,
|
||||
[property: JsonPropertyName("matchedEmbeddingLabel")] string? MatchedEmbeddingLabel,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset UpdatedAt,
|
||||
[property: JsonPropertyName("details")] MemoryItemDetailsDto Details);
|
||||
|
||||
public sealed record MemoryItemEmbeddingDto(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("memoryItemId")] Guid MemoryItemId,
|
||||
[property: JsonPropertyName("position")] int Position,
|
||||
[property: JsonPropertyName("label")] string? Label,
|
||||
[property: JsonPropertyName("text")] string Text,
|
||||
[property: JsonPropertyName("embeddingModel")] string? EmbeddingModel,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record MemoryItemDetailsDto(
|
||||
[property: JsonPropertyName("requirementStatement")] string? RequirementStatement,
|
||||
[property: JsonPropertyName("requirementContext")] string? RequirementContext,
|
||||
[property: JsonPropertyName("locationItemName")] string? LocationItemName,
|
||||
[property: JsonPropertyName("locationPlace")] string? LocationPlace,
|
||||
[property: JsonPropertyName("locationDetails")] string? LocationDetails,
|
||||
[property: JsonPropertyName("todoTask")] string? TodoTask,
|
||||
[property: JsonPropertyName("todoPriority")] string? TodoPriority,
|
||||
[property: JsonPropertyName("todoDueAt")] DateTimeOffset? TodoDueAt,
|
||||
[property: JsonPropertyName("todoDetails")] string? TodoDetails,
|
||||
[property: JsonPropertyName("noteSubject")] string? NoteSubject,
|
||||
[property: JsonPropertyName("noteBody")] string? NoteBody);
|
||||
|
||||
@@ -13,24 +13,80 @@ public static class McpTestClientExtensions
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> AddItemAsync(
|
||||
public static ValueTask<CallToolResult> AddRequirementAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
string category,
|
||||
string title,
|
||||
string content,
|
||||
string statement,
|
||||
string context,
|
||||
string[]? tags = null,
|
||||
string? metadataJson = null,
|
||||
string? status = null)
|
||||
{
|
||||
return client.CallToolAsync("AddItem", new Dictionary<string, object?>
|
||||
return client.CallToolAsync("AddRequirement", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["category"] = category,
|
||||
["title"] = title,
|
||||
["content"] = content,
|
||||
["statement"] = statement,
|
||||
["context"] = context,
|
||||
["tags"] = tags,
|
||||
["status"] = status
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> AddLocationAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
string itemName,
|
||||
string place,
|
||||
string? details = null,
|
||||
string[]? tags = null,
|
||||
string? status = null)
|
||||
{
|
||||
return client.CallToolAsync("AddLocation", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["itemName"] = itemName,
|
||||
["place"] = place,
|
||||
["details"] = details,
|
||||
["tags"] = tags,
|
||||
["status"] = status
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> AddTodoAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
string task,
|
||||
string priority,
|
||||
DateTimeOffset? dueAt = null,
|
||||
string? details = null,
|
||||
string[]? tags = null,
|
||||
string? status = null)
|
||||
{
|
||||
return client.CallToolAsync("AddTodo", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["task"] = task,
|
||||
["priority"] = priority,
|
||||
["dueAt"] = dueAt,
|
||||
["details"] = details,
|
||||
["tags"] = tags,
|
||||
["status"] = status
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> AddNoteAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
string subject,
|
||||
string body,
|
||||
string[]? tags = null,
|
||||
string? status = null)
|
||||
{
|
||||
return client.CallToolAsync("AddNote", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["subject"] = subject,
|
||||
["body"] = body,
|
||||
["tags"] = tags,
|
||||
["metadataJson"] = metadataJson,
|
||||
["status"] = status
|
||||
});
|
||||
}
|
||||
@@ -42,10 +98,21 @@ public static class McpTestClientExtensions
|
||||
bool searchAllProjects = false,
|
||||
string? category = null,
|
||||
int limit = 10)
|
||||
{
|
||||
return client.FindItemsAsync([query], project, searchAllProjects, category, limit);
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> FindItemsAsync(
|
||||
this McpClient client,
|
||||
string[] queries,
|
||||
string? project = null,
|
||||
bool searchAllProjects = false,
|
||||
string? category = null,
|
||||
int limit = 10)
|
||||
{
|
||||
return client.CallToolAsync("FindItems", new Dictionary<string, object?>
|
||||
{
|
||||
["query"] = query,
|
||||
["queries"] = queries,
|
||||
["project"] = project,
|
||||
["searchAllProjects"] = searchAllProjects,
|
||||
["category"] = category,
|
||||
@@ -53,26 +120,70 @@ public static class McpTestClientExtensions
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> UpdateItemAsync(
|
||||
public static ValueTask<CallToolResult> ListItemEmbeddingsAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
Guid itemId)
|
||||
{
|
||||
return client.CallToolAsync("ListItemEmbeddings", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["itemId"] = itemId
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> AddItemEmbeddingAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
Guid itemId,
|
||||
string text,
|
||||
string? label = null)
|
||||
{
|
||||
return client.CallToolAsync("AddItemEmbedding", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["itemId"] = itemId,
|
||||
["text"] = text,
|
||||
["label"] = label
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> DeleteItemEmbeddingAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
Guid itemId,
|
||||
Guid embeddingId)
|
||||
{
|
||||
return client.CallToolAsync("DeleteItemEmbedding", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["itemId"] = itemId,
|
||||
["embeddingId"] = embeddingId
|
||||
});
|
||||
}
|
||||
|
||||
public static ValueTask<CallToolResult> UpdateTodoAsync(
|
||||
this McpClient client,
|
||||
string project,
|
||||
Guid id,
|
||||
string? category = null,
|
||||
string? title = null,
|
||||
string? content = null,
|
||||
string? task = null,
|
||||
string? priority = null,
|
||||
DateTimeOffset? dueAt = null,
|
||||
bool clearDueAt = false,
|
||||
string? details = null,
|
||||
string[]? tags = null,
|
||||
string? metadataJson = null,
|
||||
string? status = null)
|
||||
{
|
||||
return client.CallToolAsync("UpdateItem", new Dictionary<string, object?>
|
||||
return client.CallToolAsync("UpdateTodo", new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["id"] = id,
|
||||
["category"] = category,
|
||||
["title"] = title,
|
||||
["content"] = content,
|
||||
["task"] = task,
|
||||
["priority"] = priority,
|
||||
["dueAt"] = dueAt,
|
||||
["clearDueAt"] = clearDueAt,
|
||||
["details"] = details,
|
||||
["tags"] = tags,
|
||||
["metadataJson"] = metadataJson,
|
||||
["status"] = status
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,15 +9,15 @@ namespace Cortex.IntegrationTests.Tests;
|
||||
public sealed class MemoryItemTests(CortexTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Add_item_fails_when_project_is_missing()
|
||||
public async Task Add_todo_fails_when_project_is_missing()
|
||||
{
|
||||
await using var client = await fixture.CreateClientAsync();
|
||||
|
||||
var result = await client.AddItemAsync(
|
||||
var result = await client.AddTodoAsync(
|
||||
fixture.NewProjectName("missing"),
|
||||
"note",
|
||||
"Should not be stored",
|
||||
"This item should fail because its project does not exist.");
|
||||
"normal",
|
||||
details: "This item should fail because its project does not exist.");
|
||||
|
||||
Assert.True(result.IsError is true);
|
||||
}
|
||||
@@ -30,25 +30,35 @@ public sealed class MemoryItemTests(CortexTestFixture fixture)
|
||||
_ = await client.CreateProjectAsync(projectName);
|
||||
|
||||
var added = CortexTestFixture.ReadStructuredContent<MemoryItemDto>(
|
||||
await client.AddItemAsync(
|
||||
await client.AddTodoAsync(
|
||||
projectName,
|
||||
"todo",
|
||||
"Measure cabinet opening",
|
||||
"Measure the kitchen cabinet opening before ordering shelves.",
|
||||
["kitchen", "measure"],
|
||||
"""{"priority":"normal"}"""));
|
||||
"normal",
|
||||
details: "Measure the kitchen cabinet opening before ordering shelves.",
|
||||
tags: ["kitchen", "measure"],
|
||||
status: "active"));
|
||||
|
||||
Assert.Equal("Todo", added.Category);
|
||||
Assert.Equal("BAAI/bge-small-en-v1.5", added.EmbeddingModel);
|
||||
Assert.Equal("Measure cabinet opening", added.Details.TodoTask);
|
||||
Assert.Equal("normal", added.Details.TodoPriority);
|
||||
Assert.Equal("BAAI/bge-small-en-v1.5", added.DefaultEmbeddingModel);
|
||||
Assert.Equal(1, added.EmbeddingCount);
|
||||
|
||||
var defaultEmbeddings = CortexTestFixture.ReadStructuredContent<MemoryItemEmbeddingDto[]>(
|
||||
await client.ListItemEmbeddingsAsync(projectName, added.Id));
|
||||
|
||||
Assert.Single(defaultEmbeddings);
|
||||
Assert.Equal(0, defaultEmbeddings[0].Position);
|
||||
|
||||
var updated = CortexTestFixture.ReadStructuredContent<MemoryItemDto>(
|
||||
await client.UpdateItemAsync(
|
||||
await client.UpdateTodoAsync(
|
||||
projectName,
|
||||
added.Id,
|
||||
content: "Measure the kitchen cabinet opening and record width, height, and depth.",
|
||||
details: "Measure the kitchen cabinet opening and record width, height, and depth.",
|
||||
status: "active"));
|
||||
|
||||
Assert.Contains("width", updated.Content);
|
||||
Assert.Contains("width", updated.Details.TodoDetails);
|
||||
Assert.True(updated.UpdatedAt >= added.UpdatedAt);
|
||||
|
||||
var deleted = CortexTestFixture.ReadStructuredContent<MemoryItemDto>(
|
||||
@@ -61,4 +71,37 @@ public sealed class MemoryItemTests(CortexTestFixture fixture)
|
||||
|
||||
Assert.DoesNotContain(searchAfterDelete, item => item.Id == added.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Additional_embedding_can_match_search_without_changing_content()
|
||||
{
|
||||
await using var client = await fixture.CreateClientAsync();
|
||||
var projectName = fixture.NewProjectName("embeddings");
|
||||
_ = await client.CreateProjectAsync(projectName);
|
||||
|
||||
var note = CortexTestFixture.ReadStructuredContent<MemoryItemDto>(
|
||||
await client.AddNoteAsync(
|
||||
projectName,
|
||||
"Build diagnostics",
|
||||
"Keep the diagnostics concise and focused on failing subsystems."));
|
||||
|
||||
var extra = CortexTestFixture.ReadStructuredContent<MemoryItemEmbeddingDto>(
|
||||
await client.AddItemEmbeddingAsync(
|
||||
projectName,
|
||||
note.Id,
|
||||
"Mentions observability, telemetry, traces, and runtime health checks.",
|
||||
"observability"));
|
||||
|
||||
Assert.Equal(1, extra.Position);
|
||||
|
||||
var results = CortexTestFixture.ReadStructuredContent<SearchResultDto[]>(
|
||||
await client.FindItemsAsync(["telemetry traces", "runtime health"], projectName));
|
||||
|
||||
var match = Assert.Single(results, result => result.Id == note.Id);
|
||||
Assert.Equal(1, match.MatchedEmbeddingPosition);
|
||||
Assert.Equal("observability", match.MatchedEmbeddingLabel);
|
||||
|
||||
var deleteDefault = await client.DeleteItemEmbeddingAsync(projectName, note.Id, note.Id);
|
||||
Assert.True(deleteDefault.IsError is true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ public sealed class SearchTests(CortexTestFixture fixture)
|
||||
_ = await client.CreateProjectAsync(beta);
|
||||
|
||||
var alphaItem = CortexTestFixture.ReadStructuredContent<MemoryItemDto>(
|
||||
await client.AddItemAsync(alpha, "location", "Charging brick", "The USB-C charging brick is in the blue backpack."));
|
||||
await client.AddLocationAsync(alpha, "USB-C charging brick", "blue backpack"));
|
||||
|
||||
_ = await client.AddItemAsync(beta, "location", "Charging brick", "The spare USB-C charging brick is in the garage drawer.");
|
||||
_ = await client.AddLocationAsync(beta, "spare USB-C charging brick", "garage drawer");
|
||||
|
||||
var alphaResults = CortexTestFixture.ReadStructuredContent<SearchResultDto[]>(
|
||||
await client.FindItemsAsync("where is the charging brick", alpha));
|
||||
@@ -39,9 +39,9 @@ public sealed class SearchTests(CortexTestFixture fixture)
|
||||
_ = await client.CreateProjectAsync(projectName);
|
||||
|
||||
var note = CortexTestFixture.ReadStructuredContent<MemoryItemDto>(
|
||||
await client.AddItemAsync(projectName, "note", "Shelf idea", "Use oak shelves for the reading nook."));
|
||||
await client.AddNoteAsync(projectName, "Shelf idea", "Use oak shelves for the reading nook."));
|
||||
|
||||
_ = await client.AddItemAsync(projectName, "todo", "Buy shelf brackets", "Buy black metal brackets for the oak shelves.");
|
||||
_ = await client.AddTodoAsync(projectName, "Buy shelf brackets", "normal", details: "Buy black metal brackets for the oak shelves.");
|
||||
|
||||
var allProjects = CortexTestFixture.ReadStructuredContent<SearchResultDto[]>(
|
||||
await client.FindItemsAsync("oak shelves reading nook", searchAllProjects: true, category: "note"));
|
||||
|
||||
@@ -26,9 +26,19 @@ public sealed class SmokeTests(CortexTestFixture fixture)
|
||||
|
||||
Assert.Contains("CreateProject", names);
|
||||
Assert.Contains("ListProjects", names);
|
||||
Assert.Contains("AddItem", names);
|
||||
Assert.Contains("AddRequirement", names);
|
||||
Assert.Contains("AddLocation", names);
|
||||
Assert.Contains("AddTodo", names);
|
||||
Assert.Contains("AddNote", names);
|
||||
Assert.Contains("FindItems", names);
|
||||
Assert.Contains("UpdateItem", names);
|
||||
Assert.Contains("ListItemEmbeddings", names);
|
||||
Assert.Contains("AddItemEmbedding", names);
|
||||
Assert.Contains("UpdateItemEmbedding", names);
|
||||
Assert.Contains("DeleteItemEmbedding", names);
|
||||
Assert.Contains("UpdateRequirement", names);
|
||||
Assert.Contains("UpdateLocation", names);
|
||||
Assert.Contains("UpdateTodo", names);
|
||||
Assert.Contains("UpdateNote", names);
|
||||
Assert.Contains("DeleteItem", names);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user