Skip to content

Commit 1eeaa5b

Browse files
bb-connorclaude
andcommitted
Phase 1.2: kb-engine real — plugin protocols + Registry + import-rule CI
(d) Replace the import-guard stub with the real engine contract surface. kb_engine/__init__.py Public API. Re-exports the four plugin protocols and shared dataclasses. Bumped version 0.0.1 → 0.1.0. kb_engine/types.py Shared dataclasses used across hooks: - ParsedFile: a SourceIngester's parse result (path, language, text, symbols, properties). - Symbol: extracted code symbol (frozen, hashable; properties dict attached for plugin metadata). - Node, Edge: graph projection output (frozen — value-equal). - DerivedRecord: vault-frontmatter handler output, routed by target ("graphiti" | "pgvector" | "neo4j" | <plugin>). kb_engine/plugin.py The four plugin hooks defined as runtime-checkable Protocols: - SourceIngester(file_path) -> ParsedFile | None - GraphProjector(parsed) -> Iterable[Node | Edge] - ToolRegistrar(server) -> None - FrontmatterHandler(type, frontmatter) -> Iterable[DerivedRecord] Plus Registry: loads via importlib.metadata entry points (group="kb_engine.plugins"), or direct registration in tests. Dispatch methods: ingest_file (first-non-None wins), project_graph (union all), register_tools (call all), handle_frontmatter (by type + wildcard). tests/test_plugin.py 8 tests covering Registry dispatch semantics, type-safety guards (raises TypeError on non-callable hooks), Protocol-based hook acceptance, and dataclass equality. ops/ci/check-imports.py Hard rule #3 enforcement: scans kb-engine/kb_engine/ for any `import chio_*` or `from chio_* import` line. Exit 1 on violation with file:line:content output. Today's count: 0 violations. Wired into .github/workflows/eval.yml as a NON-continue-on-error step so the boundary is non-negotiable on every PR. Why this is "Phase 1.2 done" vs "Phase 1.x done": - The contract surface (protocols + types + registry dispatch) is fixed. Plugins can be authored against it now. - Backing stores (pgvector / Neo4j / Graphiti / MCP server framework) are not. They land in Phase 1.3+ when CocoIndex ingestion and the docker stack come online. Updated kb-engine/_README.md to reflect the new state and document the current vs planned layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ffeeed commit 1eeaa5b

9 files changed

Lines changed: 558 additions & 33 deletions

File tree

.github/workflows/eval.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ jobs:
3737
cache-dependency-glob: |
3838
**/pyproject.toml
3939
40+
- name: Engine ↔ pack boundary check
41+
# Hard rule #3 from AGENTS.md: kb-engine cannot import chio_*.
42+
# Fails the build (no continue-on-error) — this rule is non-negotiable.
43+
run: python3 ops/ci/check-imports.py
44+
4045
- name: Run outcome evals
4146
id: eval
4247
# Phase 0: the runner reports "blocked-fixtures" / "blocked-runner" and

kb-engine/_README.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
11
# kb-engine/
22

3-
> **Status:** placeholder. The engine package lands in **Phase 1.2** ([PLAN.md](../PLAN.md) phased delivery).
3+
> **Status:** Phase 1.2 contract surface landed (commit [`0f275da`+](https://github.com/backbay-labs/chio-developer-base/commits/main/kb-engine/)). The four plugin protocols + registry exist and are tested. Backing stores (pgvector / Neo4j / Graphiti / MCP server framework) are Phase 1.3+.
44
55
The generic retrieval / graph / MCP framework. **Zero Chio knowledge.**
66

77
## Boundary contract
88

9-
This package MUST NOT import anything from `chio_pack` or any other domain pack. The boundary is enforced by `ops/ci/check-imports.py` (Phase 1 deliverable) and stated explicitly in [`AGENTS.md`](../AGENTS.md#hard-rules).
9+
This package MUST NOT import anything from `chio_pack` or any other domain pack. The boundary is enforced by [`ops/ci/check-imports.py`](../ops/ci/check-imports.py) and stated in [`AGENTS.md`](../AGENTS.md#hard-rules) hard rule #3.
1010

1111
If `kb_engine/` ever needs Chio-specific behavior, the answer is to **register a plugin** via the four hooks below — never to leak Chio names into the engine.
1212

13-
## Planned layout
13+
## Current layout (Phase 1.2)
1414

1515
```
1616
kb-engine/
1717
├── pyproject.toml
1818
├── kb_engine/
19-
│ ├── __init__.py
20-
│ ├── ingest/
21-
│ │ ├── code.py CocoIndex hooks for source repos
22-
│ │ ├── docs.py CocoIndex hooks for docs/specs
23-
│ │ └── vault.py frontmatter parser; emits pgvector + graphiti
24-
│ ├── graph/ Neo4j projector framework (schema-pack pluggable)
25-
│ ├── search/ ranker, filters, rank_components emission
26-
│ ├── mcp/ MCP server framework, tool registrar
27-
│ ├── receipt/ signed-retrieval envelope (Phase 2B)
28-
│ └── plugin.py the four plugin hooks
19+
│ ├── __init__.py ← public API (protocols + types)
20+
│ ├── plugin.py ← the four hook protocols + Registry
21+
│ └── types.py ← ParsedFile, Symbol, Node, Edge, DerivedRecord
2922
└── tests/
23+
└── test_plugin.py ← Registry dispatch, hook protocol conformance
24+
```
25+
26+
## Planned (Phase 1.3+)
27+
28+
```
29+
kb_engine/
30+
├── ingest/
31+
│ ├── code.py CocoIndex hooks for source repos
32+
│ ├── docs.py CocoIndex hooks for docs/specs
33+
│ └── vault.py frontmatter parser; emits pgvector + graphiti
34+
├── graph/ Neo4j projector framework (schema-pack pluggable)
35+
├── search/ ranker, filters, rank_components emission
36+
├── mcp/ MCP server framework, tool registrar
37+
└── receipt/ signed-retrieval envelope (Phase 2B)
3038
```
3139

3240
## The four plugin hooks

kb-engine/kb_engine/__init__.py

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,60 @@
11
"""kb-engine — generic retrieval / graph / MCP framework.
22
3-
Phase 1.2 deliverable. Today this is a placeholder package so:
3+
Phase 1.2 deliverable. This module exposes the engine's public API: the
4+
four plugin hooks that domain packs (chio-pack, and future <team>-pack)
5+
implement against, plus the registry that loads and dispatches them.
46
5-
1. `make kb-status` reports kb-engine as ok.
6-
2. A contributor can `uv sync` from a fresh checkout without errors.
7-
3. The engine ↔ pack import-rule CI check has a real package to inspect.
7+
There is **no Chio knowledge** in this package. Per PLAN.md and ADR-0003,
8+
`kb_engine/` MUST NOT import anything `chio_*`. The boundary is enforced
9+
at CI time by ops/ci/check-imports.py.
810
9-
There is no functional engine yet. See ../_README.md for the planned
10-
layout and the four plugin hooks that domain packs (chio-pack and
11-
future <team>-pack) implement against.
11+
What's here today
12+
-----------------
13+
- The four plugin protocols (SourceIngester, GraphProjector,
14+
ToolRegistrar, FrontmatterHandler).
15+
- A `Registry` that loads plugin classes by entry point or by direct
16+
registration in tests.
17+
- Shared dataclasses for parsed files, graph nodes/edges, derived records.
1218
13-
Importing anything beyond `__version__` from this package right now
14-
will raise NotImplementedError on purpose — to make it visibly clear
15-
that the engine is not yet implemented and to prevent a Phase 0
16-
contributor from accidentally building against vapor.
19+
What's NOT here yet (deferred to Phase 1.3+)
20+
--------------------------------------------
21+
- pgvector / Neo4j / Graphiti backing stores.
22+
- The MCP server framework that ToolRegistrar hooks into.
23+
- The CocoIndex ingestion pipeline.
24+
- Any actual ranking or retrieval logic.
25+
26+
The engine today is the **boundary contract**. Functional ingestion lands
27+
when Phase 1.3+ wires backing stores into the registered hooks.
1728
"""
18-
__version__ = "0.0.1"
29+
from .plugin import (
30+
FrontmatterHandler,
31+
GraphProjector,
32+
Registry,
33+
SourceIngester,
34+
ToolRegistrar,
35+
)
36+
from .types import (
37+
DerivedRecord,
38+
Edge,
39+
Node,
40+
ParsedFile,
41+
Symbol,
42+
)
1943

44+
__version__ = "0.1.0"
2045

21-
def __getattr__(name: str):
22-
if name == "__version__":
23-
return globals()["__version__"]
24-
raise NotImplementedError(
25-
f"kb_engine.{name} is not yet implemented. "
26-
f"See chio-developer-base/PLAN.md (Phase 1.2) and kb-engine/_README.md."
27-
)
46+
__all__ = [
47+
"__version__",
48+
# Plugin protocols
49+
"SourceIngester",
50+
"GraphProjector",
51+
"ToolRegistrar",
52+
"FrontmatterHandler",
53+
"Registry",
54+
# Types
55+
"ParsedFile",
56+
"Symbol",
57+
"Node",
58+
"Edge",
59+
"DerivedRecord",
60+
]

kb-engine/kb_engine/plugin.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""The four plugin hooks plus the registry that loads and dispatches them.
2+
3+
Per PLAN.md "Plugin/extension architecture":
4+
5+
A plugin is a Python package implementing any subset of the four
6+
hooks. No fork, no monkey-patch.
7+
8+
The four hooks
9+
--------------
10+
11+
- `SourceIngester(file_path) -> Optional[ParsedFile]`
12+
Decide if/how to index a file. Chio-pack's Rust tree-sitter
13+
extractor is one example.
14+
15+
- `GraphProjector(parsed: ParsedFile) -> Iterable[Node | Edge]`
16+
Take parsed AST/symbols and emit nodes + edges using a schema
17+
pack's vocabulary.
18+
19+
- `ToolRegistrar(server: object) -> None`
20+
Declare MCP tools on the gateway. `server` is the Phase 1.3+
21+
MCP-server-framework handle; here typed as `object` to keep the
22+
plugin protocol stable across server-framework iterations.
23+
24+
- `FrontmatterHandler(type: str, frontmatter: dict) -> Iterable[DerivedRecord]`
25+
Interpret a vault note's `type:` value to decide what derived
26+
store(s) to write to.
27+
28+
Each hook is a Protocol, not an abstract base class. Plugins can be
29+
plain functions OR classes — whatever fits. The registry dispatches by
30+
type rather than by inheritance.
31+
"""
32+
from __future__ import annotations
33+
34+
import importlib
35+
import importlib.metadata
36+
from collections.abc import Iterable
37+
from dataclasses import dataclass, field
38+
from typing import Any, Protocol, runtime_checkable
39+
40+
from .types import DerivedRecord, Edge, Node, ParsedFile
41+
42+
43+
# === Hook protocols ===
44+
45+
46+
@runtime_checkable
47+
class SourceIngester(Protocol):
48+
"""Decide if/how to index a single file.
49+
50+
Plugins return None to skip a file (most files are skipped — a
51+
Rust ingester returns None for .py files, etc).
52+
"""
53+
54+
def __call__(self, file_path: str) -> ParsedFile | None: ...
55+
56+
57+
@runtime_checkable
58+
class GraphProjector(Protocol):
59+
"""Emit graph nodes and edges from a parsed file."""
60+
61+
def __call__(self, parsed: ParsedFile) -> Iterable[Node | Edge]: ...
62+
63+
64+
@runtime_checkable
65+
class ToolRegistrar(Protocol):
66+
"""Declare MCP tools on the gateway server.
67+
68+
`server` is the Phase 1.3+ MCP-server-framework handle. The protocol
69+
keeps it as `object` so plugin signatures stay stable across server
70+
iterations. Plugins call methods on `server` per its documented API.
71+
"""
72+
73+
def __call__(self, server: object) -> None: ...
74+
75+
76+
@runtime_checkable
77+
class FrontmatterHandler(Protocol):
78+
"""Interpret a vault note's `type:` value, emit derived records.
79+
80+
The handler decides what derived stores to write (Graphiti, pgvector,
81+
Neo4j, ...) based on the frontmatter's `type` and content. Multiple
82+
handlers can fire for the same note; results are unioned.
83+
"""
84+
85+
def __call__(
86+
self, type: str, frontmatter: dict[str, Any]
87+
) -> Iterable[DerivedRecord]: ...
88+
89+
90+
# === Registry ===
91+
92+
93+
@dataclass
94+
class Registry:
95+
"""Loads and dispatches plugin hooks.
96+
97+
Two registration modes:
98+
99+
1. **Entry-point loading** (production): plugins declare themselves
100+
in their pyproject.toml under
101+
[project.entry-points."kb_engine.plugins"]. Call `load_entry_points()`
102+
to discover them.
103+
104+
2. **Direct registration** (tests, single-file plugins): call
105+
`register_*()` methods to add hooks programmatically.
106+
107+
Both modes coexist; entry-point hooks merge with direct ones.
108+
"""
109+
110+
source_ingesters: list[SourceIngester] = field(default_factory=list)
111+
graph_projectors: list[GraphProjector] = field(default_factory=list)
112+
tool_registrars: list[ToolRegistrar] = field(default_factory=list)
113+
frontmatter_handlers: dict[str, list[FrontmatterHandler]] = field(default_factory=dict)
114+
115+
# === Direct registration ===
116+
117+
def register_source_ingester(self, hook: SourceIngester) -> None:
118+
if not isinstance(hook, SourceIngester) and not callable(hook):
119+
raise TypeError(f"hook {hook!r} is not callable / not a SourceIngester")
120+
self.source_ingesters.append(hook)
121+
122+
def register_graph_projector(self, hook: GraphProjector) -> None:
123+
if not isinstance(hook, GraphProjector) and not callable(hook):
124+
raise TypeError(f"hook {hook!r} is not callable / not a GraphProjector")
125+
self.graph_projectors.append(hook)
126+
127+
def register_tool_registrar(self, hook: ToolRegistrar) -> None:
128+
if not isinstance(hook, ToolRegistrar) and not callable(hook):
129+
raise TypeError(f"hook {hook!r} is not callable / not a ToolRegistrar")
130+
self.tool_registrars.append(hook)
131+
132+
def register_frontmatter_handler(
133+
self, type_value: str, hook: FrontmatterHandler
134+
) -> None:
135+
if not isinstance(hook, FrontmatterHandler) and not callable(hook):
136+
raise TypeError(f"hook {hook!r} is not callable / not a FrontmatterHandler")
137+
self.frontmatter_handlers.setdefault(type_value, []).append(hook)
138+
139+
# === Entry-point loading ===
140+
141+
def load_entry_points(self, group: str = "kb_engine.plugins") -> int:
142+
"""Discover plugins via importlib.metadata entry points.
143+
144+
Each entry point should be a module with a `register(registry)`
145+
function that calls back into `register_*` methods.
146+
147+
Returns the number of entry points successfully loaded.
148+
"""
149+
n = 0
150+
for ep in importlib.metadata.entry_points(group=group):
151+
try:
152+
module = ep.load()
153+
except Exception as e:
154+
print(f"warning: failed to load entry point {ep.name}: {e}")
155+
continue
156+
register_fn = getattr(module, "register", None)
157+
if register_fn is None:
158+
print(f"warning: entry point {ep.name} has no `register` function")
159+
continue
160+
register_fn(self)
161+
n += 1
162+
return n
163+
164+
# === Dispatch ===
165+
166+
def ingest_file(self, file_path: str) -> ParsedFile | None:
167+
"""Run source ingesters in registration order; first non-None wins."""
168+
for ingester in self.source_ingesters:
169+
result = ingester(file_path)
170+
if result is not None:
171+
return result
172+
return None
173+
174+
def project_graph(self, parsed: ParsedFile) -> list[Node | Edge]:
175+
"""Run all graph projectors and union their output."""
176+
out: list[Node | Edge] = []
177+
for projector in self.graph_projectors:
178+
out.extend(projector(parsed))
179+
return out
180+
181+
def register_tools(self, server: object) -> None:
182+
"""Run all tool registrars against the given server."""
183+
for registrar in self.tool_registrars:
184+
registrar(server)
185+
186+
def handle_frontmatter(
187+
self, type_value: str, frontmatter: dict[str, Any]
188+
) -> list[DerivedRecord]:
189+
"""Run handlers registered for `type_value` and union their output.
190+
191+
Handlers registered with `type_value="*"` always run.
192+
"""
193+
out: list[DerivedRecord] = []
194+
for hook in self.frontmatter_handlers.get(type_value, []):
195+
out.extend(hook(type_value, frontmatter))
196+
for hook in self.frontmatter_handlers.get("*", []):
197+
out.extend(hook(type_value, frontmatter))
198+
return out

0 commit comments

Comments
 (0)