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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ All notable changes to vouch are documented here. Format follows
- `vouch serve` now fails fast with a clear `vouch init` hint when no `.vouch/` KB is present, instead of starting a server that immediately misbehaves (#95).

### Added
- `kb.volunteer_context` — confidence-gated push context for active sessions.
`kb.session_start(task=…)` opens a background watch on retrieval salience;
when an approved claim's normalized relevance exceeds the configured
threshold (default `0.85`), vouch queues `{claim_id, relevance, why}` and
emits an MCP notification (`kb.volunteer_context`). JSONL and CLI clients
poll via `kb.volunteer_context` / `vouch session volunteer`. Pushes are
throttled (default 30s) and respect scope visibility (#236).
- Auto-extracted typed edges: approving a page now files `mentions` (wiki-links),
`relates_to` (entity frontmatter), and `derived_from` (source frontmatter)
relation proposals automatically, tagged `proposed_by: vouch-extractor`.
Expand Down
1 change: 1 addition & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"kb.source_verify",
"kb.session_start",
"kb.session_end",
"kb.volunteer_context",
"kb.crystallize",
"kb.index_rebuild",
"kb.lint",
Expand Down
22 changes: 21 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import click
import yaml

from . import __version__, bundle, health
from . import __version__, bundle, health, volunteer_context
from . import audit as audit_mod
from . import install_adapter as install_mod
from . import lifecycle as life
Expand Down Expand Up @@ -1170,6 +1170,26 @@ def session_start_cmd(agent: str | None, task: str | None, note: str | None) ->
click.echo(sess.id)


@session.command("volunteer")
@click.argument("session_id")
@click.option("--no-clear", is_flag=True, help="Peek without draining the queue.")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
def session_volunteer_cmd(session_id: str, no_clear: bool, as_json: bool) -> None:
"""Poll volunteered context for an active session."""
offers = volunteer_context.drain_pending(session_id, clear=not no_clear)
payload = {"volunteers": [o.to_dict() for o in offers]}
if as_json:
_emit_json(payload)
return
if not offers:
click.echo("(no volunteered context)")
return
for offer in offers:
click.echo(
f"{offer.claim_id} relevance={offer.relevance:.2f} {offer.why}"
)


@session.command("end")
@click.argument("session_id")
@click.option("--note", default=None)
Expand Down
89 changes: 89 additions & 0 deletions src/vouch/hot_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Per-session hot memory — task query and salience snapshots for push context.

Tracks what the active session is working on and the last relevance scores
seen for approved claims. ``volunteer_context`` diffs snapshots to decide when
a claim newly crosses the confidence threshold.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, field


@dataclass
class SalienceSnapshot:
"""Relevance scores for a single evaluation pass."""

scores: dict[str, float] = field(default_factory=dict)


@dataclass
class HotMemory:
"""In-memory state for one active session watch."""

session_id: str
query: str
agent: str
project: str | None = None
last_snapshot: SalienceSnapshot = field(default_factory=SalienceSnapshot)
last_push_at: float | None = None
push_count: int = 0
volunteered: set[str] = field(default_factory=set)
active: bool = True


_registry: dict[str, HotMemory] = {}
_lock = threading.Lock()


def register(
*,
session_id: str,
query: str,
agent: str,
project: str | None = None,
) -> HotMemory:
"""Create or replace hot memory for *session_id*."""
mem = HotMemory(
session_id=session_id,
query=query,
agent=agent,
project=project,
)
with _lock:
_registry[session_id] = mem
return mem


def get(session_id: str) -> HotMemory | None:
with _lock:
return _registry.get(session_id)


def unregister(session_id: str) -> None:
with _lock:
mem = _registry.pop(session_id, None)
if mem is not None:
mem.active = False


def update_snapshot(session_id: str, scores: dict[str, float]) -> SalienceSnapshot | None:
"""Store *scores* and return the previous snapshot (for delta detection)."""
with _lock:
mem = _registry.get(session_id)
if mem is None:
return None
prev = mem.last_snapshot
mem.last_snapshot = SalienceSnapshot(scores=dict(scores))
return prev


def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> None:
with _lock:
mem = _registry.get(session_id)
if mem is None:
return
mem.volunteered.add(claim_id)
mem.last_push_at = pushed_at
mem.push_count += 1
11 changes: 10 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from pathlib import Path
from typing import Any

from . import audit, bundle, health
from . import audit, bundle, health, volunteer_context
from . import lifecycle as life
from . import sessions as sess_mod
from . import verify as verify_mod
Expand Down Expand Up @@ -421,6 +421,14 @@ def _h_crystallize(p: dict) -> dict:
)


def _h_volunteer_context(p: dict) -> dict:
offers = volunteer_context.drain_pending(
p["session_id"],
clear=bool(p.get("clear", True)),
)
return {"volunteers": [o.to_dict() for o in offers]}


def _h_index_rebuild(_: dict) -> dict:
return health.rebuild_index(_store())

Expand Down Expand Up @@ -605,6 +613,7 @@ def _h_provenance_rebuild(_: dict) -> dict:
"kb.source_verify": _h_source_verify,
"kb.session_start": _h_session_start,
"kb.session_end": _h_session_end,
"kb.volunteer_context": _h_volunteer_context,
"kb.crystallize": _h_crystallize,
"kb.index_rebuild": _h_index_rebuild,
"kb.lint": _h_lint,
Expand Down
20 changes: 17 additions & 3 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@

from __future__ import annotations

import asyncio
import os
from pathlib import Path
from typing import Any

from mcp.server.fastmcp import FastMCP

from . import audit, bundle, health
from . import audit, bundle, health, volunteer_context
from . import lifecycle as life
from . import sessions as sess_mod
from . import verify as verify_mod
Expand Down Expand Up @@ -556,11 +557,24 @@ def kb_source_verify() -> list[dict[str, Any]]:


@mcp.tool()
def kb_session_start(task: str | None = None, note: str | None = None) -> dict[str, Any]:
sess = sess_mod.session_start(_store(), agent=_agent(), task=task, note=note)
async def kb_session_start(task: str | None = None, note: str | None = None) -> dict[str, Any]:
store = _store()
sess = sess_mod.session_start(store, agent=_agent(), task=task, note=note)
ctx = mcp.get_context()
if ctx.session is not None:
volunteer_context.register_mcp_push(
sess.id, ctx.session, asyncio.get_running_loop(),
)
return sess.model_dump(mode="json")


@mcp.tool()
def kb_volunteer_context(session_id: str, *, clear: bool = True) -> dict[str, Any]:
"""Poll confidence-gated context volunteered for an active session."""
offers = volunteer_context.drain_pending(session_id, clear=clear)
return {"volunteers": [o.to_dict() for o in offers]}


@mcp.tool()
def kb_session_end(session_id: str, note: str | None = None) -> dict[str, Any]:
sess = sess_mod.session_end(_store(), session_id, note=note)
Expand Down
4 changes: 3 additions & 1 deletion src/vouch/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import uuid
from datetime import UTC, datetime

from . import audit, index_db
from . import audit, index_db, volunteer_context
from .models import Page, PageType, ProposalStatus, Session
from .proposals import approve
from .storage import KBStore
Expand All @@ -33,6 +33,7 @@ def session_start(store: KBStore, *, agent: str, task: str | None = None,
store.kb_dir, event="session.start", actor=agent,
object_ids=[sess.id], data={"task": task},
)
volunteer_context.on_session_start(store, sess)
return sess


Expand All @@ -52,6 +53,7 @@ def session_end(store: KBStore, session_id: str, *, note: str | None = None) ->
store.kb_dir, event="session.end", actor=sess.agent,
object_ids=[sess.id], data={"proposals": len(sess.proposal_ids)},
)
volunteer_context.on_session_end(session_id)
return sess


Expand Down
Loading
Loading