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
73 changes: 73 additions & 0 deletions plugins/devagentic-docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# devagentic-docs plugin

Hermes plugin that surfaces devagentic's GraphQL `writeDoc` /
`searchDocs` primitives as `/doc` slash commands inside a hermes
session.

Sibling to [`devagentic-canvas`](../devagentic-canvas/README.md).
Same env conventions, same loss-tolerant failure semantics, same
plugin enable/disable contract.

## Enable / disable

```bash
hermes plugin enable devagentic-docs
hermes plugin disable devagentic-docs
hermes plugin list
```

When disabled, the plugin is fully inert — no `/doc` command is
registered.

## Surface

* `/doc search <query> [--tag <tag>] [--limit N]` — top hits from
`searchDocs`. Lexical + embedding ranking; tag scopes to docs
with that exact tag.
* `/doc write <body> [--tags t1,t2,...]` — persist a doc via
`writeDoc`. Auto-tags with `source:hermes-cli` for downstream
filtering.
* `/doc show <id>` — fetch a doc by id (top-1 search match,
identity-verified).

## Configuration

Reuses the devagentic-local provider's environment, same as
`devagentic-canvas`:

| env var | default | purpose |
|---|---|---|
| `DEVAGENTIC_BASE_URL` | `http://127.0.0.1:6071/v1` | devagentic root; GraphQL lives at `<root-without-/v1>/graphql` |
| `DEVAGENTIC_API_KEY` | _(empty)_ | bearer token; any non-empty value works when devagentic runs in `DEVAGENTIC_TRUST_HEADER=1` mode |
| `DEVAGENTIC_USER_ID` | _(empty)_ | manual override for the `X-User-Id` header; falls back to `hermes_cli.profiles.get_active_profile_name()` |

## Failure semantics

Every code path is loss-tolerant:

* Devagentic unreachable → slash commands return a user-facing
error string with a `Reason:` clause naming the specific failure
(auth / 404 / unreachable / parse / no user_id).
* Empty result set → user-facing "no docs matched" message;
not a failure.
* The plugin **never raises** out of a slash command — a broken
devagentic doesn't brick the session.

## Layout

```
plugins/devagentic-docs/
├── plugin.yaml # manifest (no hooks in MVP)
├── __init__.py # register() entrypoint
├── client.py # GraphQL client + last_error_text()
└── commands.py # /doc slash command handlers
```

## Out of scope for MVP

* `/fork open <doc_id>` + `/fork close` (planned per #12; deferred
to keep MVP small).
* `pre_llm_call` hook for pinned-doc context injection (planned
per #12; deferred — needs the `/fork` marker file first).
* `/doc delete` — devagentic's doc graph is append-only by design;
if a delete primitive lands, surface it then.
31 changes: 31 additions & 0 deletions plugins/devagentic-docs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""devagentic-docs plugin entrypoint (hermes issue #12).

Surfaces devagentic's writeDoc + searchDocs GraphQL primitives as
`/doc` slash commands. Sibling to devagentic-canvas — same env
conventions, same loss-tolerant contract, same enable/disable
semantics.

When disabled, the plugin is fully inert — no slash command is
registered. Hermes' other flows are unchanged.
"""
from __future__ import annotations

import logging

from . import commands as _commands


logger = logging.getLogger(__name__)


def register(ctx) -> None:
"""Plugin loader entrypoint. Wires the /doc slash command into
the host's PluginContext."""
ctx.register_command(
name="doc",
handler=_commands.doc_command,
description=("Search, write, and show devagentic doc-graph "
"entries from inside a hermes session."),
args_hint=("search <query> [--tag t] [--limit N] | "
"write <body> [--tags a,b] | show <id>"),
)
219 changes: 219 additions & 0 deletions plugins/devagentic-docs/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Thin GraphQL client for devagentic's writeDoc + searchDocs
primitives. Sibling to plugins/devagentic-canvas/client.py — same
env conventions (DEVAGENTIC_BASE_URL, DEVAGENTIC_API_KEY,
DEVAGENTIC_USER_ID + profile fallback), same loss-tolerant contract.

Each method returns Optional[<shape>] — None on any failure. The
slash commands read `last_error_text()` to surface the specific
failure kind in user-facing messages (auth vs unreachable vs parse
vs no user_id), the same pattern used by the canvas plugin after
#15.

Devagentic's GraphQL surface is at $DEVAGENTIC_BASE_URL/graphql
(the /v1 suffix in the env var is stripped if present, mirroring
agent/devagentic_skills.py + agent/devagentic_memory.py).
"""
from __future__ import annotations

import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Optional


logger = logging.getLogger(__name__)


_DEFAULT_TIMEOUT = 8.0


def _base_url() -> str:
"""Resolve the GraphQL root URL. Accepts both `…:6070` and
`…:6070/v1` forms (the latter has its /v1 stripped so we can
compose `…/graphql`). Mirrors devagentic_skills + memory.
"""
raw = os.environ.get("DEVAGENTIC_BASE_URL", "http://127.0.0.1:6071/v1")
base = raw.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
return base


def _api_key() -> str:
return (os.environ.get("DEVAGENTIC_API_KEY") or "").strip()


def _user_id() -> Optional[str]:
override = (os.environ.get("DEVAGENTIC_USER_ID") or "").strip()
if override:
return override
try:
from hermes_cli.profiles import get_active_profile_name
name = (get_active_profile_name() or "").strip()
return name or None
except Exception as exc: # noqa: BLE001
logger.debug("docs client: profile resolution failed: %s", exc)
return None


_last_error: Optional[str] = None


def last_error_text() -> Optional[str]:
"""Short, human-readable description of the most recent failure.
Slash commands append this as `Reason: …` to their user-facing
error strings. Cleared on every successful call."""
return _last_error


def _record_error(text: Optional[str]) -> None:
global _last_error
_last_error = text


def _post_graphql(query: str, variables: dict,
*, timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]:
"""POST a GraphQL query. Returns the `data` dict on success,
None on any failure. Never raises. Populates last_error_text()
with the specific failure kind on None returns."""
_record_error(None)
user = _user_id()
if not user:
msg = ("could not resolve user_id — set DEVAGENTIC_USER_ID "
"or run inside a hermes profile")
logger.debug("docs client: %s", msg)
_record_error(msg)
return None
base = _base_url()
url = f"{base}/graphql"
body = json.dumps({"query": query, "variables": variables}).encode("utf-8")
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
req.add_header("X-User-Id", user)
api_key = _api_key()
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
msg = ("authentication failed — set DEVAGENTIC_API_KEY "
"(any non-empty value works when devagentic runs "
"in trust-header mode)")
elif exc.code == 404:
msg = f"not found at {url}"
else:
msg = f"HTTP {exc.code} from {url}"
logger.debug("docs client: %s %s → %s", "POST", url, msg)
_record_error(msg)
return None
except (urllib.error.URLError, OSError, TimeoutError) as exc:
msg = f"unreachable at {url} ({exc})"
logger.debug("docs client: %s", msg)
_record_error(msg)
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
logger.debug("docs client: parse failed: %s", exc)
_record_error("invalid response body (not JSON)")
return None
if not isinstance(payload, dict):
_record_error("response was not a JSON object")
return None
if payload.get("errors"):
# GraphQL-level error (resolver failure, invalid query, etc.)
first = (payload.get("errors") or [{}])[0]
msg = f"GraphQL error: {first.get('message', 'unknown')}"
logger.debug("docs client: %s", msg)
_record_error(msg)
return None
return payload.get("data") or None


# --- public API ----------------------------------------------

def search_docs(query: str, limit: int = 10,
tag: Optional[str] = None,
*, timeout: float = _DEFAULT_TIMEOUT
) -> Optional[list[dict]]:
"""searchDocs(query, limit, tag) → list of {id, content, tags,
score}. Returns None on any failure (caller falls through);
empty list when the search succeeds but returns no hits.
"""
if not query:
_record_error("query is required")
return None
gql = (
"query($q:String!,$l:Int,$t:String){"
" searchDocs(query:$q,limit:$l,tag:$t){ id content tags score }"
"}"
)
variables: dict[str, Any] = {"q": query, "l": int(limit)}
if tag:
variables["t"] = tag
data = _post_graphql(gql, variables, timeout=timeout)
if data is None:
return None
hits = data.get("searchDocs")
if not isinstance(hits, list):
return []
return hits


def write_doc(content: str, tags: Optional[list[str]] = None,
source: Optional[str] = None,
*, timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]:
"""writeDoc(content, tags, source) → {id}. Returns the new doc
dict on success, None on any failure."""
if not content:
_record_error("content is required")
return None
gql = (
"mutation($c:String!,$t:[String!],$s:String){"
" writeDoc(content:$c, tags:$t, source:$s){ id }"
"}"
)
variables: dict[str, Any] = {"c": content}
if tags:
variables["t"] = list(tags)
if source:
variables["s"] = source
data = _post_graphql(gql, variables, timeout=timeout)
if data is None:
return None
return data.get("writeDoc") or None


def get_doc(doc_id: str, *,
timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]:
"""Fetch a single doc by id via searchDocs (devagentic's
canonical retrieval primitive — there is no GET /doc/<id>
GraphQL field in the current surface). Filters down to the
requested id from a limit=1-by-id lookup."""
if not doc_id:
_record_error("doc_id is required")
return None
gql = (
"query($q:String!){"
" searchDocs(query:$q, limit:1){ id content tags }"
"}"
)
data = _post_graphql(gql, {"q": doc_id}, timeout=timeout)
if data is None:
return None
hits = data.get("searchDocs") or []
if not isinstance(hits, list) or not hits:
_record_error(f"no doc matched id={doc_id}")
return None
# searchDocs is lexical+embedding — verify the top hit's id
top = hits[0]
if (top.get("id") or "") != doc_id:
_record_error(
f"top match was {top.get('id')!r}, not requested {doc_id!r}")
return None
return top
Loading