diff --git a/context-graph/skills-graph/README.md b/context-graph/skills-graph/README.md index cd05149..88b7905 100644 --- a/context-graph/skills-graph/README.md +++ b/context-graph/skills-graph/README.md @@ -6,8 +6,6 @@ A small library to persist, retrieve and evolve AI skills in [Memgraph](https:// ``` (:Skill {name, description, content, created_at, updated_at}) -(:Tag {name}) -(:Skill)-[:HAS_TAG]->(:Tag) (:Skill)-[:DEPENDS_ON]->(:Skill) ``` @@ -27,14 +25,12 @@ sg.add_skill(Skill( name="memgraph-cypher", description="Writing Cypher queries for Memgraph", content="# Cypher for Memgraph\n\nUse MATCH, CREATE, MERGE ...", - tags=["cypher", "memgraph"], )) # Retrieve by name skill = sg.get_skill("memgraph-cypher") # Search -sg.search_by_tags(["cypher"]) sg.search_by_name("memgraph") # Dependencies @@ -45,7 +41,7 @@ deps = sg.get_dependencies("advanced-cypher") all_skills = sg.list_skills() # Update -sg.update_skill("memgraph-cypher", content="updated content", tags=["cypher"]) +sg.update_skill("memgraph-cypher", content="updated content") # Delete sg.delete_skill("memgraph-cypher") diff --git a/context-graph/skills-graph/src/skills_graph/connector.py b/context-graph/skills-graph/src/skills_graph/connector.py index ff5d8eb..16ab39c 100644 --- a/context-graph/skills-graph/src/skills_graph/connector.py +++ b/context-graph/skills-graph/src/skills_graph/connector.py @@ -21,6 +21,8 @@ from __future__ import annotations import json +import shlex +from pathlib import Path from typing import TYPE_CHECKING, Any from agent_context_graph.events import Event, EventType, ToolEndEvent, ToolStartEvent @@ -35,6 +37,7 @@ EventType.TOOL_END, } _MAX_RESULT_DEPTH = 10 +_SKILL_FILE_NAME = "SKILL.md" class SkillGraphConnector(GraphConnector): @@ -58,7 +61,6 @@ class SkillGraphConnector(GraphConnector): "delete_skill", "list_skills", "search_skills", - "search_by_tags", "search_by_name", } ) @@ -79,7 +81,11 @@ def __init__( def supports(self, event: Event) -> bool: if event.event_type not in _SUPPORTED_EVENTS: return False - if isinstance(event, ToolStartEvent | ToolEndEvent): + if isinstance(event, ToolStartEvent): + if self._operation_name(event.tool_name) in self._skill_tool_names: + return True + return self._extract_skill_file_read(event.tool_input) is not None + if isinstance(event, ToolEndEvent): return self._operation_name(event.tool_name) in self._skill_tool_names return False @@ -95,6 +101,23 @@ def on_event(self, event: Event) -> None: def _on_tool_start(self, event: ToolStartEvent) -> None: """A skill-related tool was invoked — record the access.""" + skill_file = self._extract_skill_file_read(event.tool_input) + if skill_file is not None: + metadata = self._metadata_from_skill_file(skill_file) + skill_name = metadata.get("name") or skill_file.parent.name + self._record_skill_access( + session_id=event.session_id, + skill_name=skill_name, + action="read_skill_file", + timestamp=event.timestamp, + create_missing=True, + description=metadata.get("description", ""), + content=metadata.get("content", ""), + source_path=str(skill_file), + metadata=metadata.get("metadata", {}), + ) + return + skill_name = self._extract_skill_name(event.tool_input) if skill_name: self._record_skill_access( @@ -107,7 +130,7 @@ def _on_tool_start(self, event: ToolStartEvent) -> None: def _on_tool_end(self, event: ToolEndEvent) -> None: """A search/list tool returned — record which skills appeared.""" operation_name = self._operation_name(event.tool_name) - if operation_name not in {"list_skills", "search_skills", "search_by_tags", "search_by_name"}: + if operation_name not in {"list_skills", "search_skills", "search_by_name"}: return for name in self._extract_result_skill_names(event.result): self._record_skill_access( @@ -186,6 +209,79 @@ def _parse_json_result(value: str) -> Any: except json.JSONDecodeError: return None + @classmethod + def _extract_skill_file_read(cls, tool_input: Any) -> Path | None: + for value in cls._iter_string_values(tool_input): + for candidate in cls._candidate_paths(value): + skill_file = cls._skill_file_from_candidate(candidate) + if skill_file is not None: + return skill_file + return None + + @classmethod + def _iter_string_values(cls, value: Any, *, _depth: int = 0) -> list[str]: + if _depth > _MAX_RESULT_DEPTH: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + values: list[str] = [] + for item in value: + values.extend(cls._iter_string_values(item, _depth=_depth + 1)) + return values + if isinstance(value, dict): + values = [] + for item in value.values(): + values.extend(cls._iter_string_values(item, _depth=_depth + 1)) + return values + return [] + + @staticmethod + def _candidate_paths(value: str) -> list[str]: + try: + candidates = shlex.split(value) + except ValueError: + candidates = value.split() + return candidates or [value] + + @staticmethod + def _skill_file_from_candidate(candidate: str) -> Path | None: + if _SKILL_FILE_NAME not in candidate: + return None + + path = Path(candidate.strip()) + if path.name != _SKILL_FILE_NAME: + return None + if path.parent.name == "": + return None + return path + + @staticmethod + def _metadata_from_skill_file(path: Path) -> dict[str, Any]: + metadata: dict[str, Any] = { + "content": "", + "metadata": {"source": "local_skill_file", "source_path": str(path)}, + } + try: + content = path.read_text(encoding="utf-8") + except OSError: + metadata["name"] = path.parent.name + return metadata + + metadata["content"] = content + frontmatter = _parse_frontmatter(content) + name = frontmatter.get("name") + description = frontmatter.get("description") + if isinstance(name, str) and name: + metadata["name"] = name + else: + metadata["name"] = path.parent.name + if isinstance(description, str): + metadata["description"] = description + metadata_keys = {"version", "category", "author", "last_updated", "license", "compatibility"} + metadata["metadata"].update({key: str(value) for key, value in frontmatter.items() if key in metadata_keys}) + return metadata + def _record_skill_access( self, *, @@ -193,25 +289,52 @@ def _record_skill_access( skill_name: str, action: str, timestamp: str, + create_missing: bool = False, + description: str = "", + content: str = "", + source_path: str | None = None, + metadata: dict[str, str] | None = None, ) -> None: """Persist a ``(:Session)-[:USED_SKILL]->(:Skill)`` edge.""" - self._graph._db.query( - """ - MERGE (sess:Session {session_id: $session_id}) - WITH sess - MATCH (sk:Skill {name: $skill_name}) - MERGE (sess)-[r:USED_SKILL]->(sk) - ON CREATE SET r.first_access = $timestamp, - r.access_count = 1, - r.actions = [$action] - ON MATCH SET r.last_access = $timestamp, - r.access_count = r.access_count + 1, - r.actions = r.actions + $action - """, - params={ - "session_id": session_id, - "skill_name": skill_name, - "timestamp": timestamp, - "action": action, - }, + self._graph.record_skill_usage( + session_id=session_id, + skill_name=skill_name, + action=action, + timestamp=timestamp, + create_missing=create_missing, + description=description, + content=content, + source_path=source_path, + metadata=metadata, ) + + +def _parse_frontmatter(content: str) -> dict[str, Any]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return {} + + data: dict[str, Any] = {} + key_for_list: str | None = None + for line in lines[1:]: + stripped = line.strip() + if stripped == "---": + return data + if not stripped: + continue + if stripped.startswith("- ") and key_for_list: + data.setdefault(key_for_list, []).append(stripped[2:].strip().strip("\"'")) + continue + if ":" not in line: + key_for_list = None + continue + key, value = line.split(":", maxsplit=1) + key = key.strip() + value = value.strip() + if value: + data[key] = value.strip("\"'") + key_for_list = None + else: + data[key] = [] + key_for_list = key + return {} diff --git a/context-graph/skills-graph/src/skills_graph/core.py b/context-graph/skills-graph/src/skills_graph/core.py index d5d748c..b4cfc17 100644 --- a/context-graph/skills-graph/src/skills_graph/core.py +++ b/context-graph/skills-graph/src/skills_graph/core.py @@ -9,8 +9,8 @@ class SkillGraph: """Persist, retrieve and evolve AI skills in Memgraph. - Stores skills as (:Skill) nodes with optional (:Tag) relationships - and (:Skill)-[:DEPENDS_ON]->(:Skill) dependency edges. + Stores skills as (:Skill) nodes with optional + (:Skill)-[:DEPENDS_ON]->(:Skill) dependency edges. """ def __init__(self, memgraph: Memgraph | None = None, **kwargs): @@ -31,13 +31,11 @@ def setup(self) -> None: """Create constraints and indexes required for skill storage.""" self._db.query("CREATE CONSTRAINT ON (s:Skill) ASSERT s.name IS UNIQUE;") self._db.query("CREATE INDEX ON :Skill(name);") - self._db.query("CREATE INDEX ON :Tag(name);") def drop(self) -> None: """Remove all skill-related constraints and indexes.""" self._db.query("DROP CONSTRAINT ON (s:Skill) ASSERT s.name IS UNIQUE;") self._db.query("DROP INDEX ON :Skill(name);") - self._db.query("DROP INDEX ON :Tag(name);") # ------------------------------------------------------------------ # CRUD @@ -46,22 +44,19 @@ def drop(self) -> None: def add_skill(self, skill: Skill) -> Skill: """Persist a skill to Memgraph. - Creates the :Skill node, links it to :Tag nodes (MERGE-ed), - and returns the stored skill. + Creates or replaces the :Skill node fields and returns the stored skill. """ self._db.query( """ - CREATE (s:Skill { - name: $name, - description: $description, - content: $content, - license: $license, - compatibility: $compatibility, - metadata: $metadata, - allowed_tools: $allowed_tools, - created_at: $created_at, - updated_at: $updated_at - }) + MERGE (s:Skill {name: $name}) + ON CREATE SET s.created_at = $created_at + SET s.description = $description, + s.content = $content, + s.license = $license, + s.compatibility = $compatibility, + s.metadata = $metadata, + s.allowed_tools = $allowed_tools, + s.updated_at = $updated_at """, params={ "name": skill.name, @@ -76,25 +71,13 @@ def add_skill(self, skill: Skill) -> Skill: }, ) - if skill.tags: - self._db.query( - """ - MATCH (s:Skill {name: $name}) - UNWIND $tags AS tag_name - MERGE (t:Tag {name: tag_name}) - MERGE (s)-[:HAS_TAG]->(t) - """, - params={"name": skill.name, "tags": skill.tags}, - ) - return skill def get_skill(self, name: str) -> Skill | None: - """Retrieve a single skill by name, including its tags.""" + """Retrieve a single skill by name.""" rows = self._db.query( """ MATCH (s:Skill {name: $name}) - OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag) RETURN s.name AS name, s.description AS description, s.content AS content, @@ -103,8 +86,7 @@ def get_skill(self, name: str) -> Skill | None: s.metadata AS metadata, s.allowed_tools AS allowed_tools, s.created_at AS created_at, - s.updated_at AS updated_at, - collect(t.name) AS tags + s.updated_at AS updated_at """, params={"name": name}, ) @@ -125,7 +107,6 @@ def update_skill( compatibility: str | None = None, metadata: dict[str, str] | None = None, allowed_tools: list[str] | None = None, - tags: list[str] | None = None, ) -> Skill | None: """Update an existing skill. Only provided fields are changed.""" sets: list[str] = [] @@ -160,27 +141,10 @@ def update_skill( params=params, ) - if tags is not None: - # Remove old tag relationships and set new ones - self._db.query( - "MATCH (s:Skill {name: $name})-[r:HAS_TAG]->() DELETE r", - params={"name": name}, - ) - if tags: - self._db.query( - """ - MATCH (s:Skill {name: $name}) - UNWIND $tags AS tag_name - MERGE (t:Tag {name: tag_name}) - MERGE (s)-[:HAS_TAG]->(t) - """, - params={"name": name, "tags": tags}, - ) - return self.get_skill(name) def delete_skill(self, name: str) -> bool: - """Delete a skill and its tag relationships. Returns True if deleted.""" + """Delete a skill and its relationships. Returns True if deleted.""" rows = self._db.query( """ MATCH (s:Skill {name: $name}) @@ -191,6 +155,81 @@ def delete_skill(self, name: str) -> bool: ) return bool(rows and rows[0].get("deleted", 0) > 0) + def record_skill_usage( + self, + *, + session_id: str, + skill_name: str, + action: str, + timestamp: str, + create_missing: bool = False, + description: str = "", + content: str = "", + source_path: str | None = None, + metadata: dict[str, str] | None = None, + ) -> None: + """Record that a session used a skill. + + By default this preserves the historical behavior and only records + usage for skills that already exist. Inferred local SKILL.md reads can + opt into creating a minimal Skill node so filesystem-based skill use is + not dropped. + """ + params = { + "session_id": session_id, + "skill_name": skill_name, + "timestamp": timestamp, + "action": action, + "description": description, + "content": content, + "metadata": json.dumps(metadata or {}), + "source_path": source_path, + } + + if create_missing: + self._db.query( + """ + MERGE (sess:Session {session_id: $session_id}) + WITH sess + MERGE (sk:Skill {name: $skill_name}) + ON CREATE SET sk.description = $description, + sk.content = $content, + sk.license = null, + sk.compatibility = null, + sk.metadata = $metadata, + sk.allowed_tools = "[]", + sk.created_at = $timestamp, + sk.updated_at = $timestamp, + sk.source_path = $source_path + ON MATCH SET sk.source_path = coalesce(sk.source_path, $source_path) + MERGE (sess)-[r:USED_SKILL]->(sk) + ON CREATE SET r.first_access = $timestamp, + r.access_count = 1, + r.actions = [$action] + ON MATCH SET r.last_access = $timestamp, + r.access_count = r.access_count + 1, + r.actions = r.actions + $action + """, + params=params, + ) + return + + self._db.query( + """ + MERGE (sess:Session {session_id: $session_id}) + WITH sess + MATCH (sk:Skill {name: $skill_name}) + MERGE (sess)-[r:USED_SKILL]->(sk) + ON CREATE SET r.first_access = $timestamp, + r.access_count = 1, + r.actions = [$action] + ON MATCH SET r.last_access = $timestamp, + r.access_count = r.access_count + 1, + r.actions = r.actions + $action + """, + params=params, + ) + # ------------------------------------------------------------------ # Query / Search # ------------------------------------------------------------------ @@ -200,7 +239,6 @@ def list_skills(self) -> list[Skill]: rows = self._db.query( """ MATCH (s:Skill) - OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag) RETURN s.name AS name, s.description AS description, s.content AS content, @@ -209,45 +247,18 @@ def list_skills(self) -> list[Skill]: s.metadata AS metadata, s.allowed_tools AS allowed_tools, s.created_at AS created_at, - s.updated_at AS updated_at, - collect(t.name) AS tags + s.updated_at AS updated_at ORDER BY name """ ) return [self._row_to_skill(r) for r in rows] - def search_by_tags(self, tags: list[str]) -> list[Skill]: - """Find skills that have *all* of the given tags.""" - rows = self._db.query( - """ - MATCH (s:Skill)-[:HAS_TAG]->(mt:Tag) - WHERE mt.name IN $tags - WITH s, count(DISTINCT mt) AS matched - WHERE matched = size($tags) - OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag) - RETURN s.name AS name, - s.description AS description, - s.content AS content, - s.license AS license, - s.compatibility AS compatibility, - s.metadata AS metadata, - s.allowed_tools AS allowed_tools, - s.created_at AS created_at, - s.updated_at AS updated_at, - collect(t.name) AS tags - ORDER BY name - """, - params={"tags": tags}, - ) - return [self._row_to_skill(r) for r in rows] - def search_by_name(self, pattern: str) -> list[Skill]: """Find skills whose name contains the given substring (case-insensitive).""" rows = self._db.query( """ MATCH (s:Skill) WHERE toLower(s.name) CONTAINS toLower($pattern) - OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag) RETURN s.name AS name, s.description AS description, s.content AS content, @@ -256,8 +267,7 @@ def search_by_name(self, pattern: str) -> list[Skill]: s.metadata AS metadata, s.allowed_tools AS allowed_tools, s.created_at AS created_at, - s.updated_at AS updated_at, - collect(t.name) AS tags + s.updated_at AS updated_at ORDER BY name """, params={"pattern": pattern}, @@ -293,7 +303,6 @@ def get_dependencies(self, skill_name: str) -> list[Skill]: rows = self._db.query( """ MATCH (a:Skill {name: $skill_name})-[:DEPENDS_ON]->(s:Skill) - OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag) RETURN s.name AS name, s.description AS description, s.content AS content, @@ -302,8 +311,7 @@ def get_dependencies(self, skill_name: str) -> list[Skill]: s.metadata AS metadata, s.allowed_tools AS allowed_tools, s.created_at AS created_at, - s.updated_at AS updated_at, - collect(t.name) AS tags + s.updated_at AS updated_at ORDER BY name """, params={"skill_name": skill_name}, @@ -315,7 +323,6 @@ def get_dependents(self, skill_name: str) -> list[Skill]: rows = self._db.query( """ MATCH (s:Skill)-[:DEPENDS_ON]->(b:Skill {name: $skill_name}) - OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag) RETURN s.name AS name, s.description AS description, s.content AS content, @@ -324,8 +331,7 @@ def get_dependents(self, skill_name: str) -> list[Skill]: s.metadata AS metadata, s.allowed_tools AS allowed_tools, s.created_at AS created_at, - s.updated_at AS updated_at, - collect(t.name) AS tags + s.updated_at AS updated_at ORDER BY name """, params={"skill_name": skill_name}, @@ -352,7 +358,6 @@ def _row_to_skill(row: dict) -> Skill: compatibility=row.get("compatibility"), metadata=metadata, allowed_tools=allowed_tools, - tags=[t for t in row["tags"] if t is not None], created_at=row["created_at"], updated_at=row["updated_at"], ) diff --git a/context-graph/skills-graph/src/skills_graph/models.py b/context-graph/skills-graph/src/skills_graph/models.py index e844c62..8481b1f 100644 --- a/context-graph/skills-graph/src/skills_graph/models.py +++ b/context-graph/skills-graph/src/skills_graph/models.py @@ -60,8 +60,6 @@ class Skill: metadata: dict[str, str] = field(default_factory=dict) allowed_tools: list[str] = field(default_factory=list) - # --- Graph extensions --- - tags: list[str] = field(default_factory=list) created_at: str | None = None updated_at: str | None = None diff --git a/context-graph/skills-graph/tests/test_connector.py b/context-graph/skills-graph/tests/test_connector.py index bb411cc..c49d741 100644 --- a/context-graph/skills-graph/tests/test_connector.py +++ b/context-graph/skills-graph/tests/test_connector.py @@ -10,7 +10,7 @@ def _connector(): - graph = SimpleNamespace(_db=MagicMock()) + graph = SimpleNamespace(record_skill_usage=MagicMock()) return SkillGraphConnector(graph), graph @@ -27,10 +27,11 @@ def test_mcp_tool_name_is_treated_as_skill_tool(): connector.on_event(event) - params = graph._db.query.call_args.kwargs["params"] + params = graph.record_skill_usage.call_args.kwargs assert params["session_id"] == "s1" assert params["skill_name"] == "cypher-basics" assert params["action"] == "get_skill" + assert params["create_missing"] is False def test_mcp_search_result_records_nested_json_skill_names(): @@ -46,7 +47,7 @@ def test_mcp_search_result_records_nested_json_skill_names(): connector.on_event(event) - params = [call.kwargs["params"] for call in graph._db.query.call_args_list] + params = [call.kwargs for call in graph.record_skill_usage.call_args_list] assert [param["skill_name"] for param in params] == ["s1", "s2"] assert {param["action"] for param in params} == {"list_skills_result"} @@ -85,3 +86,49 @@ def test_result_extraction_still_reads_reasonable_nested_json(): result = {"content": [{"text": '{"results": [{"name": "s1"}]}'}]} assert SkillGraphConnector._extract_result_skill_names(result) == ["s1"] + + +def test_exec_command_reading_skill_file_is_recorded(tmp_path): + skill_dir = tmp_path / "skills" / "memgraph-console" + skill_dir.mkdir(parents=True) + skill_file = skill_dir / "SKILL.md" + skill_file.write_text( + """--- +name: memgraph-console +description: Use mgconsole with Memgraph +--- + +# Memgraph Console +""", + encoding="utf-8", + ) + connector, graph = _connector() + event = ToolStartEvent( + session_id="s1", + tool_name="exec_command", + tool_input={"cmd": f"sed -n '1,220p' {skill_file}"}, + timestamp="2026-04-30T00:00:00+00:00", + ) + + assert connector.supports(event) + + connector.on_event(event) + + params = graph.record_skill_usage.call_args.kwargs + assert params["session_id"] == "s1" + assert params["skill_name"] == "memgraph-console" + assert params["action"] == "read_skill_file" + assert params["create_missing"] is True + assert params["description"] == "Use mgconsole with Memgraph" + assert params["source_path"] == str(skill_file) + + +def test_non_skill_file_read_is_ignored(): + connector, _graph = _connector() + event = ToolStartEvent( + session_id="s1", + tool_name="exec_command", + tool_input={"cmd": "sed -n '1,80p' README.md"}, + ) + + assert not connector.supports(event) diff --git a/context-graph/skills-graph/tests/test_e2e.py b/context-graph/skills-graph/tests/test_e2e.py index 42b14fe..56f6bb8 100644 --- a/context-graph/skills-graph/tests/test_e2e.py +++ b/context-graph/skills-graph/tests/test_e2e.py @@ -13,7 +13,7 @@ def sg(): """SkillGraph connected to a live Memgraph, cleaned before each test.""" sg = SkillGraph() - # Clean up any leftover skill/tag nodes from prior runs + # Clean up any leftover graph nodes from prior runs sg._db.query("MATCH (n) DETACH DELETE n") sg.setup() yield sg @@ -34,7 +34,6 @@ def test_add_and_get_skill(sg): compatibility="Requires Python 3.10+", metadata={"author": "example-org", "version": "1.0"}, allowed_tools=["Bash(git:*)", "Read"], - tags=["pdf", "extraction"], ) sg.add_skill(skill) @@ -47,17 +46,15 @@ def test_add_and_get_skill(sg): assert retrieved.compatibility == "Requires Python 3.10+" assert retrieved.metadata == {"author": "example-org", "version": "1.0"} assert retrieved.allowed_tools == ["Bash(git:*)", "Read"] - assert set(retrieved.tags) == {"pdf", "extraction"} def test_update_skill(sg): sg.add_skill(Skill(name="s1", description="original", content="v1")) - updated = sg.update_skill("s1", description="changed", content="v2", tags=["new"]) + updated = sg.update_skill("s1", description="changed", content="v2") assert updated is not None assert updated.description == "changed" assert updated.content == "v2" - assert updated.tags == ["new"] def test_delete_skill(sg): @@ -77,22 +74,6 @@ def test_list_skills(sg): assert "b2" in names -def test_search_by_tags(sg): - sg.add_skill(Skill(name="s1", description="d", content="c", tags=["python", "graph"])) - sg.add_skill(Skill(name="s2", description="d", content="c", tags=["python"])) - sg.add_skill(Skill(name="s3", description="d", content="c", tags=["rust"])) - - results = sg.search_by_tags(["python"]) - names = [s.name for s in results] - assert "s1" in names - assert "s2" in names - assert "s3" not in names - - results = sg.search_by_tags(["python", "graph"]) - assert len(results) == 1 - assert results[0].name == "s1" - - def test_search_by_name(sg): sg.add_skill(Skill(name="cypher-basics", description="d", content="c")) sg.add_skill(Skill(name="advanced-cypher", description="d", content="c")) diff --git a/context-graph/skills-graph/tests/test_models.py b/context-graph/skills-graph/tests/test_models.py index 16a190f..2a35f09 100644 --- a/context-graph/skills-graph/tests/test_models.py +++ b/context-graph/skills-graph/tests/test_models.py @@ -142,10 +142,6 @@ def test_allowed_tools_default_empty(self): s = Skill(name="s1", description="does stuff", content="body") assert s.allowed_tools == [] - def test_tags_default_empty(self): - s = Skill(name="s1", description="does stuff", content="body") - assert s.tags == [] - # ------------------------------------------------------------------ # Full construction with all fields @@ -162,14 +158,12 @@ def test_all_fields(self): compatibility="Requires Python 3.10+", metadata={"author": "example-org", "version": "1.0"}, allowed_tools=["Bash(git:*)", "Read"], - tags=["pdf", "extraction"], ) assert s.name == "pdf-processing" assert s.license == "Apache-2.0" assert s.compatibility == "Requires Python 3.10+" assert s.metadata == {"author": "example-org", "version": "1.0"} assert s.allowed_tools == ["Bash(git:*)", "Read"] - assert s.tags == ["pdf", "extraction"] assert s.created_at is not None assert s.updated_at is not None diff --git a/context-graph/skills-graph/tests/test_skill_graph.py b/context-graph/skills-graph/tests/test_skill_graph.py index a5a7baf..613b4a8 100644 --- a/context-graph/skills-graph/tests/test_skill_graph.py +++ b/context-graph/skills-graph/tests/test_skill_graph.py @@ -26,7 +26,6 @@ def _make_row( allowed_tools="[]", created_at="2025-01-01", updated_at="2025-01-01", - tags=None, ): return { "name": name, @@ -38,7 +37,6 @@ def _make_row( "allowed_tools": allowed_tools, "created_at": created_at, "updated_at": updated_at, - "tags": tags or [], } @@ -52,7 +50,6 @@ def test_setup_creates_constraints_and_indexes(sg, mock_memgraph): calls = [c.args[0] for c in mock_memgraph.query.call_args_list] assert any("CONSTRAINT" in c and "Skill" in c for c in calls) assert any("INDEX" in c and "Skill" in c for c in calls) - assert any("INDEX" in c and "Tag" in c for c in calls) def test_drop_removes_constraints_and_indexes(sg, mock_memgraph): @@ -60,7 +57,6 @@ def test_drop_removes_constraints_and_indexes(sg, mock_memgraph): calls = [c.args[0] for c in mock_memgraph.query.call_args_list] assert any("DROP CONSTRAINT" in c for c in calls) assert any("DROP INDEX" in c and "Skill" in c for c in calls) - assert any("DROP INDEX" in c and "Tag" in c for c in calls) # ------------------------------------------------------------------ @@ -68,23 +64,13 @@ def test_drop_removes_constraints_and_indexes(sg, mock_memgraph): # ------------------------------------------------------------------ -def test_add_skill_without_tags(sg, mock_memgraph): +def test_add_skill(sg, mock_memgraph): skill = Skill(name="s1", description="desc", content="body") result = sg.add_skill(skill) assert result.name == "s1" - # Only the CREATE call, no UNWIND for tags assert mock_memgraph.query.call_count == 1 - - -def test_add_skill_with_tags(sg, mock_memgraph): - skill = Skill(name="s1", description="desc", content="body", tags=["a", "b"]) - sg.add_skill(skill) - - assert mock_memgraph.query.call_count == 2 - tag_call = mock_memgraph.query.call_args_list[1] - assert "UNWIND" in tag_call.args[0] - assert tag_call.kwargs["params"]["tags"] == ["a", "b"] + assert "MERGE (s:Skill {name: $name})" in mock_memgraph.query.call_args.args[0] def test_add_skill_persists_spec_fields(sg, mock_memgraph): @@ -117,11 +103,10 @@ def test_get_skill_returns_none_when_missing(sg, mock_memgraph): def test_get_skill_returns_skill(sg, mock_memgraph): - mock_memgraph.query.return_value = [_make_row(name="s1", tags=["x"])] + mock_memgraph.query.return_value = [_make_row(name="s1")] skill = sg.get_skill("s1") assert skill is not None assert skill.name == "s1" - assert skill.tags == ["x"] def test_get_skill_deserializes_spec_fields(sg, mock_memgraph): @@ -157,14 +142,6 @@ def test_update_skill_sets_fields(sg, mock_memgraph): assert "content" in set_call.args[0] -def test_update_skill_replaces_tags(sg, mock_memgraph): - mock_memgraph.query.return_value = [_make_row(name="s1", tags=["new-tag"])] - sg.update_skill("s1", tags=["new-tag"]) - - # SET call, DELETE old tags, MERGE new tags, GET - assert mock_memgraph.query.call_count == 4 - - def test_update_skill_sets_spec_fields(sg, mock_memgraph): mock_memgraph.query.return_value = [_make_row(name="s1")] sg.update_skill( @@ -203,6 +180,48 @@ def test_delete_skill_returns_false_when_missing(sg, mock_memgraph): assert sg.delete_skill("s1") is False +# ------------------------------------------------------------------ +# Usage +# ------------------------------------------------------------------ + + +def test_record_skill_usage_matches_existing_skill_by_default(sg, mock_memgraph): + sg.record_skill_usage( + session_id="s1", + skill_name="cypher-basics", + action="get_skill", + timestamp="2026-04-30T00:00:00+00:00", + ) + + call = mock_memgraph.query.call_args + assert "MATCH (sk:Skill {name: $skill_name})" in call.args[0] + assert "MERGE (sess)-[r:USED_SKILL]->(sk)" in call.args[0] + assert call.kwargs["params"]["skill_name"] == "cypher-basics" + assert call.kwargs["params"]["action"] == "get_skill" + + +def test_record_skill_usage_can_create_missing_skill(sg, mock_memgraph): + sg.record_skill_usage( + session_id="s1", + skill_name="memgraph-console", + action="read_skill_file", + timestamp="2026-04-30T00:00:00+00:00", + create_missing=True, + description="Use mgconsole", + content="# Skill", + source_path="/tmp/skills/memgraph-console/SKILL.md", + metadata={"source": "local_skill_file"}, + ) + + call = mock_memgraph.query.call_args + assert "MERGE (sk:Skill {name: $skill_name})" in call.args[0] + assert "ON CREATE SET sk.description = $description" in call.args[0] + params = call.kwargs["params"] + assert params["skill_name"] == "memgraph-console" + assert params["description"] == "Use mgconsole" + assert params["metadata"] == json.dumps({"source": "local_skill_file"}) + + # ------------------------------------------------------------------ # List / Search # ------------------------------------------------------------------ @@ -211,20 +230,13 @@ def test_delete_skill_returns_false_when_missing(sg, mock_memgraph): def test_list_skills(sg, mock_memgraph): mock_memgraph.query.return_value = [ _make_row(name="a"), - _make_row(name="b", tags=["x"]), + _make_row(name="b"), ] skills = sg.list_skills() assert len(skills) == 2 assert skills[0].name == "a" -def test_search_by_tags(sg, mock_memgraph): - mock_memgraph.query.return_value = [] - result = sg.search_by_tags(["python"]) - assert result == [] - assert "HAS_TAG" in mock_memgraph.query.call_args.args[0] - - def test_search_by_name(sg, mock_memgraph): mock_memgraph.query.return_value = [] sg.search_by_name("graph")