diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index b161a70d..2d086308 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -5,9 +5,11 @@ from pydantic import BaseModel, Field from deerflow.agents.memory.updater import ( clear_memory_data, + create_memory_fact, delete_memory_fact, get_memory_data, reload_memory_data, + update_memory_fact, ) from deerflow.config.memory_config import get_memory_config @@ -58,6 +60,31 @@ class MemoryResponse(BaseModel): facts: list[Fact] = Field(default_factory=list) +def _map_memory_fact_value_error(exc: ValueError) -> HTTPException: + """Convert updater validation errors into stable API responses.""" + if exc.args and exc.args[0] == "confidence": + detail = "Invalid confidence value; must be between 0 and 1." + else: + detail = "Memory fact content cannot be empty." + return HTTPException(status_code=400, detail=detail) + + +class FactCreateRequest(BaseModel): + """Request model for creating a memory fact.""" + + content: str = Field(..., min_length=1, description="Fact content") + category: str = Field(default="context", description="Fact category") + confidence: float = Field(default=0.5, ge=0.0, le=1.0, description="Confidence score (0-1)") + + +class FactPatchRequest(BaseModel): + """PATCH request model that preserves existing values for omitted fields.""" + + content: str | None = Field(default=None, min_length=1, description="Fact content") + category: str | None = Field(default=None, description="Fact category") + confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)") + + class MemoryConfigResponse(BaseModel): """Response model for memory configuration.""" @@ -156,6 +183,28 @@ async def clear_memory() -> MemoryResponse: return MemoryResponse(**memory_data) +@router.post( + "/memory/facts", + response_model=MemoryResponse, + summary="Create Memory Fact", + description="Create a single saved memory fact manually.", +) +async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryResponse: + """Create a single fact manually.""" + try: + memory_data = create_memory_fact( + content=request.content, + category=request.category, + confidence=request.confidence, + ) + except ValueError as exc: + raise _map_memory_fact_value_error(exc) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail="Failed to create memory fact.") from exc + + return MemoryResponse(**memory_data) + + @router.delete( "/memory/facts/{fact_id}", response_model=MemoryResponse, @@ -174,6 +223,31 @@ async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse: return MemoryResponse(**memory_data) +@router.patch( + "/memory/facts/{fact_id}", + response_model=MemoryResponse, + summary="Patch Memory Fact", + description="Partially update a single saved memory fact by its fact id while preserving omitted fields.", +) +async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -> MemoryResponse: + """Partially update a single fact manually.""" + try: + memory_data = update_memory_fact( + fact_id=fact_id, + content=request.content, + category=request.category, + confidence=request.confidence, + ) + except ValueError as exc: + raise _map_memory_fact_value_error(exc) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc + except OSError as exc: + raise HTTPException(status_code=500, detail="Failed to update memory fact.") from exc + + return MemoryResponse(**memory_data) + + @router.get( "/memory/config", response_model=MemoryConfigResponse, diff --git a/backend/docs/MEMORY_SETTINGS_REVIEW.md b/backend/docs/MEMORY_SETTINGS_REVIEW.md index 3d3bcf7a..31cb1153 100644 --- a/backend/docs/MEMORY_SETTINGS_REVIEW.md +++ b/backend/docs/MEMORY_SETTINGS_REVIEW.md @@ -1,34 +1,58 @@ # Memory Settings Review -Use this when reviewing the Memory Settings search, filter, delete, and clear-all flow locally. +Use this when reviewing the Memory Settings add/edit flow locally with the fewest possible manual steps. ## Quick Review -1. Start DeerFlow locally. +1. Start DeerFlow locally using any working development setup you already use. + + Examples: ```bash make dev ``` + or + + ```bash + make docker-start + ``` + + If you already have DeerFlow running locally, you can reuse that existing setup. + 2. Load the sample memory fixture. ```bash python scripts/load_memory_sample.py ``` -3. Open the app and review `Settings > Memory`. +3. Open `Settings > Memory`. Default local URLs: - App: `http://localhost:2026` - Local frontend-only fallback: `http://localhost:3000` -## What To Check +## Minimal Manual Test -- Search `memory` and confirm multiple facts are matched. -- Search `Chinese` and confirm text filtering works. -- Search `workflow` and confirm category text is also searchable. +1. Click `Add fact`. +2. Create a new fact with: + - Content: `Reviewer-added memory fact` + - Category: `testing` + - Confidence: `0.88` +3. Confirm the new fact appears immediately and shows `Manual` as the source. +4. Edit the sample fact `This sample fact is intended for edit testing.` and change it to: + - Content: `This sample fact was edited during manual review.` + - Category: `testing` + - Confidence: `0.91` +5. Confirm the edited fact updates immediately. +6. Refresh the page and confirm both the newly added fact and the edited fact still persist. + +## Optional Sanity Checks + +- Search `Reviewer-added` and confirm the new fact is matched. +- Search `workflow` and confirm category text is searchable. - Switch between `All`, `Facts`, and `Summaries`. -- Delete the disposable sample fact and confirm the list updates immediately. +- Delete the disposable sample fact `Delete fact testing can target this disposable sample entry.` and confirm the list updates immediately. - Clear all memory and confirm the page enters the empty state. ## Fixture Files diff --git a/backend/docs/memory-settings-sample.json b/backend/docs/memory-settings-sample.json index 41320e75..d225dee9 100644 --- a/backend/docs/memory-settings-sample.json +++ b/backend/docs/memory-settings-sample.json @@ -101,6 +101,14 @@ "confidence": 0.78, "createdAt": "2026-03-28T10:06:00Z", "source": "thread_delete_demo" + }, + { + "id": "fact_review_010", + "content": "This sample fact is intended for edit testing.", + "category": "testing", + "confidence": 0.8, + "createdAt": "2026-03-28T10:08:00Z", + "source": "manual" } ] } diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 75b9e7b5..0a4369bc 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -2,6 +2,7 @@ import json import logging +import math import re import uuid from datetime import datetime @@ -40,12 +41,54 @@ def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]: def clear_memory_data(agent_name: str | None = None) -> dict[str, Any]: """Clear all stored memory data and persist an empty structure.""" - cleared_memory = _create_empty_memory() + cleared_memory = create_empty_memory() if not _save_memory_to_file(cleared_memory, agent_name): raise OSError("Failed to save cleared memory data") return cleared_memory +def _validate_confidence(confidence: float) -> float: + """Validate persisted fact confidence so stored JSON stays standards-compliant.""" + if not math.isfinite(confidence) or confidence < 0 or confidence > 1: + raise ValueError("confidence") + return confidence + + +def create_memory_fact( + content: str, + category: str = "context", + confidence: float = 0.5, + agent_name: str | None = None, +) -> dict[str, Any]: + """Create a new fact and persist the updated memory data.""" + normalized_content = content.strip() + if not normalized_content: + raise ValueError("content") + + normalized_category = category.strip() or "context" + validated_confidence = _validate_confidence(confidence) + now = datetime.utcnow().isoformat() + "Z" + memory_data = get_memory_data(agent_name) + updated_memory = dict(memory_data) + facts = list(memory_data.get("facts", [])) + facts.append( + { + "id": f"fact_{uuid.uuid4().hex[:8]}", + "content": normalized_content, + "category": normalized_category, + "confidence": validated_confidence, + "createdAt": now, + "source": "manual", + } + ) + updated_memory["facts"] = facts + + if not _save_memory_to_file(updated_memory, agent_name): + raise OSError("Failed to save memory data after creating fact") + + return updated_memory + + def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str, Any]: """Delete a fact by its id and persist the updated memory data.""" memory_data = get_memory_data(agent_name) @@ -63,6 +106,47 @@ def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str, return updated_memory +def update_memory_fact( + fact_id: str, + content: str | None = None, + category: str | None = None, + confidence: float | None = None, + agent_name: str | None = None, +) -> dict[str, Any]: + """Update an existing fact and persist the updated memory data.""" + memory_data = get_memory_data(agent_name) + updated_memory = dict(memory_data) + updated_facts: list[dict[str, Any]] = [] + found = False + + for fact in memory_data.get("facts", []): + if fact.get("id") == fact_id: + found = True + updated_fact = dict(fact) + if content is not None: + normalized_content = content.strip() + if not normalized_content: + raise ValueError("content") + updated_fact["content"] = normalized_content + if category is not None: + updated_fact["category"] = category.strip() or "context" + if confidence is not None: + updated_fact["confidence"] = _validate_confidence(confidence) + updated_facts.append(updated_fact) + else: + updated_facts.append(fact) + + if not found: + raise KeyError(fact_id) + + updated_memory["facts"] = updated_facts + + if not _save_memory_to_file(updated_memory, agent_name): + raise OSError(f"Failed to save memory data after updating fact '{fact_id}'") + + return updated_memory + + def _extract_text(content: Any) -> str: """Extract plain text from LLM response content (str or list of content blocks). diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 8fe38ae9..8d3544c8 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -688,12 +688,35 @@ class DeerFlowClient: return clear_memory_data() + def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict: + """Create a single fact manually.""" + from deerflow.agents.memory.updater import create_memory_fact + + return create_memory_fact(content=content, category=category, confidence=confidence) + def delete_memory_fact(self, fact_id: str) -> dict: """Delete a single fact from memory by fact id.""" from deerflow.agents.memory.updater import delete_memory_fact return delete_memory_fact(fact_id) + def update_memory_fact( + self, + fact_id: str, + content: str | None = None, + category: str | None = None, + confidence: float | None = None, + ) -> dict: + """Update a single fact manually, preserving omitted fields.""" + from deerflow.agents.memory.updater import update_memory_fact + + return update_memory_fact( + fact_id=fact_id, + content=content, + category=category, + confidence=confidence, + ) + def get_memory_config(self) -> dict: """Get memory system configuration. diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index b03ab033..a50ea589 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -673,6 +673,21 @@ class TestMemoryManagement: result = client.clear_memory() assert result == data + def test_create_memory_fact(self, client): + data = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.updater.create_memory_fact", return_value=data) as create_fact: + result = client.create_memory_fact( + "User prefers concise code reviews.", + category="preference", + confidence=0.88, + ) + create_fact.assert_called_once_with( + content="User prefers concise code reviews.", + category="preference", + confidence=0.88, + ) + assert result == data + def test_delete_memory_fact(self, client): data = {"version": "1.0", "facts": []} with patch("deerflow.agents.memory.updater.delete_memory_fact", return_value=data) as delete_fact: @@ -680,6 +695,38 @@ class TestMemoryManagement: delete_fact.assert_called_once_with("fact_123") assert result == data + def test_update_memory_fact(self, client): + data = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.updater.update_memory_fact", return_value=data) as update_fact: + result = client.update_memory_fact( + "fact_123", + "User prefers spaces", + category="workflow", + confidence=0.91, + ) + update_fact.assert_called_once_with( + fact_id="fact_123", + content="User prefers spaces", + category="workflow", + confidence=0.91, + ) + assert result == data + + def test_update_memory_fact_preserves_omitted_fields(self, client): + data = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.updater.update_memory_fact", return_value=data) as update_fact: + result = client.update_memory_fact( + "fact_123", + "User prefers spaces", + ) + update_fact.assert_called_once_with( + fact_id="fact_123", + content="User prefers spaces", + category=None, + confidence=None, + ) + assert result == data + def test_get_memory_config(self, client): config = MagicMock() config.enabled = True diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py index d99a97b9..ce4e6aea 100644 --- a/backend/tests/test_memory_router.py +++ b/backend/tests/test_memory_router.py @@ -36,6 +36,37 @@ def test_clear_memory_route_returns_cleared_memory() -> None: assert response.json()["facts"] == [] +def test_create_memory_fact_route_returns_updated_memory() -> None: + app = FastAPI() + app.include_router(memory.router) + updated_memory = _sample_memory( + facts=[ + { + "id": "fact_new", + "content": "User prefers concise code reviews.", + "category": "preference", + "confidence": 0.88, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + + with patch("app.gateway.routers.memory.create_memory_fact", return_value=updated_memory): + with TestClient(app) as client: + response = client.post( + "/api/memory/facts", + json={ + "content": "User prefers concise code reviews.", + "category": "preference", + "confidence": 0.88, + }, + ) + + assert response.status_code == 200 + assert response.json()["facts"] == updated_memory["facts"] + + def test_delete_memory_fact_route_returns_updated_memory() -> None: app = FastAPI() app.include_router(memory.router) @@ -70,3 +101,106 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None: assert response.status_code == 404 assert response.json()["detail"] == "Memory fact 'fact_missing' not found." + + +def test_update_memory_fact_route_returns_updated_memory() -> None: + app = FastAPI() + app.include_router(memory.router) + updated_memory = _sample_memory( + facts=[ + { + "id": "fact_edit", + "content": "User prefers spaces", + "category": "workflow", + "confidence": 0.91, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + + with patch("app.gateway.routers.memory.update_memory_fact", return_value=updated_memory): + with TestClient(app) as client: + response = client.patch( + "/api/memory/facts/fact_edit", + json={ + "content": "User prefers spaces", + "category": "workflow", + "confidence": 0.91, + }, + ) + + assert response.status_code == 200 + assert response.json()["facts"] == updated_memory["facts"] + + +def test_update_memory_fact_route_preserves_omitted_fields() -> None: + app = FastAPI() + app.include_router(memory.router) + updated_memory = _sample_memory( + facts=[ + { + "id": "fact_edit", + "content": "User prefers spaces", + "category": "preference", + "confidence": 0.8, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + + with patch("app.gateway.routers.memory.update_memory_fact", return_value=updated_memory) as update_fact: + with TestClient(app) as client: + response = client.patch( + "/api/memory/facts/fact_edit", + json={ + "content": "User prefers spaces", + }, + ) + + assert response.status_code == 200 + update_fact.assert_called_once_with( + fact_id="fact_edit", + content="User prefers spaces", + category=None, + confidence=None, + ) + assert response.json()["facts"] == updated_memory["facts"] + + +def test_update_memory_fact_route_returns_404_for_missing_fact() -> None: + app = FastAPI() + app.include_router(memory.router) + + with patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")): + with TestClient(app) as client: + response = client.patch( + "/api/memory/facts/fact_missing", + json={ + "content": "User prefers spaces", + "category": "workflow", + "confidence": 0.91, + }, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Memory fact 'fact_missing' not found." + + +def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None: + app = FastAPI() + app.include_router(memory.router) + + with patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")): + with TestClient(app) as client: + response = client.patch( + "/api/memory/facts/fact_edit", + json={ + "content": "User prefers spaces", + "confidence": 0.91, + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid confidence value; must be between 0 and 1." diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 2f2cec7c..a360a641 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -5,7 +5,9 @@ from deerflow.agents.memory.updater import ( MemoryUpdater, _extract_text, clear_memory_data, + create_memory_fact, delete_memory_fact, + update_memory_fact, ) from deerflow.config.memory_config import MemoryConfig @@ -184,6 +186,43 @@ def test_delete_memory_fact_removes_only_matching_fact() -> None: assert [fact["id"] for fact in result["facts"]] == ["fact_keep"] +def test_create_memory_fact_appends_manual_fact() -> None: + with ( + patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), + patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), + ): + result = create_memory_fact( + content=" User prefers concise code reviews. ", + category="preference", + confidence=0.88, + ) + + assert len(result["facts"]) == 1 + assert result["facts"][0]["content"] == "User prefers concise code reviews." + assert result["facts"][0]["category"] == "preference" + assert result["facts"][0]["confidence"] == 0.88 + assert result["facts"][0]["source"] == "manual" + + +def test_create_memory_fact_rejects_empty_content() -> None: + try: + create_memory_fact(content=" ") + except ValueError as exc: + assert exc.args == ("content",) + else: + raise AssertionError("Expected ValueError for empty fact content") + + +def test_create_memory_fact_rejects_invalid_confidence() -> None: + for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")): + try: + create_memory_fact(content="User likes tests", confidence=confidence) + except ValueError as exc: + assert exc.args == ("confidence",) + else: + raise AssertionError("Expected ValueError for invalid fact confidence") + + def test_delete_memory_fact_raises_for_unknown_id() -> None: with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()): try: @@ -194,6 +233,121 @@ def test_delete_memory_fact_raises_for_unknown_id() -> None: raise AssertionError("Expected KeyError for missing fact id") +def test_update_memory_fact_updates_only_matching_fact() -> None: + current_memory = _make_memory( + facts=[ + { + "id": "fact_keep", + "content": "User likes Python", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-03-18T00:00:00Z", + "source": "thread-a", + }, + { + "id": "fact_edit", + "content": "User prefers tabs", + "category": "preference", + "confidence": 0.8, + "createdAt": "2026-03-18T00:00:00Z", + "source": "manual", + }, + ] + ) + + with ( + patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory), + patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), + ): + result = update_memory_fact( + fact_id="fact_edit", + content="User prefers spaces", + category="workflow", + confidence=0.91, + ) + + assert result["facts"][0]["content"] == "User likes Python" + assert result["facts"][1]["content"] == "User prefers spaces" + assert result["facts"][1]["category"] == "workflow" + assert result["facts"][1]["confidence"] == 0.91 + assert result["facts"][1]["createdAt"] == "2026-03-18T00:00:00Z" + assert result["facts"][1]["source"] == "manual" + + +def test_update_memory_fact_preserves_omitted_fields() -> None: + current_memory = _make_memory( + facts=[ + { + "id": "fact_edit", + "content": "User prefers tabs", + "category": "preference", + "confidence": 0.8, + "createdAt": "2026-03-18T00:00:00Z", + "source": "manual", + }, + ] + ) + + with ( + patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory), + patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True), + ): + result = update_memory_fact( + fact_id="fact_edit", + content="User prefers spaces", + ) + + assert result["facts"][0]["content"] == "User prefers spaces" + assert result["facts"][0]["category"] == "preference" + assert result["facts"][0]["confidence"] == 0.8 + + +def test_update_memory_fact_raises_for_unknown_id() -> None: + with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()): + try: + update_memory_fact( + fact_id="fact_missing", + content="User prefers concise code reviews.", + category="preference", + confidence=0.88, + ) + except KeyError as exc: + assert exc.args == ("fact_missing",) + else: + raise AssertionError("Expected KeyError for missing fact id") + + +def test_update_memory_fact_rejects_invalid_confidence() -> None: + current_memory = _make_memory( + facts=[ + { + "id": "fact_edit", + "content": "User prefers tabs", + "category": "preference", + "confidence": 0.8, + "createdAt": "2026-03-18T00:00:00Z", + "source": "manual", + }, + ] + ) + + for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")): + with patch( + "deerflow.agents.memory.updater.get_memory_data", + return_value=current_memory, + ): + try: + update_memory_fact( + fact_id="fact_edit", + content="User prefers spaces", + confidence=confidence, + ) + except ValueError as exc: + assert exc.args == ("confidence",) + else: + raise AssertionError("Expected ValueError for invalid fact confidence") + + # --------------------------------------------------------------------------- # _extract_text — LLM response content normalization # --------------------------------------------------------------------------- diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index 03b75481..58208c7a 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -1,8 +1,8 @@ "use client"; -import { Trash2Icon } from "lucide-react"; +import { PenLineIcon, PlusIcon, Trash2Icon } from "lucide-react"; import Link from "next/link"; -import { useDeferredValue, useState } from "react"; +import { useDeferredValue, useId, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -16,14 +16,21 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useI18n } from "@/core/i18n/hooks"; import { useClearMemory, + useCreateMemoryFact, useDeleteMemoryFact, useMemory, + useUpdateMemoryFact, } from "@/core/memory/hooks"; -import type { UserMemory } from "@/core/memory/types"; +import type { + MemoryFactInput, + MemoryFactPatchInput, + UserMemory, +} from "@/core/memory/types"; import { streamdownPlugins } from "@/core/streamdown/plugins"; import { pathOfThread } from "@/core/threads/utils"; import { formatTimeAgo } from "@/core/utils/datetime"; @@ -44,6 +51,18 @@ type MemorySectionGroup = { sections: MemorySection[]; }; +type FactFormState = { + content: string; + category: string; + confidence: string; +}; + +const DEFAULT_FACT_FORM_STATE: FactFormState = { + content: "", + category: "context", + confidence: "0.8", +}; + function confidenceToLevelKey(confidence: unknown): { key: "veryHigh" | "high" | "normal" | "unknown"; value?: number; @@ -191,13 +210,24 @@ export function MemorySettingsPage() { const { t } = useI18n(); const { memory, isLoading, error } = useMemory(); const clearMemory = useClearMemory(); + const createMemoryFact = useCreateMemoryFact(); const deleteMemoryFact = useDeleteMemoryFact(); + const updateMemoryFact = useUpdateMemoryFact(); const [clearDialogOpen, setClearDialogOpen] = useState(false); const [factToDelete, setFactToDelete] = useState(null); + const [factToEdit, setFactToEdit] = useState(null); + const [factEditorOpen, setFactEditorOpen] = useState(false); + const [factForm, setFactForm] = useState( + DEFAULT_FACT_FORM_STATE, + ); const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); const deferredQuery = useDeferredValue(query); const normalizedQuery = deferredQuery.trim().toLowerCase(); + const factContentInputId = useId(); + const factCategoryInputId = useId(); + const factConfidenceInputId = useId(); + const factConfidenceHintId = useId(); const clearAllLabel = t.settings.memory.clearAll ?? "Clear all memory"; const clearAllConfirmTitle = @@ -214,10 +244,23 @@ export function MemorySettingsPage() { "This fact will be removed from memory immediately. This action cannot be undone."; const factDeleteSuccess = t.settings.memory.factDeleteSuccess ?? "Fact deleted"; + const addFactLabel = t.settings.memory.addFact; + const addFactTitle = t.settings.memory.addFactTitle; + const editFactTitle = t.settings.memory.editFactTitle; + const addFactSuccess = t.settings.memory.addFactSuccess; + const editFactSuccess = t.settings.memory.editFactSuccess; + const factContentLabel = t.settings.memory.factContentLabel; + const factCategoryLabel = t.settings.memory.factCategoryLabel; + const factConfidenceLabel = t.settings.memory.factConfidenceLabel; + const factContentPlaceholder = t.settings.memory.factContentPlaceholder; + const factCategoryPlaceholder = t.settings.memory.factCategoryPlaceholder; + const factConfidenceHint = t.settings.memory.factConfidenceHint; + const factSave = t.settings.memory.factSave; + const factValidationContent = t.settings.memory.factValidationContent; + const factValidationConfidence = t.settings.memory.factValidationConfidence; + const manualFactSource = t.settings.memory.manualFactSource; const noFacts = t.settings.memory.noFacts ?? "No saved facts yet."; - const summaryReadOnly = - t.settings.memory.summaryReadOnly ?? - "Summary sections are read-only for now. You can currently clear all memory or delete individual facts."; + const summaryReadOnly = t.settings.memory.summaryReadOnly; const memoryFullyEmpty = t.settings.memory.memoryFullyEmpty ?? "No memory saved yet."; const factPreviewLabel = @@ -287,6 +330,68 @@ export function MemorySettingsPage() { } } + function openCreateFactDialog() { + setFactToEdit(null); + setFactForm(DEFAULT_FACT_FORM_STATE); + setFactEditorOpen(true); + } + + function openEditFactDialog(fact: MemoryFact) { + setFactToEdit(fact); + setFactForm({ + content: fact.content, + category: fact.category, + confidence: String(fact.confidence), + }); + setFactEditorOpen(true); + } + + async function handleSaveFact() { + const trimmedContent = factForm.content.trim(); + if (!trimmedContent) { + toast.error(factValidationContent); + return; + } + + const confidence = Number(factForm.confidence); + if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) { + toast.error(factValidationConfidence); + return; + } + + const input: MemoryFactInput = { + content: trimmedContent, + category: factForm.category.trim() || "context", + confidence, + }; + + try { + if (factToEdit) { + const patchInput: MemoryFactPatchInput = { + content: input.content, + category: input.category, + confidence: input.confidence, + }; + await updateMemoryFact.mutateAsync({ + factId: factToEdit.id, + input: patchInput, + }); + toast.success(editFactSuccess); + } else { + await createMemoryFact.mutateAsync(input); + toast.success(addFactSuccess); + } + setFactEditorOpen(false); + setFactToEdit(null); + setFactForm(DEFAULT_FACT_FORM_STATE); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + const isFactFormPending = + createMemoryFact.isPending || updateMemoryFact.isPending; + return ( <> - +
+ + +
{!hasMatchingVisibleContent && normalizedQuery ? ( @@ -412,25 +523,45 @@ export function MemorySettingsPage() {

{fact.content}

- - {t.settings.memory.markdown.table.view} - + {fact.source === "manual" ? ( + + {manualFactSource} + + ) : ( + + {t.settings.memory.markdown.table.view} + + )} - +
+ + + +
); })} @@ -467,6 +598,118 @@ export function MemorySettingsPage() { + { + setFactEditorOpen(open); + if (!open) { + setFactToEdit(null); + setFactForm(DEFAULT_FACT_FORM_STATE); + } + }} + > + + + + {factToEdit ? editFactTitle : addFactTitle} + + +
+
+ +