memory-graph
Skill2 starsChanged 18 days agoMITView on GitHub
Build a knowledge-graph memory: entity extraction, conservative entity resolution, edge storage, and seed-and-traverse retrieval
- Reads credentials
3 other projects use a version of this.
Adopted by
Most recently taken up by
- gitByEOS/open-part-skillstheir own version, first seen Nov 2, 2026
- Adarshukumar/skillstheir own version, first seen Sep 22, 2026
- jiawood2006/hermes-skillstheir own version, first seen Aug 24, 2026
Other versions3 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 3
- gitByEOS/open-part-skills★ 38same name
- jiawood2006/hermes-skills★ 2same name
- Adarshukumar/skills★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository9 other files it gives its agents.
- memory-consolidationSkill
- memory-episodicSkill
- memory-evalSkill
- memory-proceduralSkill
- memory-scratchpadSkill
- memory-semanticSkill
- memory-sharedSkill
- memory-temporalSkill
- memory-workingSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 548
All offline. See references/evaluation.md for the universal set.
- Very specific: line 624
Follow references/evaluation.md.
- Distinctive: line 630
Run each one against a plain top-k store as the control: If top-k answers them too, you did not need the graph, and that is the most useful …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Graph Memory
Top-k retrieval answers "what text is similar to this question". A graph answers "what is connected to what". The difference only matters for multi-hop questions — "who else worked on the thing she shipped last quarter" — where the answer exists in no single record and vector search structurally cannot find it, because no stored chunk is similar to the question.
That is the entire case for this skill, and it is narrower than it looks. Most questions are single-hop. Buy the graph when you have demonstrated multi-hop questions that top-k provably fails on, not when the diagram looks impressive.
text ──► extract (entity, relation, entity) triples
│
▼
resolve entities ──► same node, or new node?
│ (conservative: when in doubt, new)
▼
upsert nodes + edges, each with provenance
│
query ─────┴──► seed: which nodes does the question name?
│
▼
traverse: 1..N hops, budgeted, scored by path
│
▼
render the subgraph as sentences, with the path shownUse this when
- Questions chain across entities: relationship lookups, "who/what connects these two", provenance chains, org and dependency structures.
- The domain has a natural entity structure that a human would draw as boxes and arrows — people, services, repos, tickets, papers and their citations.
- You need to answer aggregate questions over connections: "how many services depend on this one", which top-k cannot do at all because it sees only what it ranked.
Do not use this when your lookups are single-hop, which is most of them. The cost is an extraction step that must decide entity identity, and getting that wrong poisons every traversal that passes through the bad node. A graph with confused entities is worse than no graph: it returns confidently connected nonsense.
Do not use this when the entity vocabulary is open and enormous. Free text about the whole world yields a graph with one edge per node and no traversal worth doing. Graphs pay off in bounded domains.
Pair with memory-semantic: extract entities once, store the sentence form as a
fact and the triple as an edge, then traverse when the question is multi-hop and
fall back to top-k when it is not.
Workflow
- Ask for the entity types and the relations. What are the boxes, what are the arrows? A closed list of 5-15 relation types is the single strongest predictor of whether this works. If the user has no list, propose one from their domain and get it confirmed — do not default to an open vocabulary.
- Ask for the identity rule. How do you know two mentions are the same entity? An ID field? An exact name? Only human judgement? This determines how aggressive resolution can be.
- Scaffold the project into the directory the user names. Kebab-case
folder, snake_case package,
hatchling+pyproject.toml,src/layout. - Write the six modules:
graph.py(nodes, edges),store.py(SQLite),extractor.py(text → triples),resolver.py(entity identity),traverse.py(seed and walk),render.py. Thencli.py. - Verify against the contract in
references/evaluation.md, plus the graph-specific tests below.
The decisions that matter
1. Entity resolution — the decision the whole skill rests on
Two mentions, one entity or two? Get it wrong in one direction and the graph fragments (three "Arttu" nodes, none connected). Get it wrong in the other and it collapses — two people merged into one node, every edge on both now false, and a privacy incident besides.
The two errors are not symmetric. Fragmentation is a recall problem, visible, and fixable at any time by adding an alias. Collapse is a correctness problem, invisible, and hard to undo once edges have accumulated on the merged node. So: when in doubt, create a new node.
A ladder of resolution strategies, in descending order of safety:
- Exact ID match. A ticket number, a repo URL, an email. Always safe, and the reason to ask for the identity rule up front.
- Exact normalized name within a scope and type. Case-folded, punctuation stripped. Safe enough in a bounded domain.
- Explicit alias table. Populated by a human or by the user saying "that's the same person". Safe and auditable.
- Model adjudication with the surrounding context. "Are these the same entity? Here are three sentences about each." Use only for candidates that already passed a similarity filter, and only to confirm — never let a positive verdict alone merge nodes that share no identifier.
- Embedding similarity. Never on its own. "Arttu Antikainen" and "Anna Antikainen" are close vectors and different people.
Whatever merges, record the merge with both source IDs and the reason. It is the only path back.
MERGE_LOG = "node_merges(kept_id, merged_id, strategy, evidence, at)"2. Closed relation vocabulary
Open extraction ("find any relations") produces works_at, employed_by,
is_employed_at, and has_job_at as four distinct predicates over the same
facts. Traversal then misses two-thirds of the graph.
Fix it at extraction: give the model the list and require it to pick, with an explicit escape.
RELATIONS = [
"works_on", "depends_on", "owns", "created", "part_of",
"located_in", "reports_to", "uses", "supersedes", "mentions",
]And handle the escape honestly: a triple whose relation is not in the list is
stored with relation="OTHER" and the raw phrase in metadata, not dropped.
Review the OTHER bucket periodically — it is how the vocabulary grows, and a
bucket that is 40% of your edges means the vocabulary is wrong.
Decide direction once and normalize on write. (a, depends_on, b) and
(b, required_by, a) must not both exist; pick the canonical direction per
relation and store the inverse as a query-time view.
3. Traversal: seed, budget, score by path
seed ── which nodes does the question actually name? ──► exact + fuzzy match
walk ── expand 1 hop at a time, breadth-first, budgeted
score ── each path decays per hop, weighted by edge confidence
stop ── max_hops (2, sometimes 3) or max_nodes, whichever firstTwo hops is the answer almost always. One hop is a lookup you did not need a
graph for. Three hops in a connected domain reaches most of the graph and
returns noise with a path attached. Make max_hops configurable and default it
to 2.
Score paths so that distance costs something:
path_score = product(edge.confidence for edge in path) * (decay ** (len(path) - 1))with decay ≈ 0.6. A two-hop path through two confident edges beats a one-hop
path through a shaky one, which is the behaviour you want.
Budget by nodes, not just hops. A single hub node — "the company", "Python",
a shared dependency — can have thousands of edges, and expanding it once blows
the budget and floods the result. Cap the per-node fan-out (max_fanout ≈ 20,
highest-confidence edges first) and skip nodes whose degree exceeds a threshold
unless the question named them directly. Hub nodes are where naive traversal
dies.
4. Provenance on every edge
An edge with no source is an assertion nobody can check. Store, per edge: the source record or document ID, the sentence it came from, the extraction confidence, and the timestamp. It costs a few columns and buys you the ability to answer "why do you think these are connected", to delete a source document and have its edges go with it, and to debug a bad traversal by reading the sentence that produced the wrong edge.
5. Do not reach for a graph database
Two SQLite tables and a breadth-first walk in Python handle graphs into the hundreds of thousands of edges at interactive speed. Neo4j, Cypher, and a server process buy you query expressiveness you will not use at this size and cost you an operational dependency.
CREATE TABLE nodes (id TEXT PRIMARY KEY, scope TEXT, type TEXT, name TEXT,
aliases TEXT, attrs TEXT, created_at REAL, embedding BLOB);
CREATE TABLE edges (id TEXT PRIMARY KEY, scope TEXT, src TEXT, relation TEXT,
dst TEXT, confidence REAL, source_id TEXT, sentence TEXT,
created_at REAL, valid_to REAL);
CREATE INDEX edges_src ON edges(scope, src, relation);
CREATE INDEX edges_dst ON edges(scope, dst, relation);Both indexes matter: traversal goes in both directions, and a graph that can only be walked forwards answers half the questions.
Build it
<project>/
├── pyproject.toml
├── .env.example
├── README.md
└── src/<package>/
├── __init__.py
├── config.py # relation vocabulary, max_hops, fanout, decay
├── graph.py # Node, Edge, Path
├── store.py # SQLite: nodes, edges, merges; both-direction indexes
├── extractor.py # text -> triples against the closed vocabulary
├── resolver.py # the identity ladder + merge log
├── traverse.py # seed, budgeted BFS, path scoring
├── render.py # subgraph -> sentences with the path shown
└── cli.py # ingest / node / neighbours / path / ask / statsgraph.py
from __future__ import annotations
import hashlib
import time
from dataclasses import dataclass, field
def node_id(scope: str, type_: str, name: str) -> str:
payload = f"{scope}\x1f{type_}\x1f{name.strip().lower()}".encode()
return "n_" + hashlib.blake2b(payload, digest_size=8).hexdigest()
@dataclass
class Node:
name: str
type: str = "thing"
scope: str = "default"
aliases: list[str] = field(default_factory=list)
attrs: dict = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
id: str = ""
def __post_init__(self):
if not self.id:
self.id = node_id(self.scope, self.type, self.name)
@dataclass
class Edge:
src: str
relation: str
dst: str
scope: str = "default"
confidence: float = 0.8
source_id: str | None = None # the record this came from
sentence: str = "" # the text that produced it
created_at: float = field(default_factory=time.time)
valid_to: float | None = None
id: str = ""
def __post_init__(self):
if not self.id:
payload = f"{self.scope}\x1f{self.src}\x1f{self.relation}\x1f{self.dst}".encode()
self.id = "e_" + hashlib.blake2b(payload, digest_size=8).hexdigest()
@dataclass
class Path:
nodes: list[Node]
edges: list[Edge]
score: float
def render(self) -> str:
parts = [self.nodes[0].name]
for edge, node in zip(self.edges, self.nodes[1:]):
parts.append(f" -[{edge.relation}]-> {node.name}")
return "".join(parts)extractor.py
"""Text -> triples, against a closed vocabulary, with an honest escape."""
from __future__ import annotations
import json
from .graph import Edge, Node
EXTRACT_PROMPT = """Extract entity relationships from the text.
Use ONLY these relations: {relations}
If a relationship does not fit any of them, use "OTHER" and put the actual
phrase in "raw_relation". Do not invent new relation names.
Rules:
- Only relationships stated in the text. Do not infer, do not use world knowledge.
- Use the most specific name for each entity as it appears in the text.
- Give each entity a type from: {types}
- Empty array if the text states no relationships.
Reply as a JSON array:
[{{"src": "...", "src_type": "...", "relation": "...", "raw_relation": null,
"dst": "...", "dst_type": "...", "sentence": "<the sentence it came from>",
"confidence": 0.0-1.0}}]
Text:
{text}"""
class TripleExtractor:
def __init__(self, client, *, relations: list[str], types: list[str], scope: str = "default"):
self.client, self.relations, self.types, self.scope = client, relations, types, scope
self.other_bucket: list[str] = [] # raw phrases that missed the vocabulary
def extract(self, text: str, *, source_id: str | None = None):
reply = self.client.complete([{"role": "user", "content": EXTRACT_PROMPT.format(
relations=", ".join(self.relations), types=", ".join(self.types), text=text)}])
raw = (reply.content or "").strip()
raw = raw[raw.find("[") : raw.rfind("]") + 1] if "[" in raw else "[]"
try:
triples = json.loads(raw)
except json.JSONDecodeError:
return [], []
if not isinstance(triples, list):
return [], []
nodes: dict[str, Node] = {}
edges: list[Edge] = []
for triple in triples:
if not isinstance(triple, dict) or not all(triple.get(k) for k in ("src", "dst", "relation")):
continue
relation = triple["relation"]
if relation not in self.relations and relation != "OTHER":
# Model invented a relation. Keep the edge, bucket the phrase.
self.other_bucket.append(relation)
triple["raw_relation"], relation = relation, "OTHER"
elif relation == "OTHER":
self.other_bucket.append(triple.get("raw_relation") or "?")
src = Node(name=triple["src"], type=triple.get("src_type", "thing"), scope=self.scope)
dst = Node(name=triple["dst"], type=triple.get("dst_type", "thing"), scope=self.scope)
nodes[src.id], nodes[dst.id] = src, dst
edges.append(Edge(
src=src.id, relation=relation, dst=dst.id, scope=self.scope,
confidence=float(triple.get("confidence", 0.8)),
source_id=source_id, sentence=triple.get("sentence", "")[:500],
))
return list(nodes.values()), edgesresolver.py
"""The identity ladder. Biased towards creating rather than merging."""
from __future__ import annotations
import re
CONFIRM_PROMPT = """Are these two mentions the same real-world entity?
Answer exactly YES or NO. Answer NO if you are not sure.
A: {a_name} ({a_type}) - known facts: {a_facts}
B: {b_name} ({b_type}) - known facts: {b_facts}"""
def normalize(name: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"[^\w\s-]", "", name.strip().lower()))
class Resolver:
def __init__(self, store, client=None, *, aliases: dict[str, str] | None = None,
id_fields: tuple[str, ...] = ("email", "url", "ticket")):
self.store, self.client = store, client
self.aliases = {normalize(k): v for k, v in (aliases or {}).items()}
self.id_fields = id_fields
def resolve(self, node, *, allow_model: bool = False):
"""Returns the node to use: an existing one, or `node` itself."""
# 1. Exact identifier.
for field_ in self.id_fields:
value = node.attrs.get(field_)
if value:
existing = self.store.by_attr(field_, value, scope=node.scope)
if existing:
return self._merge(existing, node, "identifier", f"{field_}={value}")
# 2. Exact normalized name, same type, same scope.
existing = self.store.by_name(normalize(node.name), type_=node.type, scope=node.scope)
if existing:
return self._merge(existing, node, "exact-name", normalize(node.name))
# 3. Explicit alias table.
target = self.aliases.get(normalize(node.name))
if target:
existing = self.store.by_name(normalize(target), type_=node.type, scope=node.scope)
if existing:
return self._merge(existing, node, "alias", f"{node.name} -> {target}")
# 4. Model confirmation, only for a same-type near-name candidate.
if allow_model and self.client is not None:
candidate = self.store.nearest_name(node.name, type_=node.type, scope=node.scope)
if candidate and self._confirm(candidate, node):
return self._merge(candidate, node, "model-confirmed", node.name)
# 5. Default: a new node. Fragmentation is recoverable; collapse is not.
self.store.put_node(node)
return node
def _confirm(self, a, b) -> bool:
reply = self.client.complete([{"role": "user", "content": CONFIRM_PROMPT.format(
a_name=a.name, a_type=a.type, a_facts=self.store.facts_about(a.id)[:3],
b_name=b.name, b_type=b.type, b_facts=[])}])
return (reply.content or "").strip().upper().startswith("YES")
def _merge(self, kept, merged, strategy: str, evidence: str):
if kept.id == merged.id:
return kept
if normalize(merged.name) not in {normalize(a) for a in kept.aliases}:
kept.aliases.append(merged.name)
self.store.put_node(kept)
self.store.log_merge(kept.id, merged.id, strategy, evidence)
return kepttraverse.py
"""Seed, then a budgeted breadth-first walk. Hub nodes are the thing to fear."""
from __future__ import annotations
from collections import deque
from .graph import Path
def traverse(store, seed_ids: list[str], *, scope: str = "default", max_hops: int = 2,
max_nodes: int = 60, max_fanout: int = 20, hub_degree: int = 200,
decay: float = 0.6) -> list[Path]:
seen: set[str] = set(seed_ids)
paths: list[Path] = []
queue = deque(
(node_id, [store.get_node(node_id)], [], 1.0) for node_id in seed_ids
if store.get_node(node_id)
)
while queue and len(seen) < max_nodes:
current, nodes, edges, score = queue.popleft()
if len(edges) >= max_hops:
continue
outgoing = store.edges_of(current, scope=scope, limit=max_fanout)
for edge in outgoing:
other_id = edge.dst if edge.src == current else edge.src
if other_id in {n.id for n in nodes}: # no cycles in one path
continue
# A hub reached indirectly floods everything downstream of it.
if store.degree(other_id) > hub_degree and len(edges) > 0:
continue
other = store.get_node(other_id)
if other is None:
continue # dangling edge; see failure modes
new_score = score * edge.confidence * (decay if edges else 1.0)
path = Path(nodes=[*nodes, other], edges=[*edges, edge], score=new_score)
paths.append(path)
seen.add(other_id)
queue.append((other_id, path.nodes, path.edges, new_score))
paths.sort(key=lambda p: p.score, reverse=True)
return pathsrender.py
def render(paths, *, limit: int = 15) -> str:
if not paths:
return ""
lines = ["Related information from the knowledge graph "
"(derived from stored text; verify before relying on it):"]
for path in paths[:limit]:
sentence = path.edges[-1].sentence
lines.append(f"- {path.render()} [score {path.score:.2f}]")
if sentence:
lines.append(f" source: \"{sentence[:160]}\"")
return "\n".join(lines)Rendering the path, not just the endpoint, is what makes graph memory debuggable and what makes the model's answer checkable. "Anna works on Herdr, which depends on the gateway, which Arttu owns" is auditable; "Anna is related to Arttu" is not.
cli.py
<command> ingest <file|->— extract and store, printing new nodes, new edges, merges, and theOTHERbucket.<command> node <name>— the node, its aliases, its degree, its edges.<command> neighbours <name> [--hops 2]— the subgraph, rendered as paths.<command> path <a> <b>— shortest scored path between two nodes. This is the command that demonstrates why you built a graph.<command> stats— node and edge counts, degree distribution, theOTHERshare, the top hub nodes. Run this after every ingest: a sudden hub or a growingOTHERshare is the early warning for both failure modes.
Failure modes
- Node collapse. Two entities merged into one. Every edge on both is now wrong, and the traversal output is confident nonsense. This is the failure to design against: keep the merge log, prefer new nodes, and never merge on embedding similarity alone.
- Entity fragmentation. Three nodes for one person, none connected, so
traversal finds nothing. Visible in
statsas a node count far above the number of real entities. Fix with aliases, not with looser matching. - Hub explosion. One node with 5,000 edges — "the company", a shared
library — turns every two-hop traversal into a scan of the whole graph. Cap
fan-out, skip high-degree nodes reached indirectly, and check the degree
distribution in
stats. - Dangling edges. A node deleted, its edges left behind. Traversal then hits
Noneand either crashes or silently drops paths. Cascade deletes, and have the traversal tolerate a missing node rather than assuming referential integrity. - Vocabulary drift. Six synonymous relations for one concept because
extraction was open. Traversal misses most of the graph. The closed list plus
a monitored
OTHERbucket is the fix; enforce it at extraction, not in review. - Inferred edges stored as observed. The model connects two entities because
it knows about them from pretraining, not because your text said so. "Only
relationships stated in the text" in the prompt, and a low confidence on
anything whose
sentencefield is empty. - Three-hop defaults. In a connected domain, three hops reaches almost everything and the result is noise with a path attached. Default to two and make the user ask for more.
Required tests
All offline. See references/evaluation.md for the universal set; these are
mandatory:
def test_two_hops_reach_what_one_cannot(store):
build_chain(store, "anna", "works_on", "herdr", "depends_on", "gateway")
one = traverse(store, [node_id_for("anna")], max_hops=1)
two = traverse(store, [node_id_for("anna")], max_hops=2)
assert "gateway" not in rendered(one)
assert "gateway" in rendered(two)
def test_aliases_resolve_to_one_node(resolver, store):
resolver.aliases = {"@arttuan": "Arttu Antikainen"}
store.put_node(Node(name="Arttu Antikainen", type="person"))
resolved = resolver.resolve(Node(name="@ArttuAn", type="person"))
assert resolved.name == "Arttu Antikainen"
def test_similar_names_are_not_merged(resolver, store):
store.put_node(Node(name="Arttu Antikainen", type="person"))
resolved = resolver.resolve(Node(name="Anna Antikainen", type="person"))
assert resolved.id != node_id("default", "person", "Arttu Antikainen")
assert len(list(store.all_nodes())) == 2
def test_model_says_no_when_unsure(resolver):
resolver.client = FakeChat(["NO"])
resolved = resolver.resolve(Node(name="A. Antikainen", type="person"), allow_model=True)
assert resolved.name == "A. Antikainen" # not merged
def test_every_merge_is_logged(resolver, store):
...
assert store.merges()[-1]["strategy"] == "alias"
def test_hub_node_does_not_flood_the_result(store):
make_hub(store, "python", edges=3000)
paths = traverse(store, [node_id_for("anna")], max_hops=2, max_nodes=60)
assert len(paths) <= 60
assert sum(1 for p in paths if "python" in p.render()) < 10
def test_dangling_edge_does_not_crash_traversal(store):
store.put_edge(Edge(src=node_id_for("anna"), relation="owns", dst="n_missing"))
assert traverse(store, [node_id_for("anna")], max_hops=2) is not None
def test_deleting_a_node_removes_its_edges(store):
store.delete_node(node_id_for("herdr"))
assert not [e for e in store.all_edges()
if node_id_for("herdr") in (e.src, e.dst)]
def test_unknown_relation_goes_to_other_not_dropped(extractor):
extractor.client = FakeChat(['[{"src":"a","relation":"befriended","dst":"b"}]'])
_, edges = extractor.extract("a befriended b")
assert edges[0].relation == "OTHER"
assert "befriended" in extractor.other_bucket
def test_path_score_decays_with_distance(store):
build_chain(store, "a", "r", "b", "r", "c")
paths = traverse(store, [node_id_for("a")], max_hops=2)
one_hop = next(p for p in paths if len(p.edges) == 1)
two_hop = next(p for p in paths if len(p.edges) == 2)
assert two_hop.score < one_hop.score
def test_empty_graph_renders_nothing(store):
assert render(traverse(store, [], max_hops=2)) == ""Verify
Follow references/evaluation.md. For this skill specifically:
- Tier 0 — install, import,
--help, ingest a three-sentence fixture and runneighbours. - Tier 1 —
pytest -q, offline. Every test above. - Tier 2 — a probe suite with at least 8 genuinely multi-hop questions. Run each one against a plain top-k store as the control. If top-k answers them too, you did not need the graph, and that is the most useful finding this suite can produce. Report recall for both.
- Tier 3 — ingest a real document and read
stats. Check the node count against the number of real entities you expect (fragmentation), the top-5 degrees (hubs), and theOTHERshare (vocabulary fit). Then spot-check five extracted edges against theirsentencefield: any edge whose sentence does not state the relationship is an inferred edge, and inferred edges compound.
Then tell the user what actually ran, including the graph-versus-top-k comparison. If top-k matched the graph on the multi-hop probes, say so plainly — that is a result worth acting on, and it usually means the simpler system wins.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

