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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions context-graph/skills-graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand All @@ -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
Expand All @@ -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")
Expand Down
167 changes: 145 additions & 22 deletions context-graph/skills-graph/src/skills_graph/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +37,7 @@
EventType.TOOL_END,
}
_MAX_RESULT_DEPTH = 10
_SKILL_FILE_NAME = "SKILL.md"


class SkillGraphConnector(GraphConnector):
Expand All @@ -58,7 +61,6 @@ class SkillGraphConnector(GraphConnector):
"delete_skill",
"list_skills",
"search_skills",
"search_by_tags",
"search_by_name",
}
)
Expand All @@ -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

Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -186,32 +209,132 @@ 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,
*,
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:
"""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 {}
Loading
Loading