diff --git a/src/attune/memory/graph.py b/src/attune/memory/graph.py index ac667e8e7..88523502b 100644 --- a/src/attune/memory/graph.py +++ b/src/attune/memory/graph.py @@ -154,8 +154,14 @@ def _generate_id(self, finding: dict[str, Any]) -> str: def add_finding(self, workflow: str, finding: dict[str, Any]) -> str: """Add a finding from any workflow, return node ID. + Also accepts curated cross-session memory (no workflow origin) via + the ``USER_CONTEXT``/``FEEDBACK``/``PROJECT_CONTEXT``/``REFERENCE`` + node types - pass ``workflow=""`` for those (see + ``attune.memory.nodes`` module docstring). + Args: - workflow: Name of the workflow adding this finding + workflow: Name of the workflow adding this finding, or "" for + a curated-memory node with no workflow origin. finding: Dict with at least 'type' and 'name' keys Returns: diff --git a/src/attune/memory/nodes.py b/src/attune/memory/nodes.py index 4e04f7231..3bd0e4fc4 100644 --- a/src/attune/memory/nodes.py +++ b/src/attune/memory/nodes.py @@ -1,7 +1,14 @@ """Memory Graph Node Types -Defines node types for the cross-workflow knowledge graph. -Each node represents an entity discovered by a workflow. +Defines node types for the cross-workflow knowledge graph. Most nodes +represent an entity discovered by a workflow (a bug, a fix, a test). +The "Curated memory" category (below) is the exception: those nodes +represent hand-curated cross-session memory - decisions, standing +feedback, project context - that has no workflow origin and no +resolution lifecycle. ``source_workflow`` stays empty and ``severity`` +stays unset for curated-memory nodes; ``status`` is reinterpreted as +active/superseded/stale rather than open/investigating/resolved/wontfix +(same field, no schema change). Copyright 2025 Smart AI Memory, LLC Licensed under the Apache License, Version 2.0 @@ -47,6 +54,12 @@ class NodeType(Enum): DEPENDENCY = "dependency" LICENSE = "license" + # Curated memory (not workflow findings - see module docstring) + USER_CONTEXT = "user_context" + FEEDBACK = "feedback" + PROJECT_CONTEXT = "project_context" + REFERENCE = "reference" + @dataclass class Node: diff --git a/tests/memory/test_graph.py b/tests/memory/test_graph.py index 00eb5d9e1..4d8ba6a08 100644 --- a/tests/memory/test_graph.py +++ b/tests/memory/test_graph.py @@ -56,8 +56,9 @@ def test_all_node_types_count(self): """Test total number of node types.""" # FILE, FUNCTION, CLASS, MODULE, BUG, VULNERABILITY, PERFORMANCE_ISSUE, # CODE_SMELL, TECH_DEBT, PATTERN, FIX, REFACTOR, TEST, TEST_CASE, - # COVERAGE_GAP, DOC, API_ENDPOINT, DEPENDENCY, LICENSE - assert len(NodeType) == 19 + # COVERAGE_GAP, DOC, API_ENDPOINT, DEPENDENCY, LICENSE, + # USER_CONTEXT, FEEDBACK, PROJECT_CONTEXT, REFERENCE + assert len(NodeType) == 23 def test_node_type_from_string(self): """Test creating NodeType from string.""" diff --git a/tests/unit/memory/test_graph.py b/tests/unit/memory/test_graph.py index 5f2723ba8..cbadfd74a 100644 --- a/tests/unit/memory/test_graph.py +++ b/tests/unit/memory/test_graph.py @@ -121,6 +121,38 @@ def test_handles_corrupt_json(self, temp_graph_path): graph = MemoryGraph(path=temp_graph_path) assert len(graph.nodes) == 0 + def test_loads_curated_memory_node_via_real_add_finding(self, temp_graph_path): + """Regression guard: before NodeType.FEEDBACK/USER_CONTEXT/ + PROJECT_CONTEXT/REFERENCE existed, reloading a graph containing one + of these types raised + ``ValueError: 'feedback' is not a valid NodeType`` inside + MemoryGraph._load() -> Node.from_dict() -> NodeType(data["type"]). + Exercises the real public API (add_finding, not manual Node + construction) end to end: add, save, reload in a fresh instance, + traverse via find_related.""" + graph1 = MemoryGraph(path=temp_graph_path) + feedback_id = graph1.add_finding( + workflow="", + finding={ + "type": "feedback", + "name": "Standing commitments to Patrick", + "status": "active", + }, + ) + project_id = graph1.add_finding( + workflow="", + finding={"type": "project_context", "name": "Memory subsystem motivation"}, + ) + graph1.add_edge(project_id, feedback_id, EdgeType.RELATED_TO) + + # A fresh instance must reload without raising. + graph2 = MemoryGraph(path=temp_graph_path) + + assert graph2.nodes[feedback_id].type == NodeType.FEEDBACK + assert graph2.nodes[project_id].type == NodeType.PROJECT_CONTEXT + related = graph2.find_related(project_id, edge_types=[EdgeType.RELATED_TO]) + assert [n.id for n in related] == [feedback_id] + # ============================================================================= # NODE OPERATIONS diff --git a/tests/unit/memory/test_nodes_coverage_boost.py b/tests/unit/memory/test_nodes_coverage_boost.py index 54b2347d2..f9c4e2ff7 100644 --- a/tests/unit/memory/test_nodes_coverage_boost.py +++ b/tests/unit/memory/test_nodes_coverage_boost.py @@ -63,6 +63,14 @@ def test_node_type_enum_has_dependency_types(self): assert NodeType.DEPENDENCY.value == "dependency" assert NodeType.LICENSE.value == "license" + def test_node_type_enum_has_curated_memory_types(self): + """Test that NodeType enum includes curated cross-session memory + types (not workflow findings - see module docstring).""" + assert NodeType.USER_CONTEXT.value == "user_context" + assert NodeType.FEEDBACK.value == "feedback" + assert NodeType.PROJECT_CONTEXT.value == "project_context" + assert NodeType.REFERENCE.value == "reference" + def test_node_type_can_be_created_from_string_value(self): """Test that NodeType can be created from string value.""" node_type = NodeType("bug") @@ -351,6 +359,33 @@ def test_roundtrip_serialization(self): assert restored.updated_at == original.updated_at assert restored.status == original.status + def test_curated_memory_node_roundtrip_serialization(self): + """Regression guard: before the curated-memory NodeType members + existed, Node.from_dict(node.to_dict()) on a FEEDBACK/USER_CONTEXT/ + PROJECT_CONTEXT/REFERENCE node raised + ``ValueError: '' is not a valid NodeType`` at reload time + (from_dict calls NodeType(data["type"]) against whatever the enum + actually contains). severity stays unset and status is + reinterpreted (active, not open/resolved) for these node types - + see the module docstring.""" + original = Node( + id="feedback_my_commitments_to_patrick", + type=NodeType.FEEDBACK, + name="Standing commitments to Patrick", + description="Neutral curiosity, full attention, correction without ego.", + source_file="/memory/feedback_my_commitments_to_patrick.md", + severity="", + status="active", + tags=["relationship", "standing-commitment"], + ) + + restored = Node.from_dict(original.to_dict()) + + assert restored.type == NodeType.FEEDBACK + assert restored.severity == "" + assert restored.status == "active" + assert restored.source_workflow == "" + @pytest.mark.unit class TestBugNode: