feat(integrations): add Hermes provider with shared memory utilities

This commit is contained in:
kartik-mem0
2026-09-18 13:21:43 +05:30
parent 84bf468176
commit 4284ef0d2a
40 changed files with 4168 additions and 954 deletions
+9 -5
View File
@@ -18,10 +18,11 @@ integrations/
├── cursor-plugin/ # Native Cursor package and adapter
├── codex-plugin/ # Native Codex package and adapter
├── kimi-plugin/ # Native Kimi package and adapter
└── antigravity-plugin/ # Native Antigravity package and adapter
├── antigravity-plugin/ # Native Antigravity package and adapter
└── hermes-plugin/ # Native Hermes provider; shared message helpers only
```
Each native directory owns only its manifest, native hooks or adapter, tests, and `plugin-build.json`. Its `core/` and `skills/` directories are generated from this module. They are committed because clients install a self-contained plugin directory and the Agent Plugins specification forbids package files from resolving outside the plugin root.
Each native directory owns its manifest, native hooks or adapter, tests, and `plugin-build.json`. Hermes also retains its upstream setup wizard and backend adapters. Its `core/` and `skills/` directories are generated from this module. They are committed because clients install a self-contained plugin directory and the Agent Plugins specification forbids package files from resolving outside the plugin root.
Sidekick belongs only to Claude Code. Its agent definition is in `claude-code-plugin/agents/sidekick.md`; its hooks are in `claude-code-plugin/adapters/claude/hook.py`. Other plugins must not register Sidekick. The shared core handles memory and native subagent tracking for Claude Code and Codex.
@@ -29,7 +30,7 @@ TypeScript integrations (`openclaw`, `opencode-plugin`, `pi-agent-plugin`, and `
## Shared memory behavior
The six Python packages use the same `search_memories` MCP tool and six skill templates. Native hooks collect conversations and flush them to Mem0 in the background. The portable package uses the Agent Plugins v1 layout so compatible hosts can load its MCP server and skills. It has no lifecycle hooks or flush worker; its bundled `remember` skill assumes automatic capture and cannot save a memory on its own.
The six MCP-based Python packages use the same `search_memories` MCP tool and six skill templates. Native hooks collect conversations and flush them to Mem0 in the background. The portable package uses the Agent Plugins v1 layout so compatible hosts can load its MCP server and skills. It has no lifecycle hooks or flush worker; its bundled `remember` skill assumes automatic capture and cannot save a memory on its own.
Python search accepts `query`, `top_k`, `category`, `scope`, and optional `run_id`:
@@ -49,6 +50,8 @@ TypeScript hosts reuse redaction and lifecycle utilities but retain their own to
For installation, follow the host guides: [Claude Code](../../docs/integrations/claude-code.mdx), [Cursor](../../docs/integrations/cursor.mdx), [Codex](../../docs/integrations/codex.mdx), [Kimi](../../docs/integrations/kimi.mdx), and [Antigravity](../../docs/integrations/antigravity.mdx).
Hermes bundles only `python/message_utils.py`, sharing the same secret redaction and lossless token batching. It retains Hermes user-wide recall, four native tools, three backend modes, and legacy configuration. Completed turns are queued in memory, with session `run_id` on writes; network failures are logged and are not durably retried. Explicit `sync_max_chars` values split text instead of truncating it. It does not install the generic MCP skills, repository scopes, or Sidekick.
## Build and verify
From the repository root:
@@ -58,7 +61,7 @@ python3.11 -m venv /tmp/mem0-agent-plugins
/tmp/mem0-agent-plugins/bin/pip install \
-r integrations/agent-plugin-core/requirements-dev.txt
for host in claude-code cursor codex kimi antigravity; do
for host in claude-code cursor codex kimi antigravity hermes; do
/tmp/mem0-agent-plugins/bin/python \
integrations/agent-plugin-core/build/build.py "$host" \
--kind native --check
@@ -99,7 +102,7 @@ Do not put a real key in source files, command history shared with others, or pu
For another native Python host:
1. Add `integrations/<host>-plugin/` with its native manifest and the smallest adapter that translates host events.
2. Add `plugin-build.json` declaring the plugin-root variable and runtime files.
2. Add `plugin-build.json` declaring the plugin-root variable and runtime files. Native providers can select `native.pythonFiles` and set `native.skills: false` when they expose host-native tools instead of MCP.
3. Add one adapter contract test.
4. Register the host in `build/build.py` and `conformance/run.py`.
5. Run `--sync`, `--check`, and the conformance command above.
@@ -117,6 +120,7 @@ For a TypeScript host, import the shared lifecycle modules directly and keep onl
| Codex | Native prompt and final-response fields | Structured failure indicators when present; otherwise unknown | Parent context; native agent ID |
| Kimi | Prompt hooks and completed v2 wire output | Native success/failure hooks | No plugin subagent hooks or agent declaration |
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | No plugin subagent hooks or agent declaration |
| Hermes | Completed turns via `sync_turn`, split without truncation | No separate tool-evidence capture | No plugin subagent hooks or agent declaration |
| Portable v1 | Explicit memory skills | No native lifecycle hooks | No native subagent declaration |
Python status uses `subagent_runs` and `last_subagent`. Legacy SQLite names and event handling keep existing records and running workers compatible.
+24 -5
View File
@@ -25,6 +25,7 @@ NATIVE_PLUGINS = {
"codex": INTEGRATIONS_ROOT / "codex-plugin",
"kimi": INTEGRATIONS_ROOT / "kimi-plugin",
"antigravity": INTEGRATIONS_ROOT / "antigravity-plugin",
"hermes": INTEGRATIONS_ROOT / "hermes-plugin",
}
PROTECTED_OUTPUTS = {
REPOSITORY_ROOT,
@@ -88,13 +89,26 @@ def _bundle_python(
*,
plugin_data: str = "",
portable: bool = False,
python_files: list[str] | None = None,
skills: bool = True,
) -> None:
core = staged / "core"
core.mkdir()
for source in sorted((CORE_ROOT / "python").glob("*.py")):
if portable and source.name in {"flush_worker.py", "hook_runner.py"}:
continue
shutil.copy2(source, core / source.name)
if python_files is None:
python_files = sorted(source.name for source in (CORE_ROOT / "python").glob("*.py"))
if not isinstance(python_files, list) or any(
not isinstance(name, str) or Path(name).name != name or Path(name).suffix != ".py" for name in python_files
):
raise ValueError("pythonFiles must be a list of Python source filenames")
_copy_declared_files(
core,
CORE_ROOT / "python",
{name: name for name in python_files if not portable or name not in {"flush_worker.py", "hook_runner.py"}},
)
if not isinstance(skills, bool):
raise ValueError("native skills must be a boolean")
if not skills:
return
values = {
"PLUGIN_ROOT": plugin_root,
@@ -150,6 +164,8 @@ def _build_native(host: str, source_root: Path, staged: Path, descriptor: dict)
host,
native["pluginRoot"],
plugin_data=str(native.get("pluginData") or ""),
python_files=native.get("pythonFiles"),
skills=native.get("skills", True),
)
_copy_declared_files(staged, source_root, native.get("files", {}))
@@ -221,7 +237,10 @@ def sync_generated(host: str, kind: str) -> Path:
with tempfile.TemporaryDirectory(prefix=f"mem0-sync-{host}-") as temporary:
generated = build(host, kind, Path(temporary) / "bundle")
for directory in ("core", "skills"):
replace_output(generated / directory, target / directory)
if (generated / directory).is_dir():
replace_output(generated / directory, target / directory)
elif (target / directory).exists():
shutil.rmtree(target / directory)
return target
@@ -13,10 +13,9 @@ import time
from pathlib import Path
from typing import Any
CORE_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = CORE_ROOT.parents[1]
PYTHON_HOSTS = ("claude-code", "cursor", "codex", "kimi", "antigravity")
PYTHON_HOSTS = ("claude-code", "cursor", "codex", "kimi", "antigravity", "hermes")
GROUPS = (
"python-bundles",
"python-tests",
@@ -30,9 +29,14 @@ LIVE_GROUP = "live-platform"
sys.path.insert(0, str(CORE_ROOT))
sys.path.insert(0, str(CORE_ROOT / "python"))
from memory_core import redact # noqa: E402
from build.build import build # noqa: E402
from conformance.artifacts import TYPESCRIPT_ARTIFACTS, verify_artifact as _typescript_artifact_check # noqa: E402
from conformance.artifacts import ( # noqa: E402
TYPESCRIPT_ARTIFACTS,
)
from conformance.artifacts import ( # noqa: E402
verify_artifact as _typescript_artifact_check,
)
from message_utils import redact # noqa: E402
def _package_directories() -> dict[str, Path]:
@@ -61,6 +65,14 @@ def _runtime_commands() -> dict[str, list[list[str]]]:
"-q",
"--ignore=integrations/agent-plugin-core/tests/test_conformance.py",
"--ignore=integrations/claude-code-plugin/tests/integration",
],
[
sys.executable,
"-m",
"pytest",
"integrations/hermes-plugin/tests",
"--confcutdir=integrations/hermes-plugin/tests",
"-q",
]
],
"typescript-core": [["pnpm", "test"], ["pnpm", "typecheck"]],
@@ -11,7 +11,6 @@ from __future__ import annotations
import functools
import hashlib
import json
import math
import os
import re
import sqlite3
@@ -27,6 +26,14 @@ from pathlib import Path
from typing import Any, Iterable
import telemetry
from message_utils import MAX_EXTRACTION_INPUT_TOKENS as MAX_EXTRACTION_INPUT_TOKENS
from message_utils import SECRET_PATTERNS as SECRET_PATTERNS
from message_utils import _estimated_tokens as _estimated_tokens
from message_utils import _is_agent_assignment as _is_agent_assignment
from message_utils import _is_agent_response as _is_agent_response
from message_utils import _message_tokens as _message_tokens
from message_utils import extraction_message_batches as extraction_message_batches
from message_utils import redact as redact
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.1"
@@ -66,7 +73,6 @@ CHECKPOINT_EXCHANGES = 5
CHECKPOINT_MESSAGES = 10
CHECKPOINT_SOURCE_CHARS = 40000
DEFAULT_MAX_CONTEXT_CHARS = 4000
MAX_EXTRACTION_INPUT_TOKENS = 24000
MAX_FLUSH_ATTEMPTS = 5
FORGET_PAGE_SIZE = 100
FORGET_MAX_PAGES = 50
@@ -139,48 +145,11 @@ BUILD_COMMAND_RE = re.compile(
re.IGNORECASE,
)
SECRET_PATTERNS = [
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
re.compile(
r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"
),
re.compile(
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
r"\s*[:=]\s*)[^\s&\"']+"
),
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
re.compile(
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
re.DOTALL,
),
re.compile(
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
r'|(?:access|refresh|session)[_-]?token|token|authorization|credential'
r')"\s*:\s*")(?:\\.|[^"\\])*'
),
]
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def redact(value: Any) -> str:
text = (
value
if isinstance(value, str)
else json.dumps(value, ensure_ascii=False, default=str)
)
for pattern in SECRET_PATTERNS:
if pattern.groups:
text = pattern.sub(r"\1[REDACTED]", text)
else:
text = pattern.sub("[REDACTED]", text)
return text
def bounded(value: Any, limit: int) -> str:
text = redact(value).strip()
if len(text) <= limit:
@@ -1706,100 +1675,6 @@ def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]
return messages
def _estimated_tokens(value: str) -> int:
"""Conservatively estimate tokens without adding a tokenizer dependency."""
ascii_chars = sum(ord(char) < 128 for char in value)
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
def _message_tokens(messages: list[dict[str, str]]) -> int:
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
def _is_agent_assignment(message: dict[str, str]) -> bool:
return message.get("role") == "assistant" and message.get(
"content", ""
).startswith("Subagent assignment (")
def _is_agent_response(message: dict[str, str]) -> bool:
return message.get("role") == "assistant" and message.get(
"content", ""
).startswith("Subagent response (")
def extraction_message_batches(
messages: list[dict[str, str]],
*,
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
) -> list[list[dict[str, str]]]:
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
if not messages or _message_tokens(messages) <= max_tokens:
return [messages]
exchanges: list[list[dict[str, str]]] = []
exchange: list[dict[str, str]] = []
for message in messages:
if message.get("role") == "user" and exchange:
exchanges.append(exchange)
exchange = []
exchange.append(message)
if exchange:
exchanges.append(exchange)
units: list[list[dict[str, str]]] = []
for exchange in exchanges:
if _message_tokens(exchange) <= max_tokens:
units.append(exchange)
continue
index = 0
while index < len(exchange):
message = exchange[index]
if (
_is_agent_assignment(message)
and index + 1 < len(exchange)
and _is_agent_response(exchange[index + 1])
):
units.append(exchange[index : index + 2])
index += 2
else:
units.append([message])
index += 1
bounded_units: list[list[dict[str, str]]] = []
for unit in units:
if _message_tokens(unit) <= max_tokens:
bounded_units.append(unit)
continue
for message in unit:
remaining = message["content"]
while remaining:
low, high = 0, len(remaining)
while low < high:
middle = (low + high + 1) // 2
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
low = middle
else:
high = middle - 1
if low == 0:
raise ValueError("Extraction token budget cannot fit a message")
bounded_units.append([{**message, "content": remaining[:low]}])
remaining = remaining[low:]
batches: list[list[dict[str, str]]] = []
batch: list[dict[str, str]] = []
for unit in bounded_units:
candidate = [*batch, *unit]
if batch and _message_tokens(candidate) > max_tokens:
batches.append(batch)
batch = list(unit)
else:
batch = candidate
if batch:
batches.append(batch)
return batches
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -0,0 +1,127 @@
"""Shared, host-independent redaction and lossless extraction batching."""
from __future__ import annotations
import json
import math
import re
from typing import Any
MAX_EXTRACTION_INPUT_TOKENS = 24000
SECRET_PATTERNS = [
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
re.compile(r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"),
re.compile(
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
r"\s*[:=]\s*)[^\s&\"']+"
),
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
re.compile(
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
re.DOTALL,
),
re.compile(
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
r"|(?:access|refresh|session)[_-]?token|token|authorization|credential"
r')"\s*:\s*")(?:\\.|[^"\\])*'
),
]
def redact(value: Any) -> str:
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
for pattern in SECRET_PATTERNS:
if pattern.groups:
text = pattern.sub(r"\1[REDACTED]", text)
else:
text = pattern.sub("[REDACTED]", text)
return text
def _estimated_tokens(value: str) -> int:
"""Conservatively estimate tokens without adding a tokenizer dependency."""
ascii_chars = sum(ord(char) < 128 for char in value)
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
def _message_tokens(messages: list[dict[str, str]]) -> int:
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
def _is_agent_assignment(message: dict[str, str]) -> bool:
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent assignment (")
def _is_agent_response(message: dict[str, str]) -> bool:
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent response (")
def extraction_message_batches(
messages: list[dict[str, str]],
*,
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
) -> list[list[dict[str, str]]]:
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
if not messages or _message_tokens(messages) <= max_tokens:
return [messages]
exchanges: list[list[dict[str, str]]] = []
exchange: list[dict[str, str]] = []
for message in messages:
if message.get("role") == "user" and exchange:
exchanges.append(exchange)
exchange = []
exchange.append(message)
if exchange:
exchanges.append(exchange)
units: list[list[dict[str, str]]] = []
for exchange in exchanges:
if _message_tokens(exchange) <= max_tokens:
units.append(exchange)
continue
index = 0
while index < len(exchange):
message = exchange[index]
if _is_agent_assignment(message) and index + 1 < len(exchange) and _is_agent_response(exchange[index + 1]):
units.append(exchange[index : index + 2])
index += 2
else:
units.append([message])
index += 1
bounded_units: list[list[dict[str, str]]] = []
for unit in units:
if _message_tokens(unit) <= max_tokens:
bounded_units.append(unit)
continue
for message in unit:
remaining = message["content"]
while remaining:
low, high = 0, len(remaining)
while low < high:
middle = (low + high + 1) // 2
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
low = middle
else:
high = middle - 1
if low == 0:
raise ValueError("Extraction token budget cannot fit a message")
bounded_units.append([{**message, "content": remaining[:low]}])
remaining = remaining[low:]
batches: list[list[dict[str, str]]] = []
batch: list[dict[str, str]] = []
for unit in bounded_units:
candidate = [*batch, *unit]
if batch and _message_tokens(candidate) > max_tokens:
batches.append(batch)
batch = list(unit)
else:
batch = candidate
if batch:
batches.append(batch)
return batches
@@ -1,3 +1,4 @@
jsonschema>=4.23,<5
pytest>=8,<10
skills-ref==0.1.1
httpx>=0.27,<1
@@ -15,6 +15,7 @@ from build.build import ( # noqa: E402
bundle_drift,
render_template,
replace_output,
sync_generated,
)
from build.validate import validate_bundle # noqa: E402
@@ -113,6 +114,7 @@ def test_native_control_skills_select_the_host_store(host: str, tmp_path: Path)
("codex", "native"),
("kimi", "native"),
("antigravity", "native"),
("hermes", "native"),
],
)
def test_installable_plugin_directories_are_current(host: str, kind: str) -> None:
@@ -133,3 +135,91 @@ def test_marketplaces_keep_public_names_and_reference_real_plugins() -> None:
assert [plugin["name"] for plugin in codex_marketplace["plugins"]] == ["mem0"]
codex = codex_marketplace["plugins"][0]
assert codex["source"]["path"] == "./integrations/codex-plugin"
def test_native_bundle_can_select_runtime_without_skills(tmp_path: Path, monkeypatch) -> None:
from build import build as builder
source = tmp_path / "plugin"
source.mkdir()
(source / "__init__.py").write_text("# Native plugin adapter\n", encoding="utf-8")
(source / "plugin-build.json").write_text(
json.dumps(
{
"native": {
"pluginRoot": "${PLUGIN_ROOT}",
"pythonFiles": ["message_utils.py"],
"skills": False,
"files": {"__init__.py": "__init__.py"},
}
}
),
encoding="utf-8",
)
monkeypatch.setitem(builder.NATIVE_PLUGINS, "test-host", source)
stale_skill = source / "skills" / "remember" / "SKILL.md"
stale_skill.parent.mkdir(parents=True)
stale_skill.write_text("stale generated skill", encoding="utf-8")
root = build("test-host", "native", tmp_path / "output")
assert {path.name for path in (root / "core").iterdir()} == {"message_utils.py"}
assert not (root / "skills").exists()
assert (root / "__init__.py").read_text(encoding="utf-8") == "# Native plugin adapter\n"
sync_generated("test-host", "native")
assert bundle_drift("test-host", "native") == []
assert not (source / "skills").exists()
@pytest.mark.parametrize("files", ["message_utils.py", ["../README.md"], ["/tmp/source.py"], [7], ["missing.py"]])
def test_native_runtime_selection_rejects_invalid_sources(files, tmp_path: Path) -> None:
from build.build import _build_native
with pytest.raises(ValueError, match="pythonFiles|native source file"):
_build_native(
"test",
tmp_path,
tmp_path,
{
"native": {
"pluginRoot": "${PLUGIN_ROOT}",
"pythonFiles": files,
}
},
)
def test_native_runtime_selection_rejects_escaping_symlink(tmp_path: Path, monkeypatch) -> None:
from build import build as builder
source = tmp_path / "shared" / "python"
source.mkdir(parents=True)
secret = tmp_path / "outside.py"
secret.write_text("secret", encoding="utf-8")
(source / "message_utils.py").symlink_to(secret)
monkeypatch.setattr(builder, "CORE_ROOT", source.parent)
staged = tmp_path / "bundle"
staged.mkdir()
with pytest.raises(ValueError, match="inside their roots"):
builder._build_native(
"test",
tmp_path,
staged,
{
"native": {
"pluginRoot": "${PLUGIN_ROOT}",
"pythonFiles": ["message_utils.py"],
}
},
)
def test_hermes_bundle_uses_only_host_independent_runtime(tmp_path: Path) -> None:
root = build("hermes", "native", tmp_path / "hermes")
assert {path.name for path in (root / "core").iterdir()} == {"message_utils.py"}
assert (root / "__init__.py").is_file()
assert (root / "plugin.yaml").is_file()
assert not (root / "skills").exists()
assert not (root / "agents").exists()
@@ -10,10 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from conformance import run as conformance_run # noqa: E402
from conformance.run import _command_check # noqa: E402
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
RUNNER = PLUGIN_ROOT / "conformance" / "run.py"
PYTHON_HOSTS = {"claude-code", "cursor", "codex", "kimi", "antigravity"}
PYTHON_HOSTS = {"claude-code", "cursor", "codex", "kimi", "antigravity", "hermes"}
def test_python_bundle_conformance_builds_every_host(tmp_path: Path) -> None:
@@ -78,6 +77,12 @@ def test_conformance_plan_covers_every_runtime(tmp_path: Path) -> None:
"deepseek",
}
assert all(entry["status"] == "planned" for entry in payload["checks"])
python_commands = [entry["command"] for entry in payload["checks"] if entry["group"] == "python-tests"]
assert any(
"integrations/hermes-plugin/tests" in command
and "--confcutdir=integrations/hermes-plugin/tests" in command
for command in python_commands
)
assert {
entry["group"]
for entry in payload["checks"]
@@ -0,0 +1,39 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
CORE = Path(__file__).resolve().parents[1] / "python"
def test_message_helpers_are_standalone_and_keep_legacy_exports() -> None:
result = subprocess.run(
[
sys.executable,
"-c",
"""
import json
import sys
import message_utils
assert "memory_core" not in sys.modules
assert "telemetry" not in sys.modules
text = "prefix " + "日本語🙂" * 250 + ' {"password": "secret value"}'
redacted = message_utils.redact(text)
assert "secret value" not in redacted
messages = [{"role": "user", "content": redacted}]
batches = message_utils.extraction_message_batches(messages, max_tokens=100)
assert len(batches) > 1
assert all(message_utils._message_tokens(batch) <= 100 for batch in batches)
assert "".join(m["content"] for batch in batches for m in batch) == redacted
import memory_core
for name in ("redact", "SECRET_PATTERNS", "extraction_message_batches", "_message_tokens"):
assert getattr(memory_core, name) is getattr(message_utils, name)
""",
],
cwd=CORE,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr