mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
feat(honcho): context injection overhaul, 5-tool surface, cost safety, session isolation
Context Injection Overhaul: - Base layer: peer.context() (representation + card) cached with 5-minute TTL - Dialectic supplement: cadence-gated, cached until next refresh - Trivial prompt skip: short inputs/slash commands skip injection - New peer guard: dialectic skipped at session start when peer has no context - Targeted warm prompt for better dialectic quality Tool Surface (5 bidirectional tools): - honcho_profile: read or update peer card - honcho_search: semantic search over context - honcho_context: full session context (summary, representation, card, messages) - honcho_reasoning: synthesized answer, reasoning_level param - honcho_conclude: create or delete conclusions (PII removal) Cost Safety: - dialectic_cadence defaults to 3 (~66% fewer LLM calls) - context_tokens defaults to uncapped (cap opt-in via config/wizard) - on_turn_start hook wired up (fixes broken cadence/injection gating) Correctness: - Explicit target= on peer context/card fetches (fixes identity blur) - honcho_search perspective fix under directional observation - Timeout config plumbing - peerName precedence over gateway user_id - skip_memory on temp agents (orphan session prevention) - gateway_session_key for stable per-chat session continuity - initOnSessionStart for eager tools-mode init - get_session_context fallback respects peer param - mid -> medium in reasoning level validation ABC changes (minimal, honcho-only): - run_agent.py: gateway_session_key param + memory provider wiring (+5 lines) - gateway/run.py: skip_memory on 2 temp agents, gateway_session_key on main agent (+3 lines) - agent/memory_manager.py: sanitize regex for context tag variants (+9 lines)
This commit is contained in:
parent
95d11dfd8e
commit
11b4c9ecf9
16 changed files with 1283 additions and 331 deletions
|
|
@ -125,7 +125,7 @@ Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced ba
|
|||
|-----|------|---------|-------|-------------|
|
||||
| `contextTokens` | int | SDK default | root / host | Token budget for `context()` API calls. Also gates prefetch truncation (tokens x 4 chars) |
|
||||
| `dialecticReasoningLevel` | string | `"low"` | root / host | Base reasoning level for `peer.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
|
||||
| `dialecticDynamic` | bool | `true` | root / host | Auto-bump reasoning based on query length: `<120` chars = base level, `120-400` = +1, `>400` = +2 (capped at `"high"`). Set `false` to always use `dialecticReasoningLevel` as-is |
|
||||
| `dialecticDynamic` | bool | `true` | root / host | When `true`, the model can override reasoning level per-call via the `honcho_reasoning` tool `reasoning_level` param (agentic). When `false`, always uses `dialecticReasoningLevel` and ignores model overrides |
|
||||
| `dialecticMaxChars` | int | `600` | root / host | Max chars of dialectic result injected into system prompt |
|
||||
| `dialecticMaxInputChars` | int | `10000` | root / host | Max chars for dialectic query input to `peer.chat()`. Honcho cloud limit: 10k |
|
||||
| `messageMaxChars` | int | `25000` | root / host | Max chars per message sent via `add_messages()`. Messages exceeding this are chunked with `[continued]` markers. Honcho cloud limit: 25k |
|
||||
|
|
@ -139,7 +139,7 @@ These are read from the root config object, not the host block. Must be set manu
|
|||
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context only on turn 0) |
|
||||
| `contextCadence` | int | `1` | Minimum turns between `context()` API calls |
|
||||
| `dialecticCadence` | int | `1` | Minimum turns between `peer.chat()` API calls |
|
||||
| `reasoningLevelCap` | string | -- | Hard cap on auto-bumped reasoning: `"minimal"`, `"low"`, `"mid"`, `"high"` |
|
||||
| `reasoningLevelCap` | string | -- | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` |
|
||||
|
||||
### Hardcoded Limits (Not Configurable)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,20 +33,33 @@ logger = logging.getLogger(__name__)
|
|||
PROFILE_SCHEMA = {
|
||||
"name": "honcho_profile",
|
||||
"description": (
|
||||
"Retrieve the user's peer card from Honcho — a curated list of key facts "
|
||||
"about them (name, role, preferences, communication style, patterns). "
|
||||
"Fast, no LLM reasoning, minimal cost. "
|
||||
"Use this at conversation start or when you need a quick factual snapshot."
|
||||
"Retrieve or update a peer card from Honcho — a curated list of key facts "
|
||||
"about that peer (name, role, preferences, communication style, patterns). "
|
||||
"Pass `card` to update; omit `card` to read."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
"card": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "New peer card as a list of fact strings. Omit to read the current card.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "honcho_search",
|
||||
"description": (
|
||||
"Semantic search over Honcho's stored context about the user. "
|
||||
"Semantic search over Honcho's stored context about a peer. "
|
||||
"Returns raw excerpts ranked by relevance — no LLM synthesis. "
|
||||
"Cheaper and faster than honcho_context. "
|
||||
"Cheaper and faster than honcho_reasoning. "
|
||||
"Good when you want to find specific past facts and reason over them yourself."
|
||||
),
|
||||
"parameters": {
|
||||
|
|
@ -60,17 +73,23 @@ SEARCH_SCHEMA = {
|
|||
"type": "integer",
|
||||
"description": "Token budget for returned context (default 800, max 2000).",
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONTEXT_SCHEMA = {
|
||||
"name": "honcho_context",
|
||||
REASONING_SCHEMA = {
|
||||
"name": "honcho_reasoning",
|
||||
"description": (
|
||||
"Ask Honcho a natural language question and get a synthesized answer. "
|
||||
"Uses Honcho's LLM (dialectic reasoning) — higher cost than honcho_profile or honcho_search. "
|
||||
"Can query about any peer: the user (default) or the AI assistant."
|
||||
"Can query about any peer via alias or explicit peer ID. "
|
||||
"Pass reasoning_level to control depth: minimal (fast/cheap), low (default), "
|
||||
"medium, high, max (deep/expensive). Omit for configured default."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
@ -79,37 +98,83 @@ CONTEXT_SCHEMA = {
|
|||
"type": "string",
|
||||
"description": "A natural language question.",
|
||||
},
|
||||
"reasoning_level": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Override the default reasoning depth. "
|
||||
"Omit to use the configured default (typically low). "
|
||||
"Guide:\n"
|
||||
"- minimal: quick factual lookups (name, role, simple preference)\n"
|
||||
"- low: straightforward questions with clear answers\n"
|
||||
"- medium: multi-aspect questions requiring synthesis across observations\n"
|
||||
"- high: complex behavioral patterns, contradictions, deep analysis\n"
|
||||
"- max: thorough audit-level analysis, leave no stone unturned"
|
||||
),
|
||||
"enum": ["minimal", "low", "medium", "high", "max"],
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Which peer to query about: 'user' (default) or 'ai'.",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONTEXT_SCHEMA = {
|
||||
"name": "honcho_context",
|
||||
"description": (
|
||||
"Retrieve full session context from Honcho — summary, peer representation, "
|
||||
"peer card, and recent messages. No LLM synthesis. "
|
||||
"Cheaper than honcho_reasoning. Use this to see what Honcho knows about "
|
||||
"the current conversation and the specified peer."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Optional focus query to filter context. Omit for full session context snapshot.",
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
CONCLUDE_SCHEMA = {
|
||||
"name": "honcho_conclude",
|
||||
"description": (
|
||||
"Write a conclusion about the user back to Honcho's memory. "
|
||||
"Conclusions are persistent facts that build the user's profile. "
|
||||
"Use when the user states a preference, corrects you, or shares "
|
||||
"something to remember across sessions."
|
||||
"Write or delete a conclusion about a peer in Honcho's memory. "
|
||||
"Conclusions are persistent facts that build a peer's profile. "
|
||||
"Pass `conclusion` to create. Pass `delete_id` to remove a conclusion "
|
||||
"containing personal information — Honcho self-heals incorrect "
|
||||
"conclusions over time, so deletion is only needed for PII removal."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"conclusion": {
|
||||
"type": "string",
|
||||
"description": "A factual statement about the user to persist.",
|
||||
"description": "A factual statement to persist. Omit when using delete_id.",
|
||||
},
|
||||
"delete_id": {
|
||||
"type": "string",
|
||||
"description": "Conclusion ID to delete (for removing PII). Omit when creating.",
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
}
|
||||
},
|
||||
"required": ["conclusion"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA]
|
||||
ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, REASONING_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -139,8 +204,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._turn_count = 0
|
||||
self._injection_frequency = "every-turn" # or "first-turn"
|
||||
self._context_cadence = 1 # minimum turns between context API calls
|
||||
self._dialectic_cadence = 1 # minimum turns between dialectic API calls
|
||||
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "mid", "high"
|
||||
self._dialectic_cadence = 3 # minimum turns between dialectic API calls
|
||||
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "medium", "high"
|
||||
self._last_context_turn = -999
|
||||
self._last_dialectic_turn = -999
|
||||
|
||||
|
|
@ -236,9 +301,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
raw = cfg.raw or {}
|
||||
self._injection_frequency = raw.get("injectionFrequency", "every-turn")
|
||||
self._context_cadence = int(raw.get("contextCadence", 1))
|
||||
self._dialectic_cadence = int(raw.get("dialecticCadence", 1))
|
||||
self._dialectic_cadence = int(raw.get("dialecticCadence", 3))
|
||||
cap = raw.get("reasoningLevelCap")
|
||||
if cap and cap in ("minimal", "low", "mid", "high"):
|
||||
if cap and cap in ("minimal", "low", "medium", "high"):
|
||||
self._reasoning_level_cap = cap
|
||||
except Exception as e:
|
||||
logger.debug("Honcho cost-awareness config parse error: %s", e)
|
||||
|
|
@ -251,9 +316,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
# ----- Port #1957: lazy session init for tools-only mode -----
|
||||
if self._recall_mode == "tools":
|
||||
if cfg.init_on_session_start:
|
||||
# Eager init: create session now so sync_turn() works from turn 1.
|
||||
# Does NOT enable auto-injection — prefetch() still returns empty.
|
||||
logger.debug("Honcho tools-only mode — eager session init (initOnSessionStart=true)")
|
||||
# Eager init even in tools mode (opt-in)
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
return
|
||||
# Defer actual session creation until first tool call
|
||||
|
|
@ -287,8 +350,13 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
|
||||
# ----- B3: resolve_session_name -----
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
self._session_key = (
|
||||
cfg.resolve_session_name(session_title=session_title, session_id=session_id)
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
|
|
@ -299,12 +367,21 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._session_initialized = True
|
||||
|
||||
# ----- B6: Memory file migration (one-time, for new sessions) -----
|
||||
# Skip under per-session strategy: every Hermes run creates a fresh
|
||||
# Honcho session by design, so uploading MEMORY.md/USER.md/SOUL.md to
|
||||
# each one would flood the backend with short-lived duplicates instead
|
||||
# of performing a one-time migration.
|
||||
try:
|
||||
if not session.messages:
|
||||
if not session.messages and cfg.session_strategy != "per-session":
|
||||
from hermes_constants import get_hermes_home
|
||||
mem_dir = str(get_hermes_home() / "memories")
|
||||
self._manager.migrate_memory_files(self._session_key, mem_dir)
|
||||
logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key)
|
||||
elif cfg.session_strategy == "per-session":
|
||||
logger.debug(
|
||||
"Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)",
|
||||
self._session_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Honcho memory file migration skipped: %s", e)
|
||||
|
||||
|
|
@ -382,7 +459,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
return (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile, honcho_search, "
|
||||
"honcho_context, and honcho_conclude tools to access user memory."
|
||||
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
|
@ -412,7 +489,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
header = (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile for a quick factual snapshot, "
|
||||
"honcho_search for raw excerpts, honcho_context for synthesized answers, "
|
||||
"honcho_search for raw excerpts, honcho_context for raw peer context, "
|
||||
"honcho_reasoning for synthesized answers, "
|
||||
"honcho_conclude to save facts about the user. "
|
||||
"No automatic context injection — you must use tools to access memory."
|
||||
)
|
||||
|
|
@ -421,7 +499,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
"# Honcho Memory\n"
|
||||
"Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. "
|
||||
"Use honcho_profile for a quick factual snapshot, "
|
||||
"honcho_search for raw excerpts, honcho_context for synthesized answers, "
|
||||
"honcho_search for raw excerpts, honcho_context for raw peer context, "
|
||||
"honcho_reasoning for synthesized answers, "
|
||||
"honcho_conclude to save facts about the user."
|
||||
)
|
||||
|
||||
|
|
@ -458,7 +537,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
# ----- Port #3265: token budget enforcement -----
|
||||
result = self._truncate_to_budget(result)
|
||||
|
||||
return f"## Honcho Context\n{result}"
|
||||
return result
|
||||
|
||||
def _truncate_to_budget(self, text: str) -> str:
|
||||
"""Truncate text to fit within context_tokens budget if set."""
|
||||
|
|
@ -659,7 +738,14 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
|
||||
try:
|
||||
if tool_name == "honcho_profile":
|
||||
card = self._manager.get_peer_card(self._session_key)
|
||||
peer = args.get("peer", "user")
|
||||
card_update = args.get("card")
|
||||
if card_update:
|
||||
result = self._manager.set_peer_card(self._session_key, card_update, peer=peer)
|
||||
if result is None:
|
||||
return tool_error("Failed to update peer card.")
|
||||
return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result})
|
||||
card = self._manager.get_peer_card(self._session_key, peer=peer)
|
||||
if not card:
|
||||
return json.dumps({"result": "No profile facts available yet."})
|
||||
return json.dumps({"result": card})
|
||||
|
|
@ -669,30 +755,62 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
max_tokens = min(int(args.get("max_tokens", 800)), 2000)
|
||||
peer = args.get("peer", "user")
|
||||
result = self._manager.search_context(
|
||||
self._session_key, query, max_tokens=max_tokens
|
||||
self._session_key, query, max_tokens=max_tokens, peer=peer
|
||||
)
|
||||
if not result:
|
||||
return json.dumps({"result": "No relevant context found."})
|
||||
return json.dumps({"result": result})
|
||||
|
||||
elif tool_name == "honcho_context":
|
||||
elif tool_name == "honcho_reasoning":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
peer = args.get("peer", "user")
|
||||
reasoning_level = args.get("reasoning_level")
|
||||
result = self._manager.dialectic_query(
|
||||
self._session_key, query, peer=peer
|
||||
self._session_key, query,
|
||||
reasoning_level=reasoning_level,
|
||||
peer=peer,
|
||||
)
|
||||
return json.dumps({"result": result or "No result from Honcho."})
|
||||
|
||||
elif tool_name == "honcho_context":
|
||||
peer = args.get("peer", "user")
|
||||
ctx = self._manager.get_session_context(self._session_key, peer=peer)
|
||||
if not ctx:
|
||||
return json.dumps({"result": "No context available yet."})
|
||||
parts = []
|
||||
if ctx.get("summary"):
|
||||
parts.append(f"## Summary\n{ctx['summary']}")
|
||||
if ctx.get("representation"):
|
||||
parts.append(f"## Representation\n{ctx['representation']}")
|
||||
if ctx.get("card"):
|
||||
parts.append(f"## Card\n{ctx['card']}")
|
||||
if ctx.get("recent_messages"):
|
||||
msgs = ctx["recent_messages"]
|
||||
msg_str = "\n".join(
|
||||
f" [{m['role']}] {m['content'][:200]}"
|
||||
for m in msgs[-5:] # last 5 for brevity
|
||||
)
|
||||
parts.append(f"## Recent messages\n{msg_str}")
|
||||
return json.dumps({"result": "\n\n".join(parts) or "No context available."})
|
||||
|
||||
elif tool_name == "honcho_conclude":
|
||||
delete_id = args.get("delete_id")
|
||||
peer = args.get("peer", "user")
|
||||
if delete_id:
|
||||
ok = self._manager.delete_conclusion(self._session_key, delete_id, peer=peer)
|
||||
if ok:
|
||||
return json.dumps({"result": f"Conclusion {delete_id} deleted."})
|
||||
return tool_error(f"Failed to delete conclusion {delete_id}.")
|
||||
conclusion = args.get("conclusion", "")
|
||||
if not conclusion:
|
||||
return tool_error("Missing required parameter: conclusion")
|
||||
ok = self._manager.create_conclusion(self._session_key, conclusion)
|
||||
return tool_error("Missing required parameter: conclusion or delete_id")
|
||||
ok = self._manager.create_conclusion(self._session_key, conclusion, peer=peer)
|
||||
if ok:
|
||||
return json.dumps({"result": f"Conclusion saved: {conclusion}"})
|
||||
return json.dumps({"result": f"Conclusion saved for {peer}: {conclusion}"})
|
||||
return tool_error("Failed to save conclusion.")
|
||||
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
|
|
|||
|
|
@ -440,11 +440,43 @@ def cmd_setup(args) -> None:
|
|||
if new_recall in ("hybrid", "context", "tools"):
|
||||
hermes_host["recallMode"] = new_recall
|
||||
|
||||
# --- 7. Session strategy ---
|
||||
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory")
|
||||
# --- 7. Context token budget ---
|
||||
current_ctx_tokens = hermes_host.get("contextTokens") or cfg.get("contextTokens")
|
||||
current_display = str(current_ctx_tokens) if current_ctx_tokens else "uncapped"
|
||||
print("\n Context injection per turn (hybrid/context recall modes only):")
|
||||
print(" uncapped -- no limit (default)")
|
||||
print(" N -- token limit per turn (e.g. 1200)")
|
||||
new_ctx_tokens = _prompt("Context tokens", default=current_display)
|
||||
if new_ctx_tokens.strip().lower() in ("none", "uncapped", "no limit"):
|
||||
hermes_host.pop("contextTokens", None)
|
||||
elif new_ctx_tokens.strip() == "":
|
||||
pass # keep current
|
||||
else:
|
||||
try:
|
||||
val = int(new_ctx_tokens)
|
||||
if val >= 0:
|
||||
hermes_host["contextTokens"] = val
|
||||
except (ValueError, TypeError):
|
||||
pass # keep current
|
||||
|
||||
# --- 7b. Dialectic cadence ---
|
||||
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3")
|
||||
print("\n Dialectic cadence:")
|
||||
print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).")
|
||||
print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.")
|
||||
new_dialectic = _prompt("Dialectic cadence", default=current_dialectic)
|
||||
try:
|
||||
val = int(new_dialectic)
|
||||
if val >= 1:
|
||||
hermes_host["dialecticCadence"] = val
|
||||
except (ValueError, TypeError):
|
||||
hermes_host["dialecticCadence"] = 3
|
||||
|
||||
# --- 8. Session strategy ---
|
||||
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session")
|
||||
print("\n Session strategy:")
|
||||
print(" per-directory -- one session per working directory (default)")
|
||||
print(" per-session -- new Honcho session each run")
|
||||
print(" per-session -- each run starts clean, Honcho injects context automatically")
|
||||
print(" per-directory -- reuses session per dir, prior context auto-injected each run")
|
||||
print(" per-repo -- one session per git repository")
|
||||
print(" global -- single session across all directories")
|
||||
new_strat = _prompt("Session strategy", default=current_strat)
|
||||
|
|
@ -490,10 +522,11 @@ def cmd_setup(args) -> None:
|
|||
print(f" Recall: {hcfg.recall_mode}")
|
||||
print(f" Sessions: {hcfg.session_strategy}")
|
||||
print("\n Honcho tools available in chat:")
|
||||
print(" honcho_context -- ask Honcho about the user (LLM-synthesized)")
|
||||
print(" honcho_search -- semantic search over history (no LLM)")
|
||||
print(" honcho_profile -- peer card, key facts (no LLM)")
|
||||
print(" honcho_conclude -- persist a user fact to memory (no LLM)")
|
||||
print(" honcho_context -- session context: summary, representation, card, messages")
|
||||
print(" honcho_search -- semantic search over history")
|
||||
print(" honcho_profile -- peer card, key facts")
|
||||
print(" honcho_reasoning -- ask Honcho a question, synthesized answer")
|
||||
print(" honcho_conclude -- persist a user fact to memory")
|
||||
print("\n Other commands:")
|
||||
print(" hermes honcho status -- show full config")
|
||||
print(" hermes honcho mode -- change recall/observation mode")
|
||||
|
|
@ -585,13 +618,26 @@ def cmd_status(args) -> None:
|
|||
print(f" Enabled: {hcfg.enabled}")
|
||||
print(f" API key: {masked}")
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
print(f" Config path: {active_path}")
|
||||
|
||||
# Config paths — show where config was read from and where writes go
|
||||
global_path = Path.home() / ".honcho" / "config.json"
|
||||
print(f" Config: {active_path}")
|
||||
if write_path != active_path:
|
||||
print(f" Write path: {write_path} (instance-local)")
|
||||
print(f" Write to: {write_path} (profile-local)")
|
||||
if active_path == global_path:
|
||||
print(f" Fallback: (none — using global ~/.honcho/config.json)")
|
||||
elif global_path.exists():
|
||||
print(f" Fallback: {global_path} (exists, cross-app interop)")
|
||||
|
||||
print(f" AI peer: {hcfg.ai_peer}")
|
||||
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
||||
print(f" Session key: {hcfg.resolve_session_name()}")
|
||||
print(f" Session strat: {hcfg.session_strategy}")
|
||||
print(f" Recall mode: {hcfg.recall_mode}")
|
||||
print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens")
|
||||
raw = getattr(hcfg, "raw", None) or {}
|
||||
dialectic_cadence = raw.get("dialecticCadence") or 3
|
||||
print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}")
|
||||
print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})")
|
||||
print(f" Write freq: {hcfg.write_frequency}")
|
||||
|
||||
|
|
@ -599,8 +645,8 @@ def cmd_status(args) -> None:
|
|||
print("\n Connection... ", end="", flush=True)
|
||||
try:
|
||||
client = get_honcho_client(hcfg)
|
||||
print("OK")
|
||||
_show_peer_cards(hcfg, client)
|
||||
print("OK")
|
||||
except Exception as e:
|
||||
print(f"FAILED ({e})\n")
|
||||
else:
|
||||
|
|
@ -824,6 +870,41 @@ def cmd_mode(args) -> None:
|
|||
print(f" {label}Recall mode -> {mode_arg} ({MODES[mode_arg]})\n")
|
||||
|
||||
|
||||
def cmd_strategy(args) -> None:
|
||||
"""Show or set the session strategy."""
|
||||
STRATEGIES = {
|
||||
"per-session": "each run starts clean, Honcho injects context automatically",
|
||||
"per-directory": "reuses session per dir, prior context auto-injected each run",
|
||||
"per-repo": "one session per git repository",
|
||||
"global": "single session across all directories",
|
||||
}
|
||||
cfg = _read_config()
|
||||
strat_arg = getattr(args, "strategy", None)
|
||||
|
||||
if strat_arg is None:
|
||||
current = (
|
||||
(cfg.get("hosts") or {}).get(_host_key(), {}).get("sessionStrategy")
|
||||
or cfg.get("sessionStrategy")
|
||||
or "per-session"
|
||||
)
|
||||
print("\nHoncho session strategy\n" + "─" * 40)
|
||||
for s, desc in STRATEGIES.items():
|
||||
marker = " <-" if s == current else ""
|
||||
print(f" {s:<15} {desc}{marker}")
|
||||
print(f"\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n")
|
||||
return
|
||||
|
||||
if strat_arg not in STRATEGIES:
|
||||
print(f" Invalid strategy '{strat_arg}'. Options: {', '.join(STRATEGIES)}\n")
|
||||
return
|
||||
|
||||
host = _host_key()
|
||||
label = f"[{host}] " if host != "hermes" else ""
|
||||
cfg.setdefault("hosts", {}).setdefault(host, {})["sessionStrategy"] = strat_arg
|
||||
_write_config(cfg)
|
||||
print(f" {label}Session strategy -> {strat_arg} ({STRATEGIES[strat_arg]})\n")
|
||||
|
||||
|
||||
def cmd_tokens(args) -> None:
|
||||
"""Show or set token budget settings."""
|
||||
cfg = _read_config()
|
||||
|
|
@ -1143,10 +1224,11 @@ def cmd_migrate(args) -> None:
|
|||
print(" automatically. Files become the seed, not the live store.")
|
||||
print()
|
||||
print(" Honcho tools (available to the agent during conversation)")
|
||||
print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)")
|
||||
print(" honcho_search — semantic search over stored context (no LLM)")
|
||||
print(" honcho_profile — fast peer card snapshot (no LLM)")
|
||||
print(" honcho_conclude — write a conclusion/fact back to memory (no LLM)")
|
||||
print(" honcho_context — session context: summary, representation, card, messages")
|
||||
print(" honcho_search — semantic search over stored context")
|
||||
print(" honcho_profile — fast peer card snapshot")
|
||||
print(" honcho_reasoning — ask Honcho a question, synthesized answer")
|
||||
print(" honcho_conclude — write a conclusion/fact back to memory")
|
||||
print()
|
||||
print(" Session naming")
|
||||
print(" OpenClaw: no persistent session concept — files are global.")
|
||||
|
|
@ -1197,6 +1279,8 @@ def honcho_command(args) -> None:
|
|||
cmd_peer(args)
|
||||
elif sub == "mode":
|
||||
cmd_mode(args)
|
||||
elif sub == "strategy":
|
||||
cmd_strategy(args)
|
||||
elif sub == "tokens":
|
||||
cmd_tokens(args)
|
||||
elif sub == "identity":
|
||||
|
|
@ -1211,7 +1295,7 @@ def honcho_command(args) -> None:
|
|||
cmd_sync(args)
|
||||
else:
|
||||
print(f" Unknown honcho command: {sub}")
|
||||
print(" Available: status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n")
|
||||
print(" Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n")
|
||||
|
||||
|
||||
def register_cli(subparser) -> None:
|
||||
|
|
@ -1270,6 +1354,15 @@ def register_cli(subparser) -> None:
|
|||
help="Recall mode to set (hybrid/context/tools). Omit to show current.",
|
||||
)
|
||||
|
||||
strategy_parser = subs.add_parser(
|
||||
"strategy", help="Show or set session strategy (per-session/per-directory/per-repo/global)",
|
||||
)
|
||||
strategy_parser.add_argument(
|
||||
"strategy", nargs="?", metavar="STRATEGY",
|
||||
choices=("per-session", "per-directory", "per-repo", "global"),
|
||||
help="Session strategy to set. Omit to show current.",
|
||||
)
|
||||
|
||||
tokens_parser = subs.add_parser(
|
||||
"tokens", help="Show or set token budget for context and dialectic",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,35 @@ def _resolve_bool(host_val, root_val, *, default: bool) -> bool:
|
|||
return default
|
||||
|
||||
|
||||
def _parse_context_tokens(host_val, root_val) -> int | None:
|
||||
"""Parse contextTokens: host wins, then root, then None (uncapped)."""
|
||||
for val in (host_val, root_val):
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_optional_float(*values: Any) -> float | None:
|
||||
"""Return the first non-empty value coerced to a positive float."""
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if parsed > 0:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
_VALID_OBSERVATION_MODES = {"unified", "directional"}
|
||||
_OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"}
|
||||
|
||||
|
|
@ -159,6 +188,8 @@ class HonchoClientConfig:
|
|||
environment: str = "production"
|
||||
# Optional base URL for self-hosted Honcho (overrides environment mapping)
|
||||
base_url: str | None = None
|
||||
# Optional request timeout in seconds for Honcho SDK HTTP calls
|
||||
timeout: float | None = None
|
||||
# Identity
|
||||
peer_name: str | None = None
|
||||
ai_peer: str = "hermes"
|
||||
|
|
@ -168,14 +199,14 @@ class HonchoClientConfig:
|
|||
# Write frequency: "async" (background thread), "turn" (sync per turn),
|
||||
# "session" (flush on session end), or int (every N turns)
|
||||
write_frequency: str | int = "async"
|
||||
# Prefetch budget
|
||||
# Prefetch budget (None = no cap; set to an integer to bound auto-injected context)
|
||||
context_tokens: int | None = None
|
||||
# Dialectic (peer.chat) settings
|
||||
# reasoning_level: "minimal" | "low" | "medium" | "high" | "max"
|
||||
dialectic_reasoning_level: str = "low"
|
||||
# dynamic: auto-bump reasoning level based on query length
|
||||
# true — low->medium (120+ chars), low->high (400+ chars), capped at "high"
|
||||
# false — always use dialecticReasoningLevel as-is
|
||||
# When true, the model can override reasoning_level per-call via the
|
||||
# honcho_reasoning tool param (agentic). When false, always uses
|
||||
# dialecticReasoningLevel and ignores model-provided overrides.
|
||||
dialectic_dynamic: bool = True
|
||||
# Max chars of dialectic result to inject into Hermes system prompt
|
||||
dialectic_max_chars: int = 600
|
||||
|
|
@ -189,10 +220,8 @@ class HonchoClientConfig:
|
|||
# "context" — auto-injected context only, Honcho tools removed
|
||||
# "tools" — Honcho tools only, no auto-injected context
|
||||
recall_mode: str = "hybrid"
|
||||
# When True and recallMode is "tools", create the Honcho session eagerly
|
||||
# during initialize() instead of deferring to the first tool call.
|
||||
# This ensures sync_turn() can write from the very first turn.
|
||||
# Does NOT enable automatic context injection — only changes init timing.
|
||||
# Eager init in tools mode — when true, initializes session during
|
||||
# initialize() instead of deferring to first tool call
|
||||
init_on_session_start: bool = False
|
||||
# Observation mode: legacy string shorthand ("directional" or "unified").
|
||||
# Kept for backward compat; granular per-peer booleans below are preferred.
|
||||
|
|
@ -224,12 +253,14 @@ class HonchoClientConfig:
|
|||
resolved_host = host or resolve_active_host()
|
||||
api_key = os.environ.get("HONCHO_API_KEY")
|
||||
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
|
||||
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
||||
return cls(
|
||||
host=resolved_host,
|
||||
workspace_id=workspace_id,
|
||||
api_key=api_key,
|
||||
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
ai_peer=resolved_host,
|
||||
enabled=bool(api_key or base_url),
|
||||
)
|
||||
|
|
@ -290,6 +321,11 @@ class HonchoClientConfig:
|
|||
or os.environ.get("HONCHO_BASE_URL", "").strip()
|
||||
or None
|
||||
)
|
||||
timeout = _resolve_optional_float(
|
||||
raw.get("timeout"),
|
||||
raw.get("requestTimeout"),
|
||||
os.environ.get("HONCHO_TIMEOUT"),
|
||||
)
|
||||
|
||||
# Auto-enable when API key or base_url is present (unless explicitly disabled)
|
||||
# Host-level enabled wins, then root-level, then auto-enable if key/url exists.
|
||||
|
|
@ -335,17 +371,22 @@ class HonchoClientConfig:
|
|||
api_key=api_key,
|
||||
environment=environment,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
peer_name=host_block.get("peerName") or raw.get("peerName"),
|
||||
ai_peer=ai_peer,
|
||||
enabled=enabled,
|
||||
save_messages=save_messages,
|
||||
write_frequency=write_frequency,
|
||||
context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"),
|
||||
context_tokens=_parse_context_tokens(
|
||||
host_block.get("contextTokens"),
|
||||
raw.get("contextTokens"),
|
||||
),
|
||||
dialectic_reasoning_level=(
|
||||
host_block.get("dialecticReasoningLevel")
|
||||
or raw.get("dialecticReasoningLevel")
|
||||
or "low"
|
||||
),
|
||||
|
||||
dialectic_dynamic=_resolve_bool(
|
||||
host_block.get("dialecticDynamic"),
|
||||
raw.get("dialecticDynamic"),
|
||||
|
|
@ -422,16 +463,18 @@ class HonchoClientConfig:
|
|||
cwd: str | None = None,
|
||||
session_title: str | None = None,
|
||||
session_id: str | None = None,
|
||||
gateway_session_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve Honcho session name.
|
||||
|
||||
Resolution order:
|
||||
1. Manual directory override from sessions map
|
||||
2. Hermes session title (from /title command)
|
||||
3. per-session strategy — Hermes session_id ({timestamp}_{hex})
|
||||
4. per-repo strategy — git repo root directory name
|
||||
5. per-directory strategy — directory basename
|
||||
6. global strategy — workspace name
|
||||
3. Gateway session key (stable per-chat identifier from gateway platforms)
|
||||
4. per-session strategy — Hermes session_id ({timestamp}_{hex})
|
||||
5. per-repo strategy — git repo root directory name
|
||||
6. per-directory strategy — directory basename
|
||||
7. global strategy — workspace name
|
||||
"""
|
||||
import re
|
||||
|
||||
|
|
@ -451,6 +494,16 @@ class HonchoClientConfig:
|
|||
return f"{self.peer_name}-{sanitized}"
|
||||
return sanitized
|
||||
|
||||
# Gateway session key: stable per-chat identifier passed by the gateway
|
||||
# (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens
|
||||
# for Honcho session ID compatibility. This takes priority over strategy-
|
||||
# based resolution because gateway platforms need per-chat isolation that
|
||||
# cwd-based strategies cannot provide.
|
||||
if gateway_session_key:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', gateway_session_key).strip('-')
|
||||
if sanitized:
|
||||
return sanitized
|
||||
|
||||
# per-session: inherit Hermes session_id (new Honcho session each run)
|
||||
if self.session_strategy == "per-session" and session_id:
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
|
|
@ -512,13 +565,20 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
if not resolved_base_url:
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -553,6 +613,8 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
|
||||
_honcho_client = Honcho(**kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -486,36 +486,9 @@ class HonchoSessionManager:
|
|||
|
||||
_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
|
||||
|
||||
def _dynamic_reasoning_level(self, query: str) -> str:
|
||||
"""
|
||||
Pick a reasoning level for a dialectic query.
|
||||
|
||||
When dialecticDynamic is true (default), auto-bumps based on query
|
||||
length so Honcho applies more inference where it matters:
|
||||
|
||||
< 120 chars -> configured default (typically "low")
|
||||
120-400 chars -> +1 level above default (cap at "high")
|
||||
> 400 chars -> +2 levels above default (cap at "high")
|
||||
|
||||
"max" is never selected automatically -- reserve it for explicit config.
|
||||
|
||||
When dialecticDynamic is false, always returns the configured level.
|
||||
"""
|
||||
if not self._dialectic_dynamic:
|
||||
return self._dialectic_reasoning_level
|
||||
|
||||
levels = self._REASONING_LEVELS
|
||||
default_idx = levels.index(self._dialectic_reasoning_level) if self._dialectic_reasoning_level in levels else 1
|
||||
n = len(query)
|
||||
if n < 120:
|
||||
bump = 0
|
||||
elif n < 400:
|
||||
bump = 1
|
||||
else:
|
||||
bump = 2
|
||||
# Cap at "high" (index 3) for auto-selection
|
||||
idx = min(default_idx + bump, 3)
|
||||
return levels[idx]
|
||||
def _default_reasoning_level(self) -> str:
|
||||
"""Return the configured default reasoning level."""
|
||||
return self._dialectic_reasoning_level
|
||||
|
||||
def dialectic_query(
|
||||
self, session_key: str, query: str,
|
||||
|
|
@ -532,8 +505,9 @@ class HonchoSessionManager:
|
|||
Args:
|
||||
session_key: The session key to query against.
|
||||
query: Natural language question.
|
||||
reasoning_level: Override the config default. If None, uses
|
||||
_dynamic_reasoning_level(query).
|
||||
reasoning_level: Override the configured default (dialecticReasoningLevel).
|
||||
Only honored when dialecticDynamic is true.
|
||||
If None or dialecticDynamic is false, uses the configured default.
|
||||
peer: Which peer to query — "user" (default) or "ai".
|
||||
|
||||
Returns:
|
||||
|
|
@ -543,29 +517,34 @@ class HonchoSessionManager:
|
|||
if not session:
|
||||
return ""
|
||||
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
return ""
|
||||
|
||||
# Guard: truncate query to Honcho's dialectic input limit
|
||||
if len(query) > self._dialectic_max_input_chars:
|
||||
query = query[:self._dialectic_max_input_chars].rsplit(" ", 1)[0]
|
||||
|
||||
level = reasoning_level or self._dynamic_reasoning_level(query)
|
||||
if self._dialectic_dynamic and reasoning_level:
|
||||
level = reasoning_level
|
||||
else:
|
||||
level = self._default_reasoning_level()
|
||||
|
||||
try:
|
||||
if self._ai_observe_others:
|
||||
# AI peer can observe user — use cross-observation routing
|
||||
if peer == "ai":
|
||||
ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id)
|
||||
# AI peer can observe other peers — use assistant as observer.
|
||||
ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id)
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
result = ai_peer_obj.chat(query, reasoning_level=level) or ""
|
||||
else:
|
||||
ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id)
|
||||
result = ai_peer_obj.chat(
|
||||
query,
|
||||
target=session.user_peer_id,
|
||||
target=target_peer_id,
|
||||
reasoning_level=level,
|
||||
) or ""
|
||||
else:
|
||||
# AI can't observe others — each peer queries self
|
||||
peer_id = session.assistant_peer_id if peer == "ai" else session.user_peer_id
|
||||
target_peer = self._get_or_create_peer(peer_id)
|
||||
# Without cross-observation, each peer queries its own context.
|
||||
target_peer = self._get_or_create_peer(target_peer_id)
|
||||
result = target_peer.chat(query, reasoning_level=level) or ""
|
||||
|
||||
# Apply Hermes-side char cap before caching
|
||||
|
|
@ -666,7 +645,7 @@ class HonchoSessionManager:
|
|||
|
||||
result: dict[str, str] = {}
|
||||
try:
|
||||
user_ctx = self._fetch_peer_context(session.user_peer_id)
|
||||
user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id)
|
||||
result["representation"] = user_ctx["representation"]
|
||||
result["card"] = "\n".join(user_ctx["card"])
|
||||
except Exception as e:
|
||||
|
|
@ -674,7 +653,7 @@ class HonchoSessionManager:
|
|||
|
||||
# Also fetch AI peer's own representation so Hermes knows itself.
|
||||
try:
|
||||
ai_ctx = self._fetch_peer_context(session.assistant_peer_id)
|
||||
ai_ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id)
|
||||
result["ai_representation"] = ai_ctx["representation"]
|
||||
result["ai_card"] = "\n".join(ai_ctx["card"])
|
||||
except Exception as e:
|
||||
|
|
@ -862,7 +841,7 @@ class HonchoSessionManager:
|
|||
return [str(item) for item in card if item]
|
||||
return [str(card)]
|
||||
|
||||
def _fetch_peer_card(self, peer_id: str) -> list[str]:
|
||||
def _fetch_peer_card(self, peer_id: str, *, target: str | None = None) -> list[str]:
|
||||
"""Fetch a peer card directly from the peer object.
|
||||
|
||||
This avoids relying on session.context(), which can return an empty
|
||||
|
|
@ -872,22 +851,33 @@ class HonchoSessionManager:
|
|||
peer = self._get_or_create_peer(peer_id)
|
||||
getter = getattr(peer, "get_card", None)
|
||||
if callable(getter):
|
||||
return self._normalize_card(getter())
|
||||
return self._normalize_card(getter(target=target) if target is not None else getter())
|
||||
|
||||
legacy_getter = getattr(peer, "card", None)
|
||||
if callable(legacy_getter):
|
||||
return self._normalize_card(legacy_getter())
|
||||
return self._normalize_card(legacy_getter(target=target) if target is not None else legacy_getter())
|
||||
|
||||
return []
|
||||
|
||||
def _fetch_peer_context(self, peer_id: str, search_query: str | None = None) -> dict[str, Any]:
|
||||
def _fetch_peer_context(
|
||||
self,
|
||||
peer_id: str,
|
||||
search_query: str | None = None,
|
||||
*,
|
||||
target: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch representation + peer card directly from a peer object."""
|
||||
peer = self._get_or_create_peer(peer_id)
|
||||
representation = ""
|
||||
card: list[str] = []
|
||||
|
||||
try:
|
||||
ctx = peer.context(search_query=search_query) if search_query else peer.context()
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if target is not None:
|
||||
context_kwargs["target"] = target
|
||||
if search_query is not None:
|
||||
context_kwargs["search_query"] = search_query
|
||||
ctx = peer.context(**context_kwargs) if context_kwargs else peer.context()
|
||||
representation = (
|
||||
getattr(ctx, "representation", None)
|
||||
or getattr(ctx, "peer_representation", None)
|
||||
|
|
@ -899,24 +889,106 @@ class HonchoSessionManager:
|
|||
|
||||
if not representation:
|
||||
try:
|
||||
representation = peer.representation() or ""
|
||||
representation = (
|
||||
peer.representation(target=target) if target is not None else peer.representation()
|
||||
) or ""
|
||||
except Exception as e:
|
||||
logger.debug("Direct peer.representation() failed for '%s': %s", peer_id, e)
|
||||
|
||||
if not card:
|
||||
try:
|
||||
card = self._fetch_peer_card(peer_id)
|
||||
card = self._fetch_peer_card(peer_id, target=target)
|
||||
except Exception as e:
|
||||
logger.debug("Direct peer card fetch failed for '%s': %s", peer_id, e)
|
||||
|
||||
return {"representation": representation, "card": card}
|
||||
|
||||
def get_peer_card(self, session_key: str) -> list[str]:
|
||||
def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, Any]:
|
||||
"""Fetch full session context from Honcho including summary.
|
||||
|
||||
Uses the session-level context() API which returns summary,
|
||||
peer_representation, peer_card, and messages.
|
||||
"""
|
||||
Fetch the user peer's card — a curated list of key facts.
|
||||
session = self._cache.get(session_key)
|
||||
if not session:
|
||||
return {}
|
||||
|
||||
honcho_session = self._sessions_cache.get(session.honcho_session_id)
|
||||
if not honcho_session:
|
||||
# Fall back to peer-level context
|
||||
return self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id)
|
||||
|
||||
try:
|
||||
peer_id = self._resolve_peer_id(session, peer)
|
||||
ctx = honcho_session.context(
|
||||
summary=True,
|
||||
peer_target=peer_id,
|
||||
peer_perspective=session.user_peer_id if peer == "user" else session.assistant_peer_id,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# Summary
|
||||
if ctx.summary:
|
||||
result["summary"] = ctx.summary.content
|
||||
|
||||
# Peer representation and card
|
||||
if ctx.peer_representation:
|
||||
result["representation"] = ctx.peer_representation
|
||||
if ctx.peer_card:
|
||||
result["card"] = "\n".join(ctx.peer_card)
|
||||
|
||||
# Messages (last N for context)
|
||||
if ctx.messages:
|
||||
recent = ctx.messages[-10:] # last 10 messages
|
||||
result["recent_messages"] = [
|
||||
{"role": m.role, "content": m.content[:500]}
|
||||
for m in recent
|
||||
]
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("Session context fetch failed: %s", e)
|
||||
return {}
|
||||
|
||||
def _resolve_peer_id(self, session: HonchoSession, peer: str | None) -> str | None:
|
||||
"""Resolve a peer alias or explicit peer ID to a concrete Honcho peer ID."""
|
||||
candidate = (peer or "user").strip()
|
||||
if not candidate:
|
||||
return session.user_peer_id
|
||||
|
||||
normalized = self._sanitize_id(candidate)
|
||||
if normalized == self._sanitize_id("user"):
|
||||
return session.user_peer_id
|
||||
if normalized == self._sanitize_id("ai"):
|
||||
return session.assistant_peer_id
|
||||
|
||||
return normalized
|
||||
|
||||
def _resolve_observer_target(
|
||||
self,
|
||||
session: HonchoSession,
|
||||
peer: str | None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Resolve observer and target peer IDs for context/search/profile queries."""
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
return session.user_peer_id, None
|
||||
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
return session.assistant_peer_id, session.assistant_peer_id
|
||||
|
||||
if self._ai_observe_others:
|
||||
return session.assistant_peer_id, target_peer_id
|
||||
|
||||
return target_peer_id, None
|
||||
|
||||
def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]:
|
||||
"""
|
||||
Fetch a peer card — a curated list of key facts.
|
||||
|
||||
Fast, no LLM reasoning. Returns raw structured facts Honcho has
|
||||
inferred about the user (name, role, preferences, patterns).
|
||||
inferred about the target peer (name, role, preferences, patterns).
|
||||
Empty list if unavailable.
|
||||
"""
|
||||
session = self._cache.get(session_key)
|
||||
|
|
@ -924,12 +996,19 @@ class HonchoSessionManager:
|
|||
return []
|
||||
|
||||
try:
|
||||
return self._fetch_peer_card(session.user_peer_id)
|
||||
observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)
|
||||
return self._fetch_peer_card(observer_peer_id, target=target_peer_id)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch peer card from Honcho: %s", e)
|
||||
return []
|
||||
|
||||
def search_context(self, session_key: str, query: str, max_tokens: int = 800) -> str:
|
||||
def search_context(
|
||||
self,
|
||||
session_key: str,
|
||||
query: str,
|
||||
max_tokens: int = 800,
|
||||
peer: str = "user",
|
||||
) -> str:
|
||||
"""
|
||||
Semantic search over Honcho session context.
|
||||
|
||||
|
|
@ -941,6 +1020,7 @@ class HonchoSessionManager:
|
|||
session_key: Session to search against.
|
||||
query: Search query for semantic matching.
|
||||
max_tokens: Token budget for returned content.
|
||||
peer: Peer alias or explicit peer ID to search about.
|
||||
|
||||
Returns:
|
||||
Relevant context excerpts as a string, or empty string if none.
|
||||
|
|
@ -950,7 +1030,13 @@ class HonchoSessionManager:
|
|||
return ""
|
||||
|
||||
try:
|
||||
ctx = self._fetch_peer_context(session.user_peer_id, search_query=query)
|
||||
observer_peer_id, target = self._resolve_observer_target(session, peer)
|
||||
|
||||
ctx = self._fetch_peer_context(
|
||||
observer_peer_id,
|
||||
search_query=query,
|
||||
target=target,
|
||||
)
|
||||
parts = []
|
||||
if ctx["representation"]:
|
||||
parts.append(ctx["representation"])
|
||||
|
|
@ -962,16 +1048,17 @@ class HonchoSessionManager:
|
|||
logger.debug("Honcho search_context failed: %s", e)
|
||||
return ""
|
||||
|
||||
def create_conclusion(self, session_key: str, content: str) -> bool:
|
||||
"""Write a conclusion about the user back to Honcho.
|
||||
def create_conclusion(self, session_key: str, content: str, peer: str = "user") -> bool:
|
||||
"""Write a conclusion about a target peer back to Honcho.
|
||||
|
||||
Conclusions are facts the AI peer observes about the user —
|
||||
preferences, corrections, clarifications, project context.
|
||||
They feed into the user's peer card and representation.
|
||||
Conclusions are facts a peer observes about another peer or itself —
|
||||
preferences, corrections, clarifications, and project context.
|
||||
They feed into the target peer's card and representation.
|
||||
|
||||
Args:
|
||||
session_key: Session to associate the conclusion with.
|
||||
content: The conclusion text (e.g. "User prefers dark mode").
|
||||
content: The conclusion text.
|
||||
peer: Peer alias or explicit peer ID. "user" is the default alias.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
|
|
@ -985,25 +1072,87 @@ class HonchoSessionManager:
|
|||
return False
|
||||
|
||||
try:
|
||||
if self._ai_observe_others:
|
||||
# AI peer creates conclusion about user (cross-observation)
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
logger.warning("Could not resolve conclusion peer '%s' for session '%s'", peer, session_key)
|
||||
return False
|
||||
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
conclusions_scope = assistant_peer.conclusions_of(session.user_peer_id)
|
||||
conclusions_scope = assistant_peer.conclusions_of(session.assistant_peer_id)
|
||||
elif self._ai_observe_others:
|
||||
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
conclusions_scope = assistant_peer.conclusions_of(target_peer_id)
|
||||
else:
|
||||
# AI can't observe others — user peer creates self-conclusion
|
||||
user_peer = self._get_or_create_peer(session.user_peer_id)
|
||||
conclusions_scope = user_peer.conclusions_of(session.user_peer_id)
|
||||
target_peer = self._get_or_create_peer(target_peer_id)
|
||||
conclusions_scope = target_peer.conclusions_of(target_peer_id)
|
||||
|
||||
conclusions_scope.create([{
|
||||
"content": content.strip(),
|
||||
"session_id": session.honcho_session_id,
|
||||
}])
|
||||
logger.info("Created conclusion for %s: %s", session_key, content[:80])
|
||||
logger.info("Created conclusion about %s for %s: %s", target_peer_id, session_key, content[:80])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to create conclusion: %s", e)
|
||||
return False
|
||||
|
||||
def delete_conclusion(self, session_key: str, conclusion_id: str, peer: str = "user") -> bool:
|
||||
"""Delete a conclusion by ID. Use only for PII removal.
|
||||
|
||||
Args:
|
||||
session_key: Session key for peer resolution.
|
||||
conclusion_id: The conclusion ID to delete.
|
||||
peer: Peer alias or explicit peer ID.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
session = self._cache.get(session_key)
|
||||
if not session:
|
||||
return False
|
||||
try:
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
observer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
scope = observer.conclusions_of(session.assistant_peer_id)
|
||||
elif self._ai_observe_others:
|
||||
observer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
scope = observer.conclusions_of(target_peer_id)
|
||||
else:
|
||||
target_peer = self._get_or_create_peer(target_peer_id)
|
||||
scope = target_peer.conclusions_of(target_peer_id)
|
||||
scope.delete(conclusion_id)
|
||||
logger.info("Deleted conclusion %s for %s", conclusion_id, session_key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete conclusion %s: %s", conclusion_id, e)
|
||||
return False
|
||||
|
||||
def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") -> list[str] | None:
|
||||
"""Update a peer's card.
|
||||
|
||||
Args:
|
||||
session_key: Session key for peer resolution.
|
||||
card: New peer card as list of fact strings.
|
||||
peer: Peer alias or explicit peer ID.
|
||||
|
||||
Returns:
|
||||
Updated card on success, None on failure.
|
||||
"""
|
||||
session = self._cache.get(session_key)
|
||||
if not session:
|
||||
return None
|
||||
try:
|
||||
peer_id = self._resolve_peer_id(session, peer)
|
||||
peer_obj = self._get_or_create_peer(peer_id)
|
||||
result = peer_obj.set_card(card)
|
||||
logger.info("Updated peer card for %s (%d facts)", peer_id, len(card))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Failed to set peer card: %s", e)
|
||||
return None
|
||||
|
||||
def seed_ai_identity(self, session_key: str, content: str, source: str = "manual") -> bool:
|
||||
"""
|
||||
Seed the AI peer's Honcho representation from text content.
|
||||
|
|
@ -1061,7 +1210,7 @@ class HonchoSessionManager:
|
|||
return {"representation": "", "card": ""}
|
||||
|
||||
try:
|
||||
ctx = self._fetch_peer_context(session.assistant_peer_id)
|
||||
ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id)
|
||||
return {
|
||||
"representation": ctx["representation"] or "",
|
||||
"card": "\n".join(ctx["card"]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue