Skip to content

Commit 9a87f49

Browse files
joshuafontanyclaude
andcommitted
feat: route mine through the source-adapter registry (--source)
Wire `cmd_mine` to the RFC 002 source-adapter registry: when `--source NAME` resolves a registered adapter, mine runs its `ingest()` and files each `DrawerRecord` through a `PalaceContext` (the §1.2 incremental loop, under the palace lock). Opt-in and non-breaking — absent `--source`, the legacy `--mode` dispatch runs unchanged. This wires the seam RFC 002 §3.3/§9 reserve; the scaffolding (BaseSourceAdapter, registry, PalaceContext) was already complete. Adds `_mine_via_source_adapter`, the `--source` flag, and a conformance test (registry-routes-and-files / dry-run / unknown-adapter-exits). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 17f6a70 commit 9a87f49

2 files changed

Lines changed: 183 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,79 @@ def _maybe_run_mine_after_init(args, cfg) -> None:
545545
sys.exit(1)
546546

547547

548+
def _mine_via_source_adapter(args, palace_path):
549+
"""Route ``mine --source NAME`` through the RFC 002 source-adapter registry.
550+
551+
Opt-in seam: when an explicit ``--source`` names a registered adapter, mine
552+
resolves it from ``mempalace.sources.registry``, runs its ``ingest()``, and
553+
files each ``DrawerRecord`` through a ``PalaceContext``. When no ``--source``
554+
is given, ``cmd_mine`` keeps its legacy ``--mode`` dispatch — the
555+
filesystem/conversations miners have not moved onto the contract yet, so the
556+
registry path stays opt-in and changes no existing behavior. This wires the
557+
registry the spec reserves.
558+
"""
559+
from .knowledge_graph import KnowledgeGraph
560+
from .palace import MineAlreadyRunning, get_collection, mine_palace_lock
561+
from .sources.base import DrawerRecord, SourceItemMetadata, SourceRef
562+
from .sources.context import PalaceContext
563+
from .sources.registry import get_adapter, resolve_adapter_for_source
564+
565+
name = resolve_adapter_for_source(explicit=args.source)
566+
try:
567+
adapter = get_adapter(name)
568+
except KeyError as exc:
569+
print(f"mempalace: {exc}", file=sys.stderr)
570+
sys.exit(2)
571+
572+
# Routing precedence (§2.5): explicit --wing flows to the adapter via
573+
# SourceRef.options; the adapter honors it. Secrets never go here (§2.2).
574+
options = {}
575+
if getattr(args, "wing", None):
576+
options["wing"] = args.wing
577+
ref = SourceRef(local_path=os.path.abspath(os.path.expanduser(args.dir)), options=options)
578+
579+
filed = 0
580+
skipped = 0
581+
try:
582+
with mine_palace_lock(palace_path):
583+
collection = get_collection(palace_path, create=True)
584+
kg = KnowledgeGraph(db_path=os.path.join(palace_path, "knowledge_graph.sqlite3"))
585+
ctx = PalaceContext(
586+
drawer_collection=collection,
587+
knowledge_graph=kg,
588+
palace_path=palace_path,
589+
config=MempalaceConfig(),
590+
adapter_name=getattr(adapter, "name", name),
591+
adapter_version=getattr(adapter, "adapter_version", ""),
592+
)
593+
594+
skip_item = False
595+
for result in adapter.ingest(source=ref, palace=ctx):
596+
if isinstance(result, SourceItemMetadata):
597+
skip_item = False
598+
ctx._skip_requested = False
599+
existing = collection.get(where={"source_file": result.source_file}, limit=1)
600+
metas = (existing or {}).get("metadatas") or []
601+
if adapter.is_current(item=result, existing_metadata=metas[0] if metas else None):
602+
ctx.skip_current_item()
603+
skip_item = True
604+
elif isinstance(result, DrawerRecord):
605+
if skip_item or ctx._skip_requested:
606+
skipped += 1
607+
continue
608+
if not args.dry_run:
609+
ctx.upsert_drawer(result)
610+
filed += 1
611+
adapter.close()
612+
except MineAlreadyRunning as exc:
613+
print(f"mempalace: {exc}", file=sys.stderr)
614+
sys.exit(1)
615+
616+
verb = "Would file" if args.dry_run else "Drawers filed:"
617+
tail = f" (skipped {skipped} up-to-date)" if skipped else ""
618+
print(f" source={name} {verb} {filed}{tail}")
619+
620+
548621
def cmd_mine(args):
549622
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
550623
include_ignored = []
@@ -555,6 +628,10 @@ def cmd_mine(args):
555628
print("mempalace: --background requires --daemon", file=sys.stderr)
556629
sys.exit(2)
557630

631+
if getattr(args, "source", None) and getattr(args, "daemon", False):
632+
print("mempalace: --source does not support --daemon yet", file=sys.stderr)
633+
sys.exit(2)
634+
558635
if getattr(args, "daemon", False):
559636
payload = {
560637
"source": args.dir,
@@ -582,6 +659,12 @@ def cmd_mine(args):
582659
llm_provider=None,
583660
)
584661

662+
# RFC 002 source-adapter seam: an explicit --source routes mine through the
663+
# registry; absent it, the legacy --mode dispatch below runs unchanged.
664+
if getattr(args, "source", None):
665+
_mine_via_source_adapter(args, palace_path)
666+
return
667+
585668
from .palace import MineAlreadyRunning, MineValidationError
586669

587670
try:
@@ -1813,6 +1896,16 @@ def main():
18131896
"mempalace[extract])"
18141897
),
18151898
)
1899+
p_mine.add_argument(
1900+
"--source",
1901+
default=None,
1902+
metavar="NAME",
1903+
help=(
1904+
"RFC 002 source adapter to mine through (e.g. a third-party "
1905+
"mempalace-source-<name> package). Explicit selection only — no "
1906+
"auto-detect. Omit to use the legacy --mode dispatch."
1907+
),
1908+
)
18161909
p_mine.add_argument("--wing", default=None, help="Wing name (default: directory name)")
18171910
p_mine.add_argument(
18181911
"--no-gitignore",

tests/test_mine_source_registry.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""RFC 002 source-adapter CLI seam: ``mine --source NAME`` routes through the
2+
registry and files adapter-authored ``DrawerRecord``s via ``PalaceContext``.
3+
4+
This covers the seam wired into ``cmd_mine`` — the registry/ingest-loop path is
5+
opt-in and leaves the legacy ``--mode`` dispatch untouched.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from argparse import Namespace
11+
from typing import Iterator
12+
13+
import pytest
14+
15+
from mempalace.sources.base import (
16+
AdapterSchema,
17+
BaseSourceAdapter,
18+
DrawerRecord,
19+
FieldSpec,
20+
IngestResult,
21+
SourceItemMetadata,
22+
SourceRef,
23+
)
24+
from mempalace.sources.registry import register, reset_adapters, unregister
25+
26+
27+
class _FakeAdapter(BaseSourceAdapter):
28+
name = "fake-test"
29+
adapter_version = "0.1.0"
30+
capabilities = frozenset({"byte_preserving"})
31+
supported_modes = frozenset({"whole_record"})
32+
declared_transformations = frozenset()
33+
default_privacy_class = "internal"
34+
35+
def ingest(self, *, source: SourceRef, palace) -> Iterator[IngestResult]:
36+
yield SourceItemMetadata(source_file="fake://item/1", version="v1")
37+
yield DrawerRecord(
38+
content="the operator said the verb leads",
39+
source_file="fake://item/1",
40+
metadata={"wing": "wing_test", "room": "general", "lar_demo": "1"},
41+
)
42+
43+
def describe_schema(self) -> AdapterSchema:
44+
return AdapterSchema(
45+
version="1.0",
46+
fields={"lar_demo": FieldSpec(type="string", required=False, description="demo field")},
47+
)
48+
49+
50+
@pytest.fixture
51+
def _fake_adapter():
52+
register("fake-test", _FakeAdapter)
53+
yield
54+
reset_adapters()
55+
unregister("fake-test")
56+
57+
58+
def test_mine_source_files_pre_annotated_record(tmp_dir, palace_path, _fake_adapter):
59+
from mempalace.cli import _mine_via_source_adapter
60+
from mempalace.palace import get_collection
61+
62+
args = Namespace(source="fake-test", dir=tmp_dir, wing=None, dry_run=False)
63+
_mine_via_source_adapter(args, palace_path)
64+
65+
col = get_collection(palace_path, create=True)
66+
got = col.get(where={"source_file": "fake://item/1"})
67+
assert got["documents"] == ["the operator said the verb leads"]
68+
meta = got["metadatas"][0]
69+
assert meta["lar_demo"] == "1" # adapter-authored metadata lands verbatim
70+
assert meta["adapter_name"] == "fake-test" # stamped by PalaceContext, not by the adapter
71+
assert meta["adapter_version"] == "0.1.0"
72+
73+
74+
def test_mine_source_dry_run_files_nothing(tmp_dir, palace_path, _fake_adapter):
75+
from mempalace.cli import _mine_via_source_adapter
76+
from mempalace.palace import get_collection
77+
78+
args = Namespace(source="fake-test", dir=tmp_dir, wing=None, dry_run=True)
79+
_mine_via_source_adapter(args, palace_path)
80+
81+
col = get_collection(palace_path, create=True)
82+
assert col.get(where={"source_file": "fake://item/1"})["ids"] == []
83+
84+
85+
def test_mine_source_unknown_adapter_exits(tmp_dir, palace_path):
86+
from mempalace.cli import _mine_via_source_adapter
87+
88+
args = Namespace(source="does-not-exist", dir=tmp_dir, wing=None, dry_run=False)
89+
with pytest.raises(SystemExit):
90+
_mine_via_source_adapter(args, palace_path)

0 commit comments

Comments
 (0)