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
204 changes: 204 additions & 0 deletions agent/devagentic_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Phase D devagentic-graph memory adapter (devagentic issue #54).

Talks to a running devagentic over HTTP to resolve user-facts from
graph nodes (kind:user-fact) before hermes falls back to its
~/.hermes/MEMORY.md / USER.md / SOUL.md files.

Opt-in via env `DEVAGENTIC_MEMORY_GRAPH=1`. Default off keeps the
file-based flow byte-stable. The migration script
`scripts/migrate_memory_to_graph.py` populates the graph from
existing files; once enabled, `query_user_facts(query_text)`
returns relevance-ranked facts the caller can splice into a
system prompt or memory rollup.

Env vars:
DEVAGENTIC_MEMORY_GRAPH set to `1` to enable graph reads
(default: off). When off,
query_user_facts always returns
[]; callers fall straight to files.
DEVAGENTIC_BASE_URL devagentic base URL (default
http://127.0.0.1:6071/v1). Reused
from the devagentic-local provider
so a single configuration covers
skills, memory, and completions.
DEVAGENTIC_API_KEY bearer token forwarded to devagentic.
With DEVAGENTIC_TRUST_HEADER=1 on
devagentic, any non-empty value works.

Failure semantics: every code path that touches the network is
wrapped in try/except. Resolver returns an empty list on any
failure (network, parse, no facts, etc.); callers MUST keep their
existing file fallback so a transient devagentic outage doesn't
brick memory retrieval.
"""
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__)


GRAPH_ENV = "DEVAGENTIC_MEMORY_GRAPH"

# Network timeout. Generous on a local loopback; faster than that
# and a slow devagentic startup would race the caller's file
# fallback.
_DEFAULT_TIMEOUT = 8.0


def graph_enabled() -> bool:
"""True iff `DEVAGENTIC_MEMORY_GRAPH` is in `1|true|yes|on`."""
return os.environ.get(GRAPH_ENV, "0").strip().lower() in (
"1", "true", "yes", "on")


def _base_url() -> str:
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]:
"""Resolve the X-User-Id to send.

Same precedence as the devagentic-local provider plugin
(Phase G #50) and the skill adapter (Phase C #52):
1. `DEVAGENTIC_USER_ID` env override.
2. `hermes_cli.profiles.get_active_profile_name()`.
3. None — caller doesn't inject the header; adapter returns
empty.
"""
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("devagentic_memory: profile resolution failed: %s", exc)
return None


def _post_graphql(query: str, variables: dict,
*, timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]:
"""POST a GraphQL query to devagentic. Returns parsed `data`
on success, None on any failure (network, non-200, parse error,
GraphQL error). Failures log at DEBUG only."""
base = _base_url()
user = _user_id()
if not user:
logger.debug("devagentic_memory: no user_id resolved; skipping")
return None
body = json.dumps({"query": query, "variables": variables}).encode("utf-8")
req = urllib.request.Request(
f"{base}/graphql", 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.URLError, OSError, TimeoutError) as exc:
logger.debug("devagentic_memory: request failed: %s", exc)
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
logger.debug("devagentic_memory: parse failed: %s", exc)
return None
if not isinstance(payload, dict):
return None
if payload.get("errors"):
logger.debug("devagentic_memory: graphql errors: %s",
payload.get("errors"))
return None
return payload.get("data") or None


def query_user_facts(
query: str,
*,
top_k: int = 10,
timeout: float = _DEFAULT_TIMEOUT,
) -> list[dict]:
"""Relevance-rank user-facts from the devagentic graph.

Returns a list of dicts shaped `{id, body, tags, source,
confidence}` — keys mirror the GraphQL UserFact type. Empty
list on:
* gate off (`DEVAGENTIC_MEMORY_GRAPH` unset / `0`)
* empty query
* no user_id resolvable
* network / parse error
* no matching facts

Callers MUST keep their existing file fallback; this is a
passive read-through, not a hard dependency."""
if not graph_enabled():
return []
if not query:
return []
gql = (
"query($u:String!,$q:String!,$k:Int)"
"{userFactQuery(userId:$u,query:$q,topK:$k)"
"{id body tags source confidence}}"
)
user = _user_id()
if not user:
return []
data = _post_graphql(
gql, {"u": user, "q": query, "k": top_k}, timeout=timeout)
if data is None:
return []
facts = data.get("userFactQuery") or []
if not isinstance(facts, list):
return []
return [f for f in facts if isinstance(f, dict) and f.get("body")]


def create_user_fact(
body: str,
source: str,
*,
tags: Optional[list[str]] = None,
confidence: Optional[float] = None,
timeout: float = _DEFAULT_TIMEOUT,
) -> Optional[str]:
"""Write a new `kind:user-fact` node to the devagentic graph.
Returns the new fact's head_id on success, None on any
failure. Used by `scripts/migrate_memory_to_graph.py`."""
user = _user_id()
if not user:
return None
gql = (
"mutation($u:String!,$b:String!,$s:String!,$t:[String!],$c:Float)"
"{userFactCreate(userId:$u,body:$b,source:$s,tags:$t,confidence:$c)"
"{id source}}"
)
data = _post_graphql(gql, {
"u": user, "b": body, "s": source,
"t": list(tags) if tags else None,
"c": confidence,
}, timeout=timeout)
if data is None:
return None
fact = data.get("userFactCreate")
if not isinstance(fact, dict):
return None
return fact.get("id")
174 changes: 174 additions & 0 deletions scripts/migrate_memory_to_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Phase D one-way memory migration (devagentic issue #54).

Reads `MEMORY.md`, `USER.md`, `SOUL.md` from `HERMES_HOME` (or a
custom dir via `--from`) and writes each one as a
`kind:user-fact` graph node in devagentic via `userFactCreate`.
After the migration, devagentic's `userFactQuery` returns facts
sourced from these files, and `agent.devagentic_memory` short-
circuits memory reads when `DEVAGENTIC_MEMORY_GRAPH=1`.

Usage:

python scripts/migrate_memory_to_graph.py # migrate
python scripts/migrate_memory_to_graph.py --dry-run # preview
python scripts/migrate_memory_to_graph.py --from /custom/path

Granularity: one node per file (file-level migration v0). Each
file's full content becomes the `body` of one `kind:user-fact`;
tags identify the origin file (`origin:MEMORY.md`,
`origin:USER.md`, etc.) plus the migration date. Per-paragraph
or per-bullet granularity is documented as deferred — file-level
keeps the migration round-trippable and the supersede story
simple (refining one file == one supersede).

Idempotency: append-only. Re-running creates fresh facts; the
old ones remain visible in the graph. To dedupe a re-migration,
manually `userFactSupersede(old_id, new_id)` for each pair, or
use `--supersede-prior` (flagged but not implemented in v0).

Requires:
* A running devagentic at $DEVAGENTIC_BASE_URL (default
http://127.0.0.1:6071/v1).
* A bearer in $DEVAGENTIC_API_KEY (any value works when
devagentic runs in trust-header mode).
* X-User-Id resolution via the active hermes profile (or
$DEVAGENTIC_USER_ID override).
"""
from __future__ import annotations

import argparse
import os
import sys
from datetime import datetime, timezone
from pathlib import Path


# Files we attempt to migrate. Order matters only for the printed
# plan; the actual writes can happen in any order.
_MEMORY_FILES = ("MEMORY.md", "USER.md", "SOUL.md")


def _default_memory_dir() -> Path:
"""Resolve the active hermes home dir. Prefers
`hermes_constants.get_hermes_home()` when importable; falls
back to `$HERMES_HOME` or `~/.hermes`."""
try:
from hermes_constants import get_hermes_home
return get_hermes_home()
except Exception:
home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
return Path(home)


def _today_tag() -> str:
"""`migration:YYYY-MM-DD` tag for traceability."""
return f"migration:{datetime.now(tz=timezone.utc).strftime('%Y-%m-%d')}"


def _iter_memory_files(root: Path) -> list[Path]:
"""Yield each MEMORY.md / USER.md / SOUL.md file in `root` that
exists and is non-empty."""
out: list[Path] = []
for fname in _MEMORY_FILES:
p = root / fname
if not p.is_file():
continue
try:
if not p.read_text(encoding="utf-8").strip():
continue
except OSError:
continue
out.append(p)
return out


def main() -> int:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--from", dest="from_dir", type=Path, default=None,
help="hermes home dir to migrate from "
"(default: HERMES_HOME)")
p.add_argument("--dry-run", action="store_true",
help="print the migration plan without writing")
p.add_argument("--tag", action="append", default=[],
metavar="TAG",
help="extra tag to attach to every migrated fact; "
"repeatable")
p.add_argument("--confidence", type=float, default=0.9,
help="confidence value for migrated facts "
"(default: 0.9; user-facts from files are "
"high-trust but not perfect since they may "
"have aged)")
args = p.parse_args()

root = args.from_dir or _default_memory_dir()
print(f"migration source: {root}")
files = _iter_memory_files(root)
if not files:
print(f"no MEMORY.md / USER.md / SOUL.md files found "
f"under {root}; nothing to do")
return 0
print(f"discovered {len(files)} memory file(s): "
f"{[f.name for f in files]}")

# Lazy import so --help works without the adapter on the path.
try:
from agent.devagentic_memory import create_user_fact
except Exception as exc:
print(f"ABORT: could not import agent.devagentic_memory: {exc}")
return 2

today = _today_tag()
extra_tags = list(args.tag)

written: list[str] = []
failures: list[tuple[str, str]] = []
for path in files:
try:
body = path.read_text(encoding="utf-8")
except OSError as exc:
failures.append((str(path), f"read failed: {exc}"))
continue
if not body.strip():
print(f" SKIP {path.name}: file empty")
continue
tags = [
f"origin:{path.name}",
today,
"kind:hermes-memory-migration",
*extra_tags,
]
source = f"hermes-migration:{path.name}"
if args.dry_run:
print(f" [DRY] would migrate {path.name} "
f"({len(body)} chars, tags={tags!r}, "
f"source={source!r})")
continue
fact_id = create_user_fact(
body=body, source=source, tags=tags,
confidence=args.confidence)
if fact_id is None:
failures.append((str(path),
"create_user_fact returned None "
"(check devagentic is reachable + "
"X-User-Id resolvable)"))
continue
print(f" wrote {path.name} → {fact_id}")
written.append(fact_id)

print()
if args.dry_run:
print(f"dry-run complete; would have migrated "
f"{len(files)} fact(s)")
return 0
print(f"migrated {len(written)} fact(s); "
f"{len(failures)} failure(s)")
for path, reason in failures:
print(f" FAIL {path}: {reason}")
return 0 if not failures else 1


if __name__ == "__main__":
sys.exit(main())
Loading