"""Knowledge graph store + heuristic entity extraction.
Entities are keyed by (type, name); each tracks how many times it's been seen and
when last seen. ``record_from_text`` extracts entities by pattern. Persistence is a
single JSON file (default ``~/.clawcodex/knowledge/graph.json``).
"""
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
def default_graph_path() -> Path:
return Path.home() / ".clawcodex" / "knowledge" / "graph.json"
@dataclass
class Entity:
name: str
type: str
count: int = 1
last_seen: float = 0.0
attributes: dict[str, str] = field(default_factory=dict)
_FILE_RE = re.compile(r"(?<![\w/])([\w./-]+\.(?:py|ts|tsx|js|jsx|md|json|toml|yaml|yml|rs|go|sh|txt))\b")
_SYMBOL_RE = re.compile(r"`([A-Za-z_][\w./]{1,60})`")
_URL_RE = re.compile(r"https?://[^\s)\]>'\"]+")
class KnowledgeGraph:
"""In-memory entity store with heuristic extraction + JSON persistence."""
def __init__(self) -> None:
self.entities: dict[str, Entity] = {}
@staticmethod
def _key(name: str, etype: str) -> str:
return f"{etype}:{name}"
def add(self, name: str, etype: str, now: float = 0.0) -> None:
name = name.strip()
if not name:
return
key = self._key(name, etype)
ent = self.entities.get(key)
if ent is None:
self.entities[key] = Entity(name=name, type=etype, count=1, last_seen=now)
else:
ent.count += 1
if now:
ent.last_seen = now
def record_from_text(self, text: str, now: float = 0.0) -> int:
"""Extract entities from ``text``; returns the number of mentions recorded."""
if not text:
return 0
n = 0
for m in _FILE_RE.finditer(text):
self.add(m.group(1), "file", now)
n += 1
for m in _SYMBOL_RE.finditer(text):
self.add(m.group(1), "symbol", now)
n += 1
for m in _URL_RE.finditer(text):
self.add(m.group(0), "url", now)
n += 1
return n
def stats(self) -> dict[str, int]:
by_type: dict[str, int] = {}
for ent in self.entities.values():
by_type[ent.type] = by_type.get(ent.type, 0) + 1
return {"total": len(self.entities), **by_type}
def top(self, limit: int = 20) -> list[Entity]:
return sorted(self.entities.values(), key=lambda e: (-e.count, -e.last_seen, e.name))[:limit]
def clear(self) -> None:
self.entities.clear()
def to_dict(self) -> dict:
return {"entities": [asdict(e) for e in self.entities.values()]}
@classmethod
def from_dict(cls, data: dict) -> "KnowledgeGraph":
g = cls()
for e in data.get("entities", []) or []:
try:
ent = Entity(
name=str(e["name"]),
type=str(e.get("type", "")),
count=int(e.get("count", 1)),
last_seen=float(e.get("last_seen", 0.0)),
attributes=dict(e.get("attributes", {})),
)
g.entities[cls._key(ent.name, ent.type)] = ent
except Exception:
continue
return g
def save(self, path: Path | None = None) -> None:
p = path or default_graph_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(self.to_dict()), encoding="utf-8")
@classmethod
def load(cls, path: Path | None = None) -> "KnowledgeGraph":
p = path or default_graph_path()
try:
return cls.from_dict(json.loads(p.read_text(encoding="utf-8")))
except Exception:
return cls()