Skip to content

Commit 7fbaadc

Browse files
bb-connorclaude
andcommitted
Phase 1.3 ingest paths + 1.4 vault-sync + 1.5 chio-dev CLI
(a) Phase 1.3 — engine ingest paths kb_engine/store/embed.py Embedder protocol; FakeEmbedder (sha256-deterministic, dependency-free) + OpenAIEmbedder (lazy SDK import). kb_engine/store/postgres.py PostgresStore: connection-injected pgvector adapter. bootstrap() creates schema + ivfflat index idempotently. insert_code_chunks() validates dim. search_similar() returns top-k by cosine. from_url() opens a real psycopg connection. kb_engine/store/neo4j.py Neo4jStore: connection-injected. apply_constraints(), upsert_nodes() (batched MERGE per label), upsert_edges() (batched MERGE per rel), query_neighbors(), reset(). Idempotent — re-runs are no-ops. kb_engine/ingest.py IngestPipeline: walks source_root, runs Registry hooks, writes nodes/edges to Neo4jStore + chunks to PostgresStore. IngestStats aggregates files_seen / files_ingested / nodes_upserted / edges_upserted / chunks_inserted. Tests: test_store.py (13 tests with mock psycopg/neo4j) + test_ingest.py (5 tests with FakePostgres/FakeNeo4j stubs). Total kb-engine: 26 tests. (b) Phase 1.4 — vault-sync daemon kb_engine/sync.py VaultSyncDaemon class. process_file() parses frontmatter (PyYAML with key:value fallback), routes through Registry.handle_frontmatter(), writes to configured Routers. SyncState tracks per-file content hashes for idempotent re-runs. run_once() one-shot scan; run_forever() with watchdog (lazy) or polling fallback. Two routers ship: NullRouter (test/dry-run), JsonlRouter (audit log). Production wires GraphitiHttpRouter / Neo4jStoreRouter on top. Tests: test_sync.py — 16 tests covering frontmatter parsing, idempotency, state persistence, multi-router fanout, missing-frontmatter skip, modified-file re-processing. Total kb-engine: 42 tests. (c) Phase 1.5 — chio-dev CLI chio-pack/chio_pack/cli.py Click-based CLI exposing 11 subcommands: status / up / down shell to make targets eval / dogfood shell to make targets migrate-seeds wraps ops/scripts/migrate-seeds.py ingest [SRC] IngestPipeline with optional Postgres+Neo4j sync [VAULT] [--watch] VaultSyncDaemon one-shot or forever query <Q> [--limit] pgvector similarity search session show / tool-call session_log harness Repo-root detection via PLAN.md + Makefile presence (or CHIO_DEV_REPO env override). Lazy imports for backing-store deps so `chio-dev --help` works on systems without psycopg/neo4j installed. Tests: test_cli.py — 5 tests (--help, subcommand listing, session show, session tool-call writes event). Click's CliRunner used for in-process invocation. Total chio-pack: 31 tests. Entry point: `chio-dev = "chio_pack.cli:main"` in chio-pack/pyproject.toml. After `uv sync` the binary is on PATH inside the chio-pack venv. What's still missing: - Real Postgres+Neo4j integration not exercised end-to-end (requires `make kb-up` to bring the docker stack live). The unit tests cover the mocked surface. - GraphitiHttpRouter for production graphiti-mcp HTTP integration — the Router interface is stable; the HTTP impl lands in Phase 1.4+. - The chio-kb MCP gateway server (real, not stub) is still Phase 1.3+ work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3732954 commit 7fbaadc

13 files changed

Lines changed: 1845 additions & 24 deletions

File tree

chio-pack/chio_pack/cli.py

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""chio-dev CLI — Phase 1.5 developer wrapper.
2+
3+
Subcommands:
4+
5+
status print make kb-status
6+
up / down start/stop the docker stack
7+
ingest [SRC] run kb_engine.IngestPipeline against a source root
8+
query <Q> retrieve top-K via pgvector similarity (Phase 1.3+)
9+
sync [VAULT] run vault-sync daemon (one-shot by default; --watch for forever)
10+
migrate-seeds convert arc PR #599 graphiti seeds → vault/episodes
11+
eval shell: make kb-eval-outcomes
12+
dogfood shell: make kb-dogfood
13+
session start emit a `tool_call`/`edit`/`test_run`/`mistake` from CLI
14+
session show print recent session log
15+
16+
Most subcommands either shell out to the relevant Makefile target OR
17+
delegate to a Python module that already exists. The CLI's job is
18+
discovery — `chio-dev --help` is the entrypoint a new dev finds before
19+
they read PLAN.md.
20+
21+
Per AGENTS.md hard rules, every action that would write to Graphiti
22+
goes through the vault-sync daemon. The CLI never bypasses that.
23+
"""
24+
from __future__ import annotations
25+
26+
import json
27+
import os
28+
import shlex
29+
import subprocess
30+
import sys
31+
from pathlib import Path
32+
33+
import click
34+
35+
36+
REPO_ROOT_HINT = "Run from the chio-developer-base/ root (or set CHIO_DEV_REPO)."
37+
38+
39+
def _repo_root() -> Path:
40+
"""Best-effort repo root detection. Walk up looking for PLAN.md + Makefile."""
41+
env = os.environ.get("CHIO_DEV_REPO")
42+
if env:
43+
return Path(env).resolve()
44+
here = Path.cwd()
45+
for candidate in [here, *here.parents]:
46+
if (candidate / "PLAN.md").exists() and (candidate / "Makefile").exists():
47+
return candidate
48+
return here
49+
50+
51+
def _shell(cmd: str | list[str], *, cwd: Path | None = None, check: bool = True) -> int:
52+
"""Run a shell command; relay stdout/stderr to the user's terminal."""
53+
if isinstance(cmd, list):
54+
printable = " ".join(shlex.quote(c) for c in cmd)
55+
else:
56+
printable = cmd
57+
click.secho(f"$ {printable}", fg="blue", err=True)
58+
res = subprocess.run(
59+
cmd if isinstance(cmd, list) else cmd,
60+
shell=isinstance(cmd, str),
61+
cwd=str(cwd) if cwd else None,
62+
check=False,
63+
)
64+
if check and res.returncode != 0:
65+
sys.exit(res.returncode)
66+
return res.returncode
67+
68+
69+
@click.group()
70+
@click.version_option()
71+
def main() -> None:
72+
"""chio-dev — Chio knowledge-base developer CLI.
73+
74+
Repo-rooted at the directory containing PLAN.md + Makefile, or the
75+
path set in CHIO_DEV_REPO.
76+
"""
77+
78+
79+
# === Stack lifecycle ===
80+
81+
82+
@main.command()
83+
def status() -> None:
84+
"""Run `make kb-status` to see what exists vs. what's expected."""
85+
_shell("make kb-status", cwd=_repo_root(), check=False)
86+
87+
88+
@main.command()
89+
def up() -> None:
90+
"""Bring up the Phase 1.1+ docker stack (`make kb-up`)."""
91+
_shell("make kb-up", cwd=_repo_root())
92+
93+
94+
@main.command()
95+
def down() -> None:
96+
"""Stop the docker stack (`make kb-down`)."""
97+
_shell("make kb-down", cwd=_repo_root(), check=False)
98+
99+
100+
# === Eval / dogfood ===
101+
102+
103+
@main.command()
104+
def eval() -> None:
105+
"""Run the outcome-eval suite (`make kb-eval-outcomes`)."""
106+
_shell("make kb-eval-outcomes", cwd=_repo_root(), check=False)
107+
108+
109+
@main.command()
110+
def dogfood() -> None:
111+
"""Regenerate `DOGFOOD-REVIEW.md` (`make kb-dogfood`). Phase 1.x."""
112+
_shell("make kb-dogfood", cwd=_repo_root(), check=False)
113+
114+
115+
# === Vault operations ===
116+
117+
118+
@main.command()
119+
@click.argument("seeds_dir", required=False)
120+
@click.option("--vault", default="vault", help="Path to vault/ root (default: vault)")
121+
@click.option("--branch", default="HEAD", help="Provenance label for imported_from")
122+
def migrate_seeds(seeds_dir: str | None, vault: str, branch: str) -> None:
123+
"""One-shot: convert arc PR #599 seeds/graphiti/*.json → vault/episodes/*.md.
124+
125+
SEEDS_DIR defaults to ../arc/ops/knowledge-base/seeds/graphiti.
126+
"""
127+
repo = _repo_root()
128+
seeds = seeds_dir or str(repo.parent / "arc" / "ops" / "knowledge-base" / "seeds" / "graphiti")
129+
cmd = [
130+
sys.executable, str(repo / "ops" / "scripts" / "migrate-seeds.py"),
131+
"--seeds", seeds,
132+
"--vault", str(repo / vault),
133+
"--branch", branch,
134+
]
135+
_shell(cmd, cwd=repo, check=False)
136+
137+
138+
@main.command()
139+
@click.argument("vault_path", required=False)
140+
@click.option("--watch", is_flag=True, help="Run forever (watchdog/polling).")
141+
@click.option("--state", default=None, help="Path to sync state JSON (default: ~/.chio-dev/vault-sync.state.json)")
142+
def sync(vault_path: str | None, watch: bool, state: str | None) -> None:
143+
"""Run the vault-sync daemon. One-shot by default; --watch for forever.
144+
145+
VAULT_PATH defaults to <repo>/vault.
146+
"""
147+
repo = _repo_root()
148+
vault_root = Path(vault_path) if vault_path else repo / "vault"
149+
state_path = Path(state) if state else Path.home() / ".chio-dev" / "vault-sync.state.json"
150+
151+
from kb_engine import Registry
152+
from kb_engine.sync import JsonlRouter, NullRouter, VaultSyncDaemon
153+
154+
registry = Registry()
155+
n_loaded = registry.load_entry_points()
156+
click.secho(f"Loaded {n_loaded} plugin entry point(s).", fg="green", err=True)
157+
158+
audit_path = repo / "vault" / "_meta" / "dashboards" / "vault-sync-audit.jsonl"
159+
routers = [JsonlRouter(audit_path), NullRouter()]
160+
161+
daemon = VaultSyncDaemon(registry, routers, state_path=state_path)
162+
163+
if watch:
164+
click.secho(f"Watching {vault_root} (Ctrl+C to stop)…", fg="yellow", err=True)
165+
daemon.run_forever(vault_root)
166+
else:
167+
stats = daemon.run_once(vault_root)
168+
click.echo(json.dumps(
169+
{
170+
"files_seen": stats.files_seen,
171+
"files_processed": stats.files_processed,
172+
"files_skipped_unchanged": stats.files_skipped_unchanged,
173+
"files_skipped_no_frontmatter": stats.files_skipped_no_frontmatter,
174+
"records_routed": stats.records_routed,
175+
"audit_log": str(audit_path),
176+
},
177+
indent=2,
178+
))
179+
180+
181+
@main.command()
182+
@click.argument("source_root", required=False)
183+
@click.option("--no-postgres", is_flag=True, help="Skip embedding/pgvector ingest")
184+
@click.option("--no-neo4j", is_flag=True, help="Skip graph projection/neo4j")
185+
def ingest(source_root: str | None, no_postgres: bool, no_neo4j: bool) -> None:
186+
"""Run the ingest pipeline against a source root (default: ../arc).
187+
188+
Phase 1.3+ wiring: requires Postgres + Neo4j up and configured via
189+
.env (or sets via flags). Without --no-postgres / --no-neo4j, the
190+
pipeline tries to connect using POSTGRES_URL / NEO4J_URI from env.
191+
"""
192+
from kb_engine import IngestPipeline, Registry
193+
194+
repo = _repo_root()
195+
src = Path(source_root) if source_root else repo.parent / "arc"
196+
if not src.exists():
197+
click.secho(f"error: source root {src} does not exist", fg="red", err=True)
198+
sys.exit(2)
199+
200+
registry = Registry()
201+
n_loaded = registry.load_entry_points()
202+
click.secho(f"Loaded {n_loaded} plugin entry point(s).", fg="green", err=True)
203+
204+
postgres = None
205+
if not no_postgres:
206+
url = os.environ.get("POSTGRES_URL")
207+
if url:
208+
try:
209+
from kb_engine.store import PostgresStore
210+
postgres = PostgresStore.from_url(url)
211+
postgres.bootstrap()
212+
except RuntimeError as e:
213+
click.secho(f"warning: postgres unavailable: {e}", fg="yellow", err=True)
214+
215+
neo4j_store = None
216+
if not no_neo4j:
217+
uri = os.environ.get("NEO4J_URI")
218+
user = os.environ.get("NEO4J_USER", "neo4j")
219+
password = os.environ.get("NEO4J_PASSWORD", "demodemo")
220+
if uri:
221+
try:
222+
from kb_engine.store import Neo4jStore
223+
neo4j_store = Neo4jStore.from_url(uri, user, password)
224+
except RuntimeError as e:
225+
click.secho(f"warning: neo4j unavailable: {e}", fg="yellow", err=True)
226+
227+
embedder = None
228+
if postgres:
229+
from kb_engine.store import FakeEmbedder, OpenAIEmbedder
230+
231+
if os.environ.get("OPENAI_API_KEY"):
232+
embedder = OpenAIEmbedder()
233+
click.secho("Using OpenAI embedder.", fg="green", err=True)
234+
else:
235+
embedder = FakeEmbedder(dim=postgres.embedding_dim)
236+
click.secho(
237+
"OPENAI_API_KEY not set; using FakeEmbedder (deterministic, "
238+
"no semantic value). Real ingest needs OpenAI key.",
239+
fg="yellow", err=True,
240+
)
241+
242+
pipeline = IngestPipeline(registry, postgres=postgres, neo4j=neo4j_store, embedder=embedder)
243+
click.secho(f"Ingesting {src}…", fg="blue", err=True)
244+
stats = pipeline.ingest_tree(src)
245+
click.echo(json.dumps(
246+
{
247+
"files_seen": stats.files_seen,
248+
"files_ingested": stats.files_ingested,
249+
"nodes_upserted": stats.nodes_upserted,
250+
"edges_upserted": stats.edges_upserted,
251+
"chunks_inserted": stats.chunks_inserted,
252+
},
253+
indent=2,
254+
))
255+
256+
257+
@main.command()
258+
@click.argument("query")
259+
@click.option("--limit", default=10)
260+
def query(query: str, limit: int) -> None:
261+
"""Phase 1.3+ retrieval. Today, calls pgvector similarity search."""
262+
from kb_engine.store import OpenAIEmbedder, PostgresStore
263+
264+
url = os.environ.get("POSTGRES_URL")
265+
if not url:
266+
click.secho("error: POSTGRES_URL not set", fg="red", err=True)
267+
sys.exit(2)
268+
if not os.environ.get("OPENAI_API_KEY"):
269+
click.secho("error: OPENAI_API_KEY not set (required to embed query)", fg="red", err=True)
270+
sys.exit(2)
271+
272+
try:
273+
store = PostgresStore.from_url(url)
274+
embedder = OpenAIEmbedder()
275+
vec = embedder([query])[0]
276+
results = store.search_similar(vec, limit=limit)
277+
click.echo(json.dumps(results, indent=2, default=str))
278+
except RuntimeError as e:
279+
click.secho(f"error: {e}", fg="red", err=True)
280+
sys.exit(2)
281+
282+
283+
# === Session log ===
284+
285+
286+
@main.group()
287+
def session() -> None:
288+
"""Manage chio-dev session logs (~/.chio-dev/sessions/*.jsonl).
289+
290+
Sessions are the input to the repeated-mistake-rate eval (Eval 2).
291+
"""
292+
293+
294+
@session.command("show")
295+
def session_show() -> None:
296+
"""Print the current session's path and last 20 events."""
297+
from chio_pack.eval import session_log
298+
299+
path = session_log.session_path()
300+
click.echo(str(path))
301+
if path.exists():
302+
events = session_log.load_session(path)[-20:]
303+
for ev in events:
304+
click.echo(json.dumps(ev))
305+
306+
307+
@session.command("tool-call")
308+
@click.option("--tool", required=True)
309+
@click.option("--args", default="{}")
310+
@click.option("--result-ids", default="")
311+
def session_tool_call(tool: str, args: str, result_ids: str) -> None:
312+
"""Manually record a tool_call event (mostly for testing the harness)."""
313+
from chio_pack.eval import session_log
314+
315+
parsed_args = json.loads(args) if args else {}
316+
rids = [s for s in result_ids.split(",") if s]
317+
session_log.tool_call(tool=tool, args=parsed_args, result_ids=rids)
318+
click.secho(f"Recorded tool_call: {tool}", fg="green", err=True)
319+
320+
321+
if __name__ == "__main__":
322+
main()

chio-pack/pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ authors = [{ name = "Backbay" }]
77
dependencies = [
88
"pyyaml>=6.0",
99
"kb-engine",
10+
"click>=8.1",
1011
]
1112

1213
[project.entry-points."kb_engine.plugins"]
@@ -16,7 +17,10 @@ chio = "chio_pack.plugin"
1617
kb-engine = { path = "../kb-engine", editable = true }
1718

1819
[project.scripts]
19-
# Phase 0+: outcome eval runner (skeleton; runners are Phase 1).
20+
# Phase 1.5: developer CLI — wraps make targets + session_log + ingest.
21+
chio-dev = "chio_pack.cli:main"
22+
23+
# Phase 0+: outcome eval runner.
2024
chio-pack-eval = "chio_pack.eval.runner:main"
2125

2226
# Phase 2A: PR-impact gate backtest. Stub that exits with a clear blocker.

chio-pack/tests/test_cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Smoke tests for the chio-dev CLI."""
2+
from __future__ import annotations
3+
4+
from click.testing import CliRunner
5+
6+
from chio_pack.cli import main
7+
8+
9+
def test_help_runs():
10+
runner = CliRunner()
11+
result = runner.invoke(main, ["--help"])
12+
assert result.exit_code == 0
13+
assert "chio-dev" in result.output
14+
15+
16+
def test_session_show_does_not_error():
17+
runner = CliRunner()
18+
result = runner.invoke(main, ["session", "show"])
19+
# session show returns 0 even if no log exists yet
20+
assert result.exit_code == 0
21+
22+
23+
def test_subcommands_are_listed_in_help():
24+
runner = CliRunner()
25+
result = runner.invoke(main, ["--help"])
26+
for cmd in ["status", "up", "down", "ingest", "query", "sync",
27+
"migrate-seeds", "eval", "dogfood", "session"]:
28+
assert cmd in result.output, f"missing subcommand in --help: {cmd}"
29+
30+
31+
def test_session_subcommand_help():
32+
runner = CliRunner()
33+
result = runner.invoke(main, ["session", "--help"])
34+
assert result.exit_code == 0
35+
assert "show" in result.output
36+
assert "tool-call" in result.output
37+
38+
39+
def test_session_tool_call_writes_event(tmp_path, monkeypatch):
40+
monkeypatch.setenv("CHIO_DEV_HOME", str(tmp_path))
41+
monkeypatch.delenv("CHIO_DEV_SESSION_ID", raising=False)
42+
runner = CliRunner()
43+
result = runner.invoke(
44+
main,
45+
["session", "tool-call", "--tool", "kb_search_code",
46+
"--args", '{"q": "x"}', "--result-ids", "a,b,c"],
47+
)
48+
assert result.exit_code == 0
49+
sessions = list((tmp_path / "sessions").glob("*.jsonl"))
50+
assert len(sessions) == 1
51+
content = sessions[0].read_text()
52+
assert "kb_search_code" in content
53+
assert '"q": "x"' in content

0 commit comments

Comments
 (0)