全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Gateway Layer
- 2 · Identity and Session Routing
- 3 · The Main Loop ★
- 3.1 The Loop Entry: A Triple Budget Gate
- 3.2 Per-Turn Preparation: build_turn_context
- 3.3 Mid-Turn Interjection: /steer
- 3.4 The Wall-Clock Budget Wrap-Up Reminder
- 3.5 The Interrupt Check
- 3.6 The Step Callback: An Observation Point for the Gateway
- 3.7 Other State Inside the Loop
- 3.8 Why the Loop Exited
- 3.9 An Optional Bypass: The Codex App Server Runtime
- 4 · The Tool System
- 4.1 The Most Important Design Decision: Implementation Separated from Exposure
- 4.2 Composing and Resolving Toolsets
- 4.3 Central Tool Dispatch
- 4.4 The Argument Coercion Layer
- 4.5 Sanitizing Tool Error Messages
- 4.6 Observing Tool Results, and Hooks
- 4.7 Recognizing a Delegation Context
- 4.8 Caching Tool Definitions
- 5 · Approval and Safety Red Lines ★
- 5.1 The Overall Structure
- 5.2 The 12 Hard Red Lines
- 5.3 The Real Difficulty: Telling “Command” from “Data”
- 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
- 5.5 Quoting Is Not a Bypass
- 5.6 Sensitive Paths and Write Targets
- 5.7 The sudo Stdin Guard
- 5.8 Performance: Why Precompile
- 5.9 Retaining Blocked Commands
- 5.10 Smart Approval: An Optional Model Judgment
- 5.11 Context Awareness: Different Situations, Different Policies
- 5.12 Where This Layer Sits: Outermost, and Thinnest
- 6 · Execution Environments
- 7 · The Context Engine ★
- 7.1 What It Defines
- 7.2 Lifecycle
- 7.3 The Three Methods You Must Implement
- 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
- 7.5 The Post-Turn Observation Hook
- 7.6 Other Optional Hooks
- 7.7 Default Parameter Values
- 7.8 Control Over User-Visible Status
- 7.9 Size Comparison of the Built-in Implementation
- 8 · The Memory System ★
- 8.1 The Memory Provider Interface
- 8.2 Lifecycle and Hooks
- 8.3 The Interface's Versioned Contract
- 8.4 The Trivial-Prompt Filter
- 8.5 The Memory-Usage Indicator
- 8.6 The Built-in Holographic Memory
- 8.7 The Storage Layer and Trust Scores
- 8.8 The SQLite State Layer
- 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
- 9 · The Plugin System
- 9.1 Three Discovery Sources
- 9.2 What a Plugin Can Provide
- 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
- 9.4 Plugin Storage
- 9.5 How Plugins Tie into Toolsets
- 9.6 Plugin Hooks
- 9.7 MCP: The Other Extension Path
- 9.8 The Overall Shape of This Extension System
- 10 · Delegation and Multi-Agent
- 11 · Model Providers and the Credential Pool
- 12 · Scheduled Tasks (Cron)
- 12.1 What “cron” Is
- 12.2 Scenarios for Scheduled Agents
- 12.3 The Single Most Important Class: CronPromptInjectionBlocked
- 12.4 Narrowing the Toolset for Scheduled Tasks
- 12.5 Failure Handling
- 12.6 Preventing Duplicate Runs
- 12.7 Why scheduler.py Is 367 KB
- 12.8 Where Scheduled Tasks Sit in the Overall Architecture
- 13 · The Skill System
- 13.1 What a Skill Is
- 13.2 The Front Matter, Field by Field
- 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
- 13.4 The Infrastructure Around Skills
- 13.5 Skills vs. Tools vs. Plugins
- 13.6 An Implicit Design in the Skill System: Composability
- 13.7 The 15 Skill Categories
- 13.8 Looking Back Across the Book: The Overall Shape of Hermes
8 · 记忆系统 ★
这一章讲 Hermes 怎么「跨会话记住事情」。它由三部分组成:一个可插拔的提供者接口、一个内置的全息记忆实现、以及一个 SQLite 状态层。
8.1 记忆提供者接口
「Memory providers give the agent persistent recall across sessions. The MemoryManager enforces a one-external-provider limit to prevent tool schema bloat and conflicting memory backends.」
译:记忆提供者让智能体拥有跨会话的持久回忆能力。MemoryManager 强制「只能有一个外部提供者」,以防止工具 schema 膨胀和记忆后端互相冲突。
「只能有一个」这条限制的两个理由
| 理由 | 说明 |
|---|---|
| 工具 schema 膨胀 | 每个记忆提供者都可以向模型暴露自己的工具(get_tool_schemas())。装 3 个就有 3 套「搜索记忆」「写入记忆」工具 —— 模型会困惑该用哪个,而且每套都占常驻 token |
| 后端冲突 | 两个提供者各自维护一份「用户是谁」的模型,可能互相矛盾。而且写入时该写哪个?读取时听谁的?没有正确答案 |
所以记忆提供者和上下文引擎一样,属于「互斥策略」而非「可叠加能力」。这个区分在第 9 章会展开。
八种可选后端
plugins/memory/
├── holographic/ ★ 内置:HRR 全息记忆(见 8.6)
├── honcho/ 外部服务
├── hindsight/ 外部服务
├── mem0/ 外部服务
├── byterover/ 外部服务
├── openviking/ 外部服务
├── retaindb/ 外部服务
├── supermemory/ 外部服务
├── query_rewrite.py 查询改写(通用辅助)
└── config_schema.py
8.2 生命周期与钩子
"""
Lifecycle (called by MemoryManager, wired in run_agent.py):
initialize() — connect, create resources, warm up
system_prompt_block() — static text for the system prompt
prefetch(query) — background recall before each turn
sync_turn(user, asst) — async write after each turn
get_tool_schemas() — tool schemas to expose to the model
handle_tool_call() — dispatch a tool call
shutdown() — clean exit
Optional hooks (override to opt in):
on_turn_start(turn, message, **kwargs) — 每轮的时钟滴答,带运行时上下文
on_session_end(messages) — 会话结束时的提取
on_session_switch(new_session_id, **kwargs) — 进程中途的会话 ID 轮转
on_pre_compress(messages) -> str — ★ 上下文压缩前的提取
on_memory_write(action, target, content, metadata=None)
— 镜像内置记忆的写入
on_delegation(task, result, **kwargs) — 父侧观察子智能体的工作
backup_paths() -> list[str] — 备份时要包含的额外磁盘路径
"""
hermes-agent/agent/memory_provider.py
三个值得单独说的钩子
on_pre_compress(messages) -> str(压缩前提取)是整套设计里最关键的一个:
on_delegation(task, result)(委派观察)解决的是:子智能体的工作过程不在父的上下文里(这正是子智能体的价值,见第 10 章)。但子智能体可能发现了值得长期记住的事实。这个钩子让父侧的记忆系统能观察到子任务的输入和结果。
backup_paths()(备份路径)是一个很实在的运维考虑:hermes backup 命令需要知道记忆提供者把数据存在哪些额外的磁盘位置,才能完整备份。
8.3 接口的版本化契约
# Version 1 is the historical, implicit contract every provider is already
# on: best-effort on_pre_compress() with the raw message list. Version 2 is
# the opt-in fail-closed checkpoint contract (normalized evidence handoff +
# strict-mode failure propagation).
PRE_COMPRESS_CHECKPOINT_API_VERSION = 2
译:版本 1 是历史上的隐式契约,每个已有的提供者都在用:拿到原始消息列表、尽力而为地做 on_pre_compress。版本 2 是可选加入的「失败即闭合」检查点契约(归一化的证据交接 + 严格模式下的失败传播)。
| 版本 1(尽力而为) | 版本 2(失败即闭合) | |
|---|---|---|
| 输入 | 原始消息列表,提供者自己解析 | 归一化的证据交接 —— 宿主先整理好格式 |
| 提取失败时 | 静默继续,压缩照常进行 → 数据就这么丢了 |
失败会传播上去,严格模式下会阻止压缩 → 宁可不压缩,也不丢数据 |
为什么这个升级是必要的:版本 1 有一个静默数据丢失的风险 —— 记忆提取失败了(网络问题、服务宕机),但压缩照常执行,于是那批消息的原文和提取结果同时消失。而且没有任何人会发现。
版本 2 把它变成显式失败:要么提取成功再压缩,要么就别压缩。
而用一个「版本号常量」而不是直接改接口,是为了让老提供者继续能跑。提供者声明自己支持哪个版本,宿主据此选择调用方式。
8.4 琐碎提问过滤器
这是一个很小但很实用的优化:
# Prompts that carry no semantic signal — trivial acknowledgements, greetings,
# slash commands, empty input. Single source of truth shared by the core
# per-turn prefetch gate and provider-side classifiers so the two can never
# drift apart.
TRIVIAL_PROMPT_RE = re.compile(
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
r'hi|hey|hello|yo|sup|'
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)'
r'[\s!?.:;,"\'~…—–()\[\]{}<>*&^%$#@!+=` ]*$',
re.IGNORECASE,
)
def is_trivial_prompt(text: Optional[str]) -> bool:
"""Callers use this to skip memory-provider prefetch/injection on turns
that carry no semantic signal — saving a blocking network round-trip
and preventing stale user-model context from derailing one-word replies."""
if not text: return True
stripped = text.strip()
if not stripped: return True
if stripped.startswith("/"): return True # 斜杠命令
return bool(TRIVIAL_PROMPT_RE.match(stripped))
它省掉的是什么
用户说「好的」「谢谢」「继续」这类话时:
- 省一次阻塞的网络往返 —— 外部记忆服务的检索是要联网的,通常几百毫秒
- 防止过期的用户模型把一个词的回复带偏 —— 用户只说了「好」,你却往上下文里注入了三段关于他的历史记忆,模型可能会莫名其妙地开始谈论那些内容
正则的锚定设计
「The alternation is anchored and may only be followed by whitespace or punctuation, so words that merely START with a trivial word ("k8s", "yolo", "note", "hindsight") do NOT match, while trailing-punctuation variants ("hi!", "hey.", "thanks :)", "done???") do.」
译:这个选择分支是锚定的,后面只允许跟空白或标点,所以那些「仅仅以琐碎词开头」的词("k8s"、"yolo"、"note"、"hindsight")不会匹配,而带尾部标点的变体("hi!"、"hey."、"thanks :)"、"done???")会匹配。
| 输入 | 判定 | 为什么 |
|---|---|---|
ok / thanks :) / done??? | 琐碎 | 整句就是一个确认词加标点 |
k8s 集群怎么配 | 不琐碎 | 虽然以 k 开头,但后面跟的是字母不是标点 |
note this down | 不琐碎 | 同上,no 后面跟的是 te |
hindsight 那个服务 | 不琐碎 | hi 后面跟的是 ndsight |
注意注释里提到的「单一真相来源」:这个正则被核心的每轮预取闸门和提供者侧的分类器共享,「so the two can never drift apart」(这样两者永远不会漂移)。如果各自实现一份,迟早会出现「核心认为琐碎、提供者认为不琐碎」的不一致。
8.5 记忆使用指示器
INDICATOR_GLYPH = "🧠" # 默认字形;各提供者可以用自己的品牌标记覆盖
# (比如 Hindsight 用 "👁️")
@dataclass(frozen=True)
class RecallStatus:
"""Summary of what a provider's most recent prefetch injected this turn.
…so the agent can emit a deterministic, model-independent
"memory was used" indicator. ``count`` is the number of discrete
memories injected; ``0`` means content was injected but has no discrete
count (e.g. a synthesized reflect answer), which the indicator renders
generically rather than as "0 memories".
"""
provider_label: str
count: int
glyph: str = INDICATOR_GLYPH
用户需要知道「这次回答用到了我的历史记忆吗」。有两种做法:
- 让模型自己说「根据我们之前的对话……」→ 不可靠。模型可能忘了说,也可能在没用记忆时也这么说
- 由系统根据「实际注入了什么」生成一个确定性指示器 → 永远准确
而 count == 0 那个特殊情况的处理也很细:有些提供者注入的不是「N 条离散记忆」,而是一段综合出来的回答。这时显示「0 条记忆」会误导用户以为没用上,所以要渲染成通用形式(比如「🧠 使用了记忆」而不是「🧠 0 条记忆」)。
8.6 内置的全息记忆
plugins/memory/holographic/,四个文件:holographic.py(HRR 数学运算)、store.py(SQLite 存储)、retrieval.py(检索)、__init__.py(提供者实现)。
HRR 是什么
「Holographic Reduced Representations (HRR) with phase encoding. HRRs are a vector symbolic architecture for encoding compositional structure into fixed-width distributed representations. This module uses phase vectors: each concept is a vector of angles in [0, 2π).」
译:带相位编码的全息缩减表示。HRR 是一种向量符号架构,用于把「组合结构」编码进固定宽度的分布式表示里。本模块使用相位向量:每个概念是一个由 [0, 2π) 区间内的角度组成的向量。
引用的两篇论文:Plate (1995) 和 Gayler (2004)。
三个核心运算
def bind(a, b): # 绑定 = 循环卷积 = 逐元素相位相加
return (a + b) % _TWO_PI
# 把两个概念绑定成一个复合向量。
# 结果与两个输入都不相似(数学上叫"准正交")
def unbind(memory, key): # 解绑 = 循环相关 = 相位相减
return (memory - key) % _TWO_PI
# unbind(bind(a, b), a) ≈ b (差一个叠加噪声)
def bundle(*vectors): # 打包 = 叠加 = 复指数的圆均值
complex_sum = np.sum([np.exp(1j * v) for v in vectors], axis=0)
return np.angle(complex_sum) % _TWO_PI
# 结果与每个输入都相似;
# 能容纳 O(√dim) 项,超过就开始退化
def similarity(a, b): # 相似度 = 相位余弦,范围 [-1, 1]
return float(np.mean(np.cos(a - b)))
hermes-agent/plugins/memory/holographic/holographic.py
不需要懂数学也能理解用途:
- 绑定把「键」和「值」粘成一个向量,比如把「用户的编辑器」和「Vim」绑起来
- 解绑是逆运算,给一个键能取回对应的值
- 打包把很多条记忆压成一个向量,用一个向量代表整个类别
源码还说明了选相位编码的理由:「Phase encoding is numerically stable, avoids the magnitude collapse of traditional complex-number HRRs, and maps cleanly to cosine similarity.」(相位编码数值稳定,避免了传统复数 HRR 的幅值塌缩,而且能干净地映射到余弦相似度。)
最值得注意的工程决策:用 SHA-256 而不是随机数
def encode_atom(word: str, dim: int = 1024) -> "np.ndarray":
"""Deterministic phase vector via SHA-256 counter blocks.
Uses hashlib (not numpy RNG) for cross-platform reproducibility.
Algorithm:
- Generate enough SHA-256 blocks by hashing f"{word}:{i}" for i=0,1,2,...
- Concatenate digests, interpret as uint16 values via struct.unpack
- Scale to [0, 2π): phases = values * (2π / 65536)
- Truncate to dim elements
"""
values_per_block = 16 # 每个 SHA-256 摘要 32 字节 = 16 个 uint16
blocks_needed = math.ceil(dim / values_per_block)
uint16_values = []
for i in range(blocks_needed):
digest = hashlib.sha256(f"{word}:{i}".encode()).digest()
uint16_values.extend(struct.unpack("<16H", digest))
phases = np.array(uint16_values[:dim], dtype=np.float64) * (_TWO_PI / 65536.0)
return phases
同一个词(比如 "docker"),在任何机器、任何 Python 版本、任何进程里,编码出来的 1024 维相位向量完全一致。
好处:
· 向量可以直接存进 SQLite 的二进制字段
· 可以跨机器同步
· 彻底避开了「换了 embedding 模型就要重算全库」这个运维噩梦 —— 这是所有基于神经网络嵌入的记忆方案最大的痛点
代价:它是词袋级的符号组合,完全没有语义理解能力。"docker" 和 "container" 的相似度接近 0,因为它们是两个不同的字符串,哈希结果毫无关系。
所以它必须和全文检索配合,而不是替代它。这是一个很清醒的定位:用零成本的确定性方法解决「组合结构」问题,把「语义理解」问题留给别的手段。
诚实的容量上限
def snr_estimate(dim: int, n_items: int) -> float: ...
因为 bundle() 打包运算只能容纳约 √维度 项 —— 1024 维大约在 32 项之后就开始退化。这个函数估算「在给定维度下塞进 N 条记忆后的信噪比」。
把自己方案的容量上限写成一个可调用的函数暴露出来,是很成熟的做法。它承认了「这个方法有边界」,并且让使用者能测出边界在哪。
8.7 存储层与信任分
CREATE TABLE IF NOT EXISTS facts (...) -- 事实,带 trust_score 和 category
CREATE TABLE IF NOT EXISTS entities (...) -- 实体(人、项目、技术名词)
CREATE TABLE IF NOT EXISTS fact_entities (...) -- 事实↔实体 多对多关联
CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts -- ★ FTS5 全文索引
CREATE TABLE IF NOT EXISTS memory_banks (...) -- 按 category 聚合的 HRR 打包向量
hermes-agent/plugins/memory/holographic/store.py
class MemoryStore:
def add_fact(...)
def search_facts(...)
def update_fact(...)
def remove_fact(fact_id: int) -> bool
def list_facts(...)
def record_feedback(self, fact_id: int, helpful: bool) -> dict # ★ 反馈闭环
def _extract_entities(self, text: str) -> list[str]
def _resolve_entity(self, name: str) -> int
def _link_fact_entity(self, fact_id: int, entity_id: int) -> None
def _compute_hrr_vector(self, fact_id: int, content: str) -> None
def _rebuild_bank(self, category: str) -> None
def rebuild_all_vectors(self, dim: int | None = None) -> int
@classmethod
def release_all_under(cls, directory) -> int
def close(self) -> None
def __enter__ / __exit__ # 支持 with 语句
设想一个真实场景:智能体在第一次会话里误以为「这个项目用的是 npm」,把这条记忆存了下来。实际上项目用的是 pnpm。
如果没有信任分衰减机制,这条错误记忆会永久污染后续所有会话 —— 每次智能体都会先读到「这个项目用 npm」,然后执行 npm 命令,然后失败,然后困惑。
有了 record_feedback(fact_id, helpful):这条记忆被证明误导之后,信任分下降;而索引 idx_facts_trust ON facts(trust_score DESC) 保证了检索时高信任分的排前面。最终它沉底、不再被召回。
记忆系统必须有自我纠错的能力,否则它是负资产。
rebuild_all_vectors(dim) 的存在也值得注意:如果要调整向量维度(比如从 1024 提到 4096),需要重算全库。虽然 SHA-256 编码是确定性的、不受模型版本影响,但维度是一个参数 —— 改了还是要重算。这个函数把这件事变成一次显式的、可控的操作。
8.8 SQLite 状态层
除了记忆,还有一套更大的状态存储:
| 文件 | 大小 | 职责 |
|---|---|---|
hermes_state.py | 682 KB | 主状态存储 |
hermes_state_search.py | 116 KB | 全文检索(会话历史搜索) |
hermes_state_schema.py | 75 KB | 数据库结构定义与迁移 |
hermes_state_common.py | 37 KB | 共用逻辑 |
hermes_state_portability.py | 37 KB | 可移植性 —— 导出/导入,跨机器迁移 |
hermes_state_portability.py 的存在是「不绑定笔记本」这个主张的落地:你在本机试用,觉得不错,要迁到云服务器上 —— 会话历史、记忆、配置都得能整体搬过去。
而 session_search 这个工具(在核心工具清单里)让模型可以搜索自己过往的对话 —— 对应 README 里那句「searches its own past conversations」。它靠的就是 hermes_state_search.py 的 FTS5 索引。
8.9 三种记忆的分工总结
| 类型 | 存在哪 | 特征 |
|---|---|---|
| 指令性记忆 人格、规范、偏好 |
SOUL.md / USER.mdAGENTS.md / .hermes.md |
纯文本,全量加载进系统提示词。人类可读可编辑 |
| 事实性记忆 谁是谁、什么时候做了什么 |
SQLite facts 表+ HRR 向量 + FTS5 索引 |
需要检索。词法 + 向量混合,带信任分排序 |
| 过程性记忆 上次这个问题怎么解的 |
会话历史 + FTS5 索引 | 通过 session_search 工具由模型主动检索 |
这个三分法是这一章最值得带走的东西。
很多项目把所有记忆一股脑塞进向量数据库,结果是:用户改了偏好设置不能立刻生效(要等重新索引)、用户看不到自己的偏好被存成了什么、而且检索出来的偏好是片段化的。
指令性记忆不该用检索。它应该是纯文本、全量加载、人类可读可 review 的。
8 · The Memory System ★
This chapter covers how Hermes “remembers things across sessions.” It has three parts: a pluggable provider interface, a built-in holographic memory implementation, and a SQLite state layer.
8.1 The Memory Provider Interface
“Memory providers give the agent persistent recall across sessions. The MemoryManager enforces a one-external-provider limit to prevent tool schema bloat and conflicting memory backends.”
In plain terms: memory providers give the agent persistent recall across sessions. The MemoryManager enforces “only one external provider,” to prevent tool-schema bloat and memory backends that conflict with each other.
Two reasons behind the “only one” limit
| Reason | Explanation |
|---|---|
| Tool-schema bloat | Every memory provider can expose its own tools to the model (get_tool_schemas()). Install three and you get three sets of “search memory” and “write memory” tools — the model gets confused about which one to use, and every set eats resident tokens |
| Backend conflicts | Two providers each maintain their own model of “who the user is,” and the two may contradict each other. Which one do you write to? Which one do you believe when reading? There is no right answer |
So memory providers, like context engines, are a “mutually exclusive strategy” rather than a “stackable capability.” Chapter 9 develops this distinction.
Eight optional backends
plugins/memory/
├── holographic/ ★ built-in: HRR holographic memory (see 8.6)
├── honcho/ external service
├── hindsight/ external service
├── mem0/ external service
├── byterover/ external service
├── openviking/ external service
├── retaindb/ external service
├── supermemory/ external service
├── query_rewrite.py query rewriting (shared helper)
└── config_schema.py
8.2 Lifecycle and Hooks
"""
Lifecycle (called by MemoryManager, wired in run_agent.py):
initialize() — connect, create resources, warm up
system_prompt_block() — static text for the system prompt
prefetch(query) — background recall before each turn
sync_turn(user, asst) — async write after each turn
get_tool_schemas() — tool schemas to expose to the model
handle_tool_call() — dispatch a tool call
shutdown() — clean exit
Optional hooks (override to opt in):
on_turn_start(turn, message, **kwargs) — per-turn clock tick, with runtime context
on_session_end(messages) — extraction at session end
on_session_switch(new_session_id, **kwargs) — mid-process session ID rotation
on_pre_compress(messages) -> str — ★ extraction before context compaction
on_memory_write(action, target, content, metadata=None)
— mirror writes to the built-in memory
on_delegation(task, result, **kwargs) — parent-side observation of subagent work
backup_paths() -> list[str] — extra on-disk paths to include in backups
"""
hermes-agent/agent/memory_provider.py
Three hooks worth calling out
on_pre_compress(messages) -> str (pre-compaction extraction) is the single most important piece of the whole design:
on_delegation(task, result) (delegation observation) solves this problem: a subagent's work happens outside the parent's context (that is precisely the point of subagents; see chapter 10). But the subagent may have discovered facts worth remembering long-term. This hook lets the parent-side memory system observe the subtask's input and result.
backup_paths() (backup paths) is a very practical operational concern: the hermes backup command needs to know which extra on-disk locations a memory provider stores its data in before it can make a complete backup.
8.3 The Interface's Versioned Contract
# Version 1 is the historical, implicit contract every provider is already
# on: best-effort on_pre_compress() with the raw message list. Version 2 is
# the opt-in fail-closed checkpoint contract (normalized evidence handoff +
# strict-mode failure propagation).
PRE_COMPRESS_CHECKPOINT_API_VERSION = 2
In plain terms: version 1 is the historical, implicit contract that every existing provider is already on: get the raw message list, do on_pre_compress on a best-effort basis. Version 2 is the opt-in “fail-closed” checkpoint contract (a normalized evidence handoff plus failure propagation in strict mode).
| Version 1 (best-effort) | Version 2 (fail-closed) | |
|---|---|---|
| Input | The raw message list; the provider parses it itself | A normalized evidence handoff — the host tidies up the format first |
| When extraction fails | Silently carries on; compaction proceeds as usual → the data is simply lost |
The failure propagates upward; in strict mode it blocks compaction → better not to compact than to lose data |
Why this upgrade was necessary: version 1 carries a risk of silent data loss — memory extraction fails (network trouble, a service outage), but compaction runs anyway, so the original text of those messages and the extraction result vanish at the same time. And nobody ever notices.
Version 2 turns that into an explicit failure: either extraction succeeds and then you compact, or you don't compact at all.
Using a “version-number constant” rather than changing the interface outright is what keeps old providers running. A provider declares which version it supports, and the host picks its calling convention accordingly.
8.4 The Trivial-Prompt Filter
This is a small but very practical optimization:
# Prompts that carry no semantic signal — trivial acknowledgements, greetings,
# slash commands, empty input. Single source of truth shared by the core
# per-turn prefetch gate and provider-side classifiers so the two can never
# drift apart.
TRIVIAL_PROMPT_RE = re.compile(
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
r'hi|hey|hello|yo|sup|'
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)'
r'[\s!?.:;,"\'~…—–()\[\]{}<>*&^%$#@!+=` ]*$',
re.IGNORECASE,
)
def is_trivial_prompt(text: Optional[str]) -> bool:
"""Callers use this to skip memory-provider prefetch/injection on turns
that carry no semantic signal — saving a blocking network round-trip
and preventing stale user-model context from derailing one-word replies."""
if not text: return True
stripped = text.strip()
if not stripped: return True
if stripped.startswith("/"): return True # slash command
return bool(TRIVIAL_PROMPT_RE.match(stripped))
What it saves
When the user says something like “ok,” “thanks,” or “continue”:
- It saves one blocking network round-trip — retrieval from an external memory service goes over the network, typically a few hundred milliseconds
- It keeps a stale user model from derailing a one-word reply — the user just said “ok,” but you injected three paragraphs of their history into the context, and the model may inexplicably start talking about that material
The regex's anchoring design
“The alternation is anchored and may only be followed by whitespace or punctuation, so words that merely START with a trivial word ("k8s", "yolo", "note", "hindsight") do NOT match, while trailing-punctuation variants ("hi!", "hey.", "thanks :)", "done???") do.”
In plain terms: the alternation is anchored and may only be followed by whitespace or punctuation, so words that “merely begin with a trivial word” ("k8s", "yolo", "note", "hindsight") do not match, while variants with trailing punctuation ("hi!", "hey.", "thanks :)", "done???") do.
| Input | Verdict | Why |
|---|---|---|
ok / thanks :) / done??? | Trivial | The whole thing is one acknowledgement word plus punctuation |
k8s cluster setup | Not trivial | It starts with k, but what follows is a character, not punctuation |
note this down | Not trivial | Same idea: no is followed by te |
hindsight service | Not trivial | hi is followed by ndsight |
Note the “single source of truth” mentioned in the comment: this regex is shared by the core per-turn prefetch gate and the provider-side classifiers, “so the two can never drift apart.” If each kept its own copy, sooner or later you would get the inconsistency where “the core thinks it's trivial, the provider thinks it isn't.”
8.5 The Memory-Usage Indicator
INDICATOR_GLYPH = "🧠" # default glyph; providers can override with their own brand mark
# (Hindsight uses "👁️", for example)
@dataclass(frozen=True)
class RecallStatus:
"""Summary of what a provider's most recent prefetch injected this turn.
…so the agent can emit a deterministic, model-independent
"memory was used" indicator. ``count`` is the number of discrete
memories injected; ``0`` means content was injected but has no discrete
count (e.g. a synthesized reflect answer), which the indicator renders
generically rather than as "0 memories".
"""
provider_label: str
count: int
glyph: str = INDICATOR_GLYPH
The user needs to know, “did this answer draw on my history?” There are two ways to do it:
- Let the model say so itself — “based on our earlier conversation…” → unreliable. The model may forget to say it, or say it when no memory was used at all
- Have the system generate a deterministic indicator from “what was actually injected” → always accurate
The handling of the special case count == 0 is thoughtful too: some providers inject not “N discrete memories” but a single synthesized answer. Showing “0 memories” would mislead the user into thinking nothing was used, so it renders generically instead (say, “🧠 memory used” rather than “🧠 0 memories”).
8.6 The Built-in Holographic Memory
plugins/memory/holographic/, four files: holographic.py (the HRR math), store.py (SQLite storage), retrieval.py (retrieval), and __init__.py (the provider implementation).
What HRR is
“Holographic Reduced Representations (HRR) with phase encoding. HRRs are a vector symbolic architecture for encoding compositional structure into fixed-width distributed representations. This module uses phase vectors: each concept is a vector of angles in [0, 2π).”
In plain terms: Holographic Reduced Representations with phase encoding. HRR is a vector symbolic architecture for encoding “compositional structure” into fixed-width distributed representations. This module uses phase vectors: each concept is a vector of angles in the interval [0, 2π).
The two papers cited: Plate (1995) and Gayler (2004).
The three core operations
def bind(a, b): # bind = circular convolution = element-wise phase addition
return (a + b) % _TWO_PI
# Binds two concepts into one composite vector.
# The result resembles neither input (mathematically, "quasi-orthogonal")
def unbind(memory, key): # unbind = circular correlation = phase subtraction
return (memory - key) % _TWO_PI
# unbind(bind(a, b), a) ≈ b (up to superposition noise)
def bundle(*vectors): # bundle = superposition = circular mean of complex exponentials
complex_sum = np.sum([np.exp(1j * v) for v in vectors], axis=0)
return np.angle(complex_sum) % _TWO_PI
# The result resembles every input;
# holds O(√dim) items, degrades beyond that
def similarity(a, b): # similarity = phase cosine, range [-1, 1]
return float(np.mean(np.cos(a - b)))
hermes-agent/plugins/memory/holographic/holographic.py
You don't need the math to understand what these are for:
- Bind glues a “key” and a “value” into one vector — say, binding “the user's editor” to “Vim”
- Unbind is the inverse: given a key, it recovers the corresponding value
- Bundle squeezes many memories into one vector, so a single vector can stand for a whole category
The source also explains why phase encoding was chosen: “Phase encoding is numerically stable, avoids the magnitude collapse of traditional complex-number HRRs, and maps cleanly to cosine similarity.” (That is, it holds up numerically, sidesteps the magnitude collapse of traditional complex-number HRRs, and maps neatly onto cosine similarity.)
The most notable engineering decision: SHA-256 instead of random numbers
def encode_atom(word: str, dim: int = 1024) -> "np.ndarray":
"""Deterministic phase vector via SHA-256 counter blocks.
Uses hashlib (not numpy RNG) for cross-platform reproducibility.
Algorithm:
- Generate enough SHA-256 blocks by hashing f"{word}:{i}" for i=0,1,2,...
- Concatenate digests, interpret as uint16 values via struct.unpack
- Scale to [0, 2π): phases = values * (2π / 65536)
- Truncate to dim elements
"""
values_per_block = 16 # each SHA-256 digest is 32 bytes = 16 uint16 values
blocks_needed = math.ceil(dim / values_per_block)
uint16_values = []
for i in range(blocks_needed):
digest = hashlib.sha256(f"{word}:{i}".encode()).digest()
uint16_values.extend(struct.unpack("<16H", digest))
phases = np.array(uint16_values[:dim], dtype=np.float64) * (_TWO_PI / 65536.0)
return phases
The same word (say, "docker") encodes to exactly the same 1024-dimensional phase vector on any machine, any Python version, any process.
The upside:
· Vectors can be stored straight into a SQLite binary field
· They can be synced across machines
· It completely sidesteps the operational nightmare of “we swapped the embedding model, now recompute the entire database” — the biggest pain point of every memory scheme built on neural embeddings
The cost: it is bag-of-words-level symbolic composition with no semantic understanding whatsoever. The similarity between "docker" and "container" is close to 0, because they are two different strings and their hashes have nothing to do with each other.
So it must work alongside full-text search, not replace it. That is a clear-eyed positioning: use a zero-cost deterministic method to solve the “compositional structure” problem, and leave the “semantic understanding” problem to other means.
An honest capacity ceiling
def snr_estimate(dim: int, n_items: int) -> float: ...
Because the bundle() operation can only hold about √dim items — at 1024 dimensions, it starts degrading after roughly 32 items. This function estimates “the signal-to-noise ratio after packing N memories into a given number of dimensions.”
Exposing your own scheme's capacity ceiling as a callable function is a mature move. It admits that “this method has limits,” and lets users measure where those limits are.
8.7 The Storage Layer and Trust Scores
CREATE TABLE IF NOT EXISTS facts (...) -- facts, with trust_score and category
CREATE TABLE IF NOT EXISTS entities (...) -- entities (people, projects, technical terms)
CREATE TABLE IF NOT EXISTS fact_entities (...) -- fact↔entity many-to-many links
CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts -- ★ FTS5 full-text index
CREATE TABLE IF NOT EXISTS memory_banks (...) -- HRR bundle vectors aggregated per category
hermes-agent/plugins/memory/holographic/store.py
class MemoryStore:
def add_fact(...)
def search_facts(...)
def update_fact(...)
def remove_fact(fact_id: int) -> bool
def list_facts(...)
def record_feedback(self, fact_id: int, helpful: bool) -> dict # ★ feedback loop
def _extract_entities(self, text: str) -> list[str]
def _resolve_entity(self, name: str) -> int
def _link_fact_entity(self, fact_id: int, entity_id: int) -> None
def _compute_hrr_vector(self, fact_id: int, content: str) -> None
def _rebuild_bank(self, category: str) -> None
def rebuild_all_vectors(self, dim: int | None = None) -> int
@classmethod
def release_all_under(cls, directory) -> int
def close(self) -> None
def __enter__ / __exit__ # supports the with statement
Picture a real scenario: in its first session, the agent wrongly concludes “this project uses npm” and stores that as a memory. The project actually uses pnpm.
Without a trust-score decay mechanism, that one wrong memory permanently poisons every later session — each time, the agent first reads “this project uses npm,” runs an npm command, fails, and gets confused.
With record_feedback(fact_id, helpful): once the memory is shown to be misleading, its trust score drops; and the index idx_facts_trust ON facts(trust_score DESC) guarantees that high-trust facts sort first at retrieval time. Eventually it sinks to the bottom and stops being recalled.
A memory system must be able to correct itself; otherwise it is a liability.
The existence of rebuild_all_vectors(dim) is also worth noting: if you want to change the vector dimension (say, from 1024 up to 4096), the whole database has to be recomputed. SHA-256 encoding is deterministic and immune to model versions, but the dimension is a parameter — change it and you still recompute. This function turns that into a single explicit, controlled operation.
8.8 The SQLite State Layer
Beyond memory, there is a larger state store:
| File | Size | Responsibility |
|---|---|---|
hermes_state.py | 682 KB | Main state store |
hermes_state_search.py | 116 KB | Full-text search (searching session history) |
hermes_state_schema.py | 75 KB | Database schema definitions and migrations |
hermes_state_common.py | 37 KB | Shared logic |
hermes_state_portability.py | 37 KB | Portability — export/import, migrating between machines |
The existence of hermes_state_portability.py is where the “not tied to your laptop” claim becomes real: you try it on your local machine, like it, and want to move it to a cloud server — session history, memory, and configuration all have to move over as a whole.
And the session_search tool (on the core tool list) lets the model search its own past conversations — matching the README's line, “searches its own past conversations.” It relies on the FTS5 index in hermes_state_search.py.
8.9 Summing Up the Division of Labor Among Three Kinds of Memory
| Type | Where it lives | Characteristics |
|---|---|---|
| Instructional memory Persona, conventions, preferences |
SOUL.md / USER.mdAGENTS.md / .hermes.md |
Plain text, loaded in full into the system prompt. Human-readable and human-editable |
| Factual memory Who is who, what was done when |
The SQLite facts table+ HRR vectors + FTS5 index |
Needs retrieval. Hybrid lexical + vector search, ranked by trust score |
| Procedural memory How this problem was solved last time |
Session history + FTS5 index | Actively retrieved by the model through the session_search tool |
This three-way split is the most valuable thing to take away from this chapter.
Many projects dump every kind of memory into a vector database, and the result is: a user changes a preference and it doesn't take effect immediately (you wait for re-indexing), the user can't see what their preferences were stored as, and the preferences that come back from retrieval are fragmented.
Instructional memory should not go through retrieval. It should be plain text, loaded in full, and human-readable and reviewable.