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
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
- **explicit pins — a working set that always enters the pack** (#615):
`vouch pin <id>` / `vouch pins list` / `vouch unpin <id>`. Pinned claims and
pages lead every context pack instead of having to win the query each turn,
which is what `hot_memory` and `salience` cannot do — they are recency-driven
and decay exactly when a long task needs them not to. Capped at
`retrieval.pins.budget_share` (default 0.3) so pins can never starve
retrieval, and de-duplicated against what retrieval already found. Pins are
**not a gate bypass and not a permission**: they point at already-approved
artifacts, and lifecycle and viewer scope are re-checked on every build, so a
pinned claim that is later superseded/archived/redacted — or one the scope
filter hides — stops being injected. Shared pins live in committed
`.vouch/pins.yaml`; `--local` keeps a personal set in gitignored
`.vouch/pins.local.yaml`. `--expires` drops a pin automatically, applied on
read so building a pack never writes.
- **`kb.effectiveness` — is this claim earning its keep?** (#426): a read-only,
measurement-only signal ranking approved artifacts by how the sessions they
were surfaced into ended. Per artifact it reports good/bad session counts, an
Expand Down
88 changes: 88 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from . import metrics as metrics_mod
from . import migrations as migrations_mod
from . import notify as notify_mod
from . import pins as pins_mod
from . import pr_cache as prc_mod
from . import provenance as prov_mod
from . import recall as recall_mod
Expand Down Expand Up @@ -115,6 +116,7 @@ def _cli_errors() -> Iterator[None]:
migrations_mod.MigrationError,
chatgpt_import_mod.ChatGPTImportError,
codex_rollout_mod.CodexRolloutError,
pins_mod.PinError,
) as e:
raise click.ClickException(str(e)) from e

Expand Down Expand Up @@ -3760,6 +3762,92 @@ def graph(session: str | None, fmt: str) -> None:
click.echo(text, nl=False)


@cli.command("pin")
@click.argument("artifact_id")
@click.option("--local", is_flag=True,
help="Pin only for me — kept out of git in .vouch/pins.local.yaml.")
@click.option("--expires", default=None,
help="Auto-drop the pin after this long (e.g. 7d) or at an ISO date.")
@click.option("--note", default=None, help="Why this is pinned.")
def pin_cmd(artifact_id: str, local: bool, expires: str | None,
note: str | None) -> None:
"""Keep a claim or page in every context pack until unpinned."""
store = _load_store()
expires_at = None
with _cli_errors():
if expires is not None:
# An absolute date is already the answer, so only a duration gets
# mirrored. parse_since returns ISO input unchanged, and mirroring
# that around now turns a future date into a past one — the pin
# would be created already expired, silently.
try:
expires_at = datetime.fromisoformat(expires)
except ValueError:
# Not a date, so read it as a duration counted backwards and
# mirror it forwards.
now = datetime.now(UTC)
past = metrics_mod.parse_since(expires)
if past is None:
# "all" and "" mean "no lower bound" to parse_since. As an
# expiry that would silently mean "never", which is already
# what omitting the flag does — so it is a typo, not a
# request worth honouring.
raise click.ClickException(
f"--expires {expires!r}: expected a duration like '7d' "
"or an ISO date like '2026-08-15'"
) from None
expires_at = now + (now - past)
else:
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
p = pins_mod.add_pin(
store, artifact_id, pinned_by=_whoami(), local=local,
expires_at=expires_at, note=note,
)
where = "local" if local else "shared"
click.echo(f"pinned {p.kind}/{p.artifact_id} ({where})")


@cli.command("unpin")
@click.argument("artifact_id")
@click.option("--local", is_flag=True, help="Remove from the local pin set.")
def unpin_cmd(artifact_id: str, local: bool) -> None:
"""Stop pinning an artifact."""
store = _load_store()
with _cli_errors():
removed = pins_mod.remove_pin(store, artifact_id, local=local)
if not removed:
raise click.ClickException(
f"{artifact_id} is not in the {'local' if local else 'shared'} pin set"
)
click.echo(f"unpinned {artifact_id}")


@cli.group(name="pins")
def pins_group() -> None:
"""The working set that always enters the context pack."""


@pins_group.command("list")
@click.option("--json", "as_json", is_flag=True, help="Emit pins as JSON.")
def pins_list(as_json: bool) -> None:
"""Show every live pin, shared then local."""
store = _load_store()
with _cli_errors():
pins = pins_mod.load_pins(store)
if as_json:
_emit_json({"pins": [p.to_dict() | {"local": p.local} for p in pins]})
return
if not pins:
click.echo("no pins. `vouch pin <id>` keeps an artifact in every pack.")
return
for p in pins:
scope = "local " if p.local else "shared"
expiry = f" expires {p.expires_at:%Y-%m-%d}" if p.expires_at else ""
note = f" — {p.note}" if p.note else ""
click.echo(f"{scope} {p.kind}/{p.artifact_id}{expiry}{note}")


@cli.group()
def provenance() -> None:
"""Provenance graph cache operations."""
Expand Down
45 changes: 43 additions & 2 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import yaml

from . import graph, hot_memory, index_db, retrieval_events
from . import pins as pins_mod
from . import strategy as strategy_mod
from .config_coerce import coerce_bool
from .embeddings.fusion import rrf_fuse
Expand Down Expand Up @@ -720,6 +721,16 @@ def _jaccard(a: set[str], b: set[str]) -> float:
return len(a & b) / len(a | b)


# The near-duplicate heuristic, shared by the retrieval pass and the pin
# injection below it so the two cannot drift to different notions of "same".
_NEAR_DUP_THRESHOLD = 0.85
_NEAR_DUP_TOKENS = 40


def _near_dup_tokens(summary: str) -> set[str]:
return set(summary.lower().split()[:_NEAR_DUP_TOKENS])


def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]:
"""Drop items whose summary is near-identical to a higher-scored one.

Expand All @@ -736,8 +747,8 @@ def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]:
kept_tokens: list[set[str]] = []
order = sorted(range(len(items)), key=lambda i: items[i].score, reverse=True)
for idx in order:
toks = set(items[idx].summary.lower().split()[:40])
if any(_jaccard(toks, seen) >= 0.85 for seen in kept_tokens):
toks = _near_dup_tokens(items[idx].summary)
if any(_jaccard(toks, seen) >= _NEAR_DUP_THRESHOLD for seen in kept_tokens):
dropped.add(idx)
continue
kept_tokens.append(toks)
Expand Down Expand Up @@ -830,6 +841,36 @@ def build_context_pack(

items = _dedupe_near_duplicates(items)

# Pins go in front of everything retrieval chose (#615): the working set is
# a standing instruction, so it must not have to win the query every turn.
# `pinned_items` re-checks lifecycle and viewer scope on every build, so a
# pin can reorder the pack but never widen what it may contain. The budget
# share caps them, and de-duplication keeps a pinned artifact that also
# ranked from occupying two slots.
pinned = pins_mod.pinned_items(store, viewer=viewer, max_chars=max_chars)
if pinned:
# Exact `(type, id)` de-duplication is not enough: the same knowledge
# can be stored under a second id, and `_dedupe_near_duplicates` ran
# before the pins existed, so a retrieved near-duplicate of a pin has
# never been compared against it.
#
# Deliberately not solved by moving the pass below this injection: it
# keeps the *highest-scored* member of a cluster rather than the first,
# and `pages_first` multiplies a page's score by `boost` (1.25 by
# default), so a retrieved page can outscore a pin's flat 1.0 and evict
# it. That is precisely the outcome pinning exists to prevent, so the
# comparison runs here instead, where the pin always wins.
pinned_keys = {(p.type, p.id) for p in pinned}
pinned_tokens = [_near_dup_tokens(p.summary) for p in pinned]
items = pinned + [
i for i in items
if (i.type, i.id) not in pinned_keys
and not any(
_jaccard(_near_dup_tokens(i.summary), pt) >= _NEAR_DUP_THRESHOLD
for pt in pinned_tokens
)
]

failed: list[str] = []
uncited: list[str] = []
budget_truncated = False
Expand Down
Loading
Loading