diff --git a/agents/gerhard-hermes/channel_directory.json b/agents/gerhard-hermes/channel_directory.json
index aa6348f..97de673 100644
--- a/agents/gerhard-hermes/channel_directory.json
+++ b/agents/gerhard-hermes/channel_directory.json
@@ -1,5 +1,5 @@
{
- "updated_at": "2026-04-25T09:38:38.117239",
+ "updated_at": "2026-04-25T11:03:49.746150",
"platforms": {
"telegram": [],
"discord": [],
diff --git a/agents/gerhard-hermes/gateway.lock b/agents/gerhard-hermes/gateway.lock
deleted file mode 100644
index 7f4fdbc..0000000
--- a/agents/gerhard-hermes/gateway.lock
+++ /dev/null
@@ -1 +0,0 @@
-{"pid": 7, "kind": "hermes-gateway", "argv": ["/opt/hermes/.venv/bin/hermes", "gateway", "run"], "start_time": 28571}
\ No newline at end of file
diff --git a/agents/gerhard-hermes/gateway_state.json b/agents/gerhard-hermes/gateway_state.json
index b384a22..616ac0d 100644
--- a/agents/gerhard-hermes/gateway_state.json
+++ b/agents/gerhard-hermes/gateway_state.json
@@ -1 +1 @@
-{"pid": 7, "kind": "hermes-gateway", "argv": ["/opt/hermes/.venv/bin/hermes", "gateway", "run"], "start_time": 28571, "gateway_state": "stopped", "exit_reason": null, "restart_requested": false, "active_agents": 0, "platforms": {}, "updated_at": "2026-04-25T09:40:22.622059+00:00"}
\ No newline at end of file
+{"pid": 7, "kind": "hermes-gateway", "argv": ["/opt/hermes/.venv/bin/hermes", "gateway", "run"], "start_time": 539712, "gateway_state": "stopped", "exit_reason": null, "restart_requested": false, "active_agents": 0, "platforms": {}, "updated_at": "2026-04-25T11:13:41.592795+00:00"}
\ No newline at end of file
diff --git a/agents/gerhard-hermes/logs/agent.log b/agents/gerhard-hermes/logs/agent.log
index f99dd95..2d18d4e 100644
--- a/agents/gerhard-hermes/logs/agent.log
+++ b/agents/gerhard-hermes/logs/agent.log
@@ -32,3 +32,49 @@
2026-04-25 09:40:22,622 INFO gateway.run: Gateway stopped
2026-04-25 09:40:22,622 INFO gateway.run: Cron ticker stopped
2026-04-25 09:40:22,997 INFO gateway.run: Exiting with code 1 (signal-initiated shutdown without restart request) so systemd Restart=on-failure can revive the gateway.
+2026-04-25 10:48:29,979 INFO hermes_cli.plugins: Plugin 'openai' registered image_gen provider: openai
+2026-04-25 10:48:29,981 INFO hermes_cli.plugins: Plugin 'openai-codex' registered image_gen provider: openai-codex
+2026-04-25 10:48:30,118 INFO hermes_cli.plugins: Plugin 'xai' registered image_gen provider: xai
+2026-04-25 10:48:30,326 INFO hermes_cli.plugins: Plugin discovery complete: 5 found, 4 enabled
+2026-04-25 10:48:30,987 INFO hermes_cli.web_server: Mounted plugin API routes: /api/plugins/example/
+2026-04-25 10:48:30,988 WARNING hermes_cli.web_server: Binding to 0.0.0.0 with --insecure — the dashboard has no robust authentication. Only use on trusted networks.
+2026-04-25 10:48:31,628 INFO gateway.run: Starting Hermes Gateway...
+2026-04-25 10:48:31,629 INFO gateway.run: Session storage: /opt/data/sessions
+2026-04-25 10:48:31,636 WARNING gateway.run: No user allowlists configured. All unauthorized users will be denied. Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).
+2026-04-25 10:48:31,640 INFO gateway.run: Previous gateway exited cleanly — skipping session suspension
+2026-04-25 10:48:31,640 WARNING gateway.run: No messaging platforms enabled.
+2026-04-25 10:48:31,641 INFO gateway.run: Gateway will continue running for cron job execution.
+2026-04-25 10:48:31,642 INFO gateway.run: 1 hook(s) loaded
+2026-04-25 10:48:31,645 INFO gateway.run: Channel directory built: 0 target(s)
+2026-04-25 10:48:31,645 INFO gateway.run: Press Ctrl+C to stop
+2026-04-25 10:48:31,686 INFO gateway.run: Cron ticker started (interval=60s)
+2026-04-25 11:03:44,640 INFO gateway.run: Received SIGTERM/SIGINT — initiating shutdown
+2026-04-25 11:03:44,686 WARNING gateway.run: Shutdown diagnostic — other hermes processes running:
+ root 1 0.0 0.0 2288 1032 ? Ss 10:48 0:00 /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh gateway run
+ hermes 35 0.0 0.0 6504 3484 ? R 11:03 0:00 ps aux
+2026-04-25 11:03:44,695 INFO gateway.run: Stopping gateway...
+2026-04-25 11:03:45,314 INFO gateway.run: Gateway stopped
+2026-04-25 11:03:45,315 INFO gateway.run: Cron ticker stopped
+2026-04-25 11:03:47,883 INFO hermes_cli.plugins: Plugin 'openai' registered image_gen provider: openai
+2026-04-25 11:03:47,886 INFO hermes_cli.plugins: Plugin 'openai-codex' registered image_gen provider: openai-codex
+2026-04-25 11:03:48,041 INFO hermes_cli.plugins: Plugin 'xai' registered image_gen provider: xai
+2026-04-25 11:03:48,333 INFO hermes_cli.plugins: Plugin discovery complete: 5 found, 4 enabled
+2026-04-25 11:03:49,226 INFO hermes_cli.web_server: Mounted plugin API routes: /api/plugins/example/
+2026-04-25 11:03:49,226 WARNING hermes_cli.web_server: Binding to 0.0.0.0 with --insecure — the dashboard has no robust authentication. Only use on trusted networks.
+2026-04-25 11:03:49,730 INFO gateway.run: Starting Hermes Gateway...
+2026-04-25 11:03:49,730 INFO gateway.run: Session storage: /opt/data/sessions
+2026-04-25 11:03:49,737 WARNING gateway.run: No user allowlists configured. All unauthorized users will be denied. Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).
+2026-04-25 11:03:49,742 INFO gateway.run: Previous gateway exited cleanly — skipping session suspension
+2026-04-25 11:03:49,742 WARNING gateway.run: No messaging platforms enabled.
+2026-04-25 11:03:49,742 INFO gateway.run: Gateway will continue running for cron job execution.
+2026-04-25 11:03:49,743 INFO gateway.run: 1 hook(s) loaded
+2026-04-25 11:03:49,747 INFO gateway.run: Channel directory built: 0 target(s)
+2026-04-25 11:03:49,747 INFO gateway.run: Press Ctrl+C to stop
+2026-04-25 11:03:49,793 INFO gateway.run: Cron ticker started (interval=60s)
+2026-04-25 11:13:40,708 INFO gateway.run: Received SIGTERM/SIGINT — initiating shutdown
+2026-04-25 11:13:40,720 WARNING gateway.run: Shutdown diagnostic — other hermes processes running:
+ root 1 0.0 0.0 2288 1108 ? Ss 11:03 0:00 /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh gateway run
+ hermes 35 0.0 0.0 6504 3488 ? R 11:13 0:00 ps aux
+2026-04-25 11:13:40,723 INFO gateway.run: Stopping gateway...
+2026-04-25 11:13:41,593 INFO gateway.run: Gateway stopped
+2026-04-25 11:13:41,593 INFO gateway.run: Cron ticker stopped
diff --git a/agents/gerhard-hermes/logs/errors.log b/agents/gerhard-hermes/logs/errors.log
index bde3b6e..4a40c2d 100644
--- a/agents/gerhard-hermes/logs/errors.log
+++ b/agents/gerhard-hermes/logs/errors.log
@@ -3,3 +3,15 @@
2026-04-25 09:40:22,113 WARNING gateway.run: Shutdown diagnostic — other hermes processes running:
root 1 0.0 0.0 2288 1104 ? Ss 09:38 0:00 /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh gateway run
hermes 37 0.0 0.0 6504 3488 ? R 09:40 0:00 ps aux
+2026-04-25 10:48:30,988 WARNING hermes_cli.web_server: Binding to 0.0.0.0 with --insecure — the dashboard has no robust authentication. Only use on trusted networks.
+2026-04-25 10:48:31,636 WARNING gateway.run: No user allowlists configured. All unauthorized users will be denied. Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).
+2026-04-25 10:48:31,640 WARNING gateway.run: No messaging platforms enabled.
+2026-04-25 11:03:44,686 WARNING gateway.run: Shutdown diagnostic — other hermes processes running:
+ root 1 0.0 0.0 2288 1032 ? Ss 10:48 0:00 /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh gateway run
+ hermes 35 0.0 0.0 6504 3484 ? R 11:03 0:00 ps aux
+2026-04-25 11:03:49,226 WARNING hermes_cli.web_server: Binding to 0.0.0.0 with --insecure — the dashboard has no robust authentication. Only use on trusted networks.
+2026-04-25 11:03:49,737 WARNING gateway.run: No user allowlists configured. All unauthorized users will be denied. Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).
+2026-04-25 11:03:49,742 WARNING gateway.run: No messaging platforms enabled.
+2026-04-25 11:13:40,720 WARNING gateway.run: Shutdown diagnostic — other hermes processes running:
+ root 1 0.0 0.0 2288 1108 ? Ss 11:03 0:00 /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh gateway run
+ hermes 35 0.0 0.0 6504 3488 ? R 11:13 0:00 ps aux
diff --git a/agents/gerhard-hermes/state.db-shm b/agents/gerhard-hermes/state.db-shm
new file mode 100644
index 0000000..fe9ac28
Binary files /dev/null and b/agents/gerhard-hermes/state.db-shm differ
diff --git a/agents/gerhard-hermes/state.db-wal b/agents/gerhard-hermes/state.db-wal
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose.yml b/docker-compose.yml
index e4a1c00..15c73ea 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -76,7 +76,8 @@ services:
- "host.docker.internal:host-gateway"
gerhard:
- image: nousresearch/hermes-agent
+ build:
+ context: ../hermes-agent
container_name: gerhard
volumes:
- ./agents/gerhard-hermes:/opt/data
@@ -89,7 +90,8 @@ services:
command: ["gateway", "run"]
gerhard-dashboard:
- image: nousresearch/hermes-agent
+ build:
+ context: ../hermes-agent
container_name: gerhard-dashboard
ports:
- "50007:9119" # web dashboard at localhost:50007
diff --git a/plugins/festinger/festinger/main.py b/plugins/festinger/festinger/main.py
index 60c4815..cbbab5c 100644
--- a/plugins/festinger/festinger/main.py
+++ b/plugins/festinger/festinger/main.py
@@ -1327,6 +1327,231 @@ async def ollama_generate_with_agent_id(agent_id: str, request: Request) -> Resp
return await _handle_ollama_generate(request, agent_name=agent_id.lower())
+# ---------------------------------------------------------------------------
+# /scan — gutask integration: scan task / letter text and return recollection
+# ---------------------------------------------------------------------------
+
+# Saliency encounter weight per context type.
+# Facts stated in tasks are stronger signals than chat overheard in passing.
+_CONTEXT_WEIGHT: dict[str, float] = {
+ "task": 2.0,
+ "letter": 1.5,
+ "chat": 1.0,
+}
+
+
+@app.post("/scan")
+async def scan_text(request: Request) -> dict:
+ """
+ Scan a block of plain text for domain concepts and return a recollection block.
+
+ Called by gutask when an agent reads a task or a letter, so that the agent
+ sees relevant knowledge-graph context alongside the task/letter content.
+
+ Body:
+ text (str) — the task description, letter body, or any free text
+ agent (str) — agent name (for logging; optional)
+ context (str) — "task" | "letter" | "chat" (default: "task")
+
+ Returns:
+ recollection_block (str | null) — the … block,
+ or null if nothing salient was found
+ salient_tokens (list[str]) — concepts that triggered the block
+ cues_found (int) — number of URD cues extracted from the text
+ """
+ pool = request.app.state.pool
+ data = await request.json()
+
+ text: str = data.get("text", "").strip()
+ agent_name: str = data.get("agent", "").strip().lower()
+ context_type: str = data.get("context", "task").strip().lower()
+ weight: float = _CONTEXT_WEIGHT.get(context_type, 1.0)
+
+ if not text:
+ return {"recollection_block": None, "salient_tokens": [], "cues_found": 0}
+
+ read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
+ conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
+ recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
+
+ # 1. Cue scanner — extract explicit relationship assertions and enqueue them.
+ cues = list(scan_cues(text))
+ cues_found = len(cues)
+ for cue in cues:
+ await enqueue_cue(cue)
+
+ # 2. Token loop — find salient concepts and record weighted encounters.
+ tokens = tokenize(text)
+ salient_ids: list[int] = []
+
+ for token in tokens:
+ row = cache.soas_by_token.get(token)
+ if row is None or row.saliency == 0.0:
+ continue
+ # Weight the encounter: task/letter mentions count more than chat.
+ # We stage fractional deltas; flush_encounter_deltas rounds to int,
+ # so accumulate weight as repeated single increments for simplicity.
+ increments = max(1, round(weight))
+ for _ in range(increments):
+ cache.record_encounter(row.id)
+ if row.saliency >= read_threshold:
+ salient_ids.append(row.id)
+
+ if not salient_ids:
+ log.debug("scan | agent=%s context=%s cues=%d → no salient concepts",
+ agent_name or "(none)", context_type, cues_found)
+ return {"recollection_block": None, "salient_tokens": [], "cues_found": cues_found}
+
+ # 3. Build recollection block (session boost not applicable here — no system message).
+ block = build_recollection_block(salient_ids, conf_floor, recency_days)
+
+ salient_tokens = [cache.soas_by_id.get(cid, str(cid)) for cid in salient_ids]
+ log.info(
+ "scan | agent=%s context=%s cues=%d salient=%s\n%s",
+ agent_name or "(none)", context_type, cues_found, salient_tokens,
+ block or "(no block)",
+ )
+
+ return {
+ "recollection_block": block,
+ "salient_tokens": salient_tokens,
+ "cues_found": cues_found,
+ }
+
+
+# ---------------------------------------------------------------------------
+# /recall/{concept} — gutask recall backend
+# ---------------------------------------------------------------------------
+
+@app.get("/recall/{concept:path}")
+async def recall_concept(
+ concept: str,
+ request: Request,
+ depth: str = "brief",
+) -> dict:
+ """
+ Return what Festinger knows about a concept, formatted for agent display.
+
+ depth:
+ brief — URD edges only (same as recollection block, more readable)
+ detailed — edges + saliency stats + related concepts in same dimensions
+ everything — detailed + full write log history
+ """
+ pool = request.app.state.pool
+ concept = concept.lower().strip()
+
+ row = cache.soas_by_token.get(concept)
+ if row is None:
+ # Try DB in case cache is stale
+ async with pool.acquire() as conn:
+ db_row = await conn.fetchrow(
+ "SELECT id, token, saliency, novelty, encounter_count, "
+ "first_seen_context, last_seen FROM soas WHERE token = $1",
+ concept,
+ )
+ if not db_row:
+ return {"concept": concept, "found": False, "text": f"No knowledge about '{concept}' in Festinger."}
+ from .cache import SoasRow
+ row = SoasRow(
+ id=db_row["id"], token=db_row["token"],
+ saliency=db_row["saliency"], novelty=db_row["novelty"],
+ encounter_count=db_row["encounter_count"],
+ first_seen_context=db_row["first_seen_context"] or "",
+ last_seen=db_row["last_seen"],
+ )
+
+ edges = cache.urd_by_concept.get(row.id, [])
+ reverse_edges = cache.urd_by_parent.get(row.id, [])
+
+ lines = [f"── {concept} {'─' * max(0, 50 - len(concept))}"]
+
+ if depth in ("detailed", "everything"):
+ from .recollection import recency_decay, centrality_bonus, effective_score
+ decay = recency_decay(row)
+ score = effective_score(row.id)
+ last_seen_str = (
+ row.last_seen.strftime("%Y-%m-%d") if row.last_seen else "never"
+ )
+ lines.append(f" saliency: {row.saliency:.2f} encounters: {row.encounter_count}"
+ f" score: {score:.2f} last seen: {last_seen_str}")
+ if row.first_seen_context:
+ lines.append(f" first seen: \"{row.first_seen_context[:80]}\"")
+ if row.id in cache.pending_conflicts:
+ lines.append(" ⚠ has pending conflict in resolution queue")
+ lines.append("")
+
+ if edges:
+ lines.append(" Relationships (outgoing):")
+ for e in edges:
+ conf_str = f" conf={e.confidence:.2f}" if e.confidence < 1.0 else ""
+ lines.append(f" [{e.dim_token}] → {e.parent_token}{conf_str}")
+ else:
+ lines.append(" No outgoing relationships stored.")
+
+ if reverse_edges:
+ lines.append("")
+ lines.append(" Referenced by:")
+ for e in reverse_edges[:10]:
+ child_token = cache.soas_by_id.get(e.concept_id, str(e.concept_id))
+ lines.append(f" [{e.dim_token}] ← {child_token}")
+ if len(reverse_edges) > 10:
+ lines.append(f" … and {len(reverse_edges) - 10} more")
+
+ if depth in ("detailed", "everything") and edges:
+ # Siblings: other concepts sharing the same parent in the same dimension
+ siblings: dict[str, list[str]] = {}
+ for e in edges:
+ peer_edges = cache.urd_by_parent.get(e.parent_id, [])
+ peers = [
+ cache.soas_by_id.get(pe.concept_id, str(pe.concept_id))
+ for pe in peer_edges
+ if pe.concept_id != row.id and pe.dim_id == e.dim_id
+ ][:5]
+ if peers:
+ siblings[f"{e.dim_token}/{e.parent_token}"] = peers
+ if siblings:
+ lines.append("")
+ lines.append(" Siblings (same parent/dimension):")
+ for label, peers in siblings.items():
+ lines.append(f" {label}: {', '.join(peers)}")
+
+ if depth == "everything":
+ async with pool.acquire() as conn:
+ log_rows = await conn.fetch(
+ """
+ SELECT op, parent_token, dim_token, is_isa, source, created_at
+ FROM kg_write_log WHERE concept_id = $1
+ ORDER BY created_at DESC LIMIT 20
+ """,
+ row.id,
+ )
+ if log_rows:
+ lines.append("")
+ lines.append(" Write history:")
+ for lr in log_rows:
+ ts = lr["created_at"].strftime("%Y-%m-%d")
+ isa = "is-a" if lr["is_isa"] else "is-part-of"
+ lines.append(
+ f" {ts} {lr['op']:<10} [{lr['dim_token']}] {isa} {lr['parent_token']}"
+ f" ({lr['source']})"
+ )
+
+ text = "\n".join(lines)
+ return {
+ "concept": concept,
+ "found": True,
+ "depth": depth,
+ "saliency": row.saliency,
+ "encounter_count": row.encounter_count,
+ "edges": [
+ {"dim": e.dim_token, "parent": e.parent_token,
+ "is_isa": e.is_isa, "confidence": e.confidence}
+ for e in edges
+ ],
+ "text": text,
+ }
+
+
# ---------------------------------------------------------------------------
# /iknowthat — manual write path
# ---------------------------------------------------------------------------
diff --git a/plugins/festinger/festinger/recollection.py b/plugins/festinger/festinger/recollection.py
index 009a12e..3bacc25 100644
--- a/plugins/festinger/festinger/recollection.py
+++ b/plugins/festinger/festinger/recollection.py
@@ -178,7 +178,25 @@ def build_recollection_block(
if not lines:
return None
- return f"\n" + "\n".join(lines) + "\n"
+ # Footer: list the concepts that have actual URD data so the agent knows
+ # it can dig deeper via gutask recall.
+ hit_tokens = [
+ cache.soas_by_id.get(cid, str(cid))
+ for _, cid in scored
+ if query_edges(cid, confidence_floor) or cache.urd_by_parent.get(cid)
+ ][:6]
+ footer_lines = []
+ if hit_tokens:
+ footer_lines.append(
+ "To recall more: gutask recall brief|detailed|everything"
+ )
+ footer_lines.append(" concepts: " + ", ".join(hit_tokens))
+
+ body = "\n".join(lines)
+ if footer_lines:
+ body += "\n" + "\n".join(footer_lines)
+
+ return "\n" + body + "\n"
# ---------------------------------------------------------------------------