upstash-hermes-memory 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ node_modules
2
+ dist
3
+ coverage
4
+ *.log
5
+ .DS_Store
6
+ .turbo
7
+ .vercel
8
+ .env
9
+ .env.*
10
+ !.env.example
11
+ *.tsbuildinfo
12
+ .vscode
13
+ .idea
14
+ .venv
15
+ __pycache__
16
+ *.egg-info
17
+ .pytest_cache
18
+ .ruff_cache
@@ -0,0 +1,7 @@
1
+ # upstash-hermes-memory
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6269e3b: First release: an Upstash Redis memory provider for Hermes Agent. It recalls related memories into each turn, saves every session as a searchable transcript, mirrors the built-in `memory` tool, and gives the model `upstash_memory_save` / `_search` / `_forget` and `upstash_chat_search`. It reads and writes the same keys and Redis Search indexes as `AgentMemory` and `ChatHistory` in `@upstash/agentkit-sdk`, so memories are shared with AgentKit apps on the same database.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: upstash-hermes-memory
3
+ Version: 0.1.0
4
+ Summary: Upstash Redis memory provider for Hermes Agent: long-term memory, searchable chat history and recall on Redis Search, with no separate vector database.
5
+ Project-URL: Homepage, https://github.com/upstash/agentkit
6
+ Project-URL: Repository, https://github.com/upstash/agentkit
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: upstash-redis>=1.8.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # upstash-hermes-memory
13
+
14
+ An Upstash Redis memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com), built on
15
+ the same Redis Search layout as `@upstash/agentkit-sdk`'s `AgentMemory` and `ChatHistory`. A memory
16
+ your AI SDK app saves is recalled by your Hermes agent, and the other way round.
17
+
18
+ ## What it does in Hermes
19
+
20
+ - **Recall:** before each turn, the memories closest to the user's message are added to the prompt.
21
+ - **Chat history:** after each turn, the session's transcript is saved as a searchable document.
22
+ - **Mirroring:** facts the built-in `memory` tool adds are also saved to Redis.
23
+ - **Tools:** `upstash_memory_save`, `upstash_memory_search`, `upstash_memory_forget` and
24
+ `upstash_chat_search` (search past conversations).
25
+
26
+ Subagents, cron jobs and context flushes can read memory but never write it.
27
+
28
+ ## Setup
29
+
30
+ ```bash
31
+ # into the Python environment Hermes runs in
32
+ pip install upstash-hermes-memory
33
+ ```
34
+
35
+ Then select it in Hermes's `config.yaml`:
36
+
37
+ ```yaml
38
+ memory:
39
+ provider: upstash
40
+ ```
41
+
42
+ And give it a database, in your environment or `~/.hermes/.env` (`hermes memory setup` asks for
43
+ these):
44
+
45
+ | Variable | |
46
+ | --- | --- |
47
+ | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | Required. From the [Upstash console](https://console.upstash.com/redis). |
48
+ | `UPSTASH_HERMES_USER_ID` | Whose memories these are. Defaults to the gateway user, then the Hermes profile name, then `default`. |
49
+ | `UPSTASH_HERMES_MEMORY_PREFIX`, `UPSTASH_HERMES_CHAT_PREFIX` | Key prefixes. Default `agentkit:memory` and `agentkit:chat`, the TS SDK's defaults, so the two share memories. |
50
+
51
+ ## Redis layout
52
+
53
+ | | Key | Index |
54
+ | --- | --- | --- |
55
+ | Memory | `agentkit:memory:<user>:<id>` → JSON `{text, userId, createdAt, ...metadata}` | `agentkit_memory` (`text`, `userId`) |
56
+ | Chat | `agentkit:chat:<user>:<session>` → JSON `{userId, sessionId, userMessages, modelMessages, messages, createdAt, updatedAt}` | `agentkit_chat` |
57
+
58
+ User and session ids are used as they are, so the same id reads the same keys from Hermes and the
59
+ TS SDK. Like the TS SDK, the provider refuses a user id with `:` (the key separator), and keeps no
60
+ transcript for a session id with one. Indexes are created on first use. `AgentMemory` and `ChatHistory` are importable on their own for
61
+ use outside Hermes.
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ uv sync --all-packages && pnpm py:lint && pnpm py:test # Redis tests need UPSTASH_REDIS_REST_* set
67
+ uv run python python/hermes-memory/e2e/hermes_e2e.py # a real Hermes session, needs OPENAI_API_KEY
68
+ ```
69
+
70
+ The e2e script installs the package into a temporary directory and runs Hermes with a temporary
71
+ `HERMES_HOME`, so your own Hermes install is not touched. Hermes's built-in memory is turned off
72
+ there. One session saves two facts; a fresh session started right after must name them without
73
+ calling any tool (checked in Hermes's `state.db`), so they can only come from the automatic recall;
74
+ and a control session as another user must not know them. The script then checks the keys and
75
+ transcripts in Redis (and, if `packages/sdk` is built, that the TS `AgentMemory` recalls them too)
76
+ before cleaning up.
@@ -0,0 +1,65 @@
1
+ # upstash-hermes-memory
2
+
3
+ An Upstash Redis memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com), built on
4
+ the same Redis Search layout as `@upstash/agentkit-sdk`'s `AgentMemory` and `ChatHistory`. A memory
5
+ your AI SDK app saves is recalled by your Hermes agent, and the other way round.
6
+
7
+ ## What it does in Hermes
8
+
9
+ - **Recall:** before each turn, the memories closest to the user's message are added to the prompt.
10
+ - **Chat history:** after each turn, the session's transcript is saved as a searchable document.
11
+ - **Mirroring:** facts the built-in `memory` tool adds are also saved to Redis.
12
+ - **Tools:** `upstash_memory_save`, `upstash_memory_search`, `upstash_memory_forget` and
13
+ `upstash_chat_search` (search past conversations).
14
+
15
+ Subagents, cron jobs and context flushes can read memory but never write it.
16
+
17
+ ## Setup
18
+
19
+ ```bash
20
+ # into the Python environment Hermes runs in
21
+ pip install upstash-hermes-memory
22
+ ```
23
+
24
+ Then select it in Hermes's `config.yaml`:
25
+
26
+ ```yaml
27
+ memory:
28
+ provider: upstash
29
+ ```
30
+
31
+ And give it a database, in your environment or `~/.hermes/.env` (`hermes memory setup` asks for
32
+ these):
33
+
34
+ | Variable | |
35
+ | --- | --- |
36
+ | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | Required. From the [Upstash console](https://console.upstash.com/redis). |
37
+ | `UPSTASH_HERMES_USER_ID` | Whose memories these are. Defaults to the gateway user, then the Hermes profile name, then `default`. |
38
+ | `UPSTASH_HERMES_MEMORY_PREFIX`, `UPSTASH_HERMES_CHAT_PREFIX` | Key prefixes. Default `agentkit:memory` and `agentkit:chat`, the TS SDK's defaults, so the two share memories. |
39
+
40
+ ## Redis layout
41
+
42
+ | | Key | Index |
43
+ | --- | --- | --- |
44
+ | Memory | `agentkit:memory:<user>:<id>` → JSON `{text, userId, createdAt, ...metadata}` | `agentkit_memory` (`text`, `userId`) |
45
+ | Chat | `agentkit:chat:<user>:<session>` → JSON `{userId, sessionId, userMessages, modelMessages, messages, createdAt, updatedAt}` | `agentkit_chat` |
46
+
47
+ User and session ids are used as they are, so the same id reads the same keys from Hermes and the
48
+ TS SDK. Like the TS SDK, the provider refuses a user id with `:` (the key separator), and keeps no
49
+ transcript for a session id with one. Indexes are created on first use. `AgentMemory` and `ChatHistory` are importable on their own for
50
+ use outside Hermes.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ uv sync --all-packages && pnpm py:lint && pnpm py:test # Redis tests need UPSTASH_REDIS_REST_* set
56
+ uv run python python/hermes-memory/e2e/hermes_e2e.py # a real Hermes session, needs OPENAI_API_KEY
57
+ ```
58
+
59
+ The e2e script installs the package into a temporary directory and runs Hermes with a temporary
60
+ `HERMES_HOME`, so your own Hermes install is not touched. Hermes's built-in memory is turned off
61
+ there. One session saves two facts; a fresh session started right after must name them without
62
+ calling any tool (checked in Hermes's `state.db`), so they can only come from the automatic recall;
63
+ and a control session as another user must not know them. The script then checks the keys and
64
+ transcripts in Redis (and, if `packages/sdk` is built, that the TS `AgentMemory` recalls them too)
65
+ before cleaning up.
@@ -0,0 +1,296 @@
1
+ """End-to-end check of the provider inside a real Hermes Agent, against a real Upstash Redis.
2
+
3
+ uv run python python/hermes-memory/e2e/hermes_e2e.py [--keep] [--model gpt-5-mini]
4
+
5
+ What it does:
6
+ 1. Builds the wheel and installs it (plus upstash-redis) into a temporary directory that is put on
7
+ Hermes's PYTHONPATH. Hermes's own install and ~/.hermes are not touched: Hermes runs with a
8
+ temporary HERMES_HOME whose config.yaml selects `memory.provider: upstash`.
9
+ 2. Session 1 asks the agent to remember two facts. Session 2, a fresh session started right away,
10
+ asks for them back. Hermes's built-in memory is off, and session 2 must answer without calling
11
+ a tool (read from Hermes's state.db), so the facts can only come from the provider's automatic
12
+ recall. A control session as another user must not know them.
13
+ 3. Checks Redis: the memories and both transcripts landed under a unique prefix, in the documents
14
+ and indexes the TS SDK uses. If packages/sdk is built, the TS AgentMemory recalls the same
15
+ memory too.
16
+ 4. Drops the e2e indexes and deletes the keys (unless --keep).
17
+
18
+ Needs: Hermes installed (`hermes` on PATH), UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN and
19
+ OPENAI_API_KEY in the environment or the repo's .env.
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import shutil
26
+ import sqlite3
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ import uuid
32
+ from pathlib import Path
33
+
34
+ REPO = Path(__file__).resolve().parents[3]
35
+ PACKAGE = REPO / "python" / "hermes-memory"
36
+ USER_ID = "e2e-user"
37
+ FACT_PROMPT = (
38
+ "Please remember this for future conversations: my cat is called Pamuk and my favourite "
39
+ "programming language is Elixir. Save it to your long-term memory."
40
+ )
41
+ RECALL_PROMPT = (
42
+ "What is my cat called, and what is my favourite programming language? "
43
+ "Answer from what you remember about me, in one sentence."
44
+ )
45
+
46
+
47
+ def load_dotenv(path: Path) -> None:
48
+ if not path.exists():
49
+ return
50
+ for line in path.read_text().splitlines():
51
+ if "=" in line and not line.lstrip().startswith("#"):
52
+ key, value = line.split("=", 1)
53
+ os.environ.setdefault(key.strip(), value.strip().strip('"'))
54
+
55
+
56
+ def hermes_python() -> list[str]:
57
+ """Hermes's own interpreter and entry script. Its launcher unsets PYTHONPATH, so skip it."""
58
+ launcher = shutil.which("hermes")
59
+ if not launcher:
60
+ sys.exit("hermes is not on PATH: install Hermes Agent first")
61
+ home = Path(os.environ.get("HERMES_INSTALL_DIR", Path.home() / ".hermes" / "hermes-agent"))
62
+ python, script = home / "venv" / "bin" / "python", home / "hermes"
63
+ if not (python.exists() and script.exists()):
64
+ sys.exit(f"cannot find Hermes's venv under {home} (set HERMES_INSTALL_DIR)")
65
+ return [str(python), str(script)]
66
+
67
+
68
+ def step(title: str) -> None:
69
+ print(f"\n== {title} ==", flush=True)
70
+
71
+
72
+ def main() -> None:
73
+ parser = argparse.ArgumentParser()
74
+ parser.add_argument("--model", default="gpt-5-mini")
75
+ parser.add_argument("--keep", action="store_true", help="leave the keys and indexes in Redis")
76
+ args = parser.parse_args()
77
+
78
+ load_dotenv(REPO / ".env")
79
+ for name in ("UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN", "OPENAI_API_KEY"):
80
+ if not os.environ.get(name):
81
+ sys.exit(f"{name} is not set")
82
+
83
+ from upstash_hermes_memory import AgentMemory, ChatHistory
84
+ from upstash_redis import Redis
85
+
86
+ run = uuid.uuid4().hex[:8]
87
+ memory_prefix, chat_prefix = f"e2e:hermes:{run}:memory", f"e2e:hermes:{run}:chat"
88
+ redis = Redis.from_env()
89
+ memory = AgentMemory(redis, prefix=memory_prefix, enable_telemetry=False)
90
+ chats = ChatHistory(redis, prefix=chat_prefix, enable_telemetry=False)
91
+ work = Path(tempfile.mkdtemp(prefix="hermes-e2e-"))
92
+ failures: list[str] = []
93
+
94
+ def check(ok: bool, what: str) -> None:
95
+ print(f" {'ok ' if ok else 'FAIL'} {what}")
96
+ if not ok:
97
+ failures.append(what)
98
+
99
+ try:
100
+ step("install the provider next to Hermes (temporary)")
101
+ dist, site, home = work / "dist", work / "site", work / "home"
102
+ subprocess.run(
103
+ ["uv", "build", "--wheel", "--package", "upstash-hermes-memory", "--out-dir", dist],
104
+ cwd=REPO,
105
+ check=True,
106
+ capture_output=True,
107
+ )
108
+ (wheel,) = dist.glob("*.whl")
109
+ # --no-deps: httpx and friends come from Hermes's venv, only upstash-redis is missing there.
110
+ subprocess.run(
111
+ [
112
+ "uv",
113
+ "pip",
114
+ "install",
115
+ "--quiet",
116
+ "--no-deps",
117
+ "--target",
118
+ site,
119
+ wheel,
120
+ "upstash-redis==1.8.0",
121
+ ],
122
+ check=True,
123
+ )
124
+ home.mkdir()
125
+ # Hermes's own stores (MEMORY.md / USER.md) default to on. Off, they cannot carry the
126
+ # facts from session 1 to session 2: the provider is the only way across.
127
+ (home / "config.yaml").write_text(
128
+ f"model:\n provider: openai-api\n default: {args.model}\n"
129
+ "memory:\n provider: upstash\n memory_enabled: false\n user_profile_enabled: false\n"
130
+ )
131
+ env = {
132
+ **os.environ,
133
+ "HERMES_HOME": str(home),
134
+ "PYTHONPATH": str(site),
135
+ "UPSTASH_HERMES_MEMORY_PREFIX": memory_prefix,
136
+ "UPSTASH_HERMES_CHAT_PREFIX": chat_prefix,
137
+ }
138
+ print(f" HERMES_HOME={home}\n prefixes {memory_prefix} / {chat_prefix}")
139
+
140
+ def hermes(
141
+ prompt: str, user: str = USER_ID, hermes_home: Path = home
142
+ ) -> tuple[str, list[str]]:
143
+ """Run one fresh one-shot session; return its answer and the tools it called."""
144
+ cmd = [*hermes_python(), "-z", prompt, "--provider", "openai-api", "-m", args.model]
145
+ done = subprocess.run(
146
+ cmd,
147
+ check=False,
148
+ env={**env, "HERMES_HOME": str(hermes_home), "UPSTASH_HERMES_USER_ID": user},
149
+ capture_output=True,
150
+ text=True,
151
+ timeout=300,
152
+ )
153
+ if done.returncode != 0:
154
+ print(done.stdout, done.stderr, sep="\n")
155
+ raise SystemExit(f"hermes exited with {done.returncode}")
156
+ # Hermes records every session in $HERMES_HOME/state.db, tool results included.
157
+ with sqlite3.connect(hermes_home / "state.db") as db:
158
+ (session,) = db.execute(
159
+ "SELECT session_id FROM messages WHERE role = 'user' AND content = ?"
160
+ " ORDER BY id DESC LIMIT 1",
161
+ (prompt,),
162
+ ).fetchone()
163
+ tools = [
164
+ row[0]
165
+ for row in db.execute(
166
+ "SELECT tool_name FROM messages WHERE session_id = ? AND role = 'tool'"
167
+ " ORDER BY id",
168
+ (session,),
169
+ )
170
+ ]
171
+ answer = done.stdout.strip()
172
+ print(" < " + answer.replace("\n", "\n "))
173
+ print(f" tools called: {tools or 'none'}")
174
+ return answer, tools
175
+
176
+ # The sessions run back to back, with no waiting in between: a fact the agent saved must
177
+ # be recallable as soon as the save tool returns.
178
+ step("session 1: tell the agent two facts")
179
+ print(" > " + FACT_PROMPT)
180
+ _, tools_1 = hermes(FACT_PROMPT)
181
+ check(
182
+ tools_1.count("upstash_memory_save") >= 1, "session 1 saved through upstash_memory_save"
183
+ )
184
+ check("memory" not in tools_1, "session 1 did not use Hermes's built-in memory tool")
185
+
186
+ step("session 2: a fresh session asks for them back")
187
+ print(" > " + RECALL_PROMPT)
188
+ answer, tools_2 = hermes(RECALL_PROMPT)
189
+ check("Pamuk" in answer, "the answer names the cat (Pamuk)")
190
+ check("Elixir" in answer, "the answer names the language (Elixir)")
191
+ # With no tool calls, the facts can only have come from the automatic recall (prefetch).
192
+ check(tools_2 == [], "session 2 called no tools: the facts came from automatic recall")
193
+
194
+ step("control: the same question as a user with no memories")
195
+ print(" > " + RECALL_PROMPT)
196
+ # Its own HERMES_HOME: Hermes's built-in session_search reads the sessions in state.db,
197
+ # whoever they belonged to, so a shared home would let it find sessions 1 and 2. This
198
+ # checks the Redis side only, which is what the provider controls.
199
+ control_home = work / "control-home"
200
+ control_home.mkdir()
201
+ shutil.copy(home / "config.yaml", control_home / "config.yaml")
202
+ control, _ = hermes(RECALL_PROMPT, user=f"{USER_ID}-control", hermes_home=control_home)
203
+ check("Pamuk" not in control, "another user's session does not know the cat's name")
204
+
205
+ step("Hermes's built-in stores stayed empty")
206
+ builtin = [
207
+ p for p in (home / "memories").glob("*") if p.is_file() and p.read_text().strip()
208
+ ]
209
+ print(f" {home / 'memories'}: {[p.name for p in builtin] or 'empty'}")
210
+ check(not builtin, "MEMORY.md / USER.md hold nothing")
211
+
212
+ step("Redis: memories")
213
+ memory.index.wait_indexing()
214
+ saved = memory.list(user_id=USER_ID)
215
+ for record in saved:
216
+ print(
217
+ f" memory {memory_prefix}:{USER_ID}:{record.id} -> {record.text!r} {record.metadata}"
218
+ )
219
+ text = " ".join(r.text for r in saved)
220
+ check("Pamuk" in text, "a memory mentioning Pamuk was saved")
221
+ check("Elixir" in text, "a memory mentioning Elixir was saved")
222
+
223
+ step("Redis: chat history")
224
+ chats.index.wait_indexing()
225
+ sessions = chats.list_chats(user_id=USER_ID)
226
+ for doc in sessions:
227
+ roles = [m["role"] for m in doc["messages"]]
228
+ print(f" chat {chat_prefix}:{USER_ID}:{doc['sessionId']} messages={roles}")
229
+ check(len(sessions) == 2, "both sessions were saved as transcripts")
230
+ check(
231
+ any("Pamuk" in d.get("userMessages", "") for d in sessions),
232
+ "session 1's transcript holds the user's message",
233
+ )
234
+ hits = chats.search_chats("cat Pamuk", user_id=USER_ID)
235
+ check(bool(hits), "the transcripts are searchable (search_chats 'cat Pamuk')")
236
+
237
+ step("Redis: layout shared with the TS SDK")
238
+ key = f"{memory_prefix}:{USER_ID}:{saved[0].id}" if saved else ""
239
+ doc = redis.execute(["JSON.GET", key]) if key else None
240
+ print(f" JSON.GET {key} -> {json.dumps(doc)}")
241
+ check(
242
+ isinstance(doc, dict) and {"text", "userId", "createdAt"} <= set(doc),
243
+ "memory documents have the AgentMemory shape",
244
+ )
245
+ ts_dist = REPO / "packages" / "sdk" / "dist" / "index.js"
246
+ if ts_dist.exists() and shutil.which("node"):
247
+ script = (
248
+ "const { AgentMemory } = await import(process.argv[1]);"
249
+ "const { Redis } = await import('@upstash/redis');"
250
+ "const m = new AgentMemory({ redis: Redis.fromEnv(), prefix: process.argv[2],"
251
+ " enableTelemetry: false });"
252
+ "const hits = await m.recall({ query: 'cat Pamuk', userId: process.argv[3] });"
253
+ "console.log(JSON.stringify(hits.map((h) => h.text)));"
254
+ )
255
+ out = subprocess.run(
256
+ ["node", "--input-type=module", "-e", script, str(ts_dist), memory_prefix, USER_ID],
257
+ check=False,
258
+ cwd=REPO / "packages" / "sdk",
259
+ capture_output=True,
260
+ text=True,
261
+ )
262
+ print(f" TS AgentMemory.recall -> {out.stdout.strip() or out.stderr.strip()[:300]}")
263
+ check("Pamuk" in out.stdout, "the TS SDK recalls the memory Hermes saved")
264
+ else:
265
+ print(" (skipped: build packages/sdk to cross-check with the TS SDK)")
266
+ finally:
267
+ if args.keep:
268
+ print(
269
+ f"\n--keep: left {memory_prefix}* and {chat_prefix}* in Redis, HERMES_HOME {work}"
270
+ )
271
+ else:
272
+ for index in (memory.index, chats.index):
273
+ try:
274
+ index.drop()
275
+ except Exception as error: # noqa: BLE001 - cleanup is best effort
276
+ print(f" could not drop {index.name}: {error}")
277
+ for prefix in (memory_prefix, chat_prefix):
278
+ cursor = 0
279
+ while True:
280
+ cursor, keys = redis.scan(cursor, match=f"{prefix}*", count=200)
281
+ if keys:
282
+ redis.delete(*keys)
283
+ if int(cursor) == 0:
284
+ break
285
+ shutil.rmtree(work, ignore_errors=True)
286
+
287
+ print(f"\n{'E2E PASSED' if not failures else 'E2E FAILED: ' + '; '.join(failures)}")
288
+ sys.exit(1 if failures else 0)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ start = time.monotonic()
293
+ try:
294
+ main()
295
+ finally:
296
+ print(f"({time.monotonic() - start:.0f}s)")
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "upstash-hermes-memory",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Version carrier for the upstash-hermes-memory PyPI package. Changesets bumps it; scripts/sync-version.mjs copies it into pyproject.toml."
6
+ }
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "upstash-hermes-memory"
3
+ version = "0.1.0"
4
+ description = "Upstash Redis memory provider for Hermes Agent: long-term memory, searchable chat history and recall on Redis Search, with no separate vector database."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = ["upstash-redis>=1.8.0"]
9
+
10
+ # Hermes discovers pip-installed memory providers through this group; the entry-point name is the
11
+ # value of `memory.provider` in Hermes's config.yaml.
12
+ [project.entry-points."hermes_agent.memory_providers"]
13
+ upstash = "upstash_hermes_memory:register"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/upstash/agentkit"
17
+ Repository = "https://github.com/upstash/agentkit"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
@@ -0,0 +1,27 @@
1
+ """Upstash Redis memory provider for Hermes Agent.
2
+
3
+ Hermes stopped taking new memory providers in its own repository: they ship as standalone plugins
4
+ and are listed in its plugin catalog. This package is that plugin, and it lives in the AgentKit
5
+ monorepo rather than in a repository of its own so the Python port reads and writes exactly the
6
+ keys and Redis Search indexes the TypeScript SDK does. A memory saved from an AI SDK app is then
7
+ recalled by a Hermes agent on the same database.
8
+ """
9
+
10
+ from ._version import VERSION
11
+ from .chat_history import ChatHistory
12
+ from .memory import AgentMemory, MemoryRecord
13
+ from .provider import UpstashMemoryProvider
14
+
15
+ __all__ = [
16
+ "VERSION",
17
+ "AgentMemory",
18
+ "ChatHistory",
19
+ "MemoryRecord",
20
+ "UpstashMemoryProvider",
21
+ "register",
22
+ ]
23
+
24
+
25
+ def register(ctx) -> None:
26
+ """Hermes plugin entry point (`hermes_agent.memory_providers`)."""
27
+ ctx.register_memory_provider(UpstashMemoryProvider())
@@ -0,0 +1,91 @@
1
+ """A Redis Search index that is created on first use, shared by AgentMemory and ChatHistory.
2
+
3
+ The commands are the ones `ReactiveSearchIndex` sends from the TS SDK, argument for argument, so
4
+ an index created from either language is the same index.
5
+ """
6
+
7
+ import json
8
+ import threading
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ # (field, type, no_tokenize)
13
+ Field = tuple[str, str, bool]
14
+
15
+
16
+ def index_name_for(prefix: str) -> str:
17
+ """The TS default: every character outside [a-zA-Z0-9_] becomes `_`."""
18
+ return "".join(c if c.isascii() and (c.isalnum() or c == "_") else "_" for c in prefix)
19
+
20
+
21
+ def dumps(value: Any) -> str:
22
+ """JSON the way JSON.stringify writes it, so stored documents match the TS SDK's."""
23
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
24
+
25
+
26
+ class SearchIndex:
27
+ def __init__(self, redis: Any, *, name: str, prefix: str, schema: Sequence[Field]):
28
+ self.redis = redis
29
+ self.name = name
30
+ self.prefix = prefix
31
+ self.schema = list(schema)
32
+ self._ready = False
33
+ self._lock = threading.Lock()
34
+
35
+ def create_command(self) -> list[str]:
36
+ cmd = ["SEARCH.CREATE", self.name, "EXISTSOK", "ON", "JSON", "PREFIX", "1", self.prefix]
37
+ cmd.append("SCHEMA")
38
+ for field, type_, no_tokenize in self.schema:
39
+ cmd += [field, type_] + (["NOTOKENIZE"] if no_tokenize else [])
40
+ return cmd
41
+
42
+ def ensure(self) -> None:
43
+ """Create the index if it does not exist yet. A no-op after the first call."""
44
+ if self._ready:
45
+ return
46
+ with self._lock:
47
+ if self._ready:
48
+ return
49
+ self.redis.execute(self.create_command())
50
+ self.redis.execute(["SEARCH.WAITINDEXING", self.name])
51
+ self._ready = True
52
+
53
+ def query(self, filter: dict[str, Any], limit: int | None = None) -> list[Any]:
54
+ self.ensure()
55
+ rows = self._query(filter, limit)
56
+ if not rows and self._raw_count(filter) < 0:
57
+ # The Python client turns a missing index into [], so an empty result is ambiguous.
58
+ # COUNT says -1 when the index is gone (another client dropped it): recreate, retry once.
59
+ self._ready = False
60
+ self.ensure()
61
+ rows = self._query(filter, limit)
62
+ return rows
63
+
64
+ def count(self, filter: dict[str, Any]) -> int:
65
+ self.ensure()
66
+ count = self._raw_count(filter)
67
+ if count < 0:
68
+ # -1 means the index is gone (dropped after we created it): recreate it and retry once.
69
+ self._ready = False
70
+ self.ensure()
71
+ count = self._raw_count(filter)
72
+ return max(count, 0)
73
+
74
+ def _query(self, filter: dict[str, Any], limit: int | None) -> list[Any]:
75
+ cmd = ["SEARCH.QUERY", self.name, dumps(filter)]
76
+ if limit is not None:
77
+ cmd += ["LIMIT", str(limit)]
78
+ return list(self.redis.execute(cmd) or [])
79
+
80
+ def _raw_count(self, filter: dict[str, Any]) -> int:
81
+ result = self.redis.execute(["SEARCH.COUNT", self.name, dumps(filter)])
82
+ return int(getattr(result, "count", result))
83
+
84
+ def wait_indexing(self) -> None:
85
+ """Block until every write so far is searchable (and every delete has left the index)."""
86
+ self.ensure()
87
+ self.redis.execute(["SEARCH.WAITINDEXING", self.name])
88
+
89
+ def drop(self) -> None:
90
+ self.redis.execute(["SEARCH.DROP", self.name])
91
+ self._ready = False
@@ -0,0 +1,27 @@
1
+ """Tags the redis client's telemetry header, the Python counterpart of the TS SDK's addTelemetry."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from ._version import VERSION
7
+
8
+ SDK_TELEMETRY = f"upstash-hermes-memory@{VERSION}"
9
+
10
+ _HEADER = "Upstash-Telemetry-Sdk"
11
+
12
+
13
+ def add_telemetry(redis: Any, *, enabled: bool = True) -> None:
14
+ """Append this package's tag to the `Upstash-Telemetry-Sdk` header, once per client.
15
+
16
+ upstash-redis only sends the header when the client allows telemetry, so a client created
17
+ with `allow_telemetry=False` stays untagged, as does any process with
18
+ `UPSTASH_DISABLE_TELEMETRY` set.
19
+ """
20
+ if not enabled or os.environ.get("UPSTASH_DISABLE_TELEMETRY"):
21
+ return
22
+ headers = getattr(redis, "_headers", None)
23
+ if not isinstance(headers, dict) or _HEADER not in headers:
24
+ return
25
+ tags = headers[_HEADER].split(",")
26
+ if SDK_TELEMETRY not in tags:
27
+ headers[_HEADER] = ",".join([*tags, SDK_TELEMETRY])
@@ -0,0 +1,2 @@
1
+ # Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand.
2
+ VERSION = "0.1.0"