feat(integrations): add Hermes provider with shared memory utilities
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user