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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## 0.5.0 (unreleased)

### Added

- `database.ladybug.layout: per-group` stores each configured group in its own Ladybug
file inside `database.ladybug.directory`, named by the SHA-256 of the group so no id
can address a path outside it. Ingest, drain and restore write into the group's
file; the MCP tools, `kg ask` and the other reads open only the file of the group
they address. Bearer tokens may be scoped to a subset of groups in this layout, and
a token scoped to one group never opens another group's file. `single` remains the
default.
- `kg_mcp.fingerprint.record_embedder`, `recorded_embedder` and `embedder_drift`
record and check the embedder that wrote a Ladybug file from its path alone, in
`<file>.embedder.json`. Configured writes also keep this record, and the configured
drift check reads it.
- `kg_mcp.retrieval.keyword_edge_search_config` and `keyword_node_search_config`
return the BM25-only search configuration behind `kg ask --keyword` and `kg-demo`.
- `kg_mcp.ladybug.extension_status` reports whether the FTS and VECTOR extensions are
installed for the running Ladybug engine version, with the command that installs
them.
- `kg_mcp.ladybug.group_database_path` returns the contained per-group file path.
- `docs/reference.md` lists the library helpers.

### Fixed

- `kg export` on a Ladybug graph exports every record when a named group holds no
edges.

## 0.4.1

- Use absolute HTTPS URLs for the README logo and demo so both images render in the
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ To review extraction before it touches the configured graph, run
printed restore command to promote those exact records. Searches return current facts
by default; `kg ask --history` is the explicit historical view.

## Multiple users on Ladybug

Give each user a group, and each group its own Ladybug file:

```yaml
graph:
groups: [alice, bob]
database:
provider: ladybug
ladybug:
layout: per-group # default: single, one file for every group
directory: ./workspace/groups
server:
transport: streamable-http
auth:
tokens:
- {name: alice, token: "${ALICE_TOKEN}", groups: [alice]}
- {name: bob, token: "${BOB_TOKEN}", groups: [bob]}
```

Each file is named by the SHA-256 of its group, so no group id can point outside the
directory. Reads, `kg ask` and the drain open only the file of the group they address;
a token granted `alice` never opens Bob's file. A read names one group, or the token's
single group is used. For a tenant boundary inside one database server, use FalkorDB
or Neo4j.

## Is it a fit?

Use it for local agent memory with explicit human review. Skip it if you need
Expand Down
3 changes: 3 additions & 0 deletions config/ladybug.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ database:
provider: ladybug
ladybug:
path: ./workspace/example.ladybug
# One file per group instead, named by the group's SHA-256 inside `directory`:
# layout: per-group
# directory: ./workspace/groups

llm:
model: ${KG_LLM_MODEL:gpt-4.1-mini}
Expand Down
36 changes: 36 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,42 @@ indexes before the read-only server opens it. `kg-ladybug-setup --apply` prepare
them explicitly. Opening a reader never installs extensions automatically.
For a database created by 0.2.x, run setup again to add the indexes.

With `database.ladybug.layout: per-group`, each configured group is a separate file in
`database.ladybug.directory`, named `<sha256 of the group>.ladybug`. Ingest, drain and
restore write each record into its group's file, which the first write creates, and
record the embedder beside that file. `kg ask`, `kg nodes`, `kg episodes`, `kg export`
and the MCP search tools read one group per call; `kg status`, `kg edge`,
`get_entity_edge` and `get_status` visit every group file the caller may read. Tokens
may be scoped to a subset of groups in this layout.

### Library use

These helpers work from a database path and need no configuration file:

| Name | Purpose |
| --- | --- |
| `kg_mcp.ladybug.build_ladybug_driver(path, read_only=...)` | A graphiti driver on one Ladybug file |
| `kg_mcp.ladybug.group_database_path(directory, group)` | The contained SHA-256 file path for a group or user id |
| `kg_mcp.ladybug.extension_status(path=None)` | `engine_version`, `installed`, `missing`, `ok` and the `fix` command for the FTS and VECTOR extensions of the running engine |
| `kg_mcp.fingerprint.record_embedder(path, model=..., dimensions=...)` | Record the embedder that wrote a file, in `<file>.embedder.json` |
| `kg_mcp.fingerprint.recorded_embedder(path)` | The recorded embedder, or `None` before the first write |
| `kg_mcp.fingerprint.embedder_drift(path, model=..., dimensions=...)` | A refusal message when a different embedder would write, else `None` |
| `kg_mcp.retrieval.keyword_edge_search_config(limit)` | A BM25-only `SearchConfig` for facts; no model call |
| `kg_mcp.retrieval.keyword_node_search_config(limit)` | The same for entities |
| `kg_mcp.write_lock.ladybug_path_lock(path, timeout)` | The cross-process writer lock the CLI uses |

```python
from kg_mcp.ladybug import build_ladybug_driver, extension_status, group_database_path
from kg_mcp.retrieval import keyword_edge_search_config

status = extension_status()
if not status["ok"]:
raise SystemExit(f"missing {status['missing']}: {status['fix']}")
path = group_database_path("./memory", user_id)
driver = build_ladybug_driver(str(path), read_only=True)
# Graphiti(graph_driver=driver, ...).search_(query, config=keyword_edge_search_config(10))
```

## Explicit ingestion

Ingestion is a separate command and is dry-run by default:
Expand Down
97 changes: 52 additions & 45 deletions src/kg_mcp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,47 +82,6 @@ def _doctor_command(args: argparse.Namespace) -> dict[str, Any]:
return {"status": worst_status(results), "checks": results}


def _keyword_edge_config(limit: int):
"""Edge search on BM25 alone, reranked by reciprocal rank fusion.

The vector half of the default hybrid search embeds the query, which means an
embedding backend has to be reachable before any question can be answered. Keyword
search needs no such call, so a shipped graph stays readable on a machine with
nothing running. Reciprocal rank fusion is kept because it orders a single result
list unchanged and costs no extra call.
"""
from graphiti_core.search.search_config import (
EdgeReranker,
EdgeSearchConfig,
EdgeSearchMethod,
SearchConfig,
)

return SearchConfig(
edge_config=EdgeSearchConfig(
search_methods=[EdgeSearchMethod.bm25], reranker=EdgeReranker.rrf
),
limit=limit,
)


def _keyword_node_config(limit: int):
"""Node equivalent of :func:`_keyword_edge_config`."""
from graphiti_core.search.search_config import (
NodeReranker,
NodeSearchConfig,
NodeSearchMethod,
SearchConfig,
)

return SearchConfig(
node_config=NodeSearchConfig(
search_methods=[NodeSearchMethod.bm25], reranker=NodeReranker.rrf
),
limit=limit,
)


async def _edge_search(
graph: Any,
args: argparse.Namespace,
Expand All @@ -134,6 +93,7 @@ async def _edge_search(
candidate_limit,
compatible_query,
current_edges,
keyword_edge_search_config,
rerank_edges,
)

Expand All @@ -144,7 +104,7 @@ async def _edge_search(
else:
results = await graph.search_(
query,
config=_keyword_edge_config(candidates),
config=keyword_edge_search_config(candidates),
group_ids=groups,
)
edges = results.edges[:candidates]
Expand All @@ -155,7 +115,50 @@ async def _edge_search(


async def _graph_command(args: argparse.Namespace) -> dict[str, Any]:
from kg_mcp.config import per_group_ladybug, settings_for_group, single_group

settings = load_config()
if not per_group_ladybug(settings):
return await _graph_command_on(args, settings)
if args.command in ("status", "edge"):
return await _each_group_file(args, settings)
# One file per group: a read opens only the file of the one group it names.
requested = list(getattr(args, "groups", None) or [])
allowed_groups(requested or None, settings)
group = single_group(requested or settings.graph.groups, settings)
args.groups = [group]
return await _graph_command_on(args, settings_for_group(settings, group))


async def _each_group_file(args: argparse.Namespace, settings: Any) -> dict[str, Any]:
"""``status`` and ``edge`` visit every configured group's file that exists."""
from pathlib import Path

from kg_mcp.config import settings_for_group

existing = [
group
for group in settings.graph.groups
if Path(settings.database.ladybug.path_for(group)).exists()
]
if args.command == "status":
counts = {group: 0 for group in settings.graph.groups}
for group in existing:
payload = await _graph_command_on(args, settings_for_group(settings, group))
counts[group] = payload["groups"][0]["nodes"]
return {
"provider": "ladybug",
"groups": [{"group": group, "nodes": count} for group, count in counts.items()],
}
for group in existing:
try:
return await _graph_command_on(args, settings_for_group(settings, group))
except CommandError:
continue
raise CommandError(f"edge not found: {args.uuid}")


async def _graph_command_on(args: argparse.Namespace, settings: Any) -> dict[str, Any]:
from kg_mcp.runtime import build_graphiti

timeout = settings.graph.query_timeout_seconds
Expand Down Expand Up @@ -185,18 +188,22 @@ async def _graph_command(args: argparse.Namespace) -> dict[str, Any]:
],
"suppressed_invalidated": suppressed,
"ranker": settings.reranker.provider,
"pending": _pending_items(groups),
"pending": _pending_items(groups or settings.graph.groups),
}
if args.command == "nodes":
groups = allowed_groups(args.groups, settings)
from kg_mcp.retrieval import candidate_limit, compatible_query
from kg_mcp.retrieval import (
candidate_limit,
compatible_query,
keyword_node_search_config,
)

candidate_limit(args.limit, settings)
query = compatible_query(args.query, settings)
from graphiti_core.search.search_config_recipes import NODE_HYBRID_SEARCH_RRF

node_config = (
_keyword_node_config(args.limit) if args.keyword else NODE_HYBRID_SEARCH_RRF
keyword_node_search_config(args.limit) if args.keyword else NODE_HYBRID_SEARCH_RRF
)
result = await bounded(
graph.search_(query, config=node_config, group_ids=groups),
Expand Down
57 changes: 56 additions & 1 deletion src/kg_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,26 @@ class Neo4jConfig(BaseModel):


class LadybugConfig(BaseModel):
"""Where the embedded graph lives.

``single`` keeps every group in one file at ``path``. ``per-group`` gives each
configured group its own file inside ``directory``, named by the SHA-256 of the
group, so a reader bound to one group never opens another group's file.
"""

path: str = "./workspace/graph.ladybug"
layout: Literal["single", "per-group"] = "single"
directory: str = "./workspace/groups"

def path_for(self, group: str | None = None) -> str:
"""The database file that holds ``group``."""
if self.layout == "single":
return self.path
if not group:
raise ValueError("the per-group Ladybug layout needs a group to choose a file")
from kg_mcp.ladybug import group_database_path

return str(group_database_path(self.directory, group))


class DatabaseConfig(BaseModel):
Expand Down Expand Up @@ -214,12 +233,14 @@ def validate_auth_scopes(self) -> Settings:
raise ValueError(f"server auth token grants an unconfigured group: {forbidden[0]}")
if (
self.database.provider == "ladybug"
and self.database.ladybug.layout == "single"
and grant.groups
and set(grant.groups) != configured
):
raise ValueError(
"Ladybug is a single embedded graph and cannot enforce a partial token "
"group scope; grant all configured groups or use FalkorDB/Neo4j"
"group scope; grant all configured groups, set "
"database.ladybug.layout: per-group, or use FalkorDB/Neo4j"
)
return self

Expand Down Expand Up @@ -256,3 +277,37 @@ def allowed_groups(requested: str | list[str] | None, settings: Settings) -> lis
if settings.database.provider == "ladybug":
return None
return groups


def per_group_ladybug(settings: Settings) -> bool:
"""Whether each group lives in its own Ladybug file."""
database = settings.database
return database.provider == "ladybug" and database.ladybug.layout == "per-group"


def settings_for_group(settings: Settings, group: str) -> Settings:
"""A view of ``settings`` bound to the one file that holds ``group``.

With the per-group Ladybug layout every reader and writer goes through this, so the
rest of the code sees an ordinary single-file graph that contains only that group.
Any other configuration is returned unchanged.
"""
if not per_group_ladybug(settings):
return settings
allowed_groups(group, settings)
ladybug = settings.database.ladybug.model_copy(
update={"path": settings.database.ladybug.path_for(group), "layout": "single"}
)
database = settings.database.model_copy(update={"ladybug": ladybug})
graph = settings.graph.model_copy(update={"groups": [group]})
return settings.model_copy(update={"database": database, "graph": graph})


def single_group(groups: list[str], settings: Settings) -> str:
"""The one group a per-group Ladybug read addresses, or a refusal naming the choice."""
if len(groups) != 1:
raise ValueError(
"the per-group Ladybug layout reads one group per call; name one of: "
+ ", ".join(groups or settings.graph.groups)
)
return groups[0]
6 changes: 2 additions & 4 deletions src/kg_mcp/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,16 @@ def _ensure_graph(home: Path) -> Path:


async def _ask(question: str, limit: int) -> list[dict[str, Any]]:
from kg_mcp.cli import _keyword_edge_config
from kg_mcp.config import load_config
from kg_mcp.retrieval import current_edges, keyword_edge_search_config
from kg_mcp.runtime import build_graphiti

settings = load_config()
graph = build_graphiti(settings, read_only=True)
try:
results = await graph.search_(
question, config=_keyword_edge_config(limit), group_ids=[GROUP]
question, config=keyword_edge_search_config(limit), group_ids=[GROUP]
)
from kg_mcp.retrieval import current_edges

edges, _ = current_edges(list(results.edges), include_invalidated=False)
return [
{"fact": edge.fact, "valid_at": edge.valid_at.isoformat() if edge.valid_at else None}
Expand Down
Loading
Loading