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
22 changes: 14 additions & 8 deletions docs/extraction-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"class": "standalone_repository_policy_or_test",
"path": "plugins/substrate/README.md",
"reason": "Usage documentation for the independent Substrate retrieval plugin.",
"sha256": "dff165ab8024d2788d52e4653e57c425cf98a8f24d1c13a5b3b0bead38287c40"
"sha256": "547e6b106337dd2fed2148fc4273fd90360822652dad4941533d85bef951838d"
},
{
"class": "standalone_plugin_implementation",
Expand Down Expand Up @@ -173,25 +173,25 @@
"class": "standalone_plugin_implementation",
"path": "plugins/substrate/onboarding.py",
"reason": "Automatic RFC 8628 device onboarding for the Substrate retrieval plugin.",
"sha256": "19708eb1ce0cb4ddc5a64523cad351d0794ce4c8397079add2098c3905a66629"
"sha256": "5cb19fd1bcdac20ef61091329e20c47c381212bac07578c6da4d480583978a31"
},
{
"class": "standalone_plugin_implementation",
"path": "plugins/substrate/plugin.py",
"reason": "Hermes hooks and tools for Substrate retrieval.",
"sha256": "b9004cde31173ad2037c7b21b5975b05b7360efed5ae4f07a78594ed037ff3c7"
"sha256": "7289109843f5325713e7a02e311078d4dd9ce1fc18d481124b343c50b952abdf"
},
{
"class": "standalone_plugin_implementation",
"path": "plugins/substrate/plugin.yaml",
"reason": "Native Hermes manifest for Substrate retrieval.",
"sha256": "dcc3a7ce11e16f474deb1887c92bc628ade01d57ebbe2ad455aca8357e9099c9"
"sha256": "5c52cf65a93525d3569a0935ca56a6398ffd8f413af289b19bcfe5902a10a03b"
},
{
"class": "standalone_plugin_implementation",
"path": "plugins/substrate/setup.py",
"reason": "Passwordless active-profile setup and authenticated preflight.",
"sha256": "caec7593ba38e6849ec65d4ba1e1951a35e07fdcc68dd5f32df335b75554f202"
"sha256": "488a0c51bf0ea534aa75c968416c40baec09f8dc8eef10fd9f112010b089ac4a"
},
{
"class": "standalone_repository_policy_or_test",
Expand Down Expand Up @@ -245,13 +245,19 @@
"class": "standalone_repository_policy_or_test",
"path": "tests/test_retrieval_onboarding.py",
"reason": "Onboarding automation tests for the independent Substrate retrieval plugin.",
"sha256": "866b7637fcdb8120b22fe2c822b18e7b211d4dc447e702af47cb2bd89b343127"
"sha256": "87a4b15e34f809160b33d4c94362a94a82fa93bd46e2b6ba6aa68aea10ef07b0"
},
{
"class": "standalone_repository_policy_or_test",
"path": "tests/test_retrieval_plugin.py",
"reason": "Behavior and transport tests for the independent Substrate retrieval plugin.",
"sha256": "768cbaefad2efcf49eaf24772410ddbf03373b54714113bc392e375626ea5b49"
"sha256": "206fa1b00515372ccd9827b1bd2ce46c1bb08bb3e53637104970c216e09d17ea"
},
{
"class": "standalone_repository_policy_or_test",
"path": "tests/test_retrieval_session_capture.py",
"reason": "Updated by PR #33: live session capture and agent display names.",
"sha256": "674b25023225ba72bb34aff83b971f5f70c1ce9c0739463fd8077e7077e0b70e"
},
{
"class": "standalone_repository_policy_or_test",
Expand Down Expand Up @@ -455,7 +461,7 @@
{
"class": "build-and-install",
"destination": "scripts/verify_public_plugin_candidate.py",
"destination_sha256": "677d119930eadc663cc8ef64587890add8a0c7db4ddd81692d50a061953d2829",
"destination_sha256": "5f53e191cab7045b83e38645ca81a24cf04f0610f84f155126b2d17e89f9cc6e",
"source": "scripts/verify_public_plugin_candidate.py",
"source_sha256": "4130935d530075fce1758e2e89bd5d973a722e2293b5b1058cfe0d17f326172b",
"transformation": "modified_for_standalone"
Expand Down
6 changes: 4 additions & 2 deletions plugins/substrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ The plugin registers:
- `post_llm_call` for nonblocking full completed-turn capture;
- `memory_search`, `memory_expand`, and `memory_evidence`.

Optional overrides are `SUBSTRATE_API_URL`, `SUBSTRATE_API_KEY`, and
`SUBSTRATE_WIKI_ORIGIN`. The active profile's secure plugin state is used when these are
Optional overrides are `SUBSTRATE_API_URL`, `SUBSTRATE_API_KEY`,
`SUBSTRATE_WIKI_ORIGIN`, and `SUBSTRATE_AGENT_NAME`. The agent name is what
you and your agents see in the Substrate agent pane; the approval page lets
you edit it before approving, and it can be renamed there later. The active profile's secure plugin state is used when these are
unset.

The bundled CA files are unmodified public ISRG roots, loaded in addition to system trust:
Expand Down
16 changes: 15 additions & 1 deletion plugins/substrate/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

CLIENT_ID = "substrate-hermes"
SCOPES = "capture retrieve"
MAX_AGENT_NAME_BYTES = 64
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
ENV_KEY = "SUBSTRATE_API_KEY"
DEFAULT_ORIGIN = "https://vm-substrate-ar-01.taile961d2.ts.net:10000"
Expand Down Expand Up @@ -87,6 +88,13 @@ def active_home() -> Path:
return (homes[0] if homes else Path.home() / ".hermes").resolve()


def resolve_agent_name() -> str:
"""A human-readable name for this agent, shown in the approval page."""
candidate = os.environ.get("SUBSTRATE_AGENT_NAME") or os.environ.get("HERMES_PROFILE") or ""
candidate = _clip(" ".join(str(candidate).split()), MAX_AGENT_NAME_BYTES)
return candidate


def resolve_origin() -> str:
return (
os.environ.get("SUBSTRATE_API_URL")
Expand Down Expand Up @@ -327,6 +335,7 @@ def describe(self, state: dict[str, Any]) -> dict[str, Any]:
),
"user_code": _clip(str(state.get("user_code", "")), 16),
"expires_in": max(0, int(float(state.get("expires_at", now)) - now)),
"agent_name": _clip(str(state.get("agent_name", "")), MAX_AGENT_NAME_BYTES),
}
if phase in {"failed", "declined", "invalid"}:
return {
Expand Down Expand Up @@ -362,7 +371,11 @@ def _begin(self) -> dict[str, Any]:
status, value = request_json(
self.origin,
"/oauth/device_authorization",
form={"client_id": CLIENT_ID, "scope": SCOPES},
form={
"client_id": CLIENT_ID,
"scope": SCOPES,
"agent_name": resolve_agent_name(),
},
timeout=BEGIN_TIMEOUT_SECONDS,
)
except OnboardingError as exc:
Expand All @@ -385,6 +398,7 @@ def _begin(self) -> dict[str, Any]:
state = {
"phase": "pending",
"user_code": user_code,
"agent_name": _clip(str(value.get("agent_name") or resolve_agent_name()), MAX_AGENT_NAME_BYTES),
"verification_uri_complete": complete,
"interval": max(1, min(int(value.get("interval", 5)), 60)),
"expires_at": now + max(1, min(int(value.get("expires_in", 900)), 3600)),
Expand Down
126 changes: 126 additions & 0 deletions plugins/substrate/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ def _onboarding_notice(status: dict[str, Any]) -> str:
link = str(status.get("verification_uri_complete", ""))
code = str(status.get("user_code", ""))
expires = int(status.get("expires_in", 0))
name = str(status.get("agent_name", ""))
name_line = (
f"Agent name (editable on the approval page): {name}\n" if name else ""
)
return (
"<substrate-connect>\n"
"Substrate memory needs one-time browser approval. Show this link to the "
Expand All @@ -217,6 +221,7 @@ def _onboarding_notice(status: dict[str, Any]) -> str:
f"One-time code: {code} (valid for {expires} seconds). After approval the "
"key is stored privately for this profile and memory works automatically. "
"Never ask the user to paste a key into chat.\n"
f"{name_line}"
"</substrate-connect>"
)

Expand Down Expand Up @@ -443,6 +448,101 @@ def _run(self) -> None:

_CAPTURE_WORKER = _CaptureWorker()

# Sessions whose end has already been queued for materialization. Hermes fires
# on_session_end per turn, so boundary hooks must deduplicate; the set is
# bounded and process-local, and the server deduplicates by event id anyway.
_SENT_SESSIONS: dict[str, int] = {}
_SENT_SESSIONS_LOCK = threading.Lock()
_MAX_SENT_SESSIONS = 4096


def _session_envelope(
session_id: Any,
boundary: str,
*,
platform: Any = "",
chat_type: Any = "",
next_session_id: Any = "",
parent_session_id: Any = "",
high_water: int = 0,
) -> dict[str, Any] | None:
"""Build a validated capture_session envelope for a session boundary."""
sid = _bounded_text(session_id, contract.MAX_SESSION_ID_BYTES)
if not sid:
return None
event_id = str(uuid.uuid4())
envelope: dict[str, Any] = {
"schema_version": contract.SCHEMA_VERSION,
"contract_version": contract.CONTRACT_VERSION,
"event_id": event_id,
"kind": "capture_session",
"session_id": sid,
"offset": {"start": 0, "end": max(0, high_water)},
"capture_origin": "live",
"batch_id": "",
"speaker": {"id": "user", "role": "owner", "display": ""},
"created_at": _utc_now(),
"payload": {
"boundary": boundary,
"session_complete": True,
"message_high_water": max(0, high_water),
"platform": _bounded_text(platform, 32) or "cli",
"chat_type": _bounded_text(chat_type, 32) or "direct",
},
}
if next_session_id:
envelope["payload"]["next_session_id"] = _bounded_text(next_session_id, contract.MAX_SESSION_ID_BYTES)
if parent_session_id:
envelope["payload"]["parent_session_id"] = _bounded_text(parent_session_id, contract.MAX_SESSION_ID_BYTES)
try:
contract.validate_envelope(envelope, idempotency_key=event_id)
except contract.ContractError:
return None
return envelope


def end_session(
session_id: Any = "",
*,
boundary: str = "end",
platform: Any = "",
chat_type: Any = "",
next_session_id: Any = "",
parent_session_id: Any = "",
high_water: int = 0,
**kwargs: Any,
) -> None:
"""Queue a live session-complete marker for server-side materialization.

Idempotent per session: repeated boundary callbacks for one session are
ignored. Every failure is swallowed; session capture never blocks the host.
"""
try:
sid = _bounded_text(session_id, contract.MAX_SESSION_ID_BYTES)
if not sid or boundary not in contract.BOUNDARIES:
return
with _SENT_SESSIONS_LOCK:
if sid in _SENT_SESSIONS:
return
if len(_SENT_SESSIONS) >= _MAX_SENT_SESSIONS:
_SENT_SESSIONS.clear()
_SENT_SESSIONS[sid] = 1
envelope = _session_envelope(
sid,
boundary,
platform=platform or kwargs.get("platform", ""),
chat_type=chat_type or kwargs.get("chat_type", ""),
next_session_id=next_session_id or kwargs.get("new_session_id", "")
or kwargs.get("next_session_id", ""),
parent_session_id=parent_session_id or kwargs.get("old_session_id", "")
or kwargs.get("parent_session_id", ""),
high_water=high_water or 0,
)
if envelope is not None:
_CAPTURE_WORKER.enqueue(envelope)
except Exception:
pass


def sync_turn(
user_content: Any = "",
Expand Down Expand Up @@ -484,6 +584,26 @@ def post_llm_call(
)


def on_session_reset(**kwargs: Any) -> None:
"""Hermes /new boundary: mark the previous session complete."""
end_session(
kwargs.get("old_session_id") or kwargs.get("session_id") or "",
boundary="reset",
platform=kwargs.get("platform", ""),
next_session_id=kwargs.get("new_session_id", ""),
parent_session_id=kwargs.get("old_session_id", ""),
)


def on_session_finalize(**kwargs: Any) -> None:
"""CLI shutdown or gateway session-expiry boundary."""
end_session(
kwargs.get("session_id") or "",
boundary="end",
platform=kwargs.get("platform", ""),
)


# ---------------------------------------------------------------------------
# Retrieval tools
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -562,6 +682,7 @@ def _onboarding_result(status: dict[str, Any]) -> str:
link = str(status.get("verification_uri_complete", ""))
code = str(status.get("user_code", ""))
expires = int(status.get("expires_in", 0))
name = str(status.get("agent_name", ""))
return json.dumps(
{
"status": "authorization_required",
Expand All @@ -572,10 +693,12 @@ def _onboarding_result(status: dict[str, Any]) -> str:
"stored privately in this profile and never needs to be pasted "
"into chat. After approving, call memory_search again; "
"authorization completes automatically."
+ (f" This agent will be named '{name}' (editable on the approval page)." if name else "")
),
"verification_uri_complete": link,
"user_code": code,
"expires_in": expires,
"agent_name": name,
},
sort_keys=True,
)
Expand Down Expand Up @@ -739,6 +862,9 @@ def register(ctx: Any) -> None:
# Current Hermes post_llm_call includes the finalized full conversation in
# conversation_history, plus user_message and assistant_response.
ctx.register_hook("post_llm_call", post_llm_call)
# Session boundaries feed live-session materialization on the server.
ctx.register_hook("on_session_reset", on_session_reset)
ctx.register_hook("on_session_finalize", on_session_finalize)
ctx.register_tool(
name="memory_search", toolset=TOOLSET, schema=MEMORY_SEARCH_SCHEMA, handler=memory_search
)
Expand Down
5 changes: 4 additions & 1 deletion plugins/substrate/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: substrate
version: 0.2.0
version: 0.3.0
description: Substrate retrieval and completed-turn capture for Hermes 0.21.x
provides_tools:
- memory_search
Expand All @@ -8,7 +8,10 @@ provides_tools:
provides_hooks:
- pre_llm_call
- post_llm_call
- on_session_reset
- on_session_finalize
optional_env:
- SUBSTRATE_API_URL
- SUBSTRATE_API_KEY
- SUBSTRATE_WIKI_ORIGIN
- SUBSTRATE_AGENT_NAME
3 changes: 3 additions & 0 deletions plugins/substrate/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,16 @@ def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Connect the active Hermes profile to Substrate")
parser.add_argument("--hermes-home", type=Path)
parser.add_argument("--origin")
parser.add_argument("--name", help="display name shown for this agent in Substrate")
args = parser.parse_args(argv)
homes = _profile_homes()
active_home = (homes[0] if homes else Path.home() / ".hermes").resolve()
home = (args.hermes_home or active_home).resolve()
if home != active_home:
print(json.dumps({"status": "failed", "error": "active_profile_mismatch"}), flush=True)
return 1
if args.name:
os.environ["SUBSTRATE_AGENT_NAME"] = str(args.name)[:64]
origin = _origin(
args.origin
or os.environ.get("SUBSTRATE_API_URL")
Expand Down
19 changes: 10 additions & 9 deletions scripts/verify_public_plugin_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
"plugins/substrate/README.md": frozenset({"35b846e7abdb4faad9382c015f6668d4c4bcbef1e5bb49b27b51368634cfd07c"}),
"plugins/substrate/client.py": frozenset({"a1b0718e6e9435d0b5530bd0baa8bdb0944c47cbdd4b74c5058267ac8eed33f8"}),
"plugins/substrate/contract.py": frozenset({"f8dabf7d1e8f0c8ce5b6f1dc3529ef5d751b6b98dec1f59705392ff099e24346"}),
"plugins/substrate/plugin.yaml": frozenset({"86e28bce3ab3f2c1d25f09be111776c21f88c2e9f03037cfd6b3cd1f2ad9b0f4"}),
"plugins/substrate/plugin.yaml": frozenset({"86e28bce3ab3f2c1d25f09be111776c21f88c2e9f03037cfd6b3cd1f2ad9b0f4", "5c52cf65a93525d3569a0935ca56a6398ffd8f413af289b19bcfe5902a10a03b"}),
"tests/test_publication_scanner.py": frozenset(
{
"c2352cc593ab08d452479f9ed59651e95acdf0cd5511a94f3830e7910bf65a21",
Expand Down Expand Up @@ -286,14 +286,15 @@
'f5f87125f1edd37bff1d44301d6bb0f44cc7faf3ba6122bdbe7569f349fea7a3'}),
'tests/test_memory_provider.py': frozenset({'2c4517847dfad341063a69afcc737316a74574471fc98a45eaff21cfe4e271fd',
'cc967199b8e877a8088937a0c951e80f188ad4c7f29a98067bf649a2f8500b72'}),
'plugins/substrate/onboarding.py': frozenset({'19708eb1ce0cb4ddc5a64523cad351d0794ce4c8397079add2098c3905a66629'}),
'tests/test_retrieval_onboarding.py': frozenset({'866b7637fcdb8120b22fe2c822b18e7b211d4dc447e702af47cb2bd89b343127'}),
'plugins/substrate/plugin.py': frozenset({'b9004cde31173ad2037c7b21b5975b05b7360efed5ae4f07a78594ed037ff3c7'}),
'plugins/substrate/README.md': frozenset({'dff165ab8024d2788d52e4653e57c425cf98a8f24d1c13a5b3b0bead38287c40'}),
'plugins/substrate/onboarding.py': frozenset({'19708eb1ce0cb4ddc5a64523cad351d0794ce4c8397079add2098c3905a66629', '5cb19fd1bcdac20ef61091329e20c47c381212bac07578c6da4d480583978a31'}),
'tests/test_retrieval_onboarding.py': frozenset({'866b7637fcdb8120b22fe2c822b18e7b211d4dc447e702af47cb2bd89b343127', '87a4b15e34f809160b33d4c94362a94a82fa93bd46e2b6ba6aa68aea10ef07b0'}),
'plugins/substrate/plugin.py': frozenset({'7289109843f5325713e7a02e311078d4dd9ce1fc18d481124b343c50b952abdf', 'b9004cde31173ad2037c7b21b5975b05b7360efed5ae4f07a78594ed037ff3c7'}),
'plugins/substrate/README.md': frozenset({'547e6b106337dd2fed2148fc4273fd90360822652dad4941533d85bef951838d', 'dff165ab8024d2788d52e4653e57c425cf98a8f24d1c13a5b3b0bead38287c40'}),
'plugins/substrate/client.py': frozenset({'61ece2e3c21517d34341a1df13fb6782e844100c25c1020c679b62e28c22885b'}),
'plugins/substrate/setup.py': frozenset({'caec7593ba38e6849ec65d4ba1e1951a35e07fdcc68dd5f32df335b75554f202'}),
'tests/test_retrieval_plugin.py': frozenset({'768cbaefad2efcf49eaf24772410ddbf03373b54714113bc392e375626ea5b49'}),
'plugins/substrate/setup.py': frozenset({'488a0c51bf0ea534aa75c968416c40baec09f8dc8eef10fd9f112010b089ac4a', 'caec7593ba38e6849ec65d4ba1e1951a35e07fdcc68dd5f32df335b75554f202'}),
'tests/test_retrieval_plugin.py': frozenset({'206fa1b00515372ccd9827b1bd2ce46c1bb08bb3e53637104970c216e09d17ea', '768cbaefad2efcf49eaf24772410ddbf03373b54714113bc392e375626ea5b49'}),
'tests/test_retrieval_setup.py': frozenset({'109b1721f12223493aec3528703c7c65db1b58355efa6c06f04821ddb9c0fe33'}),
'tests/test_retrieval_session_capture.py': frozenset({'674b25023225ba72bb34aff83b971f5f70c1ce9c0739463fd8077e7077e0b70e'}),
}
for _path, _digests in _HOSTED_ONBOARDING_EXACT_ALLOWLIST.items():
SYNTHETIC_FILE_SHA256_ALLOWLIST[_path] = (
Expand All @@ -305,10 +306,10 @@
# only that sorted policy projection, so ordinary byte changes do not change
# policy. Adding, moving, or reclassifying a file requires explicit review.
TRUSTED_INVENTORY_POLICY_SHA256 = (
"a36a7a604d07d2478936c8f614d13adf41a34b41a8873e792338a03579a12884"
"c850a1780d6de7b73d071780bfda56ac1acebb2359dfdde69679220a8ef7713c"
)
TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = (
"1af56d541392836ea5fe0f5b38271b9d391e70a2193573bc419716e4fc1fa1b2"
"6f7546ccc968b1e18a9b74f48ed895185f0f2c6068ecdcae531a0fd2c9e11732"
)
SCANNER_PATH = "scripts/verify_public_plugin_candidate.py"
DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json"
Expand Down
Loading
Loading