Skip to content
Closed
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ All notable changes to vouch are documented here. Format follows
## [Unreleased]

### Added
- **read-only kb subscriptions — federate search without copying** (#610):
sharing knowledge between KBs meant copying it — `vouch hub` export/import
moves artifacts through the receiving gate and they become local,
duplicated, and drift independently, re-review needed on every upstream
edit. `vouch subscribe <kb>` / `unsubscribe` / `subscriptions list` add
ditto-style subscription instead: a subscribed KB's approved knowledge
joins local `kb.search` / `kb.context` results, live, read-only, never
copied. Federated hits are namespaced (`<kb_id>:<artifact_id>`) so they
never resolve locally, tagged with `origin_kb_id`/`origin_kb_name`/
`trust_level` so a reader can't mistake them for locally-reviewed
knowledge, and capped by a `budget_share` knob that only fills leftover
result slots — a subscription can never crowd out local knowledge. One
hop only: a federated call never itself federates.

- **correction capture — the pushback becomes a proposal** (#430): the adapter
captured tool *outcomes* passively but never the single highest-signal event
in a session, the user correcting the agent ("no, we deploy from `main` not
Expand Down
13 changes: 13 additions & 0 deletions schemas/context-item.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@
"title": "Summary",
"type": "string"
},
"trust_level": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "vouch: the subscribed KB's trust level ('unverified' or 'trusted'), set only for hits federated in via a read-only subscription. None for local knowledge and for gated-import origin hits (those aren't federated, they're locally-owned copies).",
"title": "Trust Level"
},
"type": {
"enum": [
"claim",
Expand Down
13 changes: 13 additions & 0 deletions schemas/context-pack.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@
"title": "Summary",
"type": "string"
},
"trust_level": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "vouch: the subscribed KB's trust level ('unverified' or 'trusted'), set only for hits federated in via a read-only subscription. None for local knowledge and for gated-import origin hits (those aren't federated, they're locally-owned copies).",
"title": "Trust Level"
},
"type": {
"enum": [
"claim",
Expand Down
8 changes: 5 additions & 3 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ def load_config(store: KBStore) -> CaptureConfig:
return CaptureConfig(
enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED),
realtime=coerce_bool(raw.get("realtime", DEFAULT_REALTIME), DEFAULT_REALTIME),
min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)),
dedup_window_seconds=float(
raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS)
min_observations=coerce_numeric(
raw.get("min_observations"), DEFAULT_MIN_OBSERVATIONS, int
),
dedup_window_seconds=coerce_numeric(
raw.get("dedup_window_seconds"), DEFAULT_DEDUP_WINDOW_SECONDS, float
),
answer_mode=answer_mode,
)
Expand Down
55 changes: 55 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from . import sessions as sess_mod
from . import skills as skills_mod
from . import stats as stats_mod
from . import subscriptions as subscriptions_mod
from . import sync as sync_mod
from . import synthesize as synth
from . import trust as trust_mod
Expand Down Expand Up @@ -393,6 +394,60 @@ def hub_unregister(token: str) -> None:
click.echo(f"unregistered {removed.name} ({removed.kb_id})")


@cli.command("subscribe")
@click.argument("kb_ref")
@click.option(
"--trust-level",
default=subscriptions_mod.DEFAULT_TRUST_LEVEL,
show_default=True,
type=click.Choice(subscriptions_mod.TRUST_LEVELS),
help="How federated hits from this KB are labeled downstream.",
)
def subscribe_cmd(kb_ref: str, trust_level: str) -> None:
"""Subscribe to another KB's approved knowledge, read-only."""
store = _load_store()
with _cli_errors():
try:
sub = subscriptions_mod.subscribe(store, kb_ref, trust_level=trust_level)
except subscriptions_mod.SubscriptionError as e:
raise click.ClickException(str(e)) from e
click.echo(f"subscribed to {sub.name} ({sub.kb_id}) trust={sub.trust_level}")


@cli.command("unsubscribe")
@click.argument("kb_ref")
def unsubscribe_cmd(kb_ref: str) -> None:
"""Stop federating search/context with a subscribed KB."""
store = _load_store()
with _cli_errors():
removed = subscriptions_mod.unsubscribe(store, kb_ref)
if not removed:
raise click.ClickException(f"no subscription matches {kb_ref!r}")
click.echo(f"unsubscribed from {kb_ref}")


@cli.group(name="subscriptions")
def subscriptions_group() -> None:
"""Read-only KBs federated into this KB's search and context."""


@subscriptions_group.command("list")
@click.option("--json", "as_json", is_flag=True, help="Emit subscriptions as JSON.")
def subscriptions_list(as_json: bool) -> None:
"""List this KB's subscriptions."""
store = _load_store()
with _cli_errors():
subs = subscriptions_mod.list_subscriptions(store)
if as_json:
_emit_json({"subscriptions": [s.to_dict() for s in subs]})
return
if not subs:
click.echo("no subscriptions")
return
for s in subs:
click.echo(f"{s.name} [{s.trust_level}] {s.path} ({s.kb_id})")


def _init_personal_kb(fallback: bool | None) -> Path:
"""Create + register the personal catch-all KB; shared by the two entry
points (`vouch hub init-personal` and `install-mcp --global`'s opt-in)
Expand Down
127 changes: 127 additions & 0 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from . import graph, hot_memory, index_db, retrieval_events
from . import pins as pins_mod
from . import strategy as strategy_mod
from . import subscriptions as subscriptions_mod
from .config_coerce import coerce_bool
from .embeddings.fusion import rrf_fuse
from .models import (
Expand Down Expand Up @@ -481,6 +482,58 @@ def _retrieve(
return [(k, i, s, sc, "substring") for k, i, s, sc in filtered]


def _add_federated_hits(
store: KBStore,
local_hits: list[dict[str, Any]],
*,
query: str,
limit: int,
backend: str | None,
min_score: float,
) -> list[dict[str, Any]]:
"""Merge in read-only hits from subscribed KBs, budget-capped.

Federated hits only ever fill leftover slots after local hits -- the
budget knob caps how many slots subscriptions may use, it never lets a
subscription displace an already-ranked local hit. A large or noisy
subscribed KB therefore cannot crowd out local knowledge; at worst it
fills the remainder of the pack.
"""
subs = subscriptions_mod.list_subscriptions(store)
if not subs:
return local_hits
slack = limit - len(local_hits)
if slack <= 0:
return local_hits
cfg = subscriptions_mod.load_subscriptions_config(store)
budget = max(0, min(slack, round(limit * cfg.budget_share)))
if budget <= 0:
return local_hits

federated: list[dict[str, Any]] = []
for sub in subs:
sub_store = subscriptions_mod.open_subscribed_store(sub)
if sub_store is None:
continue # moved/deleted since subscribing -- contribute nothing
sub_result = search_kb(
sub_store, query=query, limit=budget, backend=backend,
min_score=min_score, _federate=False,
)
for hit in sub_result.get("hits", []):
federated.append(
{
**hit,
"id": f"{sub.kb_id}:{hit['id']}",
"origin_kb_id": sub.kb_id,
"origin_kb_name": sub.name,
"trust_level": sub.trust_level,
"federated": True,
}
)
federated.sort(key=lambda h: h["score"], reverse=True)
return local_hits + federated[:budget]


def search_kb(
store: KBStore,
*,
Expand All @@ -490,6 +543,7 @@ def search_kb(
min_score: float = 0.0,
project: str | None = None,
agent: str | None = None,
_federate: bool = True,
) -> dict[str, Any]:
"""The one `kb.search` implementation every surface delegates to.

Expand Down Expand Up @@ -580,6 +634,17 @@ def search_kb(
"viewer": {"project": viewer.project, "agent": viewer.agent},
"hits": hits_list,
}
# Federate: subscribed KBs are queried alongside the local one and
# merged in, read-only. One hop only -- a federated call never itself
# federates (subscriptions_mod never reads a foreign KB's own
# subscriptions.json either, so this flag is belt-and-suspenders).
if _federate:
hits_list = _add_federated_hits(
store, hits_list, query=query, limit=limit, backend=backend,
min_score=min_score,
)
result["hits"] = hits_list

# The single search path serves both agent-facing surfaces (MCP + JSONL),
# so the hot-memory sidebar (#261) is attached here rather than duplicated
# at each call site.
Expand Down Expand Up @@ -762,6 +827,58 @@ def _origin_from_tags(tags: list[str]) -> str | None:
return None


def _federated_context_items(
store: KBStore, query: str, viewer: ViewerContext, budget: int,
) -> list[ContextItem]:
"""Read-only ContextItems from every subscribed KB, budget-capped.

Mirrors the per-hit processing in build_context_pack (retracted-claim
and dead-page filtering) but against each subscription's own store, and
scoped by that store's own viewer/config -- a subscription is queried on
its own terms, same as the local KB is. Always tagged with the
subscribing KB's kb_id and trust_level; a federated hit's own origin tag
(if any) is ignored, since subscriptions are one hop only.
"""
subs = subscriptions_mod.list_subscriptions(store)
if not subs:
return []
federated: list[ContextItem] = []
for sub in subs:
sub_store = subscriptions_mod.open_subscribed_store(sub)
if sub_store is None:
continue # moved/deleted since subscribing -- contributes nothing
sub_viewer = viewer_from(config_path=sub_store.config_path)
hits = _retrieve(sub_store, query, budget, sub_viewer)
for kind, hid, summary, score, backend in hits:
cites: list[str] = []
if kind == "claim":
try:
claim = sub_store.get_claim(hid)
except ArtifactNotFoundError:
continue
if claim.status in _RETRACTED_CLAIM_STATUSES:
continue
cites = list(claim.evidence)
elif kind == "page" and not _page_is_live(sub_store, hid):
continue
summary = _enrich_summary(sub_store, kind, hid, summary)
federated.append(
ContextItem(
id=f"{sub.kb_id}:{hid}",
type=cast(ContextItemKind, kind),
summary=summary,
score=score,
backend=backend,
citations=cites,
freshness="unknown",
origin=sub.kb_id,
trust_level=sub.trust_level,
)
)
federated.sort(key=lambda i: i.score, reverse=True)
return federated[:budget]


def build_context_pack(
store: KBStore,
*,
Expand Down Expand Up @@ -837,6 +954,16 @@ def build_context_pack(
)
)

# Federate: subscribed KBs fill only the leftover slots after local
# hits, capped by the budget knob -- they never displace a ranked local
# item, only occupy what's unused.
slack = limit - len(items)
if slack > 0:
cfg = subscriptions_mod.load_subscriptions_config(store)
fed_budget = max(0, min(slack, round(limit * cfg.budget_share)))
if fed_budget > 0:
items.extend(_federated_context_items(store, query, viewer, fed_budget))

items = _dedupe_near_duplicates(items)

# Pins go in front of everything retrieval chose (#615): the working set is
Expand Down
7 changes: 7 additions & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,13 @@ class ContextItem(BaseModel):
"gated federation import (from the claim's origin:<kb> tag). None for "
"locally-authored knowledge.",
)
trust_level: str | None = Field(
default=None,
description="vouch: the subscribed KB's trust level ('unverified' or "
"'trusted'), set only for hits federated in via a read-only "
"subscription. None for local knowledge and for gated-import origin "
"hits (those aren't federated, they're locally-owned copies).",
)


class ContextQuality(BaseModel):
Expand Down
Loading
Loading