hermes-agent/plugins/memory/honcho
ethernet 5aa121ecfd refactor(deps): read the lazy-install specs from the pyproject extras
tools/lazy_deps.py held a table of about 40 features, each with its own
literal pip specs. pyproject.toml declares the same packages as extras,
so every pin existed twice and the two copies drifted.

Each feature now names an extra, and the specs come from pyproject at
run time. The table is 218 lines shorter. A test asserts that each
feature names an extra that exists and resolves to at least one spec, so
a typo cannot ship.

A wheel install, such as Nix, has no pyproject.toml beside the code.
There the same table comes from the dist metadata: each spec of an
extra is one Requires-Dist line, and its marker names the extra.
Without this fallback, each entry point raised on a Nix install, and
ensure() raised even for a feature whose packages the build baked in
through extraDependencyGroups. That call must be a no-op.
is_available() and feature_install_command() catch the failure as well
now. Their callers sit in status paths with no try/except, and their
contracts are bool and Optional[str].

The security overrides already come from pyproject (the previous
commit). This commit moves the reader onto the shared _pyproject()
cache and the shared temp-file writer.

The tier-0 installer, `uv sync --extra <name>`, names the project with
--project. uv reads the project from its working directory, and the
agent runs from the user's working directory, not from the install
tree. Without the flag the sync failed outside a checkout, and the pip
ladder always ran instead.

install_specs gets the same managed-install guard as ensure(). A Nix
venv is in the read-only store, so the pip ladder could only fail with
EROFS after a 15s ensurepip attempt. It reports the Nix remedy instead.
A durable install target overrides the guard, as it does in ensure(),
because the NixOS container module sets HERMES_MANAGED=true with a
writable target.

Spec parsing goes to packaging.requirements.Requirement, which is
already a core dependency. The hand-written version kept the
environment marker attached to the version. SpecifierSet raised on it,
so _is_satisfied answered True for every installed version of a marked
package. Such a package can never upgrade.

Reading the specs from an extra exposed a second fault, in the record of
which features are active. active_features read specs[0] as the anchor
package, and extra composition put sounddevice there for [voice] and for
each wake extra. One local STT install then marked every audio feature
active, and `hermes update` installed the wake engines that the user
never enabled.

ensure() records each feature it serves in
$HERMES_HOME/lazy-features.json, and active_features reads that record.
A recorded feature still needs its anchor package installed, so an
uninstalled backend does not come back. The anchor is the first pin
written directly in the extra, not the first spec after expansion. A
test asserts that no two extras share an anchor.

There is no seeding for an install that predates the record. Its first
`hermes update` refreshes nothing. ensure() then repairs a stale pin at
each backend's start and records the feature, and the next update covers
it.

[stt-whisper] splits out of [voice]. faster-whisper transcribes audio
files and needs no microphone and no PortAudio, so the Docker image can
bake it. [voice] composes [stt-whisper] and [audio-io] and stays the
microphone stack. stt.faster_whisper maps to the new extra.

Removed with the table:

- The literal pin list in plugins/platforms/google_chat/oauth.py. Its
  pip path targeted /nix/store on a Nix install, which is read-only.
- The bare honcho-ai fallback in the honcho setup. An unpinned install
  accepts whatever PyPI serves, which is the hole this branch closes.
  Both call sites report the remedy for the deployment instead, through
  the now-public managed_install_reason.
- install_deps() in the google-workspace skill. The SDKs ship in the
  [google] extra, so a stripped environment is a broken install. The
  repair is `hermes update`. A pip run from the script writes to
  whichever interpreter it runs under, which is not always the one
  Hermes uses.
- tests/test_runtime_pins_are_locked.py, which scanned first-party
  source for pin literals. There are none left to find.
- The spec shape check in install_specs. The same plugin.yaml hands
  external_dependencies[].install to bash with shell=True, and the
  plugin's __init__.py is imported. Anyone who can write that file
  already runs code as the user.
2026-08-09 17:33:14 -04:00
..
README.md
__init__.py fix(honcho): keep _pop_auth_notice tolerant of minimal fake managers; make fast-path test binding 2026-08-08 14:40:46 +05:30
cli.py refactor(deps): read the lazy-install specs from the pyproject extras 2026-08-09 17:33:14 -04:00
client.py
config_schema.py
oauth.py refactor(honcho): dedupe refresh-failure handling; harden exchange budget, dogpile cooldown, and rebuild race 2026-08-08 14:40:46 +05:30
oauth_flow.py
plugin.yaml
session.py refactor(honcho): dedupe refresh-failure handling; harden exchange budget, dogpile cooldown, and rebuild race 2026-08-08 14:40:46 +05:30

README.md

Honcho Memory Provider

AI-native cross-session user modeling with multi-pass dialectic reasoning, session summaries, bidirectional peer tools, and persistent conclusions.

Honcho docs: https://docs.honcho.dev/v3/guides/integrations/hermes

Requirements

  • pip install honcho-ai
  • A Honcho Cloud account — connect via OAuth sign-in or an API key from app.honcho.dev — or a self-hosted instance

Setup

hermes memory setup honcho   # configure Honcho directly (works on a fresh install)
hermes memory setup          # generic picker, choose Honcho from the list

For cloud, the wizard asks OAuth, device code, or API key. OAuth opens a browser sign-in and stores the grant itself — nothing to copy; tokens refresh automatically. On SSH/headless machines choose device: the CLI prints a short code and a link you open in a browser on any other machine; setup completes once you approve there. The desktop app offers the browser flow as a Connect link next to the memory-provider dropdown.

Or manually:

hermes config set memory.provider honcho
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env

hermes honcho setup also works, but only after Honcho is the active memory provider — the honcho subcommand is registered for the active provider only. On a fresh install, use hermes memory setup honcho.

Architecture Overview

Two-Layer Context Injection

Context is injected into the user message at API-call time (not the system prompt) to preserve prompt caching. Only a static mode header goes in the system prompt. The injected block is wrapped in <memory-context> fences with a system note clarifying it's background data, not new user input.

Two independent layers, each on its own cadence:

Layer 1 — Base context (refreshed every contextCadence turns):

  1. SESSION SUMMARY — from session.context(summary=True), placed first
  2. User Representation — Honcho's evolving model of the user
  3. User Peer Card — key facts snapshot
  4. AI Self-Representation — Honcho's model of the AI peer
  5. AI Identity Card — AI peer facts

Layer 2 — Dialectic supplement (fired every dialecticCadence turns): Multi-pass .chat() reasoning about the user, appended after base context.

Both layers are joined, then truncated to fit contextTokens budget via _truncate_to_budget (tokens × 4 chars, word-boundary safe).

Latest-Message Query Rewrite (opt-in)

When queryRewrite: true, dialectic pass 0 first uses the shared memory_query_rewrite auxiliary task to turn the latest message into one concise memory-retrieval question. The rewritten question is used for the dialectic request; base-context retrieval still uses the raw message as its search query. If rewriting times out or returns an invalid result, the plugin falls back to the existing cold/warm prompt below. With the flag on, the generic dialectic prewarm is skipped so it cannot shadow the first user message.

Off by default — the rewrite adds one auxiliary-model call per dialectic cycle (not per pass). Select a fast, inexpensive model under hermes model -> auxiliary models -> Memory query rewrite; its request timeout is auxiliary.memory_query_rewrite.timeout in config.yaml (default 8s). The task and module (plugins/memory/query_rewrite.py) are provider-agnostic — any memory provider can reuse them. dialecticCadence still controls how often the cycle runs.

Cold Start vs Warm Session Prompts

When latest-message rewriting is unavailable, dialectic pass 0 automatically selects its fallback prompt based on session state:

  • Cold (no base context cached): "Who is this person? What are their preferences, goals, and working style? Focus on facts that would help an AI assistant be immediately useful."
  • Warm (base context exists): "Given what's been discussed in this session so far, what context about this user is most relevant to the current conversation? Prioritize active context over biographical facts."

Not configurable — determined automatically.

Dialectic Depth (Multi-Pass Reasoning)

dialecticDepth (13, clamped) controls how many .chat() calls fire per dialectic cycle:

Depth Passes Behavior
1 single .chat() Base query only (cold or warm prompt)
2 audit + synthesis Pass 0 result is self-audited; pass 1 does targeted synthesis. Conditional bail-out if pass 0 returns strong signal (>300 chars or structured with bullets/sections >100 chars)
3 audit + synthesis + reconciliation Pass 2 reconciles contradictions across prior passes into a final synthesis

Proportional Reasoning Levels

When dialecticDepthLevels is not set, each pass uses a proportional level relative to dialecticReasoningLevel (the "base"):

Depth Pass levels
1 [base]
2 [minimal, base]
3 [minimal, base, low]

Override with dialecticDepthLevels: an explicit array of reasoning level strings per pass.

Query-Adaptive Reasoning Level

The auto-injected dialectic scales dialecticReasoningLevel by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at reasoningLevelCap (default "high"). Disable with reasoningHeuristic: false to pin every auto call to dialecticReasoningLevel.

Three Orthogonal Dialectic Knobs

Knob Controls Type
dialecticCadence How often — minimum turns between dialectic firings int
dialecticDepth How many — passes per firing (13) int
dialecticReasoningLevel How hard — reasoning ceiling per .chat() call string

Input Sanitization

run_conversation strips leaked <memory-context> blocks from user input before processing. When saveMessages persists a turn that included injected context, the block can reappear in subsequent turns via message history. The sanitizer removes <memory-context> blocks plus associated system notes.

Tools

Five bidirectional tools. All accept an optional peer parameter ("user" or "ai", default "user").

Tool LLM call? Description
honcho_profile No Peer card — key facts snapshot
honcho_search No Cross-session message search (hybrid semantic + keyword, ranked excerpts; 800 tok default, 2000 max)
honcho_context No Full session context: summary, representation, card, messages
honcho_reasoning Yes LLM-synthesized answer via dialectic .chat()
honcho_conclude No Write, list/search, or delete persistent conclusions (list surfaces the ids delete needs)

Tool visibility depends on recallMode: hidden in context mode, always present in tools and hybrid.

Config Resolution

Config is read from the first file that exists:

Priority Path Scope
1 $HERMES_HOME/honcho.json Profile-local (isolated Hermes instances)
2 ~/.hermes/honcho.json Default profile (shared host blocks)
3 ~/.honcho/config.json Global (cross-app interop)

Host key is derived from the active Hermes profile: hermes (default) or hermes_<profile>.

For every key, resolution order is: host block > root > env var > default.

Full Configuration Reference

Identity & Connection

Key Type Default Description
apiKey string API key. Falls back to HONCHO_API_KEY env var. When connected via OAuth, holds the auto-refreshing access token instead
oauth object OAuth grant (refresh token, expiry, client, token endpoint). Written by the Connect/sign-in flows and rotated automatically — not hand-edited. Optional: an API key alone works without it
baseUrl string Base URL for self-hosted Honcho. Local URLs auto-skip API key auth
environment string "production" SDK environment mapping
enabled bool auto Master toggle. Auto-enables when apiKey or baseUrl present
workspace string host key Honcho workspace ID. Shared environment — all profiles in the same workspace can see the same user identity and related memories
peerName string User peer identity
aiPeer string host key AI peer identity

Identity Mapping (Gateway Multi-User)

In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a platform-native runtime ID (Telegram UID, Discord snowflake, Slack user). These three keys control how those runtime IDs map to Honcho peers. The resolver is config-driven and deterministic — no automatic merging or runtime inference.

Key Type Default Description
pinUserPeer bool false When true, every gateway runtime user collapses to peerName. Single-operator deployments where you want all your platforms (and any other users) to share one peer
userPeerAliases object {} Map of runtime IDs to peer IDs ({"7654321": "alice"}). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer
runtimePeerPrefix string "" Prepended to unknown runtime IDs to namespace them (e.g. "telegram_"telegram_7654321). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape

Deprecated: pinPeerName is a legacy alias for pinUserPeer, still read for back-compat (pinUserPeer wins where both are set). hermes honcho setup migrates it onto pinUserPeer on touch and never writes it.

Resolver ladder (first match wins):

1. pinUserPeer / pinPeerName=true → return peerName (ignore runtime ID)
2. userPeerAliases[runtime_id]   → return aliased peer
3. userPeerAliases[runtime_id_alt] → check alt-ID too (Telegram UID + username, etc.)
4. runtimePeerPrefix + runtime_id → namespaced peer, with sha256 collision escalation
5. raw sanitized runtime_id      → fallback peer
6. peerName                      → no runtime ID at all (CLI/TUI)
7. session-key fallback          → no config either

Why no pinAiPeer? The AI peer is already pinned by construction — aiPeer is the only AI-side identity setting and the resolver never overrides it. Only the user-side peer has the runtime-vs-config tension that pinUserPeer resolves.

Host vs root semantics. All three keys are accepted at both root and hosts.<host> levels. Host-level wins. For maps and prefixes, host-level replaces the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with userPeerAliases: {} / runtimePeerPrefix: "".

Setup — gateway identity tree. hermes honcho setup only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks who talks to this gateway? and derives the keys:

  • just mepinUserPeer: true. Every non-agent gateway user collapses to peerName; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do not pin — leave pinUserPeer: false and map them via userPeerAliases (the [e] editor).
  • me + other people, pooledpinUserPeer: false + userPeerAliases mapping your runtime IDs to peerName. You stay on the shared history; everyone else gets their own peer.
  • me + other people / only other peoplepinUserPeer: false, optional runtimePeerPrefix. Each runtime user → own peer. For bots serving many humans.

Pick [e] at the prompt to set the three keys directly instead of going through the tree.

Un-pinning (single → per-user). Flipping pinUserPeer from true to false does not migrate data. Memory accumulated under peerName while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the pooled path — alias your runtime IDs back to peerName so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile.

Memory & Recall

Key Type Default Description
recallMode string "hybrid" "hybrid" (auto-inject + tools), "context" (auto-inject only, tools hidden), "tools" (tools only, no injection). Legacy "auto""hybrid"
observationMode string "directional" Preset: "directional" (all on) or "unified" (user observes self, AI observes others). Use observation object for granular control
observation object Per-peer observation config (see Observation section)

Write Behavior

Key Type Default Description
writeFrequency string/int "async" "async" (background), "turn" (sync per turn), "session" (batch on end), or integer N (every N turns)
saveMessages bool true Persist messages to Honcho API

Session Resolution

Key Type Default Description
sessionStrategy string "per-directory" "per-directory", "per-session", "per-repo" (git root), "global"
sessionPeerPrefix bool false Prepend peer name to session keys
sessions object {} Manual directory-to-session-name mappings

Session Name Resolution

The Honcho session name determines which conversation bucket memory lands in. Resolution follows a priority chain — first match wins:

Priority Source Example session name
1 Manual map (sessions config) "myproject-main"
2 /title command (mid-session rename) "refactor-auth"
3 Gateway session key (Telegram, Discord, etc.) "agent-main-telegram-dm-8439114563"
4 per-session strategy Hermes session ID (20260415_a3f2b1)
5 per-repo strategy Git root directory name (hermes-agent)
6 per-directory strategy Current directory basename (src)
7 global strategy Workspace name (hermes)

Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of sessionStrategy. The strategy setting only affects CLI sessions.

If sessionPeerPrefix is true, the peer name is prepended: alice-hermes-agent.

What each strategy produces

  • per-directory — basename of $PWD. Opening hermes in ~/code/myapp and ~/code/other gives two separate sessions. Same directory = same session across runs.
  • per-repo — git root directory name. All subdirectories within a repo share one session. Falls back to per-directory if not inside a git repo.
  • per-session — Hermes session ID (timestamp + hex). Every hermes invocation starts a fresh Honcho session. Falls back to per-directory if no session ID is available.
  • global — workspace name. One session for everything. Memory accumulates across all directories and runs.

Multi-Profile Pattern

Multiple Hermes profiles can share one workspace while maintaining separate AI identities. Config resolution is host block > root > env var > default — host blocks inherit from root, so shared settings only need to be declared once:

{
  "apiKey": "***",
  "workspace": "hermes",
  "peerName": "yourname",
  "hosts": {
    "hermes": {
      "aiPeer": "hermes",
      "recallMode": "hybrid",
      "sessionStrategy": "per-directory"
    },
    "hermes_coder": {
      "aiPeer": "coder",
      "recallMode": "tools",
      "sessionStrategy": "per-repo"
    }
  }
}

Both profiles see the same user (yourname) in the same shared environment (hermes), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.

Host key is derived from the active Hermes profile: hermes (default) or hermes_<profile> (e.g. hermes -p coder -> host key hermes_coder). Older hermes.<profile> host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.

Dialectic & Reasoning

Key Type Default Description
dialecticDepth int 1 Passes per dialectic cycle (13, clamped). 1=single query, 2=audit+synthesis, 3=audit+synthesis+reconciliation
dialecticDepthLevels array Optional array of reasoning level strings per pass. Overrides proportional defaults. Example: ["minimal", "low", "medium"]
dialecticReasoningLevel string "low" Base reasoning level for .chat(): "minimal", "low", "medium", "high", "max"
dialecticDynamic bool true When true, model can override reasoning level per-call via honcho_reasoning tool. When false, always uses dialecticReasoningLevel
dialecticMaxChars int 600 Max chars of the auto-injected dialectic supplement. Applies only to auto-injection — explicit honcho_reasoning tool results return in full
dialecticMaxInputChars int 10000 Max chars for dialectic query input to .chat(). Honcho cloud limit: 10k
reasoningHeuristic bool true Query-adaptive: auto-scale the auto-injected dialectic's level up by query length (+1 at ≥120 chars, +2 at ≥400), clamped at reasoningLevelCap. false pins every auto call to dialecticReasoningLevel
reasoningLevelCap string "high" Ceiling for reasoningHeuristic scaling: "minimal", "low", "medium", "high", "max"

Token Budgets

Key Type Default Description
contextTokens int SDK default Token budget for context() API calls. Also gates prefetch truncation (tokens × 4 chars)
messageMaxChars int 25000 Max chars per message sent via add_messages(). Exceeding this triggers chunking with [continued] markers. Honcho cloud limit: 25k

Cadence (Cost Control)

Key Type Default Description
contextCadence int 1 Minimum turns between base context refreshes (session summary + representation + card)
dialecticCadence int 1 Minimum turns between dialectic .chat() firings
injectionFrequency string "every-turn" "every-turn" or "first-turn" (inject base context on the first user message only; the dialectic supplement keeps its own cadence)
queryRewrite bool false Rewrite the latest message into a retrieval query before dialectic (one extra auxiliary LLM call per cycle)
firstTurnBaseWait float 3.0 Max seconds turn 1 waits for base context / session init. 0 disables the wait (fully async; context surfaces on later turns). Turns 2+ never wait on a stalled init
firstTurnDialecticWait float 2.0 Max seconds turn 1 waits for a dialectic result. 0 disables

Observation (Granular)

Maps 1:1 to Honcho's per-peer SessionPeerConfig. When present, overrides observationMode preset.

"observation": {
  "user": { "observeMe": true, "observeOthers": true },
  "ai":   { "observeMe": true, "observeOthers": true }
}
Field Default Description
user.observeMe true User peer self-observation (Honcho builds user representation)
user.observeOthers true User peer observes AI messages
ai.observeMe true AI peer self-observation (Honcho builds AI representation)
ai.observeOthers true AI peer observes user messages (enables cross-peer dialectic)

Presets:

  • "directional" (default): all four true
  • "unified": user observeMe=true, AI observeOthers=true, rest false

Hardcoded Limits

Limit Value
Search tool max tokens 2000 (hard cap), 800 (default)
Peer card fetch tokens 200

Environment Variables

Variable Fallback for
HONCHO_API_KEY apiKey
HONCHO_BASE_URL baseUrl
HONCHO_ENVIRONMENT environment
HERMES_HONCHO_HOST Host key override
HONCHO_OAUTH_DASHBOARD OAuth authorize origin (default: cloud dashboard; local-dev localhost:3000)
HONCHO_OAUTH_AUTHORIZE_URL Full authorize URL (overrides the dashboard origin)
HONCHO_OAUTH_TOKEN_URL Token endpoint (default: cloud API; local-dev localhost:8000)
HONCHO_OAUTH_DEVICE_AUTH_URL Device-authorization endpoint (default: derived from the token URL)
HONCHO_OAUTH_CLIENT_ID OAuth client (default hermes-agent)
HONCHO_OAUTH_SCOPE Requested scope (default write)

CLI Commands

Command Description
hermes memory setup honcho Configure Honcho directly — works on a fresh install
hermes honcho setup Interactive setup wizard (only registered once Honcho is the active provider; redirects to hermes memory setup)
hermes honcho status Show resolved config for active profile
hermes honcho enable / disable Toggle Honcho for active profile
hermes honcho mode <mode> Change recall or observation mode
hermes honcho peer --user <name> Update user peer name
hermes honcho peer --ai <name> Update AI peer name
hermes honcho tokens --context <N> Set context token budget
hermes honcho tokens --dialectic <N> Set dialectic max chars
hermes honcho map <name> Map current directory to a session name
hermes honcho sync Create host blocks for all Hermes profiles

Example Config

{
  "apiKey": "***",
  "workspace": "hermes",
  "peerName": "username",
  "contextCadence": 2,
  "dialecticCadence": 3,
  "dialecticDepth": 2,
  "hosts": {
    "hermes": {
      "enabled": true,
      "aiPeer": "hermes",
      "recallMode": "hybrid",
      "observation": {
        "user": { "observeMe": true, "observeOthers": true },
        "ai": { "observeMe": true, "observeOthers": true }
      },
      "writeFrequency": "async",
      "sessionStrategy": "per-directory",
      "dialecticReasoningLevel": "low",
      "dialecticDepth": 2,
      "dialecticMaxChars": 600,
      "saveMessages": true
    },
    "hermes_coder": {
      "enabled": true,
      "aiPeer": "coder",
      "sessionStrategy": "per-repo",
      "dialecticDepth": 1,
      "dialecticDepthLevels": ["low"],
      "observation": {
        "user": { "observeMe": true, "observeOthers": false },
        "ai": { "observeMe": true, "observeOthers": true }
      }
    }
  },
  "sessions": {
    "/home/user/myproject": "myproject-main"
  }
}