refactor(integrations): shared agent plugin runtimes and native adapters (#7203)
This commit is contained in:
+10
-9
@@ -1,21 +1,22 @@
|
||||
# Integrations (`integrations/`)
|
||||
|
||||
Agent and editor integrations. Each subdirectory is self-contained: its own `package.json`, lockfile, build, and tests. **There is no shared toolchain.** Check the table before running anything.
|
||||
Agent and editor integrations. Most packages are self-contained; coding-agent plugins share the code in `agent-plugin-core/`. Check the table before running anything.
|
||||
|
||||
| Directory | Package | Build | Lint | Test |
|
||||
|-----------|---------|-------|------|------|
|
||||
| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) |
|
||||
| `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest |
|
||||
| `claude-code-plugin/` | Claude Code plugin, installs as `mem0@mem0-plugins` (v0.3.0) | none | ruff | pytest |
|
||||
| `mem0-plugin/` | Cursor / Codex / Kimi / Antigravity / OpenCode plugin (legacy — Claude Code moved to `claude-code-plugin/`) | none | none | pytest |
|
||||
| `mem0-plugin/.opencode-plugin/` | `@mem0/opencode-plugin` | Bun | none | tsc type-check |
|
||||
| `agent-plugin-core/` | Shared Python/TypeScript behavior, skill templates, builds, and conformance | Python build script | ruff + tsc | pytest + node:test |
|
||||
| `mem0-agent-plugin/` | One portable Agent Plugins v1 package | Python | ruff | shared conformance |
|
||||
| `claude-code-plugin/`, `cursor-plugin/`, `codex-plugin/`, `kimi-plugin/`, `antigravity-plugin/` | Self-contained native plugins generated from the shared Python core | Python | ruff | pytest |
|
||||
| `opencode-plugin/` | `@mem0/opencode-plugin` (Bun/TypeScript) | tsup (via Bun) | tsc | bun test |
|
||||
| `pi-agent-plugin/` | `@mem0/pi-agent-plugin` | tsup | none | vitest |
|
||||
| `deepseek-plugin/` | `@mem0/deepseek-plugin` | tsup (ESM) | none | vitest |
|
||||
| `n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` | tsc | ESLint (n8n-nodes-base) | none |
|
||||
| `zapier-mem0/` | `@mem0/zapier` | tsc | none | offline unit tests + `zapier validate` |
|
||||
| `mem0-strands/` | `mem0-strands` (PyPI) | hatch | Ruff + mypy | pytest |
|
||||
|
||||
pnpm everywhere except `.opencode-plugin/` (Bun) and `mem0-strands/` (Python: pip / hatch). Never npm, never yarn.
|
||||
pnpm for TypeScript packages except `opencode-plugin/` (Bun). `mem0-strands/` uses Python/pip/hatch. Never npm or yarn.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -41,8 +42,8 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
## What each one is
|
||||
|
||||
- **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`.
|
||||
- **`claude-code-plugin/`** is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`): local evidence capture via lifecycle hooks, background memory extraction to the Mem0 Platform, a local `search_memories` MCP tool, six `/mem0:*` skills, and the `mem0:sidekick` agent. Pure-stdlib Python — no dependencies to install. Its `core/` + `adapters/claude/` split marks engine vs. harness glue; future per-harness plugins start by copying `core/` and keeping the contract tests verbatim (see its `docs/CONTRACT.md`).
|
||||
- **`mem0-plugin/`** connects Cursor, Codex, Kimi, Antigravity, and OpenCode to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. The Claude Code plugin moved to [`claude-code-plugin/`](claude-code-plugin/) in v0.3.0 (installs as `mem0@mem0-plugins`); do not run both at the same time.
|
||||
- **`agent-plugin-core/`** owns the shared Python memory runtime, TypeScript lifecycle utilities, skill templates, builds, and conformance runner. Claude Code is the behavioral source of truth. Native manifests and adapters live in sibling plugin directories; do not hand-edit their generated `core/` or `skills/` trees. Build and validation details are in [`agent-plugin-core/README.md`](agent-plugin-core/README.md).
|
||||
- **`opencode-plugin/`** is a Bun/TypeScript plugin for OpenCode (`@mem0/opencode-plugin` on npm). It registers Mem0 memory tools as an OpenCode plugin with its own skills and telemetry.
|
||||
- **`openclaw/`**, **`pi-agent-plugin/`**, **`deepseek-plugin/`** are editor and agent plugins with the same shape. `deepseek-plugin/` registers Mem0 search/add tools as a native DeepSeek Harness (Cordis) plugin.
|
||||
- **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete.
|
||||
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
|
||||
@@ -50,11 +51,11 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
|
||||
## Adding an integration
|
||||
|
||||
1. Create `integrations/<name>/` and build it there, self-contained.
|
||||
1. For a native coding-agent host, add `integrations/<name>-plugin/` with `plugin-build.json`, its manifest, and a thin adapter, then generate its shared runtime. Portable clients use the single `mem0-agent-plugin/` package. Independent TypeScript integrations stay self-contained and import shared lifecycle behavior from `agent-plugin-core/typescript/`.
|
||||
2. If it publishes to a registry, set `repository.directory: "integrations/<name>"` in `package.json` so npm provenance links to the right subdirectory.
|
||||
3. Add `.github/workflows/<name>-checks.yml` and `<name>-cd.yml`. Use `integrations/<name>` in the `paths:` trigger, `working-directory`, and `cache-dependency-path`. Register the release tag prefix in the `case` block in `release.yml`, keeping the bare `v*` arm last.
|
||||
**Workflow filenames are load-bearing:** npm OIDC trusted publishing is pinned to repository plus workflow filename. Renaming one breaks publishing.
|
||||
4. Register the CI workflow in `ci-gate.yml`: a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list.
|
||||
5. If it is a Claude Code or editor marketplace plugin, register its path in all five `marketplace.json` files: root, `.claude-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, and `.agents/plugins/`.
|
||||
5. If it is a Claude Code or editor marketplace plugin, register the generated native bundle path in the applicable marketplace files. Preserve the existing public plugin name.
|
||||
6. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`.
|
||||
7. Add rows to the table above and to the CI/CD tables in [`../.github/AGENTS.md`](../.github/AGENTS.md).
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Mem0 agent plugin core
|
||||
|
||||
This directory is the single source of shared memory behavior for Mem0 coding-agent plugins. Installable plugins remain ordinary sibling directories under `integrations/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
integrations/
|
||||
├── agent-plugin-core/ # Shared source; never installed as a plugin
|
||||
│ ├── python/ # Claude-derived capture, recall, MCP, scoping, and telemetry
|
||||
│ ├── typescript/ # Shared lifecycle, formatting, identity, scoping, and telemetry
|
||||
│ ├── skills/ # The only source for the six generated memory skills
|
||||
│ ├── build/ # Bundle builder, schemas, and validation
|
||||
│ ├── conformance/ # One offline/live verification entry point
|
||||
│ └── tests/
|
||||
├── mem0-agent-plugin/ # One portable Agent Plugins v1 package
|
||||
├── claude-code-plugin/ # Native Claude package and adapter
|
||||
├── 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Claude Code remains the behavioral source of truth. Its sidekick stays at `claude-code-plugin/agents/sidekick.md` and is not generated or copied to hosts without a compatible native subagent interface.
|
||||
|
||||
TypeScript integrations (`openclaw`, `opencode-plugin`, `pi-agent-plugin`, and `deepseek-plugin`) import `typescript/src/` at build time. Their package builders include the shared implementation in their normal output; they do not carry checked-in copies.
|
||||
|
||||
## Build and verify
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
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
|
||||
/tmp/mem0-agent-plugins/bin/python \
|
||||
integrations/agent-plugin-core/build/build.py "$host" \
|
||||
--kind native --check
|
||||
done
|
||||
|
||||
/tmp/mem0-agent-plugins/bin/python \
|
||||
integrations/agent-plugin-core/build/build.py mem0-agent-plugin \
|
||||
--kind portable --check
|
||||
```
|
||||
|
||||
Use `--sync` instead of `--check` after changing `python/` or `skills/`. This only replaces generated `core/` and `skills/` content; it does not change manifests, adapters, tests, or the Claude sidekick.
|
||||
|
||||
Run every offline Python and TypeScript check and write one machine-readable report:
|
||||
|
||||
```bash
|
||||
/tmp/mem0-agent-plugins/bin/python \
|
||||
integrations/agent-plugin-core/conformance/run.py \
|
||||
--install \
|
||||
--report /tmp/mem0-plugin-conformance.json
|
||||
```
|
||||
|
||||
For every TypeScript integration, this also builds the publishable package, verifies its required entry files, and rejects compiled artifacts that still import monorepo source. This keeps published plugins self-contained without committing their `dist/` directories.
|
||||
|
||||
The offline suite does not contact Mem0 Platform. An explicit disposable key enables the inherited live scoping suite:
|
||||
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-disposable-test-key"
|
||||
/tmp/mem0-agent-plugins/bin/python \
|
||||
integrations/agent-plugin-core/conformance/run.py \
|
||||
--group live-platform --live \
|
||||
--report /tmp/mem0-plugin-live-conformance.json
|
||||
```
|
||||
|
||||
Do not put a real key in source files, command history shared with others, or pull-request configuration.
|
||||
|
||||
## Add a plugin
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Keep capture, recall, memory scoping, redaction, skill text, and telemetry in this shared module. Host directories should contain only behavior required by their native SDK.
|
||||
|
||||
For a TypeScript host, import the shared lifecycle modules directly and keep only native SDK registration in the integration. Do not advertise capture, compaction, or sidekick behavior unless the host exposes the necessary lifecycle seam.
|
||||
|
||||
## Host capture capabilities
|
||||
|
||||
| Host | Conversation capture | Tool outcomes | Subagent context and correlation |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude Code | Incremental active transcript branch | Native success/failure hooks | Parent context; native agent ID |
|
||||
| Cursor | Prompt and response hooks; duplicate responses suppressed | Native success/failure hooks | Sidekick searches itself; no parent-injection claim |
|
||||
| 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 | Parent context; without an ID, a stop matches only one unambiguous active run |
|
||||
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | Sidekick searches itself; no worktree-isolation claim |
|
||||
| Portable v1 | Explicit memory skills | No native lifecycle hooks | No native subagent declaration |
|
||||
|
||||
An uncorrelated subagent completion is kept as its own record; the plugin never guesses which overlapping run completed. Codex's documented hook fields already match the shared input contract, so no speculative field aliases or unsupported failure event are registered.
|
||||
|
||||
Offline conformance exercises the adapters and MCP servers with native-shaped payloads and builds each distributable package. It does not establish that every installed editor or Harness version loads the plugin correctly; those checks require smoke tests in the actual hosts.
|
||||
@@ -0,0 +1 @@
|
||||
"""Build and validate self-contained Mem0 agent plugins."""
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .validate import validate_bundle
|
||||
except ImportError:
|
||||
from validate import validate_bundle
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = CORE_ROOT.parents[1]
|
||||
INTEGRATIONS_ROOT = REPOSITORY_ROOT / "integrations"
|
||||
SHARED_SKILLS = CORE_ROOT / "skills"
|
||||
PORTABLE_PLUGIN = "mem0-agent-plugin"
|
||||
NATIVE_PLUGINS = {
|
||||
"claude-code": INTEGRATIONS_ROOT / "claude-code-plugin",
|
||||
"cursor": INTEGRATIONS_ROOT / "cursor-plugin",
|
||||
"codex": INTEGRATIONS_ROOT / "codex-plugin",
|
||||
"kimi": INTEGRATIONS_ROOT / "kimi-plugin",
|
||||
"antigravity": INTEGRATIONS_ROOT / "antigravity-plugin",
|
||||
}
|
||||
PROTECTED_OUTPUTS = {
|
||||
REPOSITORY_ROOT,
|
||||
INTEGRATIONS_ROOT,
|
||||
CORE_ROOT,
|
||||
INTEGRATIONS_ROOT / PORTABLE_PLUGIN,
|
||||
*NATIVE_PLUGINS.values(),
|
||||
}
|
||||
TEMPLATE_TOKEN = re.compile(r"{{([A-Z_]+)}}")
|
||||
TEMPLATE_TOKENS = {
|
||||
"PLUGIN_ROOT",
|
||||
"PLUGIN_DATA",
|
||||
"PLUGIN_DATA_ARG",
|
||||
"COMMAND_PREFIX",
|
||||
"HARNESS_ID",
|
||||
"HARNESS_NAME",
|
||||
}
|
||||
|
||||
|
||||
def render_template(source: str, values: Mapping[str, str]) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
token = match.group(1)
|
||||
if token not in TEMPLATE_TOKENS or token not in values:
|
||||
raise ValueError(f"unknown or unresolved template token: {token}")
|
||||
return values[token]
|
||||
|
||||
rendered = TEMPLATE_TOKEN.sub(replace, source)
|
||||
if "{{" in rendered or "}}" in rendered:
|
||||
raise ValueError("unresolved template token")
|
||||
return rendered
|
||||
|
||||
|
||||
def replace_output(staged: Path, output: Path) -> Path:
|
||||
"""Replace one explicit build output without touching its siblings."""
|
||||
if not staged.is_dir():
|
||||
raise ValueError(f"staged directory does not exist: {staged}")
|
||||
|
||||
resolved_output = output.resolve()
|
||||
if resolved_output in {path.resolve() for path in PROTECTED_OUTPUTS}:
|
||||
raise ValueError(f"refusing protected output path: {output}")
|
||||
if output.exists() and not output.is_dir():
|
||||
raise ValueError(f"output path is not a directory: {output}")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=output.parent))
|
||||
try:
|
||||
shutil.copytree(staged, temporary, dirs_exist_ok=True)
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
shutil.rmtree(temporary)
|
||||
return output
|
||||
|
||||
|
||||
def _bundle_python(
|
||||
staged: Path,
|
||||
host: str,
|
||||
plugin_root: str,
|
||||
*,
|
||||
plugin_data: str = "",
|
||||
portable: bool = False,
|
||||
) -> 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)
|
||||
|
||||
values = {
|
||||
"PLUGIN_ROOT": plugin_root,
|
||||
"PLUGIN_DATA": "${PLUGIN_DATA}",
|
||||
"PLUGIN_DATA_ARG": f'--plugin-data-dir "{plugin_data}"' if plugin_data else "",
|
||||
"COMMAND_PREFIX": "mem0",
|
||||
"HARNESS_ID": host,
|
||||
"HARNESS_NAME": host.replace("-", " ").title(),
|
||||
}
|
||||
for source in sorted(SHARED_SKILLS.glob("*/SKILL.md.tmpl")):
|
||||
target = staged / "skills" / source.parent.name / "SKILL.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
rendered = render_template(source.read_text(encoding="utf-8"), values)
|
||||
if portable:
|
||||
rendered = "\n".join(
|
||||
line
|
||||
for line in rendered.splitlines()
|
||||
if not line.startswith(("argument-hint:", "disable-model-invocation:"))
|
||||
) + "\n"
|
||||
target.write_text(rendered, encoding="utf-8")
|
||||
|
||||
|
||||
def _build_portable(staged: Path) -> None:
|
||||
source = INTEGRATIONS_ROOT / PORTABLE_PLUGIN
|
||||
_copy_declared_files(staged, source, {"plugin.json": "plugin.json", "mcp.json": "mcp.json"})
|
||||
_bundle_python(staged, "coding-agent", "${PLUGIN_ROOT}", portable=True)
|
||||
|
||||
|
||||
def _copy_declared_files(staged: Path, source_root: Path, files: object) -> None:
|
||||
if not isinstance(files, dict):
|
||||
raise ValueError("native files must be an object")
|
||||
source_root = source_root.resolve()
|
||||
staged_root = staged.resolve()
|
||||
for source_name, target_name in files.items():
|
||||
if not isinstance(source_name, str) or not isinstance(target_name, str):
|
||||
raise ValueError("native file paths must be strings")
|
||||
source = (source_root / source_name).resolve()
|
||||
target = (staged / target_name).resolve()
|
||||
if not source.is_relative_to(source_root) or not target.is_relative_to(staged_root):
|
||||
raise ValueError("native file paths must stay inside their roots")
|
||||
if not source.is_file():
|
||||
raise ValueError(f"native source file does not exist: {source_name}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
def _build_native(host: str, source_root: Path, staged: Path, descriptor: dict) -> None:
|
||||
native = descriptor.get("native")
|
||||
if not isinstance(native, dict) or not isinstance(native.get("pluginRoot"), str):
|
||||
raise ValueError(f"native build is not declared for {host}")
|
||||
_bundle_python(
|
||||
staged,
|
||||
host,
|
||||
native["pluginRoot"],
|
||||
plugin_data=str(native.get("pluginData") or ""),
|
||||
)
|
||||
_copy_declared_files(staged, source_root, native.get("files", {}))
|
||||
|
||||
|
||||
def build(host: str, kind: str, output: Path) -> Path:
|
||||
if kind not in {"portable", "native"}:
|
||||
raise ValueError(f"unknown bundle kind: {kind}")
|
||||
if kind == "portable":
|
||||
if host != PORTABLE_PLUGIN:
|
||||
raise ValueError(f"the portable bundle is {PORTABLE_PLUGIN}")
|
||||
source_root = INTEGRATIONS_ROOT / PORTABLE_PLUGIN
|
||||
descriptor = None
|
||||
else:
|
||||
source_root = NATIVE_PLUGINS.get(host)
|
||||
if source_root is None:
|
||||
raise ValueError(f"unknown host: {host}")
|
||||
descriptor_path = source_root / "plugin-build.json"
|
||||
descriptor = json.loads(descriptor_path.read_text(encoding="utf-8"))
|
||||
with tempfile.TemporaryDirectory(prefix=f"mem0-{host}-{kind}-") as temporary:
|
||||
staged = Path(temporary) / "bundle"
|
||||
staged.mkdir()
|
||||
if kind == "portable":
|
||||
_build_portable(staged)
|
||||
else:
|
||||
assert descriptor is not None
|
||||
_build_native(host, source_root, staged, descriptor)
|
||||
errors = validate_bundle(staged, kind)
|
||||
if errors:
|
||||
raise ValueError("invalid bundle:\n" + "\n".join(errors))
|
||||
return replace_output(staged, output)
|
||||
|
||||
|
||||
def installable_root(host: str, kind: str) -> Path:
|
||||
if kind == "portable" and host == PORTABLE_PLUGIN:
|
||||
return INTEGRATIONS_ROOT / PORTABLE_PLUGIN
|
||||
if kind == "native" and host in NATIVE_PLUGINS:
|
||||
return NATIVE_PLUGINS[host]
|
||||
raise ValueError(f"unknown {kind} plugin: {host}")
|
||||
|
||||
|
||||
def bundle_drift(host: str, kind: str) -> list[str]:
|
||||
target = installable_root(host, kind)
|
||||
with tempfile.TemporaryDirectory(prefix=f"mem0-check-{host}-") as temporary:
|
||||
generated = build(host, kind, Path(temporary) / "bundle")
|
||||
errors: list[str] = []
|
||||
for directory in ("core", "skills"):
|
||||
expected = {
|
||||
path.relative_to(generated)
|
||||
for path in (generated / directory).rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
actual = {
|
||||
path.relative_to(target)
|
||||
for path in (target / directory).rglob("*")
|
||||
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc"
|
||||
}
|
||||
errors.extend(f"missing generated file: {path}" for path in sorted(expected - actual))
|
||||
errors.extend(f"stale generated file: {path}" for path in sorted(actual - expected))
|
||||
for source in sorted(path for path in generated.rglob("*") if path.is_file()):
|
||||
relative = source.relative_to(generated)
|
||||
installed = target / relative
|
||||
if not installed.is_file() or source.read_bytes() != installed.read_bytes():
|
||||
errors.append(f"generated file differs: {relative}")
|
||||
return errors
|
||||
|
||||
|
||||
def sync_generated(host: str, kind: str) -> Path:
|
||||
target = installable_root(host, kind)
|
||||
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)
|
||||
return target
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build a self-contained Mem0 agent plugin")
|
||||
parser.add_argument("host")
|
||||
parser.add_argument("--kind", choices=("portable", "native"), required=True)
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument("--output", type=Path)
|
||||
action.add_argument("--check", action="store_true")
|
||||
action.add_argument("--sync", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.check:
|
||||
errors = bundle_drift(args.host, args.kind)
|
||||
if errors:
|
||||
print("\n".join(errors))
|
||||
return 1
|
||||
print(f"Current {args.kind} bundle: {installable_root(args.host, args.kind)}")
|
||||
elif args.sync:
|
||||
print(sync_generated(args.host, args.kind))
|
||||
else:
|
||||
assert args.output is not None
|
||||
print(build(args.host, args.kind, args.output))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"title": "Agent Plugins MCP Configuration",
|
||||
"description": "Machine-readable schema for mcp.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"description": "Canonical identifier of the MCP configuration schema for the Agent Plugins version targeted by this document."
|
||||
},
|
||||
"mcpServers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/server" }
|
||||
}
|
||||
},
|
||||
"required": ["$schema", "mcpServers"],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"server": {
|
||||
"title": "MCP server",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/stdioServer" },
|
||||
{ "$ref": "#/$defs/streamableHttpServer" },
|
||||
{ "$ref": "#/$defs/sseServer" }
|
||||
]
|
||||
},
|
||||
"stdioServer": {
|
||||
"title": "stdio MCP server",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "const": "stdio" },
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Executable token. Resolution rules are defined by the Agent Plugins specification."
|
||||
},
|
||||
"args": { "type": "array", "items": { "type": "string" } },
|
||||
"env": {
|
||||
"type": "object",
|
||||
"propertyNames": { "not": { "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] } },
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))",
|
||||
"description": "Plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted working directory. Filesystem containment is validated separately."
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"streamableHttpServer": {
|
||||
"title": "Streamable HTTP MCP server",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "const": "streamable-http" },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification."
|
||||
},
|
||||
"headers": { "$ref": "#/$defs/headers" }
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sseServer": {
|
||||
"title": "Legacy HTTP+SSE MCP server",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "const": "sse" },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification."
|
||||
},
|
||||
"headers": { "$ref": "#/$defs/headers" }
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"headers": {
|
||||
"title": "HTTP headers",
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"title": "Agent Plugins Manifest",
|
||||
"description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$",
|
||||
"description": "Human-readable plugin name."
|
||||
},
|
||||
"version": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"author": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"url": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"homepage": { "type": "string" },
|
||||
"repository": { "type": "string" },
|
||||
"license": { "type": "string" },
|
||||
"keywords": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.",
|
||||
"additionalProperties": { "type": "object" }
|
||||
}
|
||||
},
|
||||
"required": ["$schema", "name"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from skills_ref import validate as validate_skill
|
||||
|
||||
SCHEMAS = Path(__file__).resolve().parent / "schemas"
|
||||
|
||||
|
||||
def _schema_errors(path: Path, schema_name: str) -> list[str]:
|
||||
if not path.is_file():
|
||||
return [f"{path.name}: file is required"]
|
||||
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
return [f"{path.name}: {error}"]
|
||||
|
||||
schema = json.loads((SCHEMAS / schema_name).read_text(encoding="utf-8"))
|
||||
errors = Draft202012Validator(schema).iter_errors(value)
|
||||
return [
|
||||
f"{path.name}{''.join(f'.{part}' for part in error.absolute_path)}: {error.message}"
|
||||
for error in sorted(errors, key=lambda item: tuple(str(part) for part in item.absolute_path))
|
||||
]
|
||||
|
||||
|
||||
def validate_bundle(root: Path, kind: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not root.is_dir():
|
||||
return [f"{root}: directory is required"]
|
||||
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_symlink():
|
||||
errors.append(f"{path.relative_to(root)}: symlinks are not allowed in release bundles")
|
||||
|
||||
if kind == "portable":
|
||||
errors.extend(_schema_errors(root / "plugin.json", "plugin.schema.json"))
|
||||
if (root / "mcp.json").exists():
|
||||
errors.extend(_schema_errors(root / "mcp.json", "mcp.schema.json"))
|
||||
skills = root / "skills"
|
||||
if skills.is_dir():
|
||||
for skill in sorted(path for path in skills.iterdir() if path.is_dir()):
|
||||
errors.extend(f"skills/{skill.name}: {error}" for error in validate_skill(skill))
|
||||
elif kind == "native":
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
relative = path.relative_to(root)
|
||||
try:
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
errors.append(f"{relative}: {error}")
|
||||
return sorted(errors)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate a generated Mem0 plugin bundle")
|
||||
parser.add_argument("root", type=Path)
|
||||
parser.add_argument("--kind", choices=("portable", "native"), required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
errors = validate_bundle(args.root, args.kind)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Validated {args.kind} bundle: {args.root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that a built TypeScript plugin is self-contained and publishable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
INTEGRATIONS_ROOT = CORE_ROOT.parent
|
||||
TYPESCRIPT_ARTIFACTS = {
|
||||
"openclaw": (INTEGRATIONS_ROOT / "openclaw", ("dist/index.js", "dist/index.d.ts")),
|
||||
"opencode": (INTEGRATIONS_ROOT / "opencode-plugin", ("dist/index.js", "index.d.ts")),
|
||||
"pi-agent": (
|
||||
INTEGRATIONS_ROOT / "pi-agent-plugin",
|
||||
("dist/index.js", "dist/index.d.ts", "dist/entry.js", "dist/entry.d.ts"),
|
||||
),
|
||||
"deepseek": (INTEGRATIONS_ROOT / "deepseek-plugin", ("dist/index.js", "dist/index.d.ts")),
|
||||
}
|
||||
MONOREPO_IMPORT = re.compile(
|
||||
r"(?:from\s+|import\s*\(|require\s*\()\s*['\"][^'\"]*agent-plugin-core"
|
||||
)
|
||||
|
||||
|
||||
def verify_artifact(group: str, package: Path, required: tuple[str, ...]) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
errors = [f"missing package artifact: {name}" for name in required if not (package / name).is_file()]
|
||||
dist = package / "dist"
|
||||
for pattern in ("*.js", "*.mjs", "*.cjs", "*.d.ts"):
|
||||
for artifact in dist.rglob(pattern) if dist.is_dir() else ():
|
||||
if MONOREPO_IMPORT.search(artifact.read_text(encoding="utf-8")):
|
||||
errors.append(f"monorepo source import in package artifact: {artifact.relative_to(package)}")
|
||||
return {
|
||||
"name": f"{group}-artifact",
|
||||
"group": group,
|
||||
"status": "failed" if errors else "passed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
**({"output": "\n".join(errors)} if errors else {}),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("group", choices=tuple(TYPESCRIPT_ARTIFACTS))
|
||||
args = parser.parse_args()
|
||||
package, required = TYPESCRIPT_ARTIFACTS[args.group]
|
||||
result = verify_artifact(args.group, package, required)
|
||||
if result.get("output"):
|
||||
print(result["output"])
|
||||
return 0 if result["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run every coding-agent plugin check and emit one conformance report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
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")
|
||||
GROUPS = (
|
||||
"python-bundles",
|
||||
"python-tests",
|
||||
"typescript-core",
|
||||
"openclaw",
|
||||
"opencode",
|
||||
"pi-agent",
|
||||
"deepseek",
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def _package_directories() -> dict[str, Path]:
|
||||
return {
|
||||
"typescript-core": CORE_ROOT / "typescript",
|
||||
"openclaw": REPOSITORY_ROOT / "integrations" / "openclaw",
|
||||
"opencode": REPOSITORY_ROOT / "integrations" / "opencode-plugin",
|
||||
"pi-agent": REPOSITORY_ROOT / "integrations" / "pi-agent-plugin",
|
||||
"deepseek": REPOSITORY_ROOT / "integrations" / "deepseek-plugin",
|
||||
}
|
||||
|
||||
|
||||
def _runtime_commands() -> dict[str, list[list[str]]]:
|
||||
return {
|
||||
"python-tests": [
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"integrations/agent-plugin-core/tests",
|
||||
"integrations/claude-code-plugin/tests",
|
||||
"integrations/cursor-plugin/tests",
|
||||
"integrations/codex-plugin/tests",
|
||||
"integrations/kimi-plugin/tests",
|
||||
"integrations/antigravity-plugin/tests",
|
||||
"-q",
|
||||
"--ignore=integrations/agent-plugin-core/tests/test_conformance.py",
|
||||
"--ignore=integrations/claude-code-plugin/tests/integration",
|
||||
]
|
||||
],
|
||||
"typescript-core": [["pnpm", "test"], ["pnpm", "typecheck"]],
|
||||
"openclaw": [
|
||||
["pnpm", "test"],
|
||||
["pnpm", "exec", "tsc", "--noEmit"],
|
||||
["pnpm", "build"],
|
||||
],
|
||||
"opencode": [
|
||||
["bun", "test"],
|
||||
["bun", "run", "type-check"],
|
||||
["bun", "run", "build"],
|
||||
],
|
||||
"pi-agent": [
|
||||
["pnpm", "test"],
|
||||
["pnpm", "typecheck"],
|
||||
["pnpm", "build"],
|
||||
],
|
||||
"deepseek": [
|
||||
["pnpm", "test"],
|
||||
["pnpm", "typecheck"],
|
||||
["pnpm", "build"],
|
||||
],
|
||||
LIVE_GROUP: [
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"integrations/claude-code-plugin/tests/integration",
|
||||
"-q",
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _planned_checks(groups: set[str]) -> list[dict[str, Any]]:
|
||||
checks: list[dict[str, Any]] = []
|
||||
if "python-bundles" in groups:
|
||||
checks.append(
|
||||
{
|
||||
"name": "python-bundles",
|
||||
"group": "python-bundles",
|
||||
"status": "planned",
|
||||
"hosts": list(PYTHON_HOSTS),
|
||||
"kinds": {"native": list(PYTHON_HOSTS), "portable": ["mem0-agent-plugin"]},
|
||||
}
|
||||
)
|
||||
for group, commands in _runtime_commands().items():
|
||||
if group not in groups:
|
||||
continue
|
||||
for index, command in enumerate(commands, start=1):
|
||||
checks.append(
|
||||
{
|
||||
"name": f"{group}-{index}",
|
||||
"group": group,
|
||||
"status": "planned",
|
||||
"command": command,
|
||||
}
|
||||
)
|
||||
if group in TYPESCRIPT_ARTIFACTS:
|
||||
_, required = TYPESCRIPT_ARTIFACTS[group]
|
||||
checks.append(
|
||||
{
|
||||
"name": f"{group}-artifact",
|
||||
"group": group,
|
||||
"status": "planned",
|
||||
"required": list(required),
|
||||
}
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def _command_check(
|
||||
name: str,
|
||||
group: str,
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
environment_overrides: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
environment = dict(os.environ)
|
||||
environment.update(environment_overrides or {})
|
||||
safe_command = [redact(part) for part in command]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
output = redact((result.stdout + result.stderr).strip())
|
||||
return {
|
||||
"name": name,
|
||||
"group": group,
|
||||
"status": "passed" if result.returncode == 0 else "failed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"command": safe_command,
|
||||
"exit_code": result.returncode,
|
||||
**({"output": output[-8_000:]} if output else {}),
|
||||
}
|
||||
except OSError as exc:
|
||||
return {
|
||||
"name": name,
|
||||
"group": group,
|
||||
"status": "failed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"command": safe_command,
|
||||
"exit_code": None,
|
||||
"output": redact(str(exc)),
|
||||
}
|
||||
|
||||
|
||||
def _bundle_checks(artifacts_dir: Path) -> list[dict[str, Any]]:
|
||||
checks: list[dict[str, Any]] = []
|
||||
plugins = [(host, "native") for host in PYTHON_HOSTS] + [("mem0-agent-plugin", "portable")]
|
||||
for host, kind in plugins:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
output = artifacts_dir / host
|
||||
build(host, kind, output)
|
||||
status, error = "passed", ""
|
||||
except Exception as exc:
|
||||
status, error = "failed", f"{type(exc).__name__}: {exc}"
|
||||
checks.append(
|
||||
{
|
||||
"name": f"{host}-{kind}",
|
||||
"group": "python-bundles",
|
||||
"host": host,
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
**({"output": error} if error else {}),
|
||||
}
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def _install_checks(groups: set[str]) -> list[dict[str, Any]]:
|
||||
checks: list[dict[str, Any]] = []
|
||||
for group, package in _package_directories().items():
|
||||
if group not in groups:
|
||||
continue
|
||||
command = (
|
||||
["bun", "install", "--frozen-lockfile"]
|
||||
if group == "opencode"
|
||||
else ["pnpm", "install", "--frozen-lockfile"]
|
||||
)
|
||||
checks.append(
|
||||
_command_check(
|
||||
f"{group}-install",
|
||||
group,
|
||||
command,
|
||||
cwd=package,
|
||||
environment_overrides={"CI": "true"},
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def _runtime_checks(groups: set[str]) -> list[dict[str, Any]]:
|
||||
checks: list[dict[str, Any]] = []
|
||||
directories = {
|
||||
**_package_directories(),
|
||||
"python-tests": REPOSITORY_ROOT,
|
||||
LIVE_GROUP: REPOSITORY_ROOT,
|
||||
}
|
||||
for group, commands in _runtime_commands().items():
|
||||
if group not in groups:
|
||||
continue
|
||||
for index, command in enumerate(commands, start=1):
|
||||
checks.append(
|
||||
_command_check(
|
||||
f"{group}-{index}",
|
||||
group,
|
||||
command,
|
||||
cwd=directories[group],
|
||||
)
|
||||
)
|
||||
if group in TYPESCRIPT_ARTIFACTS:
|
||||
_, required = TYPESCRIPT_ARTIFACTS[group]
|
||||
checks.append(
|
||||
_typescript_artifact_check(
|
||||
group,
|
||||
directories[group],
|
||||
required,
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def _print_report(report: dict[str, Any]) -> None:
|
||||
for check in report["checks"]:
|
||||
marker = {"passed": "PASS", "failed": "FAIL", "planned": "PLAN"}[check["status"]]
|
||||
duration = f" ({check['duration_seconds']:.3f}s)" if "duration_seconds" in check else ""
|
||||
print(f"{marker:4} {check['name']}{duration}")
|
||||
if check["status"] == "failed" and check.get("output"):
|
||||
print(check["output"])
|
||||
print(f"\nConformance: {report['status']} ({len(report['checks'])} checks)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--group", action="append", choices=("all", *GROUPS, LIVE_GROUP), default=[])
|
||||
parser.add_argument("--list", action="store_true", help="Print the checks without running them")
|
||||
parser.add_argument("--install", action="store_true", help="Install TypeScript dependencies first")
|
||||
parser.add_argument("--live", action="store_true", help="Run the opt-in tests against Mem0 Platform")
|
||||
parser.add_argument("--artifacts-dir", type=Path)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
selected = set(GROUPS if not args.group or "all" in args.group else args.group)
|
||||
if args.live:
|
||||
selected.add(LIVE_GROUP)
|
||||
if args.list:
|
||||
report = {"status": "planned", "checks": _planned_checks(selected)}
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
_print_report(report)
|
||||
return 0
|
||||
if LIVE_GROUP in selected and not os.environ.get("MEM0_API_KEY"):
|
||||
parser.error("MEM0_API_KEY is required for --live")
|
||||
|
||||
temporary = None
|
||||
if args.artifacts_dir:
|
||||
artifacts_dir = args.artifacts_dir.resolve()
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
temporary = tempfile.TemporaryDirectory(prefix="mem0-plugin-conformance-")
|
||||
artifacts_dir = Path(temporary.name)
|
||||
|
||||
checks: list[dict[str, Any]] = []
|
||||
try:
|
||||
if args.install:
|
||||
checks.extend(_install_checks(selected))
|
||||
if "python-bundles" in selected:
|
||||
checks.extend(_bundle_checks(artifacts_dir))
|
||||
checks.extend(_runtime_checks(selected))
|
||||
report = {
|
||||
"status": "passed" if all(check["status"] == "passed" for check in checks) else "failed",
|
||||
"checks": checks,
|
||||
}
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
_print_report(report)
|
||||
return 0 if report["status"] == "passed" else 1
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detached remote checkpoint worker.
|
||||
|
||||
Claude Code may cancel SessionEnd hooks as a print-mode process exits. The hook
|
||||
therefore persists its input first and launches this process in a new session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
checkpoint_session,
|
||||
configure_harness,
|
||||
touch_handoff_heartbeat,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
return 2
|
||||
handoff_path = Path(sys.argv[1])
|
||||
os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path)
|
||||
harness = os.environ.get("MEM0_PLUGIN_HARNESS")
|
||||
if harness:
|
||||
source_tag = os.environ.get("MEM0_PLUGIN_SOURCE_TAG", "")
|
||||
configure_harness(
|
||||
harness,
|
||||
env_prefix=os.environ.get("MEM0_PLUGIN_ENV_PREFIX", ""),
|
||||
data_dir_name=os.environ.get("MEM0_PLUGIN_DATA_DIR_NAME", ""),
|
||||
source_tag=source_tag,
|
||||
)
|
||||
telemetry.init(harness=harness, source_tag=source_tag.upper())
|
||||
completed = False
|
||||
try:
|
||||
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
|
||||
delay = float(payload.get("delay_seconds") or 0)
|
||||
if delay > 0:
|
||||
payload.pop("delay_seconds", None)
|
||||
temporary = handoff_path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temporary.replace(handoff_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
time.sleep(delay)
|
||||
if not handoff_path.exists():
|
||||
return 0
|
||||
hook_input = payload.get("hook_input") or {}
|
||||
reason = str(payload.get("reason") or "checkpoint")
|
||||
wait_for_inflight = bool(payload.get("wait_for_inflight"))
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if wait_for_inflight:
|
||||
session_id = str(
|
||||
hook_input.get("session_id") or "unknown-session"
|
||||
)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
deadline = time.monotonic() + float(
|
||||
os.environ.get("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "120")
|
||||
)
|
||||
while (
|
||||
store.has_inflight_flush(repo.identity, session_id)
|
||||
and time.monotonic() < deadline
|
||||
):
|
||||
touch_handoff_heartbeat()
|
||||
time.sleep(0.25)
|
||||
# Hooks capture the conversation before handoff; the worker only flushes it.
|
||||
result = checkpoint_session(store, hook_input, reason)
|
||||
print(json.dumps(result, sort_keys=True), flush=True)
|
||||
completed = result.get("status") in {
|
||||
"semantic-succeeded",
|
||||
"explicitly-stored",
|
||||
"nothing-to-flush",
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
finally:
|
||||
telemetry.flush()
|
||||
if completed:
|
||||
try:
|
||||
handoff_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
elif handoff_path.suffix == ".running":
|
||||
try:
|
||||
handoff_path.replace(handoff_path.with_suffix(".json"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Shared hook orchestration for all Mem0 agent plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
_session_id,
|
||||
api_key,
|
||||
bounded,
|
||||
cache_plugin_api_key,
|
||||
checkpoint_session,
|
||||
clear_stale_api_key_cache,
|
||||
configure_harness,
|
||||
data_dir,
|
||||
detached_process_kwargs,
|
||||
format_context,
|
||||
harness_config,
|
||||
record_session_start,
|
||||
record_tool,
|
||||
record_user_prompt,
|
||||
redact,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
STALE_RUNNING_SECONDS = 300
|
||||
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
PENDING_LAUNCH_LIMIT = 5
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 300
|
||||
|
||||
_core_dir: Path = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def read_hook_input() -> dict:
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def default_record_stop(store: EvidenceStore, hook_input: dict):
|
||||
"""Record the assistant's response without transcript parsing."""
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
if message:
|
||||
store.record_assistant_response(repo, session_id, message)
|
||||
return repo, session_id
|
||||
|
||||
|
||||
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
|
||||
"""Search once before the agent handles the first prompt in a session."""
|
||||
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
|
||||
if not is_first_prompt:
|
||||
return {}
|
||||
try:
|
||||
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
|
||||
except ValueError:
|
||||
minimum_query_chars = 20
|
||||
if len(prompt.strip()) < max(minimum_query_chars, 1):
|
||||
return {}
|
||||
result = search_memories(
|
||||
store, repo, session_id, bounded(prompt, 6000),
|
||||
top_k=5, operation="first-prompt-search", timeout=2,
|
||||
)
|
||||
if not result.memories:
|
||||
return {}
|
||||
context = format_context(
|
||||
result.memories,
|
||||
"Mem0 found these relevant memories from earlier work in this repository:",
|
||||
)
|
||||
telemetry.record(
|
||||
"context_injected",
|
||||
repo=repo, session_id=session_id, trigger="first-prompt",
|
||||
memory_count=len(result.memories), context_chars=len(context),
|
||||
prompt_chars=len(prompt),
|
||||
)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _launch_handoff(handoff_path: Path) -> bool:
|
||||
running_path = handoff_path.with_suffix(".running")
|
||||
try:
|
||||
handoff_path.replace(running_path)
|
||||
except OSError:
|
||||
return False
|
||||
worker = _core_dir / "flush_worker.py"
|
||||
log_path = data_dir() / "flush-worker.log"
|
||||
log_handle = open(log_path, "a", encoding="utf-8")
|
||||
harness = harness_config()
|
||||
child_env = os.environ.copy()
|
||||
child_env.update(
|
||||
{
|
||||
"MEM0_CODE_DATA_DIR": str(data_dir()),
|
||||
"MEM0_PLUGIN_HARNESS": harness["name"],
|
||||
"MEM0_PLUGIN_ENV_PREFIX": harness["env_prefix"],
|
||||
"MEM0_PLUGIN_DATA_DIR_NAME": harness["data_dir_name"],
|
||||
"MEM0_PLUGIN_SOURCE_TAG": harness["source_tag"],
|
||||
}
|
||||
)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(worker), str(running_path)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle, stderr=log_handle,
|
||||
close_fds=True,
|
||||
env=child_env,
|
||||
**detached_process_kwargs(),
|
||||
)
|
||||
finally:
|
||||
log_handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def recover_pending_handoffs() -> int:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
for running in pending_dir.glob("*.running"):
|
||||
try:
|
||||
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
|
||||
running.replace(running.with_suffix(".json"))
|
||||
except OSError:
|
||||
continue
|
||||
recoverable = []
|
||||
for handoff in pending_dir.glob("*.json"):
|
||||
try:
|
||||
age = now - handoff.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > PENDING_EXPIRY_SECONDS:
|
||||
handoff.unlink(missing_ok=True)
|
||||
continue
|
||||
recoverable.append((age, handoff))
|
||||
recoverable.sort(key=lambda item: item[0], reverse=True)
|
||||
launched = 0
|
||||
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
|
||||
launched += int(_launch_handoff(handoff))
|
||||
return launched
|
||||
|
||||
|
||||
def refresh_pending_handoffs() -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
if not pending_dir.is_dir():
|
||||
return
|
||||
for pattern in ("*.json", "*.running"):
|
||||
for handoff in pending_dir.glob(pattern):
|
||||
try:
|
||||
os.utime(handoff)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def hand_off_flush(
|
||||
hook_input: dict, reason: str, *, wait_for_inflight: bool = False,
|
||||
) -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = (
|
||||
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
|
||||
)
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": reason,
|
||||
"wait_for_inflight": wait_for_inflight,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
|
||||
|
||||
def automatic_flush_enabled() -> bool:
|
||||
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
def schedule_periodic_checkpoint(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
if (
|
||||
not automatic_flush_enabled()
|
||||
or not api_key()
|
||||
or not store.checkpoint_due(repo.identity, session_id)
|
||||
):
|
||||
return False
|
||||
if store.prepare_flush(repo, session_id, "periodic") is None:
|
||||
return False
|
||||
hand_off_flush(hook_input, "periodic")
|
||||
return True
|
||||
|
||||
|
||||
def _idle_flush_seconds() -> int:
|
||||
try:
|
||||
return max(
|
||||
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
|
||||
0,
|
||||
)
|
||||
except ValueError:
|
||||
return DEFAULT_IDLE_FLUSH_SECONDS
|
||||
|
||||
|
||||
def schedule_idle_flush(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
delay = _idle_flush_seconds()
|
||||
if delay <= 0 or not automatic_flush_enabled() or not api_key():
|
||||
return False
|
||||
if store.has_inflight_flush(repo.identity, session_id):
|
||||
return False
|
||||
if not store.has_unflushed_events(repo.identity, session_id):
|
||||
return False
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
for old in pending_dir.glob(f"idle-{digest}*"):
|
||||
old.unlink(missing_ok=True)
|
||||
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": "idle",
|
||||
"delay_seconds": delay,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
return True
|
||||
|
||||
|
||||
def log_failure(exc: Exception) -> None:
|
||||
try:
|
||||
log_path = data_dir() / "plugin-errors.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> int:
|
||||
if record_stop_fn is None:
|
||||
record_stop_fn = default_record_stop
|
||||
if automatic_flush_reasons is None:
|
||||
automatic_flush_reasons = {"session-end"}
|
||||
|
||||
base_actions = ["session-start", "user-prompt", "post-tool", "stop", "flush"]
|
||||
all_actions = base_actions + list((extra_actions or {}).keys())
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=all_actions)
|
||||
parser.add_argument("--reason", default="manual")
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.harness:
|
||||
configure_harness(args.harness)
|
||||
telemetry.init(harness=args.harness)
|
||||
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
|
||||
hook_input = read_hook_input()
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if store.is_paused():
|
||||
if args.action == "session-start":
|
||||
refresh_pending_handoffs()
|
||||
telemetry.record("session_start", paused=True)
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
telemetry.record("install")
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
telemetry.record("handoff_recovered", count=recovered)
|
||||
telemetry.spawn_flush()
|
||||
elif args.action == "user-prompt":
|
||||
output = first_prompt_memory_output(store, hook_input)
|
||||
if output:
|
||||
print(json.dumps(output))
|
||||
elif args.action == "post-tool":
|
||||
record_tool(store, hook_input)
|
||||
elif args.action == "stop":
|
||||
repo, session_id = record_stop_fn(store, hook_input)
|
||||
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
|
||||
schedule_idle_flush(store, hook_input, repo, session_id)
|
||||
elif args.action == "flush":
|
||||
automatic = args.reason in automatic_flush_reasons
|
||||
if automatic and not automatic_flush_enabled():
|
||||
return 0
|
||||
if args.reason == "session-end":
|
||||
record_stop_fn(store, hook_input)
|
||||
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
|
||||
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
|
||||
else:
|
||||
session_id = str(hook_input.get("session_id") or "unknown-session")
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
already_running = store.has_inflight_flush(repo.identity, session_id)
|
||||
if already_running and args.reason == "session-end":
|
||||
hand_off_flush(hook_input, args.reason, wait_for_inflight=True)
|
||||
elif not already_running and store.prepare_flush(
|
||||
repo, session_id, args.reason,
|
||||
) is not None:
|
||||
hand_off_flush(hook_input, args.reason)
|
||||
elif extra_actions and args.action in extra_actions:
|
||||
result = extra_actions[args.action](store, hook_input)
|
||||
if result:
|
||||
print(json.dumps(result))
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def entry_point(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
raise SystemExit(run(
|
||||
record_stop_fn=record_stop_fn,
|
||||
extra_actions=extra_actions,
|
||||
data_dir_env=data_dir_env,
|
||||
automatic_flush_reasons=automatic_flush_reasons,
|
||||
))
|
||||
except Exception as exc:
|
||||
log_failure(exc)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
entry_point()
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose Mem0's memory search as one local coding-agent tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
CODING_MEMORY_CATEGORY_NAMES,
|
||||
PLUGIN_VERSION,
|
||||
SEARCH_SCOPES,
|
||||
format_search_result,
|
||||
resolve_repo,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
TOOL_NAME = "search_memories"
|
||||
TOOL_DESCRIPTION = (
|
||||
"Search memories from earlier work in this repository. ALWAYS call this "
|
||||
"tool before answering anything that could depend on prior context: the "
|
||||
"user's preferences, facts about this codebase, history, people, projects, "
|
||||
"or earlier decisions. Do not rely on the chat window alone. The "
|
||||
"repository's memory is shared by everyone who works in it and includes "
|
||||
"what it took to run, test, or build here, so search before assuming an "
|
||||
"invocation works. The scope argument changes what is searched: 'repo' "
|
||||
"(default) is the whole repository's shared memory plus your own "
|
||||
"preferences, 'dir' narrows the shared part to the directory you are "
|
||||
"working in, and 'mine' is your preferences alone."
|
||||
)
|
||||
TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000,
|
||||
"description": "A direct question about earlier work in this repository.",
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum memories to return. Uses Mem0's configured default when omitted.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": list(CODING_MEMORY_CATEGORY_NAMES),
|
||||
"description": "Optional memory category. Omit to search every category.",
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": list(SEARCH_SCOPES),
|
||||
"description": (
|
||||
"Which memories to search. 'repo' (default) is the whole repository's "
|
||||
"shared memory plus your own preferences, 'dir' narrows the shared "
|
||||
"part to the current directory, 'mine' is your preferences alone."
|
||||
),
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": (
|
||||
"Optional coding-agent session ID. With any scope, restricts results to memories "
|
||||
"saved in that session. Omit to recall memories across sessions."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
class ToolInputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_arguments(
|
||||
arguments: Any,
|
||||
) -> tuple[str, int | None, str | None, str | None, str | None]:
|
||||
if not isinstance(arguments, dict):
|
||||
raise ToolInputError("Search arguments must be an object.")
|
||||
|
||||
unknown = set(arguments) - {"query", "top_k", "category", "scope", "run_id"}
|
||||
if unknown:
|
||||
raise ToolInputError(f"Unknown search argument: {sorted(unknown)[0]}")
|
||||
|
||||
query = arguments.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
raise ToolInputError("query must be a non-empty string.")
|
||||
query = query.strip()
|
||||
if len(query) > 2000:
|
||||
raise ToolInputError("query must be at most 2,000 characters.")
|
||||
|
||||
top_k = arguments.get("top_k")
|
||||
if top_k is not None and (
|
||||
isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 20
|
||||
):
|
||||
raise ToolInputError("top_k must be an integer from 1 to 20.")
|
||||
|
||||
category = arguments.get("category")
|
||||
if category is not None and category not in CODING_MEMORY_CATEGORY_NAMES:
|
||||
raise ToolInputError("category must be one of Mem0's supported categories.")
|
||||
|
||||
scope = arguments.get("scope")
|
||||
if scope is not None and scope not in SEARCH_SCOPES:
|
||||
raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.")
|
||||
|
||||
run_id = arguments.get("run_id")
|
||||
if run_id is not None:
|
||||
if not isinstance(run_id, str) or not run_id.strip():
|
||||
raise ToolInputError("run_id must be a non-empty string.")
|
||||
run_id = run_id.strip()
|
||||
|
||||
return query, top_k, category, scope, run_id
|
||||
|
||||
|
||||
def call_search_memories(arguments: Any, cwd: str | None = None) -> str:
|
||||
query, top_k, category, scope, run_id = _validate_arguments(arguments)
|
||||
repo = resolve_repo(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
|
||||
result = search_memories(
|
||||
None,
|
||||
repo,
|
||||
None,
|
||||
query,
|
||||
top_k=top_k,
|
||||
category=category,
|
||||
scope=scope,
|
||||
run_id=run_id,
|
||||
operation="mcp-search",
|
||||
)
|
||||
return format_search_result(result)
|
||||
|
||||
|
||||
def _workspace_cwd(params: dict[str, Any]) -> str | None:
|
||||
meta = params.get("_meta")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
metadata = meta.get("x-codex-turn-metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
workspaces = metadata.get("workspaces") or {}
|
||||
if isinstance(workspaces, dict):
|
||||
return next((path for path in workspaces if isinstance(path, str) and path), None)
|
||||
return None
|
||||
|
||||
|
||||
def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"isError": is_error,
|
||||
}
|
||||
|
||||
|
||||
def handle_request(message: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
request_id = message.get("id")
|
||||
method = message.get("method")
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
if method == "initialize":
|
||||
requested = (message.get("params") or {}).get("protocolVersion")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": requested or PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "mem0", "version": PLUGIN_VERSION},
|
||||
},
|
||||
}
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": TOOL_NAME,
|
||||
"description": TOOL_DESCRIPTION,
|
||||
"inputSchema": TOOL_SCHEMA,
|
||||
"annotations": {
|
||||
"readOnlyHint": True,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
if method == "tools/call":
|
||||
params = message.get("params") or {}
|
||||
if params.get("name") != TOOL_NAME:
|
||||
result = _tool_response("Unknown Mem0 tool.", is_error=True)
|
||||
else:
|
||||
try:
|
||||
result = _tool_response(
|
||||
call_search_memories(params.get("arguments"), _workspace_cwd(params))
|
||||
)
|
||||
except ToolInputError as exc:
|
||||
result = _tool_response(str(exc), is_error=True)
|
||||
except Exception:
|
||||
result = _tool_response("Memory search failed.", is_error=True)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
if request_id is None:
|
||||
return None
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": -32601, "message": "Method not found"},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for raw_line in sys.stdin:
|
||||
try:
|
||||
message = json.loads(raw_line)
|
||||
response = handle_request(message)
|
||||
except json.JSONDecodeError:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
}
|
||||
except Exception:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": "Internal error"},
|
||||
}
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mem0 diagnostics and user controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
api_key,
|
||||
data_dir,
|
||||
doctor,
|
||||
forget_remote_repo,
|
||||
configure_harness,
|
||||
resolve_repo,
|
||||
user_id,
|
||||
)
|
||||
|
||||
|
||||
def _print_status(value: dict) -> None:
|
||||
last = value.get("last_operation") or {}
|
||||
print(f"Mem0: {'paused' if value['paused'] else 'active'}")
|
||||
print(f"Repository: {value['repo_id']}")
|
||||
print(f"Local data: {value['data_dir']}")
|
||||
print(f"API key: {'configured' if value['api_key_configured'] else 'missing'}")
|
||||
print(
|
||||
"Saved on this computer: "
|
||||
f"{value['events']} session details, {value['flushes']} memory updates"
|
||||
)
|
||||
print(
|
||||
f"Used in this repository: {value['retrievals']} memories returned, "
|
||||
f"{value['sidekick_runs']} sidekick runs"
|
||||
)
|
||||
if last:
|
||||
item_label = ""
|
||||
if last["operation"] in {"flush", "flush-retry"}:
|
||||
item_label = f", {last['item_count']} memories"
|
||||
operation = (
|
||||
"memory update"
|
||||
if last["operation"] in {"flush", "flush-retry"}
|
||||
else last["operation"].replace("-", " ")
|
||||
)
|
||||
print(
|
||||
f"Last {operation}: "
|
||||
f"{'succeeded' if last['success'] else 'failed'} "
|
||||
f"({last['duration_ms']:.1f} ms{item_label})"
|
||||
)
|
||||
sidekick = value.get("last_sidekick") or {}
|
||||
if sidekick:
|
||||
state = "finished" if sidekick.get("stopped_at") else "started"
|
||||
print(
|
||||
"Last sidekick: "
|
||||
f"{state}, received {sidekick['context_chars']} characters of memory, "
|
||||
f"agent {sidekick['agent_id']}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
status = subparsers.add_parser("status")
|
||||
status.add_argument("--json", action="store_true")
|
||||
|
||||
doctor_parser = subparsers.add_parser("doctor")
|
||||
doctor_parser.add_argument("--json", action="store_true")
|
||||
|
||||
subparsers.add_parser("pause")
|
||||
subparsers.add_parser("resume")
|
||||
|
||||
forget = subparsers.add_parser("forget")
|
||||
forget.add_argument("--remote", action="store_true")
|
||||
forget.add_argument("--yes", action="store_true")
|
||||
forget.add_argument("--include-project-memory", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.harness:
|
||||
source_tag = f"{args.harness.replace('-', '_')}_plugin"
|
||||
configure_harness(args.harness, source_tag=source_tag)
|
||||
telemetry.init(harness=args.harness, source_tag=source_tag.upper())
|
||||
if args.plugin_data_dir:
|
||||
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
repo = resolve_repo(os.getcwd())
|
||||
telemetry.record("control", repo=repo, action=args.command)
|
||||
if args.command == "status":
|
||||
result = {
|
||||
**store.status(repo.identity),
|
||||
"repo_id": repo.identity,
|
||||
"app_id": repo.app_id,
|
||||
"project_id": repo.project_id,
|
||||
"directory": repo.directory,
|
||||
"user_id": user_id(),
|
||||
"data_dir": str(data_dir()),
|
||||
"api_key_configured": bool(api_key()),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_print_status(result)
|
||||
elif args.command == "doctor":
|
||||
result = doctor(os.getcwd())
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
for name, check in result["checks"].items():
|
||||
print(
|
||||
f"{'PASS' if check['ok'] else 'FAIL'} {name}: {check['detail']}"
|
||||
)
|
||||
return 0 if result["ok"] else 1
|
||||
elif args.command == "pause":
|
||||
store.set_setting("paused", "true")
|
||||
print("Mem0 stopped saving and searching memories.")
|
||||
elif args.command == "resume":
|
||||
store.set_setting("paused", "false")
|
||||
print("Mem0 resumed saving and searching memories.")
|
||||
elif args.command == "forget":
|
||||
if not args.yes:
|
||||
print(
|
||||
"Refusing to delete data without --yes. Add --remote to also "
|
||||
"delete this user/repository scope from Mem0."
|
||||
)
|
||||
return 2
|
||||
remote_result = (
|
||||
forget_remote_repo(
|
||||
repo, include_project_memory=args.include_project_memory
|
||||
)
|
||||
if args.remote
|
||||
else None
|
||||
)
|
||||
local_result = store.forget_local_repo(repo.identity)
|
||||
print(
|
||||
json.dumps(
|
||||
{"local": local_result, "remote": remote_result},
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
if remote_result and remote_result.get("status") == "error":
|
||||
return 1
|
||||
finally:
|
||||
store.close()
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anonymous usage telemetry for Mem0 agent plugins.
|
||||
|
||||
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
|
||||
touches the network: `record` appends one JSON line to a local spool and returns.
|
||||
A detached `python3 telemetry.py` drains the spool in one batched PostHog request,
|
||||
started once per session and again from the flush worker that is already detached.
|
||||
|
||||
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
|
||||
|
||||
Never sends prompts, memory text, queries, file paths, repository names, or API
|
||||
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import memory_core
|
||||
|
||||
_harness: str = "generic"
|
||||
_source_tag: str = "MEM0_PLUGIN"
|
||||
_PRIVATE_KEYS = {
|
||||
"apikey",
|
||||
"authorization",
|
||||
"password",
|
||||
"query",
|
||||
"secret",
|
||||
"prompt",
|
||||
"token",
|
||||
"text",
|
||||
"memory",
|
||||
"message",
|
||||
"error",
|
||||
"path",
|
||||
"cwd",
|
||||
"userid",
|
||||
"agentid",
|
||||
"runid",
|
||||
"repoid",
|
||||
"repositoryid",
|
||||
"projectid",
|
||||
"appid",
|
||||
"filters",
|
||||
}
|
||||
|
||||
|
||||
def init(harness: str = "generic", source_tag: str = "") -> None:
|
||||
global _harness, _source_tag
|
||||
_harness = harness
|
||||
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
|
||||
POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/"
|
||||
EVENT_PREFIX = "code"
|
||||
SPOOL_LIMIT_BYTES = 256 * 1024
|
||||
BATCH_SIZE = 100
|
||||
SEND_TIMEOUT = 5
|
||||
CLAIM_STALE_SECONDS = 120
|
||||
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Whether telemetry is switched on for this process."""
|
||||
return os.environ.get("MEM0_TELEMETRY", "true").strip().lower() not in {
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
|
||||
|
||||
def _digest(value: str, length: int = 16) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return memory_core.redact(value)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _safe_value(item)
|
||||
for key, item in value.items()
|
||||
if "".join(character for character in str(key).lower() if character.isalnum())
|
||||
not in _PRIVATE_KEYS
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_safe_value(item) for item in value]
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
return memory_core.redact(value)
|
||||
|
||||
|
||||
def _spool_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry.jsonl"
|
||||
|
||||
|
||||
def _identity_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry-identity.json"
|
||||
|
||||
|
||||
def _read_identity() -> dict[str, str]:
|
||||
try:
|
||||
value = json.loads(_identity_path().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _write_identity(identity: dict[str, str]) -> None:
|
||||
path = _identity_path()
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary.write_text(json.dumps(identity), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def anonymous_id(identity: dict[str, str] | None = None) -> str:
|
||||
"""Per-machine anonymous identifier, created and persisted on first use."""
|
||||
identity = _read_identity() if identity is None else identity
|
||||
existing = identity.get("anonymous_id")
|
||||
if existing:
|
||||
return existing
|
||||
created = f"code-anon-{uuid.uuid4().hex}"
|
||||
identity["anonymous_id"] = created
|
||||
_write_identity(identity)
|
||||
return created
|
||||
|
||||
|
||||
def is_first_run() -> bool:
|
||||
"""Whether this machine has never recorded a plugin event before."""
|
||||
return not _identity_path().exists()
|
||||
|
||||
|
||||
def record(
|
||||
event: str,
|
||||
*,
|
||||
repo: Any = None,
|
||||
session_id: str | None = None,
|
||||
**properties: Any,
|
||||
) -> None:
|
||||
"""Append one event to the local spool. Never blocks and never raises."""
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
spool = _spool_path()
|
||||
try:
|
||||
if spool.stat().st_size > SPOOL_LIMIT_BYTES:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
properties = _safe_value(properties)
|
||||
properties.update(
|
||||
harness=_harness,
|
||||
plugin_version=memory_core.PLUGIN_VERSION,
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
if repo is not None:
|
||||
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
|
||||
if session_id:
|
||||
properties["session_hash"] = _digest(session_id)
|
||||
line = json.dumps(
|
||||
{
|
||||
"event": f"{EVENT_PREFIX}.{event}",
|
||||
"timestamp": memory_core.utc_now(),
|
||||
"properties": {
|
||||
key: value for key, value in properties.items() if value is not None
|
||||
},
|
||||
},
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
spool.parent.mkdir(parents=True, exist_ok=True)
|
||||
with spool.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def error_kind(exc: BaseException | str) -> str:
|
||||
"""Coarse, content-free label for a failure, safe to send."""
|
||||
text = exc if isinstance(exc, str) else f"{type(exc).__name__}: {exc}"
|
||||
lowered = text.lower()
|
||||
if "timed out" in lowered or "timeout" in lowered:
|
||||
return "timeout"
|
||||
if "401" in lowered or "403" in lowered or "unauthor" in lowered or "forbidden" in lowered:
|
||||
return "auth"
|
||||
if "429" in lowered or "rate limit" in lowered:
|
||||
return "rate-limited"
|
||||
if any(code in lowered for code in ("500", "502", "503", "504")):
|
||||
return "server-error"
|
||||
if "400" in lowered or "422" in lowered:
|
||||
return "bad-request"
|
||||
if isinstance(exc, str):
|
||||
return "other"
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
return "network"
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def spawn_flush() -> bool:
|
||||
"""Start the detached sender that drains the spool."""
|
||||
if not is_enabled():
|
||||
return False
|
||||
try:
|
||||
if not _spool_path().exists() and not any(
|
||||
memory_core.data_dir().glob("telemetry-*.sending")
|
||||
):
|
||||
return False
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(Path(__file__).resolve())],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
**memory_core.detached_process_kwargs(),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_spool() -> Path | None:
|
||||
"""Rename the spool aside so exactly one sender owns each batch."""
|
||||
directory = memory_core.data_dir()
|
||||
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
|
||||
spool = _spool_path()
|
||||
try:
|
||||
spool.replace(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
pass
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending")):
|
||||
try:
|
||||
age = now - orphan.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > CLAIM_EXPIRY_SECONDS:
|
||||
try:
|
||||
orphan.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
continue
|
||||
try:
|
||||
orphan.replace(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_email(key: str) -> str:
|
||||
"""Trade the API key for the account email so events join other Mem0 surfaces."""
|
||||
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return ""
|
||||
email = payload.get("user_email") if isinstance(payload, dict) else ""
|
||||
return email if isinstance(email, str) else ""
|
||||
|
||||
|
||||
def _post(payload: dict[str, Any], url: str) -> bool:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload, default=str).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_distinct_id() -> tuple[str, str]:
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
|
||||
identity = _read_identity()
|
||||
email = identity.get("email", "")
|
||||
if email:
|
||||
return email, ""
|
||||
key = memory_core.api_key()
|
||||
if not key:
|
||||
return anonymous_id(identity), ""
|
||||
email = _resolve_email(key)
|
||||
if not email:
|
||||
return anonymous_id(identity), ""
|
||||
previous = identity.get("anonymous_id", "")
|
||||
identity["email"] = email
|
||||
_write_identity(identity)
|
||||
return email, previous
|
||||
|
||||
|
||||
def flush() -> int:
|
||||
"""Drain claimed spools to PostHog and return the number of events sent."""
|
||||
if not is_enabled():
|
||||
return 0
|
||||
claim = _claim_spool()
|
||||
if claim is None:
|
||||
return 0
|
||||
try:
|
||||
lines = claim.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return 0
|
||||
events = []
|
||||
for line in lines:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("event"):
|
||||
events.append(value)
|
||||
if not events:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
distinct_id, aliased_anonymous_id = resolve_distinct_id()
|
||||
if aliased_anonymous_id:
|
||||
_post(
|
||||
{
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"event": "$identify",
|
||||
"distinct_id": distinct_id,
|
||||
"properties": {
|
||||
"$anon_distinct_id": aliased_anonymous_id,
|
||||
"$lib": "posthog-python",
|
||||
},
|
||||
},
|
||||
POSTHOG_CAPTURE_URL,
|
||||
)
|
||||
|
||||
sent = 0
|
||||
for start in range(0, len(events), BATCH_SIZE):
|
||||
batch = [
|
||||
{
|
||||
"event": event["event"],
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": event.get("timestamp"),
|
||||
"properties": {
|
||||
"source": _source_tag,
|
||||
"language": "python",
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(event.get("properties") or {}),
|
||||
},
|
||||
}
|
||||
for event in events[start : start + BATCH_SIZE]
|
||||
]
|
||||
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
|
||||
return sent
|
||||
sent += len(batch)
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
@@ -0,0 +1,3 @@
|
||||
jsonschema>=4.23,<5
|
||||
pytest>=8,<10
|
||||
skills-ref==0.1.1
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: forget
|
||||
description: Delete the Mem0 memories stored for this repository and this user. Use when the user asks to forget, clear, wipe, or delete memories.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Forget this repository's memories
|
||||
|
||||
This permanently deletes remote memories. Before running anything, tell the
|
||||
user exactly what will be deleted: their own memories for this repository
|
||||
only. The repository's project memory is shared by everyone who works in it,
|
||||
so it stays unless the user explicitly asks to delete that too.
|
||||
|
||||
After the user confirms, run:
|
||||
|
||||
```bash
|
||||
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} forget --remote --yes
|
||||
```
|
||||
|
||||
If the user also asked to delete the repository's shared project memory, add
|
||||
`--include-project-memory` and say that this removes it for every teammate.
|
||||
|
||||
Report what the command output says was deleted. If the user only wants local
|
||||
data cleared (evidence log, pending queue), run the same command without
|
||||
`--remote`. Never pass `--yes` before the user has confirmed in this
|
||||
conversation.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: pause
|
||||
description: Pause Mem0 memory capture on this machine. Use when the user wants to stop memories being recorded, for example for private work or experiments.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Pause memory capture
|
||||
|
||||
To pause (hooks stop capturing and sending session content; a minimal
|
||||
anonymous telemetry ping still fires at session start unless
|
||||
`MEM0_TELEMETRY=false`):
|
||||
|
||||
```bash
|
||||
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} pause
|
||||
```
|
||||
|
||||
Confirm the new state back to the user, and remind them that already-created
|
||||
memories still exist and remain searchable. Pending unsent packets are held
|
||||
while paused, not expired, and are delivered after resuming. To turn capture
|
||||
back on, use `/mem0:resume`.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: remember
|
||||
description: Acknowledge a "remember this" request and make sure it is captured well. Use when the user explicitly asks to remember, note, or save something for future sessions.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Remember something for future sessions
|
||||
|
||||
Mem0 creates memories from the session automatically — there is no separate
|
||||
write command. When the user asks to remember something:
|
||||
|
||||
1. Restate the fact clearly and completely in your reply, in one or two
|
||||
sentences, including any names, values, or paths it depends on. Your visible
|
||||
reply is what memory extraction reads, so a precise restatement is what gets
|
||||
remembered.
|
||||
2. Tell the user it will be saved with this session's memories when the session
|
||||
ends or compacts, and that it will surface in future sessions in this
|
||||
repository (they can check later with /mem0:search).
|
||||
|
||||
Do not invent a storage confirmation or a memory ID — creation happens in the
|
||||
background after the session.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: resume
|
||||
description: Resume Mem0 memory capture after it was paused with /mem0:pause.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Resume memory capture
|
||||
|
||||
Resume memory capture for this machine.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} resume
|
||||
```
|
||||
|
||||
Confirm to the user that capture is active again. New sessions record evidence and
|
||||
create memories as normal; nothing that happened while paused is retroactively
|
||||
captured.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: search
|
||||
description: Search memories from earlier {{HARNESS_NAME}} sessions in this repository. Use it when earlier work may already explain the code, error, decision, or command you need, so you can avoid repeating file reads, searches, or experiments.
|
||||
argument-hint: "[question] [--top-k number] [--category category-name] [--scope repo|dir|mine] [--run-id session-id]"
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Search memories
|
||||
|
||||
Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
|
||||
`--scope`, and `--run-id` as tool arguments instead of including them in the
|
||||
query.
|
||||
|
||||
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
|
||||
category; a category is a best-effort label Mem0 assigned when it saved the
|
||||
memory, so if a category search misses, repeat it without the category. Omit
|
||||
`scope` to use the configured default, normally `repo`: this repository's
|
||||
shared memory, which everyone who works in it contributes to, plus your own
|
||||
preferences.
|
||||
|
||||
Pass `scope` when the question needs something else: `dir` to narrow the
|
||||
shared memory to the directory you are working in (a package inside a
|
||||
monorepo), `mine` for your own preferences alone.
|
||||
|
||||
Pass `run_id` with any scope to retrieve memories saved in a specific coding-agent
|
||||
session. Omit `run_id` to search across sessions. It filters the memories returned;
|
||||
it does not identify the session making the search request. Use a known session ID,
|
||||
never invent one. Return the tool's result directly.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: status
|
||||
description: Show whether Mem0 memory is working in this repository, covering configuration, capture state, pending flushes, and whether the Mem0 API key is valid. Use when the user asks whether memory is on, why a memory is missing, or anything looks broken.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Memory status
|
||||
|
||||
Run both commands and report the combined result in plain language:
|
||||
|
||||
```bash
|
||||
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} status --json
|
||||
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} doctor
|
||||
```
|
||||
|
||||
Summarize, using only fields the JSON actually reports: whether capture is
|
||||
active or paused, the user ID and repository scope (`repo_id`), whether an
|
||||
API key is configured, the event/flush/retrieval counts (`flushes` is the
|
||||
number of completed flushes, not a pending count), and the doctor check
|
||||
results. If doctor reports an authentication failure (401 / invalid key), say
|
||||
clearly that the Mem0 API key is invalid or expired and that memories are NOT
|
||||
being created. Never report an auth failure as "no memories found". Suggest
|
||||
reinstalling with `--config api_key=...` in that case.
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = ROOT.parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from build.build import build, bundle_drift, render_template, replace_output # noqa: E402
|
||||
from build.validate import validate_bundle # noqa: E402
|
||||
|
||||
|
||||
def test_render_rejects_unknown_or_unresolved_tokens() -> None:
|
||||
with pytest.raises(ValueError, match="UNKNOWN"):
|
||||
render_template("run {{UNKNOWN}}", {})
|
||||
|
||||
|
||||
def test_build_replaces_only_the_requested_output(tmp_path: Path) -> None:
|
||||
staged = tmp_path / "staged"
|
||||
staged.mkdir()
|
||||
(staged / "plugin.json").write_text("{}", encoding="utf-8")
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
(output / "stale.py").write_text("stale", encoding="utf-8")
|
||||
sibling = tmp_path / "keep.txt"
|
||||
sibling.write_text("keep", encoding="utf-8")
|
||||
|
||||
replace_output(staged, output)
|
||||
|
||||
assert not (output / "stale.py").exists()
|
||||
assert (output / "plugin.json").exists()
|
||||
assert sibling.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
|
||||
def test_build_cannot_replace_an_installable_source_directory(tmp_path: Path) -> None:
|
||||
staged = tmp_path / "staged"
|
||||
staged.mkdir()
|
||||
|
||||
with pytest.raises(ValueError, match="protected output path"):
|
||||
replace_output(staged, REPOSITORY_ROOT / "integrations" / "claude-code-plugin")
|
||||
|
||||
|
||||
def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> None:
|
||||
root = build("mem0-agent-plugin", "portable", tmp_path / "mem0-agent-plugin")
|
||||
|
||||
assert validate_bundle(root, "portable") == []
|
||||
assert json.loads((root / "plugin.json").read_text(encoding="utf-8"))["$schema"] == (
|
||||
"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
)
|
||||
server = json.loads((root / "mcp.json").read_text(encoding="utf-8"))["mcpServers"]["mem0"]
|
||||
assert server["type"] == "stdio"
|
||||
assert server["args"] == ["${PLUGIN_ROOT}/core/mcp_server.py"]
|
||||
assert "env" not in server
|
||||
assert not (root / "core" / "hook_runner.py").exists()
|
||||
assert not (root / "core" / "flush_worker.py").exists()
|
||||
for skill in (root / "skills").glob("*/SKILL.md"):
|
||||
frontmatter = skill.read_text(encoding="utf-8").split("---", 2)[1]
|
||||
keys = {line.split(":", 1)[0] for line in frontmatter.splitlines() if ":" in line}
|
||||
assert keys <= {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
|
||||
root = build(host, "native", tmp_path / host)
|
||||
|
||||
assert (root / "core" / "memory_core.py").is_file()
|
||||
assert (root / "skills" / "remember" / "SKILL.md").is_file()
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_native_control_skills_select_the_host_store(host: str, tmp_path: Path) -> None:
|
||||
root = build(host, "native", tmp_path / host)
|
||||
status = (root / "skills" / "status" / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
assert f'--harness "{host}"' in status
|
||||
if host == "claude-code":
|
||||
assert '--plugin-data-dir "${CLAUDE_PLUGIN_DATA}"' in status
|
||||
elif host == "codex":
|
||||
assert '--plugin-data-dir "${PLUGIN_DATA}"' in status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("host", "kind"),
|
||||
[
|
||||
("mem0-agent-plugin", "portable"),
|
||||
("claude-code", "native"),
|
||||
("cursor", "native"),
|
||||
("codex", "native"),
|
||||
("kimi", "native"),
|
||||
("antigravity", "native"),
|
||||
],
|
||||
)
|
||||
def test_installable_plugin_directories_are_current(host: str, kind: str) -> None:
|
||||
assert bundle_drift(host, kind) == []
|
||||
|
||||
|
||||
def test_marketplaces_keep_public_names_and_reference_real_plugins() -> None:
|
||||
marketplace = json.loads((REPOSITORY_ROOT / "marketplace.json").read_text(encoding="utf-8"))
|
||||
sources = {plugin["name"]: plugin["source"] for plugin in marketplace["plugins"]}
|
||||
|
||||
assert sources == {"mem0": "./integrations/claude-code-plugin"}
|
||||
for source in sources.values():
|
||||
assert (REPOSITORY_ROOT / source).exists()
|
||||
|
||||
codex_marketplace = json.loads(
|
||||
(REPOSITORY_ROOT / ".agents" / "plugins" / "marketplace.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert [plugin["name"] for plugin in codex_marketplace["plugins"]] == ["mem0"]
|
||||
codex = codex_marketplace["plugins"][0]
|
||||
assert codex["source"]["path"] == "./integrations/codex-plugin"
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
def test_python_bundle_conformance_builds_every_host(tmp_path: Path) -> None:
|
||||
report = tmp_path / "report.json"
|
||||
artifacts = tmp_path / "artifacts"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
"--group",
|
||||
"python-bundles",
|
||||
"--artifacts-dir",
|
||||
str(artifacts),
|
||||
"--report",
|
||||
str(report),
|
||||
],
|
||||
cwd=PLUGIN_ROOT.parents[1],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
payload = json.loads(report.read_text(encoding="utf-8"))
|
||||
assert payload["status"] == "passed"
|
||||
assert {
|
||||
entry["host"]
|
||||
for entry in payload["checks"]
|
||||
if entry["kind"] == "native"
|
||||
} == PYTHON_HOSTS
|
||||
assert {
|
||||
entry["host"]
|
||||
for entry in payload["checks"]
|
||||
if entry["kind"] == "portable"
|
||||
} == {"mem0-agent-plugin"}
|
||||
for host in PYTHON_HOSTS:
|
||||
assert (artifacts / host).is_dir()
|
||||
assert (artifacts / "mem0-agent-plugin").is_dir()
|
||||
|
||||
|
||||
def test_conformance_plan_covers_every_runtime(tmp_path: Path) -> None:
|
||||
report = tmp_path / "plan.json"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(RUNNER), "--list", "--report", str(report)],
|
||||
cwd=PLUGIN_ROOT.parents[1],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
payload = json.loads(report.read_text(encoding="utf-8"))
|
||||
assert {entry["group"] for entry in payload["checks"]} == {
|
||||
"python-bundles",
|
||||
"python-tests",
|
||||
"typescript-core",
|
||||
"openclaw",
|
||||
"opencode",
|
||||
"pi-agent",
|
||||
"deepseek",
|
||||
}
|
||||
assert all(entry["status"] == "planned" for entry in payload["checks"])
|
||||
assert {
|
||||
entry["group"]
|
||||
for entry in payload["checks"]
|
||||
if entry["name"].endswith("-artifact")
|
||||
} == {"openclaw", "opencode", "pi-agent", "deepseek"}
|
||||
|
||||
|
||||
def test_typescript_artifact_check_rejects_monorepo_imports(tmp_path: Path) -> None:
|
||||
dist = tmp_path / "dist"
|
||||
dist.mkdir()
|
||||
(dist / "index.js").write_text(
|
||||
'import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
artifact_check = getattr(conformance_run, "_typescript_artifact_check", None)
|
||||
assert artifact_check is not None, "TypeScript package artifacts are not checked"
|
||||
result = artifact_check("example", tmp_path, ("dist/index.js",))
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert "monorepo source import" in result["output"]
|
||||
|
||||
|
||||
def test_live_conformance_requires_an_explicit_mem0_key(tmp_path: Path) -> None:
|
||||
environment = dict(os.environ)
|
||||
environment.pop("MEM0_API_KEY", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(RUNNER), "--live", "--report", str(tmp_path / "report.json")],
|
||||
cwd=PLUGIN_ROOT.parents[1],
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "MEM0_API_KEY is required for --live" in result.stderr
|
||||
|
||||
|
||||
def test_live_conformance_reports_platform_failure_without_crashing(tmp_path: Path) -> None:
|
||||
report = tmp_path / "report.json"
|
||||
environment = {
|
||||
**os.environ,
|
||||
"MEM0_API_KEY": "m0-intentionally-invalid",
|
||||
"MEM0_API_URL": "http://127.0.0.1:1",
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
"--group",
|
||||
"live-platform",
|
||||
"--live",
|
||||
"--report",
|
||||
str(report),
|
||||
],
|
||||
cwd=PLUGIN_ROOT.parents[1],
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
payload = json.loads(report.read_text(encoding="utf-8"))
|
||||
assert payload["status"] == "failed"
|
||||
assert payload["checks"][0]["group"] == "live-platform"
|
||||
|
||||
|
||||
def test_runtime_checks_preserve_the_callers_telemetry_setting(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
|
||||
|
||||
result = _command_check(
|
||||
"environment",
|
||||
"test",
|
||||
[sys.executable, "-c", "import os; print(os.environ.get('MEM0_TELEMETRY', 'unset'))"],
|
||||
cwd=tmp_path,
|
||||
)
|
||||
|
||||
assert result["status"] == "passed"
|
||||
assert result["output"] == "unset"
|
||||
|
||||
|
||||
def test_command_check_can_force_non_interactive_installs(tmp_path: Path) -> None:
|
||||
result = _command_check(
|
||||
"environment",
|
||||
"test",
|
||||
[sys.executable, "-c", "import os; print(os.environ['CI'])"],
|
||||
cwd=tmp_path,
|
||||
environment_overrides={"CI": "true"},
|
||||
)
|
||||
|
||||
assert result["status"] == "passed"
|
||||
assert result["output"] == "true"
|
||||
|
||||
|
||||
def test_conformance_report_redacts_command_output(tmp_path: Path) -> None:
|
||||
secret = "sk-eval-12345678901234567890"
|
||||
|
||||
result = _command_check(
|
||||
"redaction",
|
||||
"test",
|
||||
[sys.executable, "-c", f"print('failure: {secret}')"],
|
||||
cwd=tmp_path,
|
||||
)
|
||||
|
||||
assert secret not in json.dumps(result)
|
||||
assert "[REDACTED]" in result["output"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Exercise bundled subagent hooks as separate host processes, without Mem0 calls."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "python"))
|
||||
|
||||
from memory_core import EvidenceStore # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "codex", "kimi", "cursor"])
|
||||
def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path, host):
|
||||
parent = tmp_path / "parent"
|
||||
child = tmp_path / "child-worktree"
|
||||
parent.mkdir()
|
||||
child.mkdir()
|
||||
database = tmp_path / "data" / "evidence.sqlite3"
|
||||
store = EvidenceStore(database)
|
||||
repo = store.repo_for_session("parent-session", str(parent))
|
||||
store.mark_injected("parent-session", repo.identity, [{"id": "one", "memory": "Parent memory marker."}])
|
||||
store.mark_injected("other-session", repo.identity, [{"id": "two", "memory": "Foreign memory marker."}])
|
||||
store.close()
|
||||
|
||||
plugin = ROOT.parent / f"{host}-plugin"
|
||||
adapter = plugin / ("adapters/claude/hook.py" if host == "claude-code" else "hooks/adapter.py")
|
||||
start, stop = "sidekick-start", "sidekick-stop"
|
||||
payload = {"session_id": "parent-session", "cwd": str(child), "agent_id": "worker", "agent_type": "sidekick"}
|
||||
response_key = "last_assistant_message"
|
||||
if host == "kimi":
|
||||
start, stop = "SubagentStart", "SubagentStop"
|
||||
payload["agent_name"] = payload.pop("agent_type")
|
||||
response_key = "response"
|
||||
elif host == "cursor":
|
||||
start, stop = "subagentStart", "subagentStop"
|
||||
payload = {
|
||||
"conversation_id": "parent-session",
|
||||
"workspace_roots": [str(child)],
|
||||
"subagent_id": "worker",
|
||||
"subagent_type": "sidekick",
|
||||
}
|
||||
response_key = "summary"
|
||||
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith(("MEM0_", "CLAUDE_PLUGIN_"))}
|
||||
env.update(MEM0_CODE_DATA_DIR=str(database.parent), MEM0_TELEMETRY="false", MEM0_API_URL="http://127.0.0.1:1")
|
||||
|
||||
def invoke(event, body):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(adapter), event],
|
||||
input=json.dumps({**body, "hook_event_name": event}),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
timeout=15,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
output = invoke(start, payload)
|
||||
if host == "cursor":
|
||||
assert json.loads(output) == {"permission": "allow"}
|
||||
else:
|
||||
context = output if host == "kimi" else json.loads(output)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "Parent memory marker." in context
|
||||
assert "Foreign memory marker." not in output
|
||||
assert "Parent memory marker." not in invoke(start, payload)
|
||||
invoke(stop, {**payload, response_key: "Finished the delegated task."})
|
||||
|
||||
store = EvidenceStore(database)
|
||||
runs = store.conn.execute("SELECT * FROM sidekick_runs").fetchall()
|
||||
store.close()
|
||||
assert len(runs) == 1
|
||||
assert (runs[0]["repo_id"], runs[0]["session_id"], runs[0]["agent_id"]) == (
|
||||
repo.identity, "parent-session", "worker"
|
||||
)
|
||||
assert runs[0]["stopped_at"]
|
||||
assert runs[0]["final_message"] == "Finished the delegated task."
|
||||
assert (runs[0]["context_chars"] > 0) == (host != "cursor")
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATE = ROOT / "build" / "validate.py"
|
||||
PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
|
||||
|
||||
def run_validator(bundle: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(VALIDATE), str(bundle), "--kind", "portable"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def write_manifest(bundle: Path, schema: str = PLUGIN_SCHEMA) -> None:
|
||||
bundle.mkdir(parents=True, exist_ok=True)
|
||||
(bundle / "plugin.json").write_text(
|
||||
json.dumps({"$schema": schema, "name": "mem0"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_wrong_agent_plugin_schema(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "plugin"
|
||||
write_manifest(bundle, "https://agent-plugins.org/v1.0.0/plugin.schema.json")
|
||||
|
||||
result = run_validator(bundle)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stderr == (
|
||||
"plugin.json.$schema: "
|
||||
"'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' was expected\n"
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_symlink_outside_bundle(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "plugin"
|
||||
write_manifest(bundle)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(bundle / "skills").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
result = run_validator(bundle)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stderr == "skills: symlinks are not allowed in release bundles\n"
|
||||
|
||||
|
||||
def test_accepts_minimal_portable_bundle(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "plugin"
|
||||
write_manifest(bundle)
|
||||
|
||||
result = run_validator(bundle)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == f"Validated portable bundle: {bundle}\n"
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_rejects_nonconformant_agent_skill(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "plugin"
|
||||
write_manifest(bundle)
|
||||
skill = bundle / "skills" / "Bad_Name"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: Bad_Name\ndescription: invalid\nunknown: true\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_validator(bundle)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "skills/Bad_Name" in result.stderr
|
||||
|
||||
|
||||
def run_native_validator(bundle: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(VALIDATE), str(bundle), "--kind", "native"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_native_bundle_rejects_malformed_json(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "plugin"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "plugin.json").write_text("{bad json", encoding="utf-8")
|
||||
|
||||
result = run_native_validator(bundle)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "plugin.json" in result.stderr
|
||||
|
||||
|
||||
def test_native_bundle_accepts_valid_json(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "plugin"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "plugin.json").write_text('{"name": "mem0"}', encoding="utf-8")
|
||||
hooks = bundle / "hooks"
|
||||
hooks.mkdir()
|
||||
(hooks / "hooks.json").write_text('{"version": 1}', encoding="utf-8")
|
||||
|
||||
result = run_native_validator(bundle)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "native" in result.stdout
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@mem0/agent-plugin-core",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.15.0
|
||||
version: 22.20.1
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@types/node@22.20.1':
|
||||
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@types/node@22.20.1':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface MemoryLike {
|
||||
id: string;
|
||||
memory?: string;
|
||||
categories?: string[];
|
||||
createdAt?: Date | string;
|
||||
}
|
||||
|
||||
export const MAX_OUTPUT_LINES = 200;
|
||||
export const MAX_OUTPUT_CHARS = 50_000;
|
||||
export const MAX_OUTPUT_BYTES = MAX_OUTPUT_CHARS;
|
||||
|
||||
export function formatAge(date: Date | string): string {
|
||||
const minutes = Math.floor((Date.now() - new Date(date).getTime()) / 60_000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function formatMemoryCompact(memory: MemoryLike): string {
|
||||
const category = memory.categories?.[0] ?? "uncategorized";
|
||||
const age = memory.createdAt ? ` (${formatAge(memory.createdAt)})` : "";
|
||||
return `[${category}] ${memory.memory ?? "(empty)"}${age} [mem0:${memory.id}]`;
|
||||
}
|
||||
|
||||
export function formatMemoryList(memories: MemoryLike[]): string {
|
||||
return memories.length
|
||||
? memories.map((memory, index) => `${index + 1}. ${formatMemoryCompact(memory)}`).join("\n")
|
||||
: "No memories found.";
|
||||
}
|
||||
|
||||
export function formatAddResult(result: unknown): string {
|
||||
const items: MemoryLike[] = Array.isArray(result)
|
||||
? result
|
||||
: ((result as { results?: MemoryLike[] } | null)?.results ?? (result ? [result as MemoryLike] : []));
|
||||
const pending = items.find((item) => (item as { status?: string }).status === "PENDING") as
|
||||
| { eventId?: string; event_id?: string }
|
||||
| undefined;
|
||||
if (pending) {
|
||||
const id = pending.eventId ?? pending.event_id;
|
||||
return `Memory queued for background extraction${id ? ` (event ${id})` : ""}; it will be searchable shortly.`;
|
||||
}
|
||||
if (!items.length) return "Memory stored.";
|
||||
return `Stored ${items.length} ${items.length === 1 ? "memory" : "memories"}:\n${formatMemoryList(items)}`;
|
||||
}
|
||||
|
||||
export function groupByCategory(memories: MemoryLike[]): Map<string, MemoryLike[]> {
|
||||
const groups = new Map<string, MemoryLike[]>();
|
||||
for (const memory of memories) {
|
||||
const category = memory.categories?.[0] ?? "uncategorized";
|
||||
groups.set(category, [...(groups.get(category) ?? []), memory]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function truncateOutput(
|
||||
text: string,
|
||||
maxChars = MAX_OUTPUT_CHARS,
|
||||
maxLines = MAX_OUTPUT_LINES,
|
||||
): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= maxLines && text.length <= maxChars) return text;
|
||||
const kept = lines.slice(0, maxLines);
|
||||
let result = kept.join("\n");
|
||||
const charCapped = result.length > maxChars;
|
||||
if (charCapped) result = result.slice(0, maxChars);
|
||||
const reasons = [];
|
||||
if (kept.length < lines.length) reasons.push(`showing ${kept.length} of ${lines.length} lines`);
|
||||
if (charCapped) reasons.push(`cut at ${Math.floor(maxChars / 1000)}KB`);
|
||||
return `${result}\n\n[Output truncated: ${reasons.join(", ")}]`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface EntityParams {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
const clean = (value: string | undefined): string | undefined => value?.trim() || undefined;
|
||||
|
||||
export function entitySearchFilters(
|
||||
params: EntityParams,
|
||||
defaultUserId: string,
|
||||
): Record<string, string> {
|
||||
const filters: Record<string, string> = { user_id: clean(params.userId) ?? defaultUserId };
|
||||
const agentId = clean(params.agentId);
|
||||
const runId = clean(params.runId);
|
||||
if (agentId) filters.agent_id = agentId;
|
||||
if (runId) filters.run_id = runId;
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function entityAddParams(params: EntityParams, defaultUserId: string): Record<string, string> {
|
||||
const values: Record<string, string> = { userId: clean(params.userId) ?? defaultUserId };
|
||||
const agentId = clean(params.agentId);
|
||||
const runId = clean(params.runId);
|
||||
if (agentId) values.agentId = agentId;
|
||||
if (runId) values.runId = runId;
|
||||
return values;
|
||||
}
|
||||
|
||||
export function parseProjectFromRemote(remote: string): string | null {
|
||||
const match = remote.trim().match(/[:/]([^/:]+)\/([^/:]+?)(?:\.git)?\/?$/);
|
||||
return match ? `${match[1]}-${match[2]}` : null;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { MemoryLike } from "./formatting.ts";
|
||||
import { formatMemoryCompact } from "./formatting.ts";
|
||||
|
||||
const MAX_RECALL_QUERY_CHARS = 6_000;
|
||||
export const DEFAULT_MAX_CONTEXT_CHARS = 4_000;
|
||||
|
||||
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s"']+/gi, "$1[REDACTED]"],
|
||||
[
|
||||
/((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s"']+/gi,
|
||||
"$1[REDACTED]",
|
||||
],
|
||||
[
|
||||
/((?:access[_-]?token|refresh[_-]?token|password|credential)\s*[:=]\s*)[^\s&"']+/gi,
|
||||
"$1[REDACTED]",
|
||||
],
|
||||
[/\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_-]{12,}\b/g, "[REDACTED]"],
|
||||
[/\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b/g, "[REDACTED]"],
|
||||
[/\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_-]{12,}\b/g, "[REDACTED]"],
|
||||
[/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, "[REDACTED]"],
|
||||
];
|
||||
|
||||
export function redactSecrets(value: unknown): string {
|
||||
let text =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: (JSON.stringify(value, null, 0) ?? String(value));
|
||||
for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function boundedText(value: unknown, limit: number): string {
|
||||
const text = redactSecrets(value).trim();
|
||||
return text.length <= limit ? text : `${text.slice(0, limit)}\n...[truncated ${text.length - limit} chars]`;
|
||||
}
|
||||
|
||||
interface MessageLike {
|
||||
role: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string | null {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
const text = content
|
||||
.filter(
|
||||
(block): block is { type: "text"; text: string } =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
(block as { type?: unknown }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string",
|
||||
)
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
return text || null;
|
||||
}
|
||||
|
||||
export function extractConversation(
|
||||
messages: MessageLike[],
|
||||
): Array<{ role: "user" | "assistant"; content: string }> {
|
||||
const conversation: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const text = extractText(message.content);
|
||||
if (!text) continue;
|
||||
const content = redactSecrets(text).trim();
|
||||
if (content) conversation.push({ role: message.role, content });
|
||||
}
|
||||
return conversation;
|
||||
}
|
||||
|
||||
interface RecallOptions {
|
||||
maxChars?: number;
|
||||
seenIds?: Set<string>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface MemoryLifecycleOptions {
|
||||
maxContextChars?: number;
|
||||
recallTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Shared lifecycle policy. Host adapters only translate native events into these operations. */
|
||||
class MemoryLifecycle {
|
||||
readonly #seenMemoryIds = new Set<string>();
|
||||
readonly #options: MemoryLifecycleOptions;
|
||||
|
||||
constructor(options: MemoryLifecycleOptions = {}) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
beginSession(): void {
|
||||
this.#seenMemoryIds.clear();
|
||||
}
|
||||
|
||||
prepareConversation(
|
||||
messages: MessageLike[],
|
||||
): Array<{ role: "user" | "assistant"; content: string }> {
|
||||
return extractConversation(messages);
|
||||
}
|
||||
|
||||
prepareUserText(value: unknown): string {
|
||||
return redactSecrets(value).trim();
|
||||
}
|
||||
|
||||
recall(
|
||||
prompt: string,
|
||||
enabled: boolean,
|
||||
search: (query: string) => Promise<{ results?: unknown[] }>,
|
||||
): Promise<string> {
|
||||
return buildRecallContext(prompt, enabled, search, {
|
||||
maxChars: this.#options.maxContextChars,
|
||||
seenIds: this.#seenMemoryIds,
|
||||
timeoutMs: this.#options.recallTimeoutMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryLifecycle(
|
||||
options: MemoryLifecycleOptions = {},
|
||||
): MemoryLifecycle {
|
||||
return new MemoryLifecycle(options);
|
||||
}
|
||||
|
||||
export async function buildRecallContext(
|
||||
prompt: string,
|
||||
enabled: boolean,
|
||||
search: (query: string) => Promise<{ results?: unknown[] }>,
|
||||
options: RecallOptions = {},
|
||||
): Promise<string> {
|
||||
if (!enabled) return "";
|
||||
const query = boundedText(prompt, MAX_RECALL_QUERY_CHARS);
|
||||
if (!query) return "";
|
||||
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let response: { results?: unknown[] } | null;
|
||||
try {
|
||||
const timeout = new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), options.timeoutMs ?? 2_000);
|
||||
});
|
||||
response = await Promise.race([search(query), timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
if (!response) return "";
|
||||
const memories = (response.results ?? []) as MemoryLike[];
|
||||
const unseen = memories.filter((memory) => !options.seenIds?.has(memory.id));
|
||||
if (!unseen.length) return "";
|
||||
|
||||
const prefix =
|
||||
"<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — search mem0_memory for more if you need it.\n";
|
||||
const suffix = "\n</mem0-relevant-memories>";
|
||||
const maxChars = options.maxChars ?? DEFAULT_MAX_CONTEXT_CHARS;
|
||||
const lines: string[] = [];
|
||||
for (const memory of unseen) {
|
||||
const line = `${lines.length + 1}. ${redactSecrets(formatMemoryCompact(memory))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}`;
|
||||
const candidate = prefix + [...lines, line].join("\n") + suffix;
|
||||
if (candidate.length > maxChars) {
|
||||
if (!lines.length) {
|
||||
const available = maxChars - prefix.length - suffix.length;
|
||||
if (available > 1) lines.push(`${line.slice(0, available - 1).trimEnd()}…`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
lines.push(line);
|
||||
options.seenIds?.add(memory.id);
|
||||
}
|
||||
if (!lines.length) return "";
|
||||
if (unseen[0] && !options.seenIds?.has(unseen[0].id)) options.seenIds?.add(unseen[0].id);
|
||||
return prefix + lines.join("\n") + suffix;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type Scope = "project" | "session" | "global";
|
||||
|
||||
export interface ScopeContext {
|
||||
userId: string;
|
||||
appId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export function normalizeScope(value: unknown): Scope {
|
||||
return value === "session" || value === "global" ? value : "project";
|
||||
}
|
||||
|
||||
export function resolveToolScope(requested: Scope | undefined, configured: Scope): Scope {
|
||||
const scope = requested ?? configured;
|
||||
if (scope === "global" && configured !== "global") {
|
||||
throw new Error("Select global scope in the plugin settings or /mem0-scope command first.");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function validateContext(scope: Scope, context: ScopeContext): void {
|
||||
const keys: (keyof ScopeContext)[] = ["userId"];
|
||||
if (scope !== "global") keys.push("appId");
|
||||
if (scope === "session") keys.push("runId");
|
||||
for (const key of keys) {
|
||||
if (!context[key]?.trim() || /^\*+$/.test(context[key].trim())) {
|
||||
throw new Error(`Invalid memory scope ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scopeSearchFilters(scope: Scope, context: ScopeContext): Record<string, string> {
|
||||
validateContext(scope, context);
|
||||
if (scope === "session") {
|
||||
return { user_id: context.userId, app_id: context.appId, run_id: context.runId };
|
||||
}
|
||||
return scope === "global"
|
||||
? { user_id: context.userId }
|
||||
: { user_id: context.userId, app_id: context.appId };
|
||||
}
|
||||
|
||||
export function scopeAddParams(scope: Scope, context: ScopeContext): Record<string, string> {
|
||||
validateContext(scope, context);
|
||||
if (scope === "session") {
|
||||
return { userId: context.userId, appId: context.appId, runId: context.runId };
|
||||
}
|
||||
return scope === "global"
|
||||
? { userId: context.userId }
|
||||
: { userId: context.userId, appId: context.appId };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { redactSecrets } from "./lifecycle.ts";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/";
|
||||
const OFF_VALUES = new Set(["false", "0", "no", "off"]);
|
||||
const PRIVATE_KEYS = new Set([
|
||||
"apikey",
|
||||
"authorization",
|
||||
"password",
|
||||
"query",
|
||||
"secret",
|
||||
"prompt",
|
||||
"token",
|
||||
"text",
|
||||
"memory",
|
||||
"message",
|
||||
"error",
|
||||
"path",
|
||||
"cwd",
|
||||
"userid",
|
||||
"agentid",
|
||||
"runid",
|
||||
"repoid",
|
||||
"repositoryid",
|
||||
"projectid",
|
||||
"appid",
|
||||
"filters",
|
||||
]);
|
||||
|
||||
export interface TelemetryConfig {
|
||||
host: string;
|
||||
source: string;
|
||||
version: string;
|
||||
distinctId: string | (() => string | undefined);
|
||||
delivery?: (batch: Record<string, unknown>[]) => void | Promise<void>;
|
||||
flushThreshold?: number;
|
||||
flushIntervalMs?: number;
|
||||
maxQueueSize?: number;
|
||||
commonProperties?: Record<string, unknown>;
|
||||
eventName?: (event: string) => string;
|
||||
enabled?: () => boolean;
|
||||
}
|
||||
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
const value = process.env.MEM0_TELEMETRY;
|
||||
return value === undefined || !OFF_VALUES.has(value.toLowerCase());
|
||||
}
|
||||
|
||||
function safeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactSecrets(value);
|
||||
if (Array.isArray(value)) return value.map(safeValue);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => !PRIVATE_KEYS.has(key.toLowerCase().replace(/[^a-z]/g, "")))
|
||||
.map(([key, nested]) => [key, safeValue(nested)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeProperties(properties: Record<string, unknown>): Record<string, unknown> {
|
||||
return safeValue(properties) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function errorKind(error: unknown): string {
|
||||
const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
if (text.includes("timeout") || text.includes("aborted")) return "timeout";
|
||||
if (text.includes("401") || text.includes("403") || text.includes("unauthor")) return "auth";
|
||||
if (text.includes("429") || text.includes("rate limit")) return "rate-limited";
|
||||
if (/50[0234]/.test(text)) return "server-error";
|
||||
if (text.includes("400") || text.includes("422")) return "bad-request";
|
||||
if (text.includes("fetch failed") || text.includes("enotfound")) return "network";
|
||||
return error instanceof Error ? error.constructor.name : "other";
|
||||
}
|
||||
|
||||
export function createTelemetry(config: TelemetryConfig) {
|
||||
let queue: Record<string, unknown>[] = [];
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const flushThreshold = config.flushThreshold ?? 10;
|
||||
const maxQueueSize = config.maxQueueSize ?? 100;
|
||||
|
||||
const deliver = config.delivery ?? (async (batch: Record<string, unknown>[]) => {
|
||||
await fetch(POSTHOG_BATCH_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
});
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
if (!queue.length) return;
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
try {
|
||||
await deliver(batch);
|
||||
} catch {
|
||||
// Telemetry must never affect plugin behavior.
|
||||
}
|
||||
}
|
||||
|
||||
function beforeExit(): void {
|
||||
void flush();
|
||||
}
|
||||
|
||||
function build(event: string, properties: Record<string, unknown> = {}): Record<string, unknown> | null {
|
||||
if (!(config.enabled?.() ?? isTelemetryEnabled())) return null;
|
||||
try {
|
||||
const distinctId = typeof config.distinctId === "function" ? config.distinctId() : config.distinctId;
|
||||
if (!distinctId) return null;
|
||||
return {
|
||||
event: config.eventName?.(event) ?? event,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
...safeProperties(properties),
|
||||
...safeProperties(config.commonProperties ?? {}),
|
||||
host: config.host,
|
||||
source: config.source,
|
||||
language: "node",
|
||||
plugin_version: config.version,
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function capture(event: string, properties: Record<string, unknown> = {}): void {
|
||||
try {
|
||||
const payload = build(event, properties);
|
||||
if (!payload) return;
|
||||
queue.push(payload);
|
||||
if (queue.length > maxQueueSize) queue = queue.slice(-maxQueueSize);
|
||||
if (!timer) {
|
||||
timer = setInterval(() => void flush(), config.flushIntervalMs ?? 5_000);
|
||||
timer.unref?.();
|
||||
process.on("beforeExit", beforeExit);
|
||||
}
|
||||
if (queue.length >= flushThreshold) void flush();
|
||||
} catch {
|
||||
// Telemetry must never affect plugin behavior.
|
||||
}
|
||||
}
|
||||
|
||||
function resetForTesting(): void {
|
||||
queue = [];
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
process.off("beforeExit", beforeExit);
|
||||
}
|
||||
|
||||
return { build, capture, flush, resetForTesting, queueForTesting: () => queue };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_OUTPUT_LINES,
|
||||
formatAddResult,
|
||||
formatAge,
|
||||
formatMemoryCompact,
|
||||
formatMemoryList,
|
||||
truncateOutput,
|
||||
} from "../src/formatting.ts";
|
||||
|
||||
test("memory formatting preserves the existing compact host output", () => {
|
||||
const now = Date.now();
|
||||
assert.equal(formatAge(new Date(now - 30 * 60_000)), "30m ago");
|
||||
assert.match(
|
||||
formatMemoryCompact({ id: "abc", memory: "Dark mode", categories: ["preference"] }),
|
||||
/^\[preference\] Dark mode \[mem0:abc\]$/,
|
||||
);
|
||||
assert.equal(formatMemoryList([]), "No memories found.");
|
||||
assert.match(formatMemoryList([{ id: "1", memory: "A" }, { id: "2", memory: "B" }]), /^1\..*\n2\./);
|
||||
});
|
||||
|
||||
test("write formatting handles pending, stored, and empty results", () => {
|
||||
assert.equal(
|
||||
formatAddResult({ eventId: "evt-9", status: "PENDING" }),
|
||||
"Memory queued for background extraction (event evt-9); it will be searchable shortly.",
|
||||
);
|
||||
assert.match(formatAddResult([{ id: "1" }, { id: "2" }]), /^Stored 2 memories:/);
|
||||
assert.equal(formatAddResult([]), "Memory stored.");
|
||||
});
|
||||
|
||||
test("output truncation preserves small output and bounds large output", () => {
|
||||
assert.equal(truncateOutput("a\nb"), "a\nb");
|
||||
const many = Array.from({ length: MAX_OUTPUT_LINES + 1 }, (_, index) => `line ${index}`).join("\n");
|
||||
assert.match(truncateOutput(many), /showing 200 of 201 lines/);
|
||||
assert.match(truncateOutput("x".repeat(MAX_OUTPUT_CHARS + 1)), /cut at 50KB/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { entityAddParams, entitySearchFilters, parseProjectFromRemote } from "../src/identity.ts";
|
||||
|
||||
test("parses common git remote forms", () => {
|
||||
assert.equal(parseProjectFromRemote("git@github.com-work:mem0ai/mem0.git"), "mem0ai-mem0");
|
||||
assert.equal(parseProjectFromRemote("https://github.com/mem0ai/mem0/"), "mem0ai-mem0");
|
||||
assert.equal(parseProjectFromRemote("not-a-remote"), null);
|
||||
assert.equal(parseProjectFromRemote(""), null);
|
||||
});
|
||||
|
||||
test("entity filters trim overrides and preserve API casing", () => {
|
||||
const params = { userId: " alice ", agentId: " agent ", runId: " " };
|
||||
assert.deepEqual(entitySearchFilters(params, "default"), { user_id: "alice", agent_id: "agent" });
|
||||
assert.deepEqual(entityAddParams(params, "default"), { userId: "alice", agentId: "agent" });
|
||||
assert.deepEqual(entitySearchFilters({ userId: " " }, "default"), { user_id: "default" });
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
boundedText,
|
||||
buildRecallContext,
|
||||
createMemoryLifecycle,
|
||||
extractConversation,
|
||||
redactSecrets,
|
||||
} from "../src/lifecycle.ts";
|
||||
|
||||
test("one lifecycle owns recall state and resets it for a new session", async () => {
|
||||
const lifecycle = createMemoryLifecycle({ recallTimeoutMs: 50 });
|
||||
const search = async () => ({ results: [{ id: "m1", memory: "Use pnpm" }] });
|
||||
|
||||
assert.match(await lifecycle.recall("package manager", true, search), /Use pnpm/);
|
||||
assert.equal(await lifecycle.recall("package manager", true, search), "");
|
||||
|
||||
lifecycle.beginSession();
|
||||
assert.match(await lifecycle.recall("package manager", true, search), /Use pnpm/);
|
||||
});
|
||||
|
||||
test("one lifecycle owns capture preparation", () => {
|
||||
const lifecycle = createMemoryLifecycle();
|
||||
|
||||
assert.deepEqual(
|
||||
lifecycle.prepareConversation([
|
||||
{ role: "user", content: "password=secret-value" },
|
||||
{ role: "assistant", content: "Configured it" },
|
||||
]),
|
||||
[
|
||||
{ role: "user", content: "password=[REDACTED]" },
|
||||
{ role: "assistant", content: "Configured it" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("capture preserves long prompts and responses while redacting secrets", () => {
|
||||
const lifecycle = createMemoryLifecycle();
|
||||
const prompt = "Repository question. ".repeat(2000) + "Final requirement. api_key=hidden-user-secret";
|
||||
const answer = "Repository answer. ".repeat(4000) + "Final detail. password=hidden-agent-secret";
|
||||
assert.deepEqual(lifecycle.prepareConversation([
|
||||
{ role: "user", content: prompt },
|
||||
{ role: "assistant", content: [{ type: "text", text: answer }] },
|
||||
]), [
|
||||
{ role: "user", content: redactSecrets(prompt) },
|
||||
{ role: "assistant", content: redactSecrets(answer) },
|
||||
]);
|
||||
assert.equal(lifecycle.prepareUserText(prompt), redactSecrets(prompt));
|
||||
});
|
||||
|
||||
test("redacts Claude-equivalent credentials before content leaves the host", () => {
|
||||
const privateKey = "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
|
||||
const input = [
|
||||
"Authorization: Bearer top-secret-token",
|
||||
"api_key=super-secret-value",
|
||||
"password=hunter2",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
privateKey,
|
||||
].join("\n");
|
||||
|
||||
const output = redactSecrets(input);
|
||||
assert.equal(output.includes("top-secret-token"), false);
|
||||
assert.equal(output.includes("super-secret-value"), false);
|
||||
assert.equal(output.includes("hunter2"), false);
|
||||
assert.equal(output.includes("ghp_"), false);
|
||||
assert.equal(output.includes("secret\n-----END"), false);
|
||||
assert.match(output, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test("bounds redacted content and reports the omitted character count", () => {
|
||||
assert.equal(boundedText(" abc ", 10), "abc");
|
||||
assert.equal(boundedText("abcdefgh", 5), "abcde\n...[truncated 3 chars]");
|
||||
});
|
||||
|
||||
test("normalizes and sanitizes user/assistant conversation content", () => {
|
||||
const messages = [
|
||||
{ role: "system", content: "ignored" },
|
||||
{ role: "user", content: [{ type: "text", text: "token=m0-abcdefghijklmnop" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use" }, { type: "text", text: "done" }] },
|
||||
];
|
||||
|
||||
assert.deepEqual(extractConversation(messages), [
|
||||
{ role: "user", content: "token=[REDACTED]" },
|
||||
{ role: "assistant", content: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("recall is bounded, fail-open, and de-duplicates already injected memories", async () => {
|
||||
const seen = new Set<string>(["old"]);
|
||||
const search = async () => ({
|
||||
results: [
|
||||
{ id: "old", memory: "already shown" },
|
||||
{ id: "new", memory: `api_key=hidden ${"x".repeat(100)}` },
|
||||
],
|
||||
});
|
||||
|
||||
const output = await buildRecallContext("what changed?", true, search, {
|
||||
maxChars: 240,
|
||||
seenIds: seen,
|
||||
});
|
||||
assert.equal(output.includes("already shown"), false);
|
||||
assert.equal(output.includes("hidden"), false);
|
||||
assert.ok(output.length <= 240);
|
||||
assert.equal(seen.has("new"), true);
|
||||
assert.equal(
|
||||
await buildRecallContext("what changed?", true, async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("recall times out without blocking the host turn", async () => {
|
||||
const never = () => new Promise<{ results?: unknown[] }>(() => {});
|
||||
const started = Date.now();
|
||||
|
||||
assert.equal(await buildRecallContext("hello", true, never, { timeoutMs: 5 }), "");
|
||||
assert.ok(Date.now() - started < 100);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizeScope, resolveToolScope, scopeAddParams, scopeSearchFilters } from "../src/scoping.ts";
|
||||
|
||||
const context = { userId: "u", appId: "app", runId: "run" };
|
||||
|
||||
test("normalizes unknown scope to project", () => {
|
||||
assert.equal(normalizeScope("session"), "session");
|
||||
assert.equal(normalizeScope("global"), "global");
|
||||
assert.equal(normalizeScope("invalid"), "project");
|
||||
});
|
||||
|
||||
test("resolves project, session, and global search filters", () => {
|
||||
assert.deepEqual(scopeSearchFilters("project", context), { user_id: "u", app_id: "app" });
|
||||
assert.deepEqual(scopeSearchFilters("session", context), { user_id: "u", app_id: "app", run_id: "run" });
|
||||
assert.deepEqual(scopeSearchFilters("global", context), { user_id: "u" });
|
||||
});
|
||||
|
||||
test("resolves camel-case add params", () => {
|
||||
assert.deepEqual(scopeAddParams("project", context), { userId: "u", appId: "app" });
|
||||
assert.deepEqual(scopeAddParams("session", context), { userId: "u", appId: "app", runId: "run" });
|
||||
assert.deepEqual(scopeAddParams("global", context), { userId: "u" });
|
||||
});
|
||||
|
||||
test("rejects empty and wildcard identities before building filters or writes", () => {
|
||||
for (const invalid of ["", " ", "*", "***"]) {
|
||||
for (const scope of ["project", "session"] as const) {
|
||||
for (const resolve of [scopeSearchFilters, scopeAddParams]) {
|
||||
assert.throws(() => resolve(scope, { ...context, appId: invalid }), /appId/);
|
||||
assert.throws(() => resolve(scope, { ...context, userId: invalid }), /userId/);
|
||||
}
|
||||
}
|
||||
assert.throws(() => scopeSearchFilters("session", { ...context, runId: invalid }), /runId/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("tools cannot enable global scope without a user-configured global default", () => {
|
||||
assert.throws(() => resolveToolScope("global", "project"), /Select global/);
|
||||
assert.throws(() => resolveToolScope("global", "session"), /Select global/);
|
||||
assert.equal(resolveToolScope("global", "global"), "global");
|
||||
assert.equal(resolveToolScope("session", "project"), "session");
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTelemetry, errorKind } from "../src/telemetry.ts";
|
||||
|
||||
test("telemetry preserves host names and strips sensitive properties", async () => {
|
||||
const delivered: Record<string, unknown>[][] = [];
|
||||
const telemetry = createTelemetry({
|
||||
host: "deepseek",
|
||||
source: "DEEPSEEK_HARNESS",
|
||||
version: "1.2.3",
|
||||
distinctId: "person",
|
||||
delivery: async (batch) => {
|
||||
delivered.push(batch);
|
||||
},
|
||||
});
|
||||
|
||||
telemetry.capture("deepseek.tool.search_memory", {
|
||||
success: true,
|
||||
query: "secret",
|
||||
apiKey: "key",
|
||||
cwd: "/private/repo",
|
||||
repo_id: "raw-repo",
|
||||
query_chars: 6,
|
||||
});
|
||||
await telemetry.flush();
|
||||
telemetry.resetForTesting();
|
||||
|
||||
const event = delivered[0][0] as { event: string; properties: Record<string, unknown> };
|
||||
assert.equal(event.event, "deepseek.tool.search_memory");
|
||||
assert.deepEqual(event.properties, {
|
||||
success: true,
|
||||
query_chars: 6,
|
||||
host: "deepseek",
|
||||
source: "DEEPSEEK_HARNESS",
|
||||
language: "node",
|
||||
plugin_version: "1.2.3",
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
});
|
||||
});
|
||||
|
||||
test("all false-like opt-out values suppress events", async () => {
|
||||
const original = process.env.MEM0_TELEMETRY;
|
||||
try {
|
||||
for (const value of ["false", "0", "no", "OFF"]) {
|
||||
process.env.MEM0_TELEMETRY = value;
|
||||
let delivered = false;
|
||||
const telemetry = createTelemetry({
|
||||
host: "pi",
|
||||
source: "PI_AGENT_PLUGIN",
|
||||
version: "1",
|
||||
distinctId: "person",
|
||||
delivery: async () => {
|
||||
delivered = true;
|
||||
},
|
||||
});
|
||||
telemetry.capture("pi.test");
|
||||
await telemetry.flush();
|
||||
telemetry.resetForTesting();
|
||||
assert.equal(delivered, false);
|
||||
}
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("telemetry redacts secrets nested inside allowed properties", () => {
|
||||
const secret = "sk-eval-12345678901234567890";
|
||||
const telemetry = createTelemetry({
|
||||
host: "pi",
|
||||
source: "PI_AGENT_PLUGIN",
|
||||
version: "1",
|
||||
distinctId: "person",
|
||||
});
|
||||
|
||||
const event = telemetry.build("pi.test", {
|
||||
note: `failure contained ${secret}`,
|
||||
details: { authorization: `Bearer ${secret}`, count: 2 },
|
||||
});
|
||||
telemetry.resetForTesting();
|
||||
|
||||
const serialized = JSON.stringify(event);
|
||||
assert.equal(serialized.includes(secret), false);
|
||||
assert.equal(serialized.includes("[REDACTED]"), true);
|
||||
assert.equal((event?.properties as { details: { count: number } }).details.count, 2);
|
||||
});
|
||||
|
||||
for (const key of ["password", "token", "secret", "authorization"]) {
|
||||
test(`telemetry removes ${key} from nested list elements`, () => {
|
||||
const secret = "sk-eval-12345678901234567890";
|
||||
const telemetry = createTelemetry({
|
||||
host: "pi",
|
||||
source: "PI_AGENT_PLUGIN",
|
||||
version: "1",
|
||||
distinctId: "person",
|
||||
});
|
||||
|
||||
const event = telemetry.build("pi.test", {
|
||||
details: [
|
||||
{ [key]: "plain-value", count: 2 },
|
||||
{ nested: { [key.toUpperCase()]: "plain-value", ok: true } },
|
||||
[`failure contained ${secret}`],
|
||||
],
|
||||
});
|
||||
telemetry.resetForTesting();
|
||||
|
||||
assert.deepEqual((event?.properties as { details: unknown[] }).details, [
|
||||
{ count: 2 },
|
||||
{ nested: { ok: true } },
|
||||
["failure contained [REDACTED]"],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
test("error classification does not expose messages", () => {
|
||||
assert.equal(errorKind(new Error("429 secret query")), "rate-limited");
|
||||
assert.equal(errorKind(new Error("401 key")), "auth");
|
||||
assert.equal(errorKind(new Error("request timeout")), "timeout");
|
||||
assert.equal(errorKind(new Error("fetch failed")), "network");
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: sidekick
|
||||
description: Coding subagent for focused implementation, investigation, testing, debugging, or review work.
|
||||
subagent: true
|
||||
---
|
||||
|
||||
You are Mem0's coding sidekick. Complete only the bounded task the main agent
|
||||
delegates to you and return a concise, self-contained result.
|
||||
|
||||
Search Mem0 before work that may depend on prior repository decisions or user
|
||||
preferences. Inspect the relevant repository rules and code, make changes when
|
||||
asked, and run the smallest decisive validation. Use only the workspace the
|
||||
caller assigned; do not assume a separate Git worktree.
|
||||
|
||||
Your final response must state the outcome, changed files, validation, and any
|
||||
remaining risk. Do not commit, push, or open a pull request unless the caller
|
||||
explicitly asks.
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detached remote checkpoint worker.
|
||||
|
||||
Claude Code may cancel SessionEnd hooks as a print-mode process exits. The hook
|
||||
therefore persists its input first and launches this process in a new session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
checkpoint_session,
|
||||
configure_harness,
|
||||
touch_handoff_heartbeat,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
return 2
|
||||
handoff_path = Path(sys.argv[1])
|
||||
os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path)
|
||||
harness = os.environ.get("MEM0_PLUGIN_HARNESS")
|
||||
if harness:
|
||||
source_tag = os.environ.get("MEM0_PLUGIN_SOURCE_TAG", "")
|
||||
configure_harness(
|
||||
harness,
|
||||
env_prefix=os.environ.get("MEM0_PLUGIN_ENV_PREFIX", ""),
|
||||
data_dir_name=os.environ.get("MEM0_PLUGIN_DATA_DIR_NAME", ""),
|
||||
source_tag=source_tag,
|
||||
)
|
||||
telemetry.init(harness=harness, source_tag=source_tag.upper())
|
||||
completed = False
|
||||
try:
|
||||
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
|
||||
delay = float(payload.get("delay_seconds") or 0)
|
||||
if delay > 0:
|
||||
payload.pop("delay_seconds", None)
|
||||
temporary = handoff_path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temporary.replace(handoff_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
time.sleep(delay)
|
||||
if not handoff_path.exists():
|
||||
return 0
|
||||
hook_input = payload.get("hook_input") or {}
|
||||
reason = str(payload.get("reason") or "checkpoint")
|
||||
wait_for_inflight = bool(payload.get("wait_for_inflight"))
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if wait_for_inflight:
|
||||
session_id = str(
|
||||
hook_input.get("session_id") or "unknown-session"
|
||||
)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
deadline = time.monotonic() + float(
|
||||
os.environ.get("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "120")
|
||||
)
|
||||
while (
|
||||
store.has_inflight_flush(repo.identity, session_id)
|
||||
and time.monotonic() < deadline
|
||||
):
|
||||
touch_handoff_heartbeat()
|
||||
time.sleep(0.25)
|
||||
# Hooks capture the conversation before handoff; the worker only flushes it.
|
||||
result = checkpoint_session(store, hook_input, reason)
|
||||
print(json.dumps(result, sort_keys=True), flush=True)
|
||||
completed = result.get("status") in {
|
||||
"semantic-succeeded",
|
||||
"explicitly-stored",
|
||||
"nothing-to-flush",
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
finally:
|
||||
telemetry.flush()
|
||||
if completed:
|
||||
try:
|
||||
handoff_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
elif handoff_path.suffix == ".running":
|
||||
try:
|
||||
handoff_path.replace(handoff_path.with_suffix(".json"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Shared hook orchestration for all Mem0 agent plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
_session_id,
|
||||
api_key,
|
||||
bounded,
|
||||
cache_plugin_api_key,
|
||||
checkpoint_session,
|
||||
clear_stale_api_key_cache,
|
||||
configure_harness,
|
||||
data_dir,
|
||||
detached_process_kwargs,
|
||||
format_context,
|
||||
harness_config,
|
||||
record_session_start,
|
||||
record_tool,
|
||||
record_user_prompt,
|
||||
redact,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
STALE_RUNNING_SECONDS = 300
|
||||
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
PENDING_LAUNCH_LIMIT = 5
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 300
|
||||
|
||||
_core_dir: Path = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def read_hook_input() -> dict:
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def default_record_stop(store: EvidenceStore, hook_input: dict):
|
||||
"""Record the assistant's response without transcript parsing."""
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
if message:
|
||||
store.record_assistant_response(repo, session_id, message)
|
||||
return repo, session_id
|
||||
|
||||
|
||||
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
|
||||
"""Search once before the agent handles the first prompt in a session."""
|
||||
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
|
||||
if not is_first_prompt:
|
||||
return {}
|
||||
try:
|
||||
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
|
||||
except ValueError:
|
||||
minimum_query_chars = 20
|
||||
if len(prompt.strip()) < max(minimum_query_chars, 1):
|
||||
return {}
|
||||
result = search_memories(
|
||||
store, repo, session_id, bounded(prompt, 6000),
|
||||
top_k=5, operation="first-prompt-search", timeout=2,
|
||||
)
|
||||
if not result.memories:
|
||||
return {}
|
||||
context = format_context(
|
||||
result.memories,
|
||||
"Mem0 found these relevant memories from earlier work in this repository:",
|
||||
)
|
||||
telemetry.record(
|
||||
"context_injected",
|
||||
repo=repo, session_id=session_id, trigger="first-prompt",
|
||||
memory_count=len(result.memories), context_chars=len(context),
|
||||
prompt_chars=len(prompt),
|
||||
)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _launch_handoff(handoff_path: Path) -> bool:
|
||||
running_path = handoff_path.with_suffix(".running")
|
||||
try:
|
||||
handoff_path.replace(running_path)
|
||||
except OSError:
|
||||
return False
|
||||
worker = _core_dir / "flush_worker.py"
|
||||
log_path = data_dir() / "flush-worker.log"
|
||||
log_handle = open(log_path, "a", encoding="utf-8")
|
||||
harness = harness_config()
|
||||
child_env = os.environ.copy()
|
||||
child_env.update(
|
||||
{
|
||||
"MEM0_CODE_DATA_DIR": str(data_dir()),
|
||||
"MEM0_PLUGIN_HARNESS": harness["name"],
|
||||
"MEM0_PLUGIN_ENV_PREFIX": harness["env_prefix"],
|
||||
"MEM0_PLUGIN_DATA_DIR_NAME": harness["data_dir_name"],
|
||||
"MEM0_PLUGIN_SOURCE_TAG": harness["source_tag"],
|
||||
}
|
||||
)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(worker), str(running_path)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle, stderr=log_handle,
|
||||
close_fds=True,
|
||||
env=child_env,
|
||||
**detached_process_kwargs(),
|
||||
)
|
||||
finally:
|
||||
log_handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def recover_pending_handoffs() -> int:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
for running in pending_dir.glob("*.running"):
|
||||
try:
|
||||
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
|
||||
running.replace(running.with_suffix(".json"))
|
||||
except OSError:
|
||||
continue
|
||||
recoverable = []
|
||||
for handoff in pending_dir.glob("*.json"):
|
||||
try:
|
||||
age = now - handoff.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > PENDING_EXPIRY_SECONDS:
|
||||
handoff.unlink(missing_ok=True)
|
||||
continue
|
||||
recoverable.append((age, handoff))
|
||||
recoverable.sort(key=lambda item: item[0], reverse=True)
|
||||
launched = 0
|
||||
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
|
||||
launched += int(_launch_handoff(handoff))
|
||||
return launched
|
||||
|
||||
|
||||
def refresh_pending_handoffs() -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
if not pending_dir.is_dir():
|
||||
return
|
||||
for pattern in ("*.json", "*.running"):
|
||||
for handoff in pending_dir.glob(pattern):
|
||||
try:
|
||||
os.utime(handoff)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def hand_off_flush(
|
||||
hook_input: dict, reason: str, *, wait_for_inflight: bool = False,
|
||||
) -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = (
|
||||
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
|
||||
)
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": reason,
|
||||
"wait_for_inflight": wait_for_inflight,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
|
||||
|
||||
def automatic_flush_enabled() -> bool:
|
||||
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
def schedule_periodic_checkpoint(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
if (
|
||||
not automatic_flush_enabled()
|
||||
or not api_key()
|
||||
or not store.checkpoint_due(repo.identity, session_id)
|
||||
):
|
||||
return False
|
||||
if store.prepare_flush(repo, session_id, "periodic") is None:
|
||||
return False
|
||||
hand_off_flush(hook_input, "periodic")
|
||||
return True
|
||||
|
||||
|
||||
def _idle_flush_seconds() -> int:
|
||||
try:
|
||||
return max(
|
||||
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
|
||||
0,
|
||||
)
|
||||
except ValueError:
|
||||
return DEFAULT_IDLE_FLUSH_SECONDS
|
||||
|
||||
|
||||
def schedule_idle_flush(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
delay = _idle_flush_seconds()
|
||||
if delay <= 0 or not automatic_flush_enabled() or not api_key():
|
||||
return False
|
||||
if store.has_inflight_flush(repo.identity, session_id):
|
||||
return False
|
||||
if not store.has_unflushed_events(repo.identity, session_id):
|
||||
return False
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
for old in pending_dir.glob(f"idle-{digest}*"):
|
||||
old.unlink(missing_ok=True)
|
||||
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": "idle",
|
||||
"delay_seconds": delay,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
return True
|
||||
|
||||
|
||||
def log_failure(exc: Exception) -> None:
|
||||
try:
|
||||
log_path = data_dir() / "plugin-errors.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> int:
|
||||
if record_stop_fn is None:
|
||||
record_stop_fn = default_record_stop
|
||||
if automatic_flush_reasons is None:
|
||||
automatic_flush_reasons = {"session-end"}
|
||||
|
||||
base_actions = ["session-start", "user-prompt", "post-tool", "stop", "flush"]
|
||||
all_actions = base_actions + list((extra_actions or {}).keys())
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=all_actions)
|
||||
parser.add_argument("--reason", default="manual")
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.harness:
|
||||
configure_harness(args.harness)
|
||||
telemetry.init(harness=args.harness)
|
||||
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
|
||||
hook_input = read_hook_input()
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if store.is_paused():
|
||||
if args.action == "session-start":
|
||||
refresh_pending_handoffs()
|
||||
telemetry.record("session_start", paused=True)
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
telemetry.record("install")
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
telemetry.record("handoff_recovered", count=recovered)
|
||||
telemetry.spawn_flush()
|
||||
elif args.action == "user-prompt":
|
||||
output = first_prompt_memory_output(store, hook_input)
|
||||
if output:
|
||||
print(json.dumps(output))
|
||||
elif args.action == "post-tool":
|
||||
record_tool(store, hook_input)
|
||||
elif args.action == "stop":
|
||||
repo, session_id = record_stop_fn(store, hook_input)
|
||||
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
|
||||
schedule_idle_flush(store, hook_input, repo, session_id)
|
||||
elif args.action == "flush":
|
||||
automatic = args.reason in automatic_flush_reasons
|
||||
if automatic and not automatic_flush_enabled():
|
||||
return 0
|
||||
if args.reason == "session-end":
|
||||
record_stop_fn(store, hook_input)
|
||||
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
|
||||
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
|
||||
else:
|
||||
session_id = str(hook_input.get("session_id") or "unknown-session")
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
already_running = store.has_inflight_flush(repo.identity, session_id)
|
||||
if already_running and args.reason == "session-end":
|
||||
hand_off_flush(hook_input, args.reason, wait_for_inflight=True)
|
||||
elif not already_running and store.prepare_flush(
|
||||
repo, session_id, args.reason,
|
||||
) is not None:
|
||||
hand_off_flush(hook_input, args.reason)
|
||||
elif extra_actions and args.action in extra_actions:
|
||||
result = extra_actions[args.action](store, hook_input)
|
||||
if result:
|
||||
print(json.dumps(result))
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def entry_point(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
raise SystemExit(run(
|
||||
record_stop_fn=record_stop_fn,
|
||||
extra_actions=extra_actions,
|
||||
data_dir_env=data_dir_env,
|
||||
automatic_flush_reasons=automatic_flush_reasons,
|
||||
))
|
||||
except Exception as exc:
|
||||
log_failure(exc)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
entry_point()
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose Mem0's memory search as one local coding-agent tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
CODING_MEMORY_CATEGORY_NAMES,
|
||||
PLUGIN_VERSION,
|
||||
SEARCH_SCOPES,
|
||||
format_search_result,
|
||||
resolve_repo,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
TOOL_NAME = "search_memories"
|
||||
TOOL_DESCRIPTION = (
|
||||
"Search memories from earlier work in this repository. ALWAYS call this "
|
||||
"tool before answering anything that could depend on prior context: the "
|
||||
"user's preferences, facts about this codebase, history, people, projects, "
|
||||
"or earlier decisions. Do not rely on the chat window alone. The "
|
||||
"repository's memory is shared by everyone who works in it and includes "
|
||||
"what it took to run, test, or build here, so search before assuming an "
|
||||
"invocation works. The scope argument changes what is searched: 'repo' "
|
||||
"(default) is the whole repository's shared memory plus your own "
|
||||
"preferences, 'dir' narrows the shared part to the directory you are "
|
||||
"working in, and 'mine' is your preferences alone."
|
||||
)
|
||||
TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000,
|
||||
"description": "A direct question about earlier work in this repository.",
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum memories to return. Uses Mem0's configured default when omitted.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": list(CODING_MEMORY_CATEGORY_NAMES),
|
||||
"description": "Optional memory category. Omit to search every category.",
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": list(SEARCH_SCOPES),
|
||||
"description": (
|
||||
"Which memories to search. 'repo' (default) is the whole repository's "
|
||||
"shared memory plus your own preferences, 'dir' narrows the shared "
|
||||
"part to the current directory, 'mine' is your preferences alone."
|
||||
),
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": (
|
||||
"Optional coding-agent session ID. With any scope, restricts results to memories "
|
||||
"saved in that session. Omit to recall memories across sessions."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
class ToolInputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_arguments(
|
||||
arguments: Any,
|
||||
) -> tuple[str, int | None, str | None, str | None, str | None]:
|
||||
if not isinstance(arguments, dict):
|
||||
raise ToolInputError("Search arguments must be an object.")
|
||||
|
||||
unknown = set(arguments) - {"query", "top_k", "category", "scope", "run_id"}
|
||||
if unknown:
|
||||
raise ToolInputError(f"Unknown search argument: {sorted(unknown)[0]}")
|
||||
|
||||
query = arguments.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
raise ToolInputError("query must be a non-empty string.")
|
||||
query = query.strip()
|
||||
if len(query) > 2000:
|
||||
raise ToolInputError("query must be at most 2,000 characters.")
|
||||
|
||||
top_k = arguments.get("top_k")
|
||||
if top_k is not None and (
|
||||
isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 20
|
||||
):
|
||||
raise ToolInputError("top_k must be an integer from 1 to 20.")
|
||||
|
||||
category = arguments.get("category")
|
||||
if category is not None and category not in CODING_MEMORY_CATEGORY_NAMES:
|
||||
raise ToolInputError("category must be one of Mem0's supported categories.")
|
||||
|
||||
scope = arguments.get("scope")
|
||||
if scope is not None and scope not in SEARCH_SCOPES:
|
||||
raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.")
|
||||
|
||||
run_id = arguments.get("run_id")
|
||||
if run_id is not None:
|
||||
if not isinstance(run_id, str) or not run_id.strip():
|
||||
raise ToolInputError("run_id must be a non-empty string.")
|
||||
run_id = run_id.strip()
|
||||
|
||||
return query, top_k, category, scope, run_id
|
||||
|
||||
|
||||
def call_search_memories(arguments: Any, cwd: str | None = None) -> str:
|
||||
query, top_k, category, scope, run_id = _validate_arguments(arguments)
|
||||
repo = resolve_repo(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
|
||||
result = search_memories(
|
||||
None,
|
||||
repo,
|
||||
None,
|
||||
query,
|
||||
top_k=top_k,
|
||||
category=category,
|
||||
scope=scope,
|
||||
run_id=run_id,
|
||||
operation="mcp-search",
|
||||
)
|
||||
return format_search_result(result)
|
||||
|
||||
|
||||
def _workspace_cwd(params: dict[str, Any]) -> str | None:
|
||||
meta = params.get("_meta")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
metadata = meta.get("x-codex-turn-metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
workspaces = metadata.get("workspaces") or {}
|
||||
if isinstance(workspaces, dict):
|
||||
return next((path for path in workspaces if isinstance(path, str) and path), None)
|
||||
return None
|
||||
|
||||
|
||||
def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"isError": is_error,
|
||||
}
|
||||
|
||||
|
||||
def handle_request(message: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
request_id = message.get("id")
|
||||
method = message.get("method")
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
if method == "initialize":
|
||||
requested = (message.get("params") or {}).get("protocolVersion")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": requested or PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "mem0", "version": PLUGIN_VERSION},
|
||||
},
|
||||
}
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": TOOL_NAME,
|
||||
"description": TOOL_DESCRIPTION,
|
||||
"inputSchema": TOOL_SCHEMA,
|
||||
"annotations": {
|
||||
"readOnlyHint": True,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
if method == "tools/call":
|
||||
params = message.get("params") or {}
|
||||
if params.get("name") != TOOL_NAME:
|
||||
result = _tool_response("Unknown Mem0 tool.", is_error=True)
|
||||
else:
|
||||
try:
|
||||
result = _tool_response(
|
||||
call_search_memories(params.get("arguments"), _workspace_cwd(params))
|
||||
)
|
||||
except ToolInputError as exc:
|
||||
result = _tool_response(str(exc), is_error=True)
|
||||
except Exception:
|
||||
result = _tool_response("Memory search failed.", is_error=True)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
if request_id is None:
|
||||
return None
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": -32601, "message": "Method not found"},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for raw_line in sys.stdin:
|
||||
try:
|
||||
message = json.loads(raw_line)
|
||||
response = handle_request(message)
|
||||
except json.JSONDecodeError:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
}
|
||||
except Exception:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": "Internal error"},
|
||||
}
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mem0 diagnostics and user controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
api_key,
|
||||
data_dir,
|
||||
doctor,
|
||||
forget_remote_repo,
|
||||
configure_harness,
|
||||
resolve_repo,
|
||||
user_id,
|
||||
)
|
||||
|
||||
|
||||
def _print_status(value: dict) -> None:
|
||||
last = value.get("last_operation") or {}
|
||||
print(f"Mem0: {'paused' if value['paused'] else 'active'}")
|
||||
print(f"Repository: {value['repo_id']}")
|
||||
print(f"Local data: {value['data_dir']}")
|
||||
print(f"API key: {'configured' if value['api_key_configured'] else 'missing'}")
|
||||
print(
|
||||
"Saved on this computer: "
|
||||
f"{value['events']} session details, {value['flushes']} memory updates"
|
||||
)
|
||||
print(
|
||||
f"Used in this repository: {value['retrievals']} memories returned, "
|
||||
f"{value['sidekick_runs']} sidekick runs"
|
||||
)
|
||||
if last:
|
||||
item_label = ""
|
||||
if last["operation"] in {"flush", "flush-retry"}:
|
||||
item_label = f", {last['item_count']} memories"
|
||||
operation = (
|
||||
"memory update"
|
||||
if last["operation"] in {"flush", "flush-retry"}
|
||||
else last["operation"].replace("-", " ")
|
||||
)
|
||||
print(
|
||||
f"Last {operation}: "
|
||||
f"{'succeeded' if last['success'] else 'failed'} "
|
||||
f"({last['duration_ms']:.1f} ms{item_label})"
|
||||
)
|
||||
sidekick = value.get("last_sidekick") or {}
|
||||
if sidekick:
|
||||
state = "finished" if sidekick.get("stopped_at") else "started"
|
||||
print(
|
||||
"Last sidekick: "
|
||||
f"{state}, received {sidekick['context_chars']} characters of memory, "
|
||||
f"agent {sidekick['agent_id']}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
status = subparsers.add_parser("status")
|
||||
status.add_argument("--json", action="store_true")
|
||||
|
||||
doctor_parser = subparsers.add_parser("doctor")
|
||||
doctor_parser.add_argument("--json", action="store_true")
|
||||
|
||||
subparsers.add_parser("pause")
|
||||
subparsers.add_parser("resume")
|
||||
|
||||
forget = subparsers.add_parser("forget")
|
||||
forget.add_argument("--remote", action="store_true")
|
||||
forget.add_argument("--yes", action="store_true")
|
||||
forget.add_argument("--include-project-memory", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.harness:
|
||||
source_tag = f"{args.harness.replace('-', '_')}_plugin"
|
||||
configure_harness(args.harness, source_tag=source_tag)
|
||||
telemetry.init(harness=args.harness, source_tag=source_tag.upper())
|
||||
if args.plugin_data_dir:
|
||||
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
repo = resolve_repo(os.getcwd())
|
||||
telemetry.record("control", repo=repo, action=args.command)
|
||||
if args.command == "status":
|
||||
result = {
|
||||
**store.status(repo.identity),
|
||||
"repo_id": repo.identity,
|
||||
"app_id": repo.app_id,
|
||||
"project_id": repo.project_id,
|
||||
"directory": repo.directory,
|
||||
"user_id": user_id(),
|
||||
"data_dir": str(data_dir()),
|
||||
"api_key_configured": bool(api_key()),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_print_status(result)
|
||||
elif args.command == "doctor":
|
||||
result = doctor(os.getcwd())
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
for name, check in result["checks"].items():
|
||||
print(
|
||||
f"{'PASS' if check['ok'] else 'FAIL'} {name}: {check['detail']}"
|
||||
)
|
||||
return 0 if result["ok"] else 1
|
||||
elif args.command == "pause":
|
||||
store.set_setting("paused", "true")
|
||||
print("Mem0 stopped saving and searching memories.")
|
||||
elif args.command == "resume":
|
||||
store.set_setting("paused", "false")
|
||||
print("Mem0 resumed saving and searching memories.")
|
||||
elif args.command == "forget":
|
||||
if not args.yes:
|
||||
print(
|
||||
"Refusing to delete data without --yes. Add --remote to also "
|
||||
"delete this user/repository scope from Mem0."
|
||||
)
|
||||
return 2
|
||||
remote_result = (
|
||||
forget_remote_repo(
|
||||
repo, include_project_memory=args.include_project_memory
|
||||
)
|
||||
if args.remote
|
||||
else None
|
||||
)
|
||||
local_result = store.forget_local_repo(repo.identity)
|
||||
print(
|
||||
json.dumps(
|
||||
{"local": local_result, "remote": remote_result},
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
if remote_result and remote_result.get("status") == "error":
|
||||
return 1
|
||||
finally:
|
||||
store.close()
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anonymous usage telemetry for Mem0 agent plugins.
|
||||
|
||||
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
|
||||
touches the network: `record` appends one JSON line to a local spool and returns.
|
||||
A detached `python3 telemetry.py` drains the spool in one batched PostHog request,
|
||||
started once per session and again from the flush worker that is already detached.
|
||||
|
||||
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
|
||||
|
||||
Never sends prompts, memory text, queries, file paths, repository names, or API
|
||||
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import memory_core
|
||||
|
||||
_harness: str = "generic"
|
||||
_source_tag: str = "MEM0_PLUGIN"
|
||||
_PRIVATE_KEYS = {
|
||||
"apikey",
|
||||
"authorization",
|
||||
"password",
|
||||
"query",
|
||||
"secret",
|
||||
"prompt",
|
||||
"token",
|
||||
"text",
|
||||
"memory",
|
||||
"message",
|
||||
"error",
|
||||
"path",
|
||||
"cwd",
|
||||
"userid",
|
||||
"agentid",
|
||||
"runid",
|
||||
"repoid",
|
||||
"repositoryid",
|
||||
"projectid",
|
||||
"appid",
|
||||
"filters",
|
||||
}
|
||||
|
||||
|
||||
def init(harness: str = "generic", source_tag: str = "") -> None:
|
||||
global _harness, _source_tag
|
||||
_harness = harness
|
||||
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
|
||||
POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/"
|
||||
EVENT_PREFIX = "code"
|
||||
SPOOL_LIMIT_BYTES = 256 * 1024
|
||||
BATCH_SIZE = 100
|
||||
SEND_TIMEOUT = 5
|
||||
CLAIM_STALE_SECONDS = 120
|
||||
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Whether telemetry is switched on for this process."""
|
||||
return os.environ.get("MEM0_TELEMETRY", "true").strip().lower() not in {
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
|
||||
|
||||
def _digest(value: str, length: int = 16) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return memory_core.redact(value)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _safe_value(item)
|
||||
for key, item in value.items()
|
||||
if "".join(character for character in str(key).lower() if character.isalnum())
|
||||
not in _PRIVATE_KEYS
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_safe_value(item) for item in value]
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
return memory_core.redact(value)
|
||||
|
||||
|
||||
def _spool_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry.jsonl"
|
||||
|
||||
|
||||
def _identity_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry-identity.json"
|
||||
|
||||
|
||||
def _read_identity() -> dict[str, str]:
|
||||
try:
|
||||
value = json.loads(_identity_path().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _write_identity(identity: dict[str, str]) -> None:
|
||||
path = _identity_path()
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary.write_text(json.dumps(identity), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def anonymous_id(identity: dict[str, str] | None = None) -> str:
|
||||
"""Per-machine anonymous identifier, created and persisted on first use."""
|
||||
identity = _read_identity() if identity is None else identity
|
||||
existing = identity.get("anonymous_id")
|
||||
if existing:
|
||||
return existing
|
||||
created = f"code-anon-{uuid.uuid4().hex}"
|
||||
identity["anonymous_id"] = created
|
||||
_write_identity(identity)
|
||||
return created
|
||||
|
||||
|
||||
def is_first_run() -> bool:
|
||||
"""Whether this machine has never recorded a plugin event before."""
|
||||
return not _identity_path().exists()
|
||||
|
||||
|
||||
def record(
|
||||
event: str,
|
||||
*,
|
||||
repo: Any = None,
|
||||
session_id: str | None = None,
|
||||
**properties: Any,
|
||||
) -> None:
|
||||
"""Append one event to the local spool. Never blocks and never raises."""
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
spool = _spool_path()
|
||||
try:
|
||||
if spool.stat().st_size > SPOOL_LIMIT_BYTES:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
properties = _safe_value(properties)
|
||||
properties.update(
|
||||
harness=_harness,
|
||||
plugin_version=memory_core.PLUGIN_VERSION,
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
if repo is not None:
|
||||
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
|
||||
if session_id:
|
||||
properties["session_hash"] = _digest(session_id)
|
||||
line = json.dumps(
|
||||
{
|
||||
"event": f"{EVENT_PREFIX}.{event}",
|
||||
"timestamp": memory_core.utc_now(),
|
||||
"properties": {
|
||||
key: value for key, value in properties.items() if value is not None
|
||||
},
|
||||
},
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
spool.parent.mkdir(parents=True, exist_ok=True)
|
||||
with spool.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def error_kind(exc: BaseException | str) -> str:
|
||||
"""Coarse, content-free label for a failure, safe to send."""
|
||||
text = exc if isinstance(exc, str) else f"{type(exc).__name__}: {exc}"
|
||||
lowered = text.lower()
|
||||
if "timed out" in lowered or "timeout" in lowered:
|
||||
return "timeout"
|
||||
if "401" in lowered or "403" in lowered or "unauthor" in lowered or "forbidden" in lowered:
|
||||
return "auth"
|
||||
if "429" in lowered or "rate limit" in lowered:
|
||||
return "rate-limited"
|
||||
if any(code in lowered for code in ("500", "502", "503", "504")):
|
||||
return "server-error"
|
||||
if "400" in lowered or "422" in lowered:
|
||||
return "bad-request"
|
||||
if isinstance(exc, str):
|
||||
return "other"
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
return "network"
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def spawn_flush() -> bool:
|
||||
"""Start the detached sender that drains the spool."""
|
||||
if not is_enabled():
|
||||
return False
|
||||
try:
|
||||
if not _spool_path().exists() and not any(
|
||||
memory_core.data_dir().glob("telemetry-*.sending")
|
||||
):
|
||||
return False
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(Path(__file__).resolve())],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
**memory_core.detached_process_kwargs(),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_spool() -> Path | None:
|
||||
"""Rename the spool aside so exactly one sender owns each batch."""
|
||||
directory = memory_core.data_dir()
|
||||
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
|
||||
spool = _spool_path()
|
||||
try:
|
||||
spool.replace(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
pass
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending")):
|
||||
try:
|
||||
age = now - orphan.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > CLAIM_EXPIRY_SECONDS:
|
||||
try:
|
||||
orphan.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
continue
|
||||
try:
|
||||
orphan.replace(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_email(key: str) -> str:
|
||||
"""Trade the API key for the account email so events join other Mem0 surfaces."""
|
||||
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return ""
|
||||
email = payload.get("user_email") if isinstance(payload, dict) else ""
|
||||
return email if isinstance(email, str) else ""
|
||||
|
||||
|
||||
def _post(payload: dict[str, Any], url: str) -> bool:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload, default=str).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_distinct_id() -> tuple[str, str]:
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
|
||||
identity = _read_identity()
|
||||
email = identity.get("email", "")
|
||||
if email:
|
||||
return email, ""
|
||||
key = memory_core.api_key()
|
||||
if not key:
|
||||
return anonymous_id(identity), ""
|
||||
email = _resolve_email(key)
|
||||
if not email:
|
||||
return anonymous_id(identity), ""
|
||||
previous = identity.get("anonymous_id", "")
|
||||
identity["email"] = email
|
||||
_write_identity(identity)
|
||||
return email, previous
|
||||
|
||||
|
||||
def flush() -> int:
|
||||
"""Drain claimed spools to PostHog and return the number of events sent."""
|
||||
if not is_enabled():
|
||||
return 0
|
||||
claim = _claim_spool()
|
||||
if claim is None:
|
||||
return 0
|
||||
try:
|
||||
lines = claim.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return 0
|
||||
events = []
|
||||
for line in lines:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("event"):
|
||||
events.append(value)
|
||||
if not events:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
distinct_id, aliased_anonymous_id = resolve_distinct_id()
|
||||
if aliased_anonymous_id:
|
||||
_post(
|
||||
{
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"event": "$identify",
|
||||
"distinct_id": distinct_id,
|
||||
"properties": {
|
||||
"$anon_distinct_id": aliased_anonymous_id,
|
||||
"$lib": "posthog-python",
|
||||
},
|
||||
},
|
||||
POSTHOG_CAPTURE_URL,
|
||||
)
|
||||
|
||||
sent = 0
|
||||
for start in range(0, len(events), BATCH_SIZE):
|
||||
batch = [
|
||||
{
|
||||
"event": event["event"],
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": event.get("timestamp"),
|
||||
"properties": {
|
||||
"source": _source_tag,
|
||||
"language": "python",
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(event.get("properties") or {}),
|
||||
},
|
||||
}
|
||||
for event in events[start : start + BATCH_SIZE]
|
||||
]
|
||||
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
|
||||
return sent
|
||||
sent += len(batch)
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mem0": {
|
||||
"PreInvocation": [
|
||||
{ "type": "command", "command": "python3 ./hooks/adapter.py PreInvocation", "timeout": 5 }
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 ./hooks/adapter.py PostToolUse", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{ "type": "command", "command": "python3 ./hooks/adapter.py Stop", "timeout": 5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Translate Antigravity hooks into the shared Mem0 runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
BUNDLED_CORE = HERE.parent.parent / "core"
|
||||
CORE = BUNDLED_CORE if BUNDLED_CORE.is_dir() else HERE.parents[2] / "core" / "python"
|
||||
sys.path.insert(0, str(CORE))
|
||||
|
||||
import hook_runner # noqa: E402
|
||||
import telemetry # noqa: E402
|
||||
from memory_core import ( # noqa: E402
|
||||
configure_harness,
|
||||
record_tool,
|
||||
redact,
|
||||
)
|
||||
|
||||
|
||||
def _read_transcript(path: str) -> list[dict[str, str]]:
|
||||
messages = []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as transcript:
|
||||
for line in transcript:
|
||||
try:
|
||||
step = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
content = step.get("content")
|
||||
if not isinstance(content, str) or step.get("status") != "DONE":
|
||||
continue
|
||||
if step.get("type") == "USER_INPUT":
|
||||
match = re.search(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", content, re.DOTALL)
|
||||
messages.append({"role": "user", "content": redact(match.group(1) if match else content).strip()})
|
||||
elif step.get("source") == "MODEL" and step.get("type") == "PLANNER_RESPONSE":
|
||||
messages.append({"role": "assistant", "content": redact(content).strip()})
|
||||
except (OSError, TypeError):
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def _transcript_messages(path: str) -> tuple[str, str]:
|
||||
messages = _read_transcript(path)
|
||||
return tuple(next((m["content"] for m in reversed(messages) if m["role"] == role), "")
|
||||
for role in ("user", "assistant"))
|
||||
|
||||
|
||||
def _record_stop(store, payload):
|
||||
session_id = str(payload.get("session_id") or "unknown-session")
|
||||
repo = store.repo_for_session(session_id, payload.get("cwd"))
|
||||
path = str(payload.get("transcript_path") or "")
|
||||
messages = _read_transcript(path)
|
||||
if not messages:
|
||||
return hook_runner.default_record_stop(store, payload)
|
||||
with store.conn:
|
||||
store.conn.execute("BEGIN IMMEDIATE")
|
||||
previous = store.latest_event_payload(repo.identity, session_id, "assistant_stop")
|
||||
offset = previous.get("transcript_count", 0) if previous.get("transcript_path") == path else 0
|
||||
if not isinstance(offset, int) or not 0 <= offset <= len(messages):
|
||||
offset = 0
|
||||
if messages[offset:]:
|
||||
store.record_event(repo, session_id, "assistant_stop", {
|
||||
"transcript_messages": messages[offset:],
|
||||
"transcript_count": len(messages),
|
||||
"transcript_path": path,
|
||||
})
|
||||
return repo, session_id
|
||||
|
||||
|
||||
def normalize(payload: dict) -> dict:
|
||||
value = dict(payload)
|
||||
value.setdefault("session_id", value.get("conversationId", ""))
|
||||
workspaces = value.get("workspacePaths") or []
|
||||
cwd = workspaces[0] if workspaces else os.environ.get("MEM0_CWD", "").strip()
|
||||
if cwd:
|
||||
value.setdefault("cwd", cwd)
|
||||
value.setdefault("transcript_path", value.get("transcriptPath", ""))
|
||||
prompt, assistant = _transcript_messages(value["transcript_path"])
|
||||
if prompt:
|
||||
value.setdefault("prompt", prompt)
|
||||
if assistant:
|
||||
value.setdefault("last_assistant_message", assistant)
|
||||
tool_call = value.get("toolCall") or {}
|
||||
if isinstance(tool_call, dict):
|
||||
value.setdefault("tool_name", tool_call.get("name", ""))
|
||||
value.setdefault("tool_input", tool_call.get("args", {}))
|
||||
if value.get("error"):
|
||||
value.setdefault("tool_response", value["error"])
|
||||
return value
|
||||
|
||||
|
||||
def _record_failure(store, payload):
|
||||
return record_tool(store, payload, failed=True)
|
||||
|
||||
|
||||
def _run_shared(arguments: list[str], payload: dict) -> tuple[int, str]:
|
||||
sys.argv = [sys.argv[0], *arguments]
|
||||
sys.stdin = io.StringIO(json.dumps(payload))
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
result = hook_runner.run(
|
||||
record_stop_fn=_record_stop,
|
||||
extra_actions={"post-tool-failure": _record_failure},
|
||||
automatic_flush_reasons={"session-end"},
|
||||
)
|
||||
return result, output.getvalue()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in {"PreInvocation", "PostToolUse", "Stop"}:
|
||||
return 2
|
||||
event = sys.argv[1]
|
||||
try:
|
||||
raw = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
raw = {}
|
||||
payload = normalize(raw if isinstance(raw, dict) else {})
|
||||
if not payload.get("cwd"):
|
||||
output = {"injectSteps": []} if event == "PreInvocation" else {}
|
||||
if event == "Stop":
|
||||
output = {"decision": "allow"}
|
||||
print(json.dumps(output))
|
||||
return 0
|
||||
configure_harness("antigravity", data_dir_name="antigravity-plugin", source_tag="antigravity_plugin")
|
||||
telemetry.init(harness="antigravity", source_tag="ANTIGRAVITY_PLUGIN")
|
||||
if event == "PreInvocation":
|
||||
if payload.get("invocationNum") != 0:
|
||||
print(json.dumps({"injectSteps": []}))
|
||||
return 0
|
||||
_run_shared(["session-start"], payload)
|
||||
result, output = _run_shared(["user-prompt"], payload)
|
||||
context = ""
|
||||
for line in output.splitlines():
|
||||
try:
|
||||
context = json.loads(line)["hookSpecificOutput"]["additionalContext"]
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
continue
|
||||
print(json.dumps({"injectSteps": [{"ephemeralMessage": context}] if context else []}))
|
||||
return result
|
||||
action = {
|
||||
"PostToolUse": ["post-tool-failure" if payload.get("error") else "post-tool"],
|
||||
"Stop": ["flush", "--reason", "session-end"],
|
||||
}[event]
|
||||
result, _ = _run_shared(action, payload)
|
||||
print(json.dumps({"decision": "allow"} if event == "Stop" else {}))
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
hook_runner.log_failure(exc)
|
||||
print("{}")
|
||||
raise SystemExit(0) from None
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "python3",
|
||||
"args": ["./core/mcp_server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"version": "0.3.1",
|
||||
"homepage": "https://docs.mem0.ai/integrations/antigravity",
|
||||
"native": {
|
||||
"pluginRoot": "${ANTIGRAVITY_PLUGIN_ROOT}",
|
||||
"files": {
|
||||
"plugin.json": "plugin.json",
|
||||
"hooks.json": "hooks.json",
|
||||
"mcp_config.json": "mcp_config.json",
|
||||
"hooks/adapter.py": "hooks/adapter.py",
|
||||
"agents/sidekick/agent.md": "agents/sidekick/agent.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://antigravity.google/schemas/v1/plugin.json",
|
||||
"name": "mem0",
|
||||
"description": "Cross-session memory and token savings for coding agents."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: forget
|
||||
description: Delete the Mem0 memories stored for this repository and this user. Use when the user asks to forget, clear, wipe, or delete memories.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Forget this repository's memories
|
||||
|
||||
This permanently deletes remote memories. Before running anything, tell the
|
||||
user exactly what will be deleted: their own memories for this repository
|
||||
only. The repository's project memory is shared by everyone who works in it,
|
||||
so it stays unless the user explicitly asks to delete that too.
|
||||
|
||||
After the user confirms, run:
|
||||
|
||||
```bash
|
||||
python3 "${ANTIGRAVITY_PLUGIN_ROOT}/core/memory_cli.py" --harness "antigravity" forget --remote --yes
|
||||
```
|
||||
|
||||
If the user also asked to delete the repository's shared project memory, add
|
||||
`--include-project-memory` and say that this removes it for every teammate.
|
||||
|
||||
Report what the command output says was deleted. If the user only wants local
|
||||
data cleared (evidence log, pending queue), run the same command without
|
||||
`--remote`. Never pass `--yes` before the user has confirmed in this
|
||||
conversation.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: pause
|
||||
description: Pause Mem0 memory capture on this machine. Use when the user wants to stop memories being recorded, for example for private work or experiments.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Pause memory capture
|
||||
|
||||
To pause (hooks stop capturing and sending session content; a minimal
|
||||
anonymous telemetry ping still fires at session start unless
|
||||
`MEM0_TELEMETRY=false`):
|
||||
|
||||
```bash
|
||||
python3 "${ANTIGRAVITY_PLUGIN_ROOT}/core/memory_cli.py" --harness "antigravity" pause
|
||||
```
|
||||
|
||||
Confirm the new state back to the user, and remind them that already-created
|
||||
memories still exist and remain searchable. Pending unsent packets are held
|
||||
while paused, not expired, and are delivered after resuming. To turn capture
|
||||
back on, use `/mem0:resume`.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: remember
|
||||
description: Acknowledge a "remember this" request and make sure it is captured well. Use when the user explicitly asks to remember, note, or save something for future sessions.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Remember something for future sessions
|
||||
|
||||
Mem0 creates memories from the session automatically — there is no separate
|
||||
write command. When the user asks to remember something:
|
||||
|
||||
1. Restate the fact clearly and completely in your reply, in one or two
|
||||
sentences, including any names, values, or paths it depends on. Your visible
|
||||
reply is what memory extraction reads, so a precise restatement is what gets
|
||||
remembered.
|
||||
2. Tell the user it will be saved with this session's memories when the session
|
||||
ends or compacts, and that it will surface in future sessions in this
|
||||
repository (they can check later with /mem0:search).
|
||||
|
||||
Do not invent a storage confirmation or a memory ID — creation happens in the
|
||||
background after the session.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: resume
|
||||
description: Resume Mem0 memory capture after it was paused with /mem0:pause.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Resume memory capture
|
||||
|
||||
Resume memory capture for this machine.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 "${ANTIGRAVITY_PLUGIN_ROOT}/core/memory_cli.py" --harness "antigravity" resume
|
||||
```
|
||||
|
||||
Confirm to the user that capture is active again. New sessions record evidence and
|
||||
create memories as normal; nothing that happened while paused is retroactively
|
||||
captured.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: search
|
||||
description: Search memories from earlier Antigravity sessions in this repository. Use it when earlier work may already explain the code, error, decision, or command you need, so you can avoid repeating file reads, searches, or experiments.
|
||||
argument-hint: "[question] [--top-k number] [--category category-name] [--scope repo|dir|mine] [--run-id session-id]"
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Search memories
|
||||
|
||||
Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
|
||||
`--scope`, and `--run-id` as tool arguments instead of including them in the
|
||||
query.
|
||||
|
||||
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
|
||||
category; a category is a best-effort label Mem0 assigned when it saved the
|
||||
memory, so if a category search misses, repeat it without the category. Omit
|
||||
`scope` to use the configured default, normally `repo`: this repository's
|
||||
shared memory, which everyone who works in it contributes to, plus your own
|
||||
preferences.
|
||||
|
||||
Pass `scope` when the question needs something else: `dir` to narrow the
|
||||
shared memory to the directory you are working in (a package inside a
|
||||
monorepo), `mine` for your own preferences alone.
|
||||
|
||||
Pass `run_id` with any scope to retrieve memories saved in a specific coding-agent
|
||||
session. Omit `run_id` to search across sessions. It filters the memories returned;
|
||||
it does not identify the session making the search request. Use a known session ID,
|
||||
never invent one. Return the tool's result directly.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: status
|
||||
description: Show whether Mem0 memory is working in this repository, covering configuration, capture state, pending flushes, and whether the Mem0 API key is valid. Use when the user asks whether memory is on, why a memory is missing, or anything looks broken.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Memory status
|
||||
|
||||
Run both commands and report the combined result in plain language:
|
||||
|
||||
```bash
|
||||
python3 "${ANTIGRAVITY_PLUGIN_ROOT}/core/memory_cli.py" --harness "antigravity" status --json
|
||||
python3 "${ANTIGRAVITY_PLUGIN_ROOT}/core/memory_cli.py" --harness "antigravity" doctor
|
||||
```
|
||||
|
||||
Summarize, using only fields the JSON actually reports: whether capture is
|
||||
active or paused, the user ID and repository scope (`repo_id`), whether an
|
||||
API key is configured, the event/flush/retrieval counts (`flushes` is the
|
||||
number of completed flushes, not a pending count), and the doctor check
|
||||
results. If doctor reports an authentication failure (401 / invalid key), say
|
||||
clearly that the Mem0 API key is invalid or expired and that memories are NOT
|
||||
being created. Never report an auth failure as "no memories found". Suggest
|
||||
reinstalling with `--config api_key=...` in that case.
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HOST = Path(__file__).resolve().parents[1]
|
||||
CORE_ROOT = HOST.parent / "agent-plugin-core"
|
||||
sys.path.insert(0, str(CORE_ROOT))
|
||||
|
||||
from build.build import build # noqa: E402
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("antigravity_adapter", HOST / "hooks" / "adapter.py")
|
||||
assert SPEC and SPEC.loader
|
||||
adapter = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(adapter)
|
||||
|
||||
|
||||
def test_normalizes_antigravity_camel_case_payload(tmp_path: Path) -> None:
|
||||
transcript = tmp_path / "transcript.jsonl"
|
||||
transcript.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"source": "USER_EXPLICIT",
|
||||
"type": "USER_INPUT",
|
||||
"status": "DONE",
|
||||
"content": "<USER_REQUEST>\nremember the parser\n</USER_REQUEST>",
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"source": "MODEL",
|
||||
"type": "PLANNER_RESPONSE",
|
||||
"status": "DONE",
|
||||
"content": "The parser is fixed.",
|
||||
}
|
||||
),
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
value = adapter.normalize(
|
||||
{
|
||||
"conversationId": "conversation-1",
|
||||
"workspacePaths": ["/repo"],
|
||||
"transcriptPath": str(transcript),
|
||||
"toolCall": {"name": "run_command", "args": {"CommandLine": "pytest"}},
|
||||
"error": "failed",
|
||||
}
|
||||
)
|
||||
|
||||
assert value["session_id"] == "conversation-1"
|
||||
assert value["cwd"] == "/repo"
|
||||
assert value["transcript_path"] == str(transcript)
|
||||
assert value["prompt"] == "remember the parser"
|
||||
assert value["last_assistant_message"] == "The parser is fixed."
|
||||
assert value["tool_name"] == "run_command"
|
||||
assert value["tool_input"] == {"CommandLine": "pytest"}
|
||||
assert value["tool_response"] == "failed"
|
||||
|
||||
|
||||
def test_uses_explicit_cwd_when_antigravity_omits_workspaces(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MEM0_CWD", "/repo")
|
||||
|
||||
value = adapter.normalize({"workspacePaths": []})
|
||||
|
||||
assert value["cwd"] == "/repo"
|
||||
|
||||
|
||||
def test_skips_capture_when_workspace_is_unknown(monkeypatch, capsys) -> None:
|
||||
monkeypatch.delenv("MEM0_CWD", raising=False)
|
||||
|
||||
def run_shared(*_):
|
||||
raise AssertionError("shared runtime should not run")
|
||||
|
||||
monkeypatch.setattr(adapter, "_run_shared", run_shared)
|
||||
monkeypatch.setattr(sys, "argv", ["adapter.py", "PostToolUse"])
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO('{"workspacePaths": []}'))
|
||||
|
||||
assert adapter.main() == 0
|
||||
assert json.loads(capsys.readouterr().out) == {}
|
||||
|
||||
|
||||
def test_pre_invocation_translates_shared_recall_to_ephemeral_message(monkeypatch, capsys) -> None:
|
||||
calls = []
|
||||
|
||||
def run_shared(arguments, payload):
|
||||
calls.append(arguments)
|
||||
if arguments == ["user-prompt"]:
|
||||
return 0, json.dumps(
|
||||
{"hookSpecificOutput": {"additionalContext": "Earlier repository context."}}
|
||||
)
|
||||
return 0, ""
|
||||
|
||||
monkeypatch.setattr(adapter, "_run_shared", run_shared)
|
||||
monkeypatch.setattr(sys, "argv", ["adapter.py", "PreInvocation"])
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO('{"invocationNum": 0, "workspacePaths": ["/repo"]}'))
|
||||
|
||||
assert adapter.main() == 0
|
||||
assert calls == [["session-start"], ["user-prompt"]]
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"injectSteps": [{"ephemeralMessage": "Earlier repository context."}]
|
||||
}
|
||||
|
||||
|
||||
def test_native_antigravity_bundle_uses_supported_events(tmp_path: Path) -> None:
|
||||
root = build("antigravity", "native", tmp_path / "antigravity")
|
||||
|
||||
manifest = json.loads((root / "plugin.json").read_text(encoding="utf-8"))
|
||||
hooks = json.loads((root / "hooks.json").read_text(encoding="utf-8"))["mem0"]
|
||||
assert manifest["$schema"] == "https://antigravity.google/schemas/v1/plugin.json"
|
||||
assert set(hooks) == {"PreInvocation", "PostToolUse", "Stop"}
|
||||
assert (root / "mcp_config.json").is_file()
|
||||
assert (root / "agents" / "sidekick" / "agent.md").is_file()
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
def test_stop_captures_later_prompts_once(tmp_path):
|
||||
transcript = tmp_path / "transcript.jsonl"
|
||||
store = adapter.hook_runner.EvidenceStore(tmp_path / "evidence.sqlite3")
|
||||
payload = {"session_id": "s1", "cwd": str(tmp_path), "transcript_path": str(transcript)}
|
||||
turns = []
|
||||
try:
|
||||
for prompt, answer in [("First question", "First answer"), ("Next question", "Next answer")]:
|
||||
turns.extend([
|
||||
{"type": "USER_INPUT", "status": "DONE", "content": prompt},
|
||||
{"type": "PLANNER_RESPONSE", "source": "MODEL", "status": "DONE", "content": answer},
|
||||
])
|
||||
transcript.write_text(''.join(json.dumps(row) + '\n' for row in turns))
|
||||
adapter._record_stop(store, payload)
|
||||
adapter._record_stop(store, payload)
|
||||
rows = store.conn.execute("SELECT payload_json FROM events WHERE kind = 'assistant_stop' ORDER BY id").fetchall()
|
||||
messages = [message for row in rows for message in json.loads(row[0])["transcript_messages"]]
|
||||
assert [m["content"] for m in messages] == ["First question", "First answer", "Next question", "Next answer"]
|
||||
finally:
|
||||
store.close()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Cross-session memory and token savings for coding agents.",
|
||||
"author": {
|
||||
"name": "Mem0"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
flush-worker.log
|
||||
plugin-errors.log
|
||||
pending/
|
||||
@@ -34,10 +34,11 @@ To remove:
|
||||
claude plugin uninstall mem0@mem0-plugins
|
||||
```
|
||||
|
||||
For local development, load the current checkout directly:
|
||||
For local development, verify and load the self-contained plugin directory:
|
||||
|
||||
```bash
|
||||
claude --plugin-dir .
|
||||
python3 integrations/agent-plugin-core/build/build.py claude-code --kind native --check
|
||||
claude --plugin-dir integrations/claude-code-plugin
|
||||
```
|
||||
|
||||
## How it works
|
||||
@@ -104,7 +105,11 @@ Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `
|
||||
| `dir` | Project memory from the current directory (and children), plus your preferences |
|
||||
| `mine` | Your personal preferences only |
|
||||
|
||||
Set the default with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE`. Pass `--run-id <session-id>` to see only what one specific session recorded.
|
||||
Set the default with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE`. Search spans earlier sessions without a session ID; `run_id` remains internal metadata.
|
||||
|
||||
New Git repository memories use a hash of the remote identity in `agent_id`. Searches also include the previous unhashed ID under the same repository `app_id`, so shared memories remain available after upgrading. Older IDs retain their original limitation: matching owner/repository names on different Git hosts share that legacy namespace. Local folders keep their path-based namespaces.
|
||||
|
||||
Explicit shared-memory deletion with `--include-project-memory` covers both repository IDs. Default deletion preserves shared memories.
|
||||
|
||||
## Settings
|
||||
|
||||
@@ -181,10 +186,10 @@ Breaking update. Memories carry over, most local config does not.
|
||||
|
||||
## Development checks
|
||||
|
||||
Run from `integrations/claude-code-plugin/`:
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests -q
|
||||
python3 -m ruff check .
|
||||
claude plugin validate --strict .
|
||||
python3 -m pytest integrations/claude-code-plugin/tests -q --ignore=integrations/claude-code-plugin/tests/integration
|
||||
python3 -m ruff check integrations/agent-plugin-core/python integrations/claude-code-plugin
|
||||
claude plugin validate --strict integrations/claude-code-plugin
|
||||
```
|
||||
|
||||
@@ -3,367 +3,52 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core"))
|
||||
_here = Path(__file__).resolve()
|
||||
_bundled_core = _here.parents[2] / "core"
|
||||
_core_dir = _bundled_core if (_bundled_core / "memory_core.py").is_file() else _bundled_core / "python"
|
||||
sys.path.insert(0, str(_core_dir))
|
||||
sys.path.insert(0, str(_here.parent))
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
api_key,
|
||||
cache_plugin_api_key,
|
||||
checkpoint_session,
|
||||
clear_stale_api_key_cache,
|
||||
data_dir,
|
||||
detached_process_kwargs,
|
||||
format_context,
|
||||
record_session_start,
|
||||
import telemetry # noqa: E402
|
||||
from memory_core import ( # noqa: E402
|
||||
configure_harness,
|
||||
record_sidekick_start,
|
||||
record_sidekick_stop,
|
||||
record_stop,
|
||||
record_tool,
|
||||
record_user_prompt,
|
||||
search_memories,
|
||||
)
|
||||
from transcript import record_stop # noqa: E402
|
||||
import hook_runner # noqa: E402
|
||||
|
||||
configure_harness("claude-code", data_dir_name="claude-code-plugin", source_tag="claude_code_plugin")
|
||||
telemetry.init(harness="claude-code", source_tag="CLAUDE_CODE_PLUGIN")
|
||||
|
||||
|
||||
STALE_RUNNING_SECONDS = 300
|
||||
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
PENDING_LAUNCH_LIMIT = 5
|
||||
def _sidekick_start(store, hook_input):
|
||||
context = record_sidekick_start(store, hook_input)
|
||||
if context:
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SubagentStart",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def read_hook_input() -> dict:
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
|
||||
"""Search once before Claude handles the first prompt in a session."""
|
||||
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
|
||||
if not is_first_prompt:
|
||||
return {}
|
||||
try:
|
||||
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
|
||||
except ValueError:
|
||||
minimum_query_chars = 20
|
||||
if len(prompt.strip()) < max(minimum_query_chars, 1):
|
||||
return {}
|
||||
result = search_memories(
|
||||
store,
|
||||
repo,
|
||||
session_id,
|
||||
prompt,
|
||||
top_k=5,
|
||||
operation="first-prompt-search",
|
||||
timeout=2,
|
||||
)
|
||||
if not result.memories:
|
||||
return {}
|
||||
context = format_context(
|
||||
result.memories,
|
||||
"Mem0 found these relevant memories from earlier work in this repository:",
|
||||
)
|
||||
telemetry.record(
|
||||
"context_injected",
|
||||
repo=repo,
|
||||
session_id=session_id,
|
||||
trigger="first-prompt",
|
||||
memory_count=len(result.memories),
|
||||
context_chars=len(context),
|
||||
prompt_chars=len(prompt),
|
||||
)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _launch_handoff(handoff_path: Path) -> bool:
|
||||
running_path = handoff_path.with_suffix(".running")
|
||||
try:
|
||||
handoff_path.replace(running_path)
|
||||
except OSError:
|
||||
return False
|
||||
worker = Path(__file__).resolve().parents[2] / "core" / "flush_worker.py"
|
||||
log_path = data_dir() / "flush-worker.log"
|
||||
log_handle = open(log_path, "a", encoding="utf-8")
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(worker), str(running_path)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle,
|
||||
stderr=log_handle,
|
||||
close_fds=True,
|
||||
**detached_process_kwargs(),
|
||||
)
|
||||
finally:
|
||||
log_handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def recover_pending_handoffs() -> int:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
for running in pending_dir.glob("*.running"):
|
||||
try:
|
||||
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
|
||||
running.replace(running.with_suffix(".json"))
|
||||
except OSError:
|
||||
continue
|
||||
recoverable = []
|
||||
for handoff in pending_dir.glob("*.json"):
|
||||
try:
|
||||
age = now - handoff.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > PENDING_EXPIRY_SECONDS:
|
||||
handoff.unlink(missing_ok=True)
|
||||
continue
|
||||
recoverable.append((age, handoff))
|
||||
recoverable.sort(key=lambda item: item[0], reverse=True)
|
||||
launched = 0
|
||||
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
|
||||
launched += int(_launch_handoff(handoff))
|
||||
return launched
|
||||
|
||||
|
||||
def refresh_pending_handoffs() -> None:
|
||||
"""Hold unsent packets while paused instead of letting them expire."""
|
||||
pending_dir = data_dir() / "pending"
|
||||
if not pending_dir.is_dir():
|
||||
return
|
||||
for pattern in ("*.json", "*.running"):
|
||||
for handoff in pending_dir.glob(pattern):
|
||||
try:
|
||||
os.utime(handoff)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def hand_off_flush(
|
||||
hook_input: dict, reason: str, *, wait_for_inflight: bool = False
|
||||
) -> None:
|
||||
"""Persist hook input and detach delivery from Claude's shutdown lifecycle."""
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = (
|
||||
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
|
||||
)
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"hook_input": hook_input,
|
||||
"reason": reason,
|
||||
"wait_for_inflight": wait_for_inflight,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
|
||||
|
||||
def automatic_flush_enabled() -> bool:
|
||||
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def schedule_periodic_checkpoint(
|
||||
store: EvidenceStore,
|
||||
hook_input: dict,
|
||||
repo,
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Start one background extraction when a complete block is ready."""
|
||||
if (
|
||||
not automatic_flush_enabled()
|
||||
or not api_key()
|
||||
or not store.checkpoint_due(repo.identity, session_id)
|
||||
):
|
||||
return False
|
||||
if store.prepare_flush(repo, session_id, "periodic") is None:
|
||||
return False
|
||||
hand_off_flush(hook_input, "periodic")
|
||||
return True
|
||||
|
||||
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 300
|
||||
|
||||
|
||||
def _idle_flush_seconds() -> int:
|
||||
try:
|
||||
return max(
|
||||
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
|
||||
0,
|
||||
)
|
||||
except ValueError:
|
||||
return DEFAULT_IDLE_FLUSH_SECONDS
|
||||
|
||||
|
||||
def schedule_idle_flush(
|
||||
store: EvidenceStore,
|
||||
hook_input: dict,
|
||||
repo,
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Launch a delayed background flush for sessions that may never end."""
|
||||
delay = _idle_flush_seconds()
|
||||
if delay <= 0 or not automatic_flush_enabled() or not api_key():
|
||||
return False
|
||||
if store.has_inflight_flush(repo.identity, session_id):
|
||||
return False
|
||||
if not store.has_unflushed_events(repo.identity, session_id):
|
||||
return False
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
for old in pending_dir.glob(f"idle-{digest}*"):
|
||||
old.unlink(missing_ok=True)
|
||||
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": "idle",
|
||||
"delay_seconds": delay,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
return True
|
||||
|
||||
|
||||
def log_failure(exc: Exception) -> None:
|
||||
try:
|
||||
log_path = data_dir() / "plugin-errors.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=[
|
||||
"session-start",
|
||||
"user-prompt",
|
||||
"post-tool",
|
||||
"post-tool-failure",
|
||||
"sidekick-start",
|
||||
"sidekick-stop",
|
||||
"stop",
|
||||
"flush",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--reason", default="manual")
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
args = parser.parse_args()
|
||||
if args.plugin_data_dir:
|
||||
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
hook_input = read_hook_input()
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if store.is_paused():
|
||||
if args.action == "session-start":
|
||||
refresh_pending_handoffs()
|
||||
telemetry.record("session_start", paused=True)
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
telemetry.record("install")
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
telemetry.record("handoff_recovered", count=recovered)
|
||||
telemetry.spawn_flush()
|
||||
elif args.action == "user-prompt":
|
||||
output = first_prompt_memory_output(store, hook_input)
|
||||
if output:
|
||||
print(json.dumps(output))
|
||||
elif args.action == "post-tool":
|
||||
record_tool(store, hook_input)
|
||||
elif args.action == "post-tool-failure":
|
||||
record_tool(store, hook_input, failed=True)
|
||||
elif args.action == "sidekick-start":
|
||||
context = record_sidekick_start(store, hook_input)
|
||||
if context:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SubagentStart",
|
||||
"additionalContext": context,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
elif args.action == "sidekick-stop":
|
||||
record_sidekick_stop(store, hook_input)
|
||||
elif args.action == "stop":
|
||||
repo, session_id = record_stop(store, hook_input)
|
||||
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
|
||||
schedule_idle_flush(store, hook_input, repo, session_id)
|
||||
elif args.action == "flush":
|
||||
automatic = args.reason in {"session-end", "pre-compact"}
|
||||
if automatic and not automatic_flush_enabled():
|
||||
return 0
|
||||
if args.reason == "session-end":
|
||||
# In print mode, SessionEnd can arrive before the Stop hook has
|
||||
# recorded Claude's final response. Read any remaining visible
|
||||
# transcript messages before preparing the final extraction.
|
||||
record_stop(store, hook_input)
|
||||
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
|
||||
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
|
||||
else:
|
||||
session_id = str(hook_input.get("session_id") or "unknown-session")
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
already_running = store.has_inflight_flush(repo.identity, session_id)
|
||||
if already_running and args.reason == "session-end":
|
||||
hand_off_flush(
|
||||
hook_input, args.reason, wait_for_inflight=True
|
||||
)
|
||||
elif not already_running and store.prepare_flush(
|
||||
repo, session_id, args.reason
|
||||
) is not None:
|
||||
hand_off_flush(hook_input, args.reason)
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
def _sidekick_stop(store, hook_input):
|
||||
record_sidekick_stop(store, hook_input)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
# Memory must never prevent the coding agent from continuing.
|
||||
log_failure(exc)
|
||||
raise SystemExit(0)
|
||||
hook_runner.entry_point(
|
||||
record_stop_fn=record_stop,
|
||||
extra_actions={
|
||||
"post-tool-failure": lambda s, h: record_tool(s, h, failed=True),
|
||||
"sidekick-start": _sidekick_start,
|
||||
"sidekick-stop": _sidekick_stop,
|
||||
},
|
||||
data_dir_env="MEM0_CODE_DATA_DIR",
|
||||
automatic_flush_reasons={"session-end", "pre-compact"},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Claude Code transcript parsing for Mem0 memory extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_here = Path(__file__).resolve()
|
||||
sys.path.insert(0, str(_here.parents[2] / "core" / "python"))
|
||||
|
||||
from memory_core import ( # noqa: E402
|
||||
EvidenceStore,
|
||||
RepoContext,
|
||||
_session_id,
|
||||
redact,
|
||||
)
|
||||
|
||||
|
||||
def _message_content_text(content: Any) -> str:
|
||||
"""Return visible text from one Claude transcript message."""
|
||||
if isinstance(content, str):
|
||||
return redact(content).strip()
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "text":
|
||||
continue
|
||||
text = redact(block.get("text", "")).strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _transcript_rows(
|
||||
path: str, offset: int = 0
|
||||
) -> tuple[list[dict[str, Any]], int, bool]:
|
||||
"""Parse transcript rows from a byte offset, returning rows, end offset, and whether the offset was honored."""
|
||||
if not path:
|
||||
return [], 0, False
|
||||
rows = []
|
||||
try:
|
||||
resolved = Path(path).expanduser()
|
||||
if not 0 <= offset <= resolved.stat().st_size:
|
||||
offset = 0
|
||||
end = offset
|
||||
with resolved.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
for line in handle:
|
||||
if not line.endswith(b"\n"):
|
||||
break
|
||||
end += len(line)
|
||||
try:
|
||||
row = json.loads(line.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(row, dict) and row.get("uuid"):
|
||||
rows.append(row)
|
||||
except OSError:
|
||||
return [], offset, False
|
||||
return rows, end, offset > 0
|
||||
|
||||
|
||||
def _active_transcript_chain(
|
||||
rows: list[dict[str, Any]], session_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Follow the current Claude conversation branch from its latest record."""
|
||||
by_uuid = {str(row["uuid"]): row for row in rows if row.get("uuid")}
|
||||
leaf = next(
|
||||
(
|
||||
row
|
||||
for row in reversed(rows)
|
||||
if not row.get("isSidechain")
|
||||
and str(row.get("sessionId") or "") == session_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if leaf is None:
|
||||
return []
|
||||
|
||||
chain = []
|
||||
seen = set()
|
||||
current = leaf
|
||||
while current is not None:
|
||||
uuid = str(current.get("uuid") or "")
|
||||
if not uuid or uuid in seen:
|
||||
break
|
||||
seen.add(uuid)
|
||||
chain.append(current)
|
||||
current = by_uuid.get(str(current.get("parentUuid") or ""))
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
|
||||
def _human_prompt_text(row: dict[str, Any]) -> str:
|
||||
if row.get("type") != "user":
|
||||
return ""
|
||||
origin = row.get("origin") or {}
|
||||
if isinstance(origin, dict) and origin.get("kind") not in {None, "human"}:
|
||||
return ""
|
||||
message = row.get("message") or {}
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, str):
|
||||
return ""
|
||||
text = redact(content).strip()
|
||||
if text.startswith("<!-- attach -->"):
|
||||
text = text.removeprefix("<!-- attach -->").strip()
|
||||
ignored_prefixes = (
|
||||
"<local-command-caveat>",
|
||||
"<local-command-stdout>",
|
||||
"<local-command-stderr>",
|
||||
"<command-name>",
|
||||
"<system-reminder>",
|
||||
"<task-notification>",
|
||||
)
|
||||
return "" if text.startswith(ignored_prefixes) else text
|
||||
|
||||
|
||||
def _xml_value(text: str, tag: str) -> str:
|
||||
match = re.search(fr"<{tag}>(.*?)</{tag}>", text, re.DOTALL)
|
||||
return html.unescape(match.group(1).strip()) if match else ""
|
||||
|
||||
|
||||
def _agent_assignment(tool_input: dict[str, Any]) -> str:
|
||||
prompt = redact(tool_input.get("prompt", "")).strip()
|
||||
if not prompt:
|
||||
return ""
|
||||
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
|
||||
description = redact(tool_input.get("description", "")).strip()
|
||||
heading = f"Subagent assignment ({agent_type}"
|
||||
if description:
|
||||
heading += f": {description}"
|
||||
return f"{heading}):\n{prompt}"
|
||||
|
||||
|
||||
def _agent_response(tool_input: dict[str, Any], result: str) -> str:
|
||||
result = redact(result).strip()
|
||||
if not result or result.startswith("Async agent launched successfully."):
|
||||
return ""
|
||||
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
|
||||
description = redact(tool_input.get("description", "")).strip()
|
||||
heading = f"Subagent response ({agent_type}"
|
||||
if description:
|
||||
heading += f": {description}"
|
||||
return f"{heading}):\n{result}"
|
||||
|
||||
|
||||
def _tool_result_text(block: dict[str, Any]) -> str:
|
||||
return _message_content_text(block.get("content"))
|
||||
|
||||
|
||||
def transcript_extraction_messages(
|
||||
transcript_path: str,
|
||||
session_id: str,
|
||||
*,
|
||||
previous_leaf_uuid: str = "",
|
||||
prompt_hint: str = "",
|
||||
fallback_assistant_message: str = "",
|
||||
label_final_response: bool = False,
|
||||
start_offset: int = 0,
|
||||
) -> tuple[list[dict[str, str]], str, int]:
|
||||
"""Read the meaningful part of the current Claude exchange."""
|
||||
rows, end_offset, resumed = _transcript_rows(transcript_path, start_offset)
|
||||
chain = _active_transcript_chain(rows, session_id)
|
||||
if not chain:
|
||||
if resumed:
|
||||
return [], previous_leaf_uuid, end_offset
|
||||
fallback = redact(fallback_assistant_message).strip()
|
||||
return (
|
||||
([{"role": "assistant", "content": f"Main Claude response:\n{fallback}"}]
|
||||
if fallback
|
||||
else []),
|
||||
"",
|
||||
end_offset,
|
||||
)
|
||||
|
||||
leaf_uuid = str(chain[-1].get("uuid") or "")
|
||||
if previous_leaf_uuid and leaf_uuid == previous_leaf_uuid:
|
||||
return [], leaf_uuid, end_offset
|
||||
start = 0
|
||||
if previous_leaf_uuid:
|
||||
for index, row in enumerate(chain):
|
||||
if str(row.get("uuid") or "") == previous_leaf_uuid:
|
||||
start = index + 1
|
||||
break
|
||||
else:
|
||||
previous_leaf_uuid = ""
|
||||
if not previous_leaf_uuid and not resumed:
|
||||
prompt_hint = redact(prompt_hint).strip()
|
||||
candidates = [
|
||||
index
|
||||
for index, row in enumerate(chain)
|
||||
if _human_prompt_text(row)
|
||||
and (
|
||||
not prompt_hint
|
||||
or _human_prompt_text(row) == prompt_hint
|
||||
)
|
||||
]
|
||||
task_notifications = [
|
||||
index
|
||||
for index, row in enumerate(chain)
|
||||
if isinstance((row.get("message") or {}).get("content"), str)
|
||||
and (row.get("message") or {})["content"].startswith("<task-notification>")
|
||||
]
|
||||
if candidates:
|
||||
start = candidates[-1]
|
||||
elif task_notifications:
|
||||
start = task_notifications[-1]
|
||||
|
||||
tool_uses: dict[str, tuple[str, dict[str, Any]]] = {}
|
||||
for row in chain:
|
||||
message = row.get("message") or {}
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
||||
continue
|
||||
tool_id = str(block.get("id") or "")
|
||||
tool_input = block.get("input") or {}
|
||||
if tool_id and isinstance(tool_input, dict):
|
||||
tool_uses[tool_id] = (str(block.get("name") or ""), tool_input)
|
||||
|
||||
output: list[dict[str, str]] = []
|
||||
|
||||
def append(role: str, content: str) -> None:
|
||||
content = redact(content).strip()
|
||||
if content:
|
||||
output.append({"role": role, "content": content})
|
||||
|
||||
for row in chain[start:]:
|
||||
message = row.get("message") or {}
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = str(message.get("role") or "")
|
||||
content = message.get("content")
|
||||
|
||||
if role == "user" and isinstance(content, str):
|
||||
if content.startswith("<task-notification>"):
|
||||
if _xml_value(content, "status") != "completed":
|
||||
continue
|
||||
tool_id = _xml_value(content, "tool-use-id")
|
||||
tool = tool_uses.get(tool_id)
|
||||
result = _xml_value(content, "result")
|
||||
if tool and tool[0] == "Agent" and result:
|
||||
assignment = _agent_assignment(tool[1])
|
||||
response = _agent_response(tool[1], result)
|
||||
append("assistant", assignment)
|
||||
append("assistant", response)
|
||||
continue
|
||||
human = _human_prompt_text(row)
|
||||
if human:
|
||||
append("user", human)
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if role == "assistant" and block_type == "text":
|
||||
append("assistant", str(block.get("text") or ""))
|
||||
continue
|
||||
if role != "user" or block_type != "tool_result":
|
||||
continue
|
||||
|
||||
tool_id = str(block.get("tool_use_id") or "")
|
||||
tool = tool_uses.get(tool_id)
|
||||
if not tool:
|
||||
continue
|
||||
name, tool_input = tool
|
||||
result = _tool_result_text(block)
|
||||
failed = bool(block.get("is_error"))
|
||||
if name == "Agent" and not failed:
|
||||
response = _agent_response(tool_input, result)
|
||||
if response:
|
||||
append("assistant", _agent_assignment(tool_input))
|
||||
append("assistant", response)
|
||||
elif name == "AskUserQuestion" and result and not failed:
|
||||
append("user", f"User answers to Claude's questions:\n{result}")
|
||||
elif name == "ExitPlanMode" and not failed:
|
||||
plan = redact(tool_input.get("plan", "")).strip()
|
||||
if plan:
|
||||
append("assistant", f"Approved implementation plan:\n{plan}")
|
||||
|
||||
fallback = redact(fallback_assistant_message).strip()
|
||||
if fallback:
|
||||
labeled = f"Main Claude response:\n{fallback}"
|
||||
for message in reversed(output):
|
||||
if message["role"] == "assistant" and message["content"] == fallback:
|
||||
message["content"] = labeled
|
||||
break
|
||||
else:
|
||||
append("assistant", labeled)
|
||||
elif label_final_response:
|
||||
last_message = chain[-1].get("message") or {}
|
||||
last_content = (
|
||||
last_message.get("content") if isinstance(last_message, dict) else None
|
||||
)
|
||||
final_parts = [
|
||||
redact(block.get("text", "")).strip()
|
||||
for block in (last_content if isinstance(last_content, list) else [])
|
||||
if isinstance(block, dict)
|
||||
and block.get("type") == "text"
|
||||
and redact(block.get("text", "")).strip()
|
||||
]
|
||||
for start in range(len(output) - len(final_parts), -1, -1):
|
||||
candidate = output[start : start + len(final_parts)]
|
||||
if final_parts and [item["content"] for item in candidate] == final_parts:
|
||||
candidate[0]["content"] = (
|
||||
f"Main Claude response:\n{candidate[0]['content']}"
|
||||
)
|
||||
break
|
||||
return output, leaf_uuid, end_offset
|
||||
|
||||
|
||||
def record_stop(
|
||||
store: EvidenceStore, hook_input: dict[str, Any]
|
||||
) -> tuple[RepoContext, str]:
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
previous_stop = store.latest_event_payload(
|
||||
repo.identity, session_id, "assistant_stop"
|
||||
)
|
||||
latest_prompt = store.latest_event_payload(repo.identity, session_id, "user_prompt")
|
||||
transcript_path = str(hook_input.get("transcript_path") or "")
|
||||
previous_offset = previous_stop.get("transcript_offset")
|
||||
start_offset = (
|
||||
previous_offset
|
||||
if isinstance(previous_offset, int)
|
||||
and str(previous_stop.get("transcript_path") or "") == transcript_path
|
||||
else 0
|
||||
)
|
||||
transcript_messages, leaf_uuid, end_offset = transcript_extraction_messages(
|
||||
transcript_path,
|
||||
session_id,
|
||||
previous_leaf_uuid=str(previous_stop.get("transcript_leaf_uuid") or ""),
|
||||
prompt_hint=str(latest_prompt.get("text") or ""),
|
||||
fallback_assistant_message=message,
|
||||
label_final_response=True,
|
||||
start_offset=start_offset,
|
||||
)
|
||||
if transcript_messages:
|
||||
payload: dict[str, Any] = {
|
||||
"text": message,
|
||||
"transcript_messages": transcript_messages,
|
||||
}
|
||||
if leaf_uuid:
|
||||
payload["transcript_leaf_uuid"] = leaf_uuid
|
||||
if transcript_path:
|
||||
payload["transcript_path"] = transcript_path
|
||||
payload["transcript_offset"] = end_offset
|
||||
store.record_event(
|
||||
repo, session_id, "assistant_stop", payload
|
||||
)
|
||||
return repo, session_id
|
||||
@@ -17,7 +17,7 @@ import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
checkpoint_session,
|
||||
record_stop,
|
||||
configure_harness,
|
||||
touch_handoff_heartbeat,
|
||||
)
|
||||
|
||||
@@ -27,15 +27,28 @@ def main() -> int:
|
||||
return 2
|
||||
handoff_path = Path(sys.argv[1])
|
||||
os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path)
|
||||
harness = os.environ.get("MEM0_PLUGIN_HARNESS")
|
||||
if harness:
|
||||
source_tag = os.environ.get("MEM0_PLUGIN_SOURCE_TAG", "")
|
||||
configure_harness(
|
||||
harness,
|
||||
env_prefix=os.environ.get("MEM0_PLUGIN_ENV_PREFIX", ""),
|
||||
data_dir_name=os.environ.get("MEM0_PLUGIN_DATA_DIR_NAME", ""),
|
||||
source_tag=source_tag,
|
||||
)
|
||||
telemetry.init(harness=harness, source_tag=source_tag.upper())
|
||||
completed = False
|
||||
try:
|
||||
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
|
||||
delay = float(payload.get("delay_seconds") or 0)
|
||||
if delay > 0:
|
||||
payload.pop("delay_seconds", None)
|
||||
handoff_path.write_text(
|
||||
json.dumps(payload), encoding="utf-8"
|
||||
)
|
||||
temporary = handoff_path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temporary.replace(handoff_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
time.sleep(delay)
|
||||
if not handoff_path.exists():
|
||||
return 0
|
||||
@@ -58,8 +71,7 @@ def main() -> int:
|
||||
):
|
||||
touch_handoff_heartbeat()
|
||||
time.sleep(0.25)
|
||||
if reason == "session-end":
|
||||
record_stop(store, hook_input)
|
||||
# Hooks capture the conversation before handoff; the worker only flushes it.
|
||||
result = checkpoint_session(store, hook_input, reason)
|
||||
print(json.dumps(result, sort_keys=True), flush=True)
|
||||
completed = result.get("status") in {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Shared hook orchestration for all Mem0 agent plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
_session_id,
|
||||
api_key,
|
||||
bounded,
|
||||
cache_plugin_api_key,
|
||||
checkpoint_session,
|
||||
clear_stale_api_key_cache,
|
||||
configure_harness,
|
||||
data_dir,
|
||||
detached_process_kwargs,
|
||||
format_context,
|
||||
harness_config,
|
||||
record_session_start,
|
||||
record_tool,
|
||||
record_user_prompt,
|
||||
redact,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
STALE_RUNNING_SECONDS = 300
|
||||
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
PENDING_LAUNCH_LIMIT = 5
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 300
|
||||
|
||||
_core_dir: Path = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def read_hook_input() -> dict:
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def default_record_stop(store: EvidenceStore, hook_input: dict):
|
||||
"""Record the assistant's response without transcript parsing."""
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
if message:
|
||||
store.record_assistant_response(repo, session_id, message)
|
||||
return repo, session_id
|
||||
|
||||
|
||||
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
|
||||
"""Search once before the agent handles the first prompt in a session."""
|
||||
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
|
||||
if not is_first_prompt:
|
||||
return {}
|
||||
try:
|
||||
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
|
||||
except ValueError:
|
||||
minimum_query_chars = 20
|
||||
if len(prompt.strip()) < max(minimum_query_chars, 1):
|
||||
return {}
|
||||
result = search_memories(
|
||||
store, repo, session_id, bounded(prompt, 6000),
|
||||
top_k=5, operation="first-prompt-search", timeout=2,
|
||||
)
|
||||
if not result.memories:
|
||||
return {}
|
||||
context = format_context(
|
||||
result.memories,
|
||||
"Mem0 found these relevant memories from earlier work in this repository:",
|
||||
)
|
||||
telemetry.record(
|
||||
"context_injected",
|
||||
repo=repo, session_id=session_id, trigger="first-prompt",
|
||||
memory_count=len(result.memories), context_chars=len(context),
|
||||
prompt_chars=len(prompt),
|
||||
)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _launch_handoff(handoff_path: Path) -> bool:
|
||||
running_path = handoff_path.with_suffix(".running")
|
||||
try:
|
||||
handoff_path.replace(running_path)
|
||||
except OSError:
|
||||
return False
|
||||
worker = _core_dir / "flush_worker.py"
|
||||
log_path = data_dir() / "flush-worker.log"
|
||||
log_handle = open(log_path, "a", encoding="utf-8")
|
||||
harness = harness_config()
|
||||
child_env = os.environ.copy()
|
||||
child_env.update(
|
||||
{
|
||||
"MEM0_CODE_DATA_DIR": str(data_dir()),
|
||||
"MEM0_PLUGIN_HARNESS": harness["name"],
|
||||
"MEM0_PLUGIN_ENV_PREFIX": harness["env_prefix"],
|
||||
"MEM0_PLUGIN_DATA_DIR_NAME": harness["data_dir_name"],
|
||||
"MEM0_PLUGIN_SOURCE_TAG": harness["source_tag"],
|
||||
}
|
||||
)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(worker), str(running_path)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle, stderr=log_handle,
|
||||
close_fds=True,
|
||||
env=child_env,
|
||||
**detached_process_kwargs(),
|
||||
)
|
||||
finally:
|
||||
log_handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def recover_pending_handoffs() -> int:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
for running in pending_dir.glob("*.running"):
|
||||
try:
|
||||
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
|
||||
running.replace(running.with_suffix(".json"))
|
||||
except OSError:
|
||||
continue
|
||||
recoverable = []
|
||||
for handoff in pending_dir.glob("*.json"):
|
||||
try:
|
||||
age = now - handoff.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > PENDING_EXPIRY_SECONDS:
|
||||
handoff.unlink(missing_ok=True)
|
||||
continue
|
||||
recoverable.append((age, handoff))
|
||||
recoverable.sort(key=lambda item: item[0], reverse=True)
|
||||
launched = 0
|
||||
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
|
||||
launched += int(_launch_handoff(handoff))
|
||||
return launched
|
||||
|
||||
|
||||
def refresh_pending_handoffs() -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
if not pending_dir.is_dir():
|
||||
return
|
||||
for pattern in ("*.json", "*.running"):
|
||||
for handoff in pending_dir.glob(pattern):
|
||||
try:
|
||||
os.utime(handoff)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def hand_off_flush(
|
||||
hook_input: dict, reason: str, *, wait_for_inflight: bool = False,
|
||||
) -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = (
|
||||
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
|
||||
)
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": reason,
|
||||
"wait_for_inflight": wait_for_inflight,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
|
||||
|
||||
def automatic_flush_enabled() -> bool:
|
||||
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
def schedule_periodic_checkpoint(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
if (
|
||||
not automatic_flush_enabled()
|
||||
or not api_key()
|
||||
or not store.checkpoint_due(repo.identity, session_id)
|
||||
):
|
||||
return False
|
||||
if store.prepare_flush(repo, session_id, "periodic") is None:
|
||||
return False
|
||||
hand_off_flush(hook_input, "periodic")
|
||||
return True
|
||||
|
||||
|
||||
def _idle_flush_seconds() -> int:
|
||||
try:
|
||||
return max(
|
||||
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
|
||||
0,
|
||||
)
|
||||
except ValueError:
|
||||
return DEFAULT_IDLE_FLUSH_SECONDS
|
||||
|
||||
|
||||
def schedule_idle_flush(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
delay = _idle_flush_seconds()
|
||||
if delay <= 0 or not automatic_flush_enabled() or not api_key():
|
||||
return False
|
||||
if store.has_inflight_flush(repo.identity, session_id):
|
||||
return False
|
||||
if not store.has_unflushed_events(repo.identity, session_id):
|
||||
return False
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
for old in pending_dir.glob(f"idle-{digest}*"):
|
||||
old.unlink(missing_ok=True)
|
||||
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": "idle",
|
||||
"delay_seconds": delay,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
return True
|
||||
|
||||
|
||||
def log_failure(exc: Exception) -> None:
|
||||
try:
|
||||
log_path = data_dir() / "plugin-errors.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> int:
|
||||
if record_stop_fn is None:
|
||||
record_stop_fn = default_record_stop
|
||||
if automatic_flush_reasons is None:
|
||||
automatic_flush_reasons = {"session-end"}
|
||||
|
||||
base_actions = ["session-start", "user-prompt", "post-tool", "stop", "flush"]
|
||||
all_actions = base_actions + list((extra_actions or {}).keys())
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=all_actions)
|
||||
parser.add_argument("--reason", default="manual")
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.harness:
|
||||
configure_harness(args.harness)
|
||||
telemetry.init(harness=args.harness)
|
||||
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
|
||||
hook_input = read_hook_input()
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if store.is_paused():
|
||||
if args.action == "session-start":
|
||||
refresh_pending_handoffs()
|
||||
telemetry.record("session_start", paused=True)
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
telemetry.record("install")
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
telemetry.record("handoff_recovered", count=recovered)
|
||||
telemetry.spawn_flush()
|
||||
elif args.action == "user-prompt":
|
||||
output = first_prompt_memory_output(store, hook_input)
|
||||
if output:
|
||||
print(json.dumps(output))
|
||||
elif args.action == "post-tool":
|
||||
record_tool(store, hook_input)
|
||||
elif args.action == "stop":
|
||||
repo, session_id = record_stop_fn(store, hook_input)
|
||||
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
|
||||
schedule_idle_flush(store, hook_input, repo, session_id)
|
||||
elif args.action == "flush":
|
||||
automatic = args.reason in automatic_flush_reasons
|
||||
if automatic and not automatic_flush_enabled():
|
||||
return 0
|
||||
if args.reason == "session-end":
|
||||
record_stop_fn(store, hook_input)
|
||||
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
|
||||
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
|
||||
else:
|
||||
session_id = str(hook_input.get("session_id") or "unknown-session")
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
already_running = store.has_inflight_flush(repo.identity, session_id)
|
||||
if already_running and args.reason == "session-end":
|
||||
hand_off_flush(hook_input, args.reason, wait_for_inflight=True)
|
||||
elif not already_running and store.prepare_flush(
|
||||
repo, session_id, args.reason,
|
||||
) is not None:
|
||||
hand_off_flush(hook_input, args.reason)
|
||||
elif extra_actions and args.action in extra_actions:
|
||||
result = extra_actions[args.action](store, hook_input)
|
||||
if result:
|
||||
print(json.dumps(result))
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def entry_point(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
raise SystemExit(run(
|
||||
record_stop_fn=record_stop_fn,
|
||||
extra_actions=extra_actions,
|
||||
data_dir_env=data_dir_env,
|
||||
automatic_flush_reasons=automatic_flush_reasons,
|
||||
))
|
||||
except Exception as exc:
|
||||
log_failure(exc)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
entry_point()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose Mem0's memory search as one local Claude Code tool."""
|
||||
"""Expose Mem0's memory search as one local coding-agent tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,13 +11,13 @@ from typing import Any
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
CODING_MEMORY_CATEGORY_NAMES,
|
||||
PLUGIN_VERSION,
|
||||
SEARCH_SCOPES,
|
||||
format_search_result,
|
||||
resolve_repo,
|
||||
SEARCH_SCOPES,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
TOOL_NAME = "search_memories"
|
||||
TOOL_DESCRIPTION = (
|
||||
@@ -30,8 +30,7 @@ TOOL_DESCRIPTION = (
|
||||
"invocation works. The scope argument changes what is searched: 'repo' "
|
||||
"(default) is the whole repository's shared memory plus your own "
|
||||
"preferences, 'dir' narrows the shared part to the directory you are "
|
||||
"working in, and 'mine' is your preferences alone. Pass run_id to look "
|
||||
"at one earlier Claude Code session only."
|
||||
"working in, and 'mine' is your preferences alone."
|
||||
)
|
||||
TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
@@ -65,8 +64,10 @@ TOOL_SCHEMA = {
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Optional Claude Code session ID. Restricts the search to memories written from that session.",
|
||||
"description": (
|
||||
"Optional coding-agent session ID. With any scope, restricts results to memories "
|
||||
"saved in that session. Omit to recall memories across sessions."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
@@ -110,16 +111,17 @@ def _validate_arguments(
|
||||
raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.")
|
||||
|
||||
run_id = arguments.get("run_id")
|
||||
if run_id is not None and (
|
||||
not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 200
|
||||
):
|
||||
raise ToolInputError("run_id must be a non-empty string of at most 200 characters.")
|
||||
if run_id is not None:
|
||||
if not isinstance(run_id, str) or not run_id.strip():
|
||||
raise ToolInputError("run_id must be a non-empty string.")
|
||||
run_id = run_id.strip()
|
||||
|
||||
return query, top_k, category, scope, run_id
|
||||
|
||||
|
||||
def call_search_memories(arguments: Any) -> str:
|
||||
def call_search_memories(arguments: Any, cwd: str | None = None) -> str:
|
||||
query, top_k, category, scope, run_id = _validate_arguments(arguments)
|
||||
repo = resolve_repo(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
|
||||
repo = resolve_repo(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
|
||||
result = search_memories(
|
||||
None,
|
||||
repo,
|
||||
@@ -134,6 +136,19 @@ def call_search_memories(arguments: Any) -> str:
|
||||
return format_search_result(result)
|
||||
|
||||
|
||||
def _workspace_cwd(params: dict[str, Any]) -> str | None:
|
||||
meta = params.get("_meta")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
metadata = meta.get("x-codex-turn-metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
workspaces = metadata.get("workspaces") or {}
|
||||
if isinstance(workspaces, dict):
|
||||
return next((path for path in workspaces if isinstance(path, str) and path), None)
|
||||
return None
|
||||
|
||||
|
||||
def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
@@ -157,7 +172,7 @@ def handle_request(message: Any) -> dict[str, Any] | None:
|
||||
"result": {
|
||||
"protocolVersion": requested or PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "mem0", "version": "0.3.0"},
|
||||
"serverInfo": {"name": "mem0", "version": PLUGIN_VERSION},
|
||||
},
|
||||
}
|
||||
if method == "ping":
|
||||
@@ -187,7 +202,9 @@ def handle_request(message: Any) -> dict[str, Any] | None:
|
||||
result = _tool_response("Unknown Mem0 tool.", is_error=True)
|
||||
else:
|
||||
try:
|
||||
result = _tool_response(call_search_memories(params.get("arguments")))
|
||||
result = _tool_response(
|
||||
call_search_memories(params.get("arguments"), _workspace_cwd(params))
|
||||
)
|
||||
except ToolInputError as exc:
|
||||
result = _tool_response(str(exc), is_error=True)
|
||||
except Exception:
|
||||
|
||||
@@ -14,6 +14,7 @@ from memory_core import (
|
||||
data_dir,
|
||||
doctor,
|
||||
forget_remote_repo,
|
||||
configure_harness,
|
||||
resolve_repo,
|
||||
user_id,
|
||||
)
|
||||
@@ -60,6 +61,7 @@ def _print_status(value: dict) -> None:
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
status = subparsers.add_parser("status")
|
||||
@@ -77,6 +79,10 @@ def main() -> int:
|
||||
forget.add_argument("--include-project-memory", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.harness:
|
||||
source_tag = f"{args.harness.replace('-', '_')}_plugin"
|
||||
configure_harness(args.harness, source_tag=source_tag)
|
||||
telemetry.init(harness=args.harness, source_tag=source_tag.upper())
|
||||
if args.plugin_data_dir:
|
||||
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
|
||||
store = EvidenceStore()
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Save useful coding memories and search them in later Claude Code sessions.
|
||||
"""Shared core for Mem0 agent plugins.
|
||||
|
||||
Hooks record small session details locally. When Claude compacts or ends the
|
||||
session, Mem0 sends the useful parts of the session to Mem0 so it can create
|
||||
memories. Claude can search those memories during later work in the repository.
|
||||
Hooks record small session details locally. When the agent compacts or ends the
|
||||
session, Mem0 sends the useful parts to the platform so it can create memories.
|
||||
The agent can search those memories during later work in the repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -20,20 +19,46 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import timezone, datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import telemetry
|
||||
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.0"
|
||||
MAX_PROMPT_CHARS = 6000
|
||||
MAX_ASSISTANT_CHARS = 6000
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
_harness_data_dir_name: str = "mem0-plugin"
|
||||
_harness_source_tag: str = "mem0_plugin"
|
||||
|
||||
|
||||
def configure_harness(
|
||||
name: str,
|
||||
env_prefix: str = "",
|
||||
data_dir_name: str = "",
|
||||
source_tag: str = "",
|
||||
) -> None:
|
||||
global _harness_name, _harness_env_prefix, _harness_data_dir_name, _harness_source_tag
|
||||
_harness_name = name
|
||||
_harness_env_prefix = env_prefix or f"MEM0_{name.upper().replace('-', '_')}"
|
||||
_harness_data_dir_name = data_dir_name or f"{name}-plugin"
|
||||
_harness_source_tag = source_tag or f"{name.replace('-', '_')}_plugin"
|
||||
|
||||
|
||||
def harness_config() -> dict[str, str]:
|
||||
return {
|
||||
"name": _harness_name,
|
||||
"env_prefix": _harness_env_prefix,
|
||||
"data_dir_name": _harness_data_dir_name,
|
||||
"source_tag": _harness_source_tag,
|
||||
}
|
||||
|
||||
|
||||
MAX_COMMAND_CHARS = 2000
|
||||
MAX_RESULT_CHARS = 2500
|
||||
MAX_EPISODE_CHARS = 12000
|
||||
@@ -52,7 +77,7 @@ A completed change should produce one memory explaining the resulting behavior,
|
||||
|
||||
A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own.
|
||||
|
||||
Use Claude's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or Claude completed them. Treat subagent responses as supporting repository evidence, not as decisions.
|
||||
Use the current coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
|
||||
|
||||
Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state.
|
||||
|
||||
@@ -130,6 +155,11 @@ SECRET_PATTERNS = [
|
||||
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*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -213,13 +243,21 @@ def directory_chain(repo: RepoContext) -> list[str]:
|
||||
return ["/".join(parts[: index + 1]) for index in range(len(parts))]
|
||||
|
||||
|
||||
def _shared_project_ids(repo: RepoContext) -> list[str]:
|
||||
"""Current and pre-upgrade namespaces, shared by recall and explicit deletion."""
|
||||
if not repo.identity.startswith("local:") and repo.project_id != repo.app_id:
|
||||
return [repo.project_id, repo.app_id]
|
||||
return [repo.project_id]
|
||||
|
||||
|
||||
def _search_filters(user: str, repo: RepoContext, scope: str) -> dict[str, Any]:
|
||||
"""Build the scope filter: app_id scopes to the repo, then union shared and personal lanes."""
|
||||
app_scope = {"app_id": repo.app_id}
|
||||
mine = {"AND": [{"user_id": user}, app_scope]}
|
||||
if scope == "mine":
|
||||
return mine
|
||||
shared: dict[str, Any] = {"AND": [{"agent_id": repo.project_id}, app_scope]}
|
||||
projects = [{"AND": [{"agent_id": project_id}, app_scope]} for project_id in _shared_project_ids(repo)]
|
||||
shared: dict[str, Any] = projects[0] if len(projects) == 1 else {"OR": projects}
|
||||
if scope == "dir" and repo.directory:
|
||||
shared = {"AND": [shared, {"metadata": {"dirs": {"contains": repo.directory}}}]}
|
||||
return {"OR": [shared, mine]}
|
||||
@@ -297,9 +335,9 @@ class RepoContext:
|
||||
|
||||
|
||||
def _project_id(root: str, identity: str, app_id: str) -> str:
|
||||
"""The shared namespace: the repository, or a folder path hashed so same-named folders stay apart."""
|
||||
"""The shared namespace: includes a host hash so repos with the same owner/name on different hosts stay apart."""
|
||||
if not identity.startswith("local:"):
|
||||
return app_id
|
||||
return f"{app_id}-{hashlib.sha256(identity.encode()).hexdigest()[:10]}"
|
||||
return f"local-{app_id}-{hashlib.sha256(root.encode()).hexdigest()[:10]}"
|
||||
|
||||
|
||||
@@ -345,8 +383,8 @@ def resolve_repo(cwd: str | None) -> RepoContext:
|
||||
def api_key() -> str:
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
# Compatibility with the pre-marketplace development harness.
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
@@ -359,9 +397,10 @@ def api_key() -> str:
|
||||
|
||||
|
||||
def cache_plugin_api_key() -> bool:
|
||||
"""Bridge Claude's hook-only sensitive config into plugin-owned storage."""
|
||||
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
|
||||
configured = (
|
||||
os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
@@ -394,6 +433,7 @@ def clear_stale_api_key_cache() -> bool:
|
||||
"""Drop the cached key file once every configured key source is gone."""
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
@@ -411,7 +451,7 @@ def clear_stale_api_key_cache() -> bool:
|
||||
|
||||
|
||||
def detached_process_kwargs(platform: str | None = None) -> dict:
|
||||
"""Keep a spawned worker alive after Claude Code exits, on POSIX and Windows."""
|
||||
"""Keep a spawned worker alive after the coding agent exits, on POSIX and Windows."""
|
||||
if (platform or sys.platform) == "win32":
|
||||
return {
|
||||
"creationflags": subprocess.DETACHED_PROCESS
|
||||
@@ -422,7 +462,8 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
|
||||
|
||||
def _plugin_option(name: str, fallback: str = "") -> str:
|
||||
return (
|
||||
os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
|
||||
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(fallback)
|
||||
or ""
|
||||
).strip()
|
||||
@@ -440,11 +481,14 @@ def user_id() -> str:
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
configured = os.environ.get("MEM0_CODE_DATA_DIR") or os.environ.get(
|
||||
"CLAUDE_PLUGIN_DATA"
|
||||
configured = (
|
||||
os.environ.get("MEM0_CODE_DATA_DIR")
|
||||
or os.environ.get("MEM0_PLUGIN_DATA_DIR")
|
||||
or os.environ.get("PLUGIN_DATA")
|
||||
or os.environ.get("CLAUDE_PLUGIN_DATA")
|
||||
)
|
||||
return (
|
||||
Path(configured).expanduser() if configured else Path.home() / ".mem0" / "claude-code-plugin"
|
||||
Path(configured).expanduser() if configured else Path.home() / ".mem0" / _harness_data_dir_name
|
||||
)
|
||||
|
||||
|
||||
@@ -464,316 +508,11 @@ def _int_option(name: str, fallback: str, default: int) -> int:
|
||||
|
||||
|
||||
|
||||
def _message_content_text(content: Any) -> str:
|
||||
"""Return visible text from one Claude transcript message."""
|
||||
if isinstance(content, str):
|
||||
return redact(content).strip()
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "text":
|
||||
continue
|
||||
text = redact(block.get("text", "")).strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _transcript_rows(
|
||||
path: str, offset: int = 0
|
||||
) -> tuple[list[dict[str, Any]], int, bool]:
|
||||
"""Parse transcript rows from a byte offset, returning rows, end offset, and whether the offset was honored."""
|
||||
if not path:
|
||||
return [], 0, False
|
||||
rows = []
|
||||
try:
|
||||
resolved = Path(path).expanduser()
|
||||
if not 0 <= offset <= resolved.stat().st_size:
|
||||
offset = 0
|
||||
end = offset
|
||||
with resolved.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
for line in handle:
|
||||
if not line.endswith(b"\n"):
|
||||
break
|
||||
end += len(line)
|
||||
try:
|
||||
row = json.loads(line.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(row, dict) and row.get("uuid"):
|
||||
rows.append(row)
|
||||
except OSError:
|
||||
return [], offset, False
|
||||
return rows, end, offset > 0
|
||||
|
||||
|
||||
def _active_transcript_chain(
|
||||
rows: list[dict[str, Any]], session_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Follow the current Claude conversation branch from its latest record."""
|
||||
by_uuid = {str(row["uuid"]): row for row in rows if row.get("uuid")}
|
||||
leaf = next(
|
||||
(
|
||||
row
|
||||
for row in reversed(rows)
|
||||
if not row.get("isSidechain")
|
||||
and str(row.get("sessionId") or "") == session_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if leaf is None:
|
||||
return []
|
||||
|
||||
chain = []
|
||||
seen = set()
|
||||
current = leaf
|
||||
while current is not None:
|
||||
uuid = str(current.get("uuid") or "")
|
||||
if not uuid or uuid in seen:
|
||||
break
|
||||
seen.add(uuid)
|
||||
chain.append(current)
|
||||
current = by_uuid.get(str(current.get("parentUuid") or ""))
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
|
||||
def _human_prompt_text(row: dict[str, Any]) -> str:
|
||||
if row.get("type") != "user":
|
||||
return ""
|
||||
origin = row.get("origin") or {}
|
||||
if isinstance(origin, dict) and origin.get("kind") not in {None, "human"}:
|
||||
return ""
|
||||
message = row.get("message") or {}
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, str):
|
||||
return ""
|
||||
text = redact(content).strip()
|
||||
if text.startswith("<!-- attach -->"):
|
||||
text = text.removeprefix("<!-- attach -->").strip()
|
||||
ignored_prefixes = (
|
||||
"<local-command-caveat>",
|
||||
"<local-command-stdout>",
|
||||
"<local-command-stderr>",
|
||||
"<command-name>",
|
||||
"<system-reminder>",
|
||||
"<task-notification>",
|
||||
)
|
||||
return "" if text.startswith(ignored_prefixes) else text
|
||||
|
||||
|
||||
def _xml_value(text: str, tag: str) -> str:
|
||||
match = re.search(fr"<{tag}>(.*?)</{tag}>", text, re.DOTALL)
|
||||
return html.unescape(match.group(1).strip()) if match else ""
|
||||
|
||||
|
||||
def _agent_assignment(tool_input: dict[str, Any]) -> str:
|
||||
prompt = redact(tool_input.get("prompt", "")).strip()
|
||||
if not prompt:
|
||||
return ""
|
||||
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
|
||||
description = redact(tool_input.get("description", "")).strip()
|
||||
heading = f"Subagent assignment ({agent_type}"
|
||||
if description:
|
||||
heading += f": {description}"
|
||||
return f"{heading}):\n{prompt}"
|
||||
|
||||
|
||||
def _agent_response(tool_input: dict[str, Any], result: str) -> str:
|
||||
result = redact(result).strip()
|
||||
if not result or result.startswith("Async agent launched successfully."):
|
||||
return ""
|
||||
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
|
||||
description = redact(tool_input.get("description", "")).strip()
|
||||
heading = f"Subagent response ({agent_type}"
|
||||
if description:
|
||||
heading += f": {description}"
|
||||
return f"{heading}):\n{result}"
|
||||
|
||||
|
||||
def _tool_result_text(block: dict[str, Any]) -> str:
|
||||
return _message_content_text(block.get("content"))
|
||||
|
||||
|
||||
def transcript_extraction_messages(
|
||||
transcript_path: str,
|
||||
session_id: str,
|
||||
*,
|
||||
previous_leaf_uuid: str = "",
|
||||
prompt_hint: str = "",
|
||||
fallback_assistant_message: str = "",
|
||||
label_final_response: bool = False,
|
||||
start_offset: int = 0,
|
||||
) -> tuple[list[dict[str, str]], str, int]:
|
||||
"""Read the meaningful part of the current Claude exchange.
|
||||
|
||||
The returned messages contain human prompts, visible Claude text, accepted
|
||||
plans, answers collected through AskUserQuestion, and completed native
|
||||
subagent assignments and responses. Raw tool output and hidden reasoning
|
||||
are deliberately excluded.
|
||||
"""
|
||||
rows, end_offset, resumed = _transcript_rows(transcript_path, start_offset)
|
||||
chain = _active_transcript_chain(rows, session_id)
|
||||
if not chain:
|
||||
if resumed:
|
||||
return [], previous_leaf_uuid, end_offset
|
||||
fallback = redact(fallback_assistant_message).strip()
|
||||
return (
|
||||
([{"role": "assistant", "content": f"Main Claude response:\n{fallback}"}]
|
||||
if fallback
|
||||
else []),
|
||||
"",
|
||||
end_offset,
|
||||
)
|
||||
|
||||
leaf_uuid = str(chain[-1].get("uuid") or "")
|
||||
if previous_leaf_uuid and leaf_uuid == previous_leaf_uuid:
|
||||
return [], leaf_uuid, end_offset
|
||||
start = 0
|
||||
if previous_leaf_uuid:
|
||||
for index, row in enumerate(chain):
|
||||
if str(row.get("uuid") or "") == previous_leaf_uuid:
|
||||
start = index + 1
|
||||
break
|
||||
else:
|
||||
previous_leaf_uuid = ""
|
||||
if not previous_leaf_uuid and not resumed:
|
||||
prompt_hint = redact(prompt_hint).strip()
|
||||
candidates = [
|
||||
index
|
||||
for index, row in enumerate(chain)
|
||||
if _human_prompt_text(row)
|
||||
and (
|
||||
not prompt_hint
|
||||
or _human_prompt_text(row) == prompt_hint
|
||||
)
|
||||
]
|
||||
task_notifications = [
|
||||
index
|
||||
for index, row in enumerate(chain)
|
||||
if isinstance((row.get("message") or {}).get("content"), str)
|
||||
and (row.get("message") or {})["content"].startswith("<task-notification>")
|
||||
]
|
||||
if candidates:
|
||||
start = candidates[-1]
|
||||
elif task_notifications:
|
||||
start = task_notifications[-1]
|
||||
|
||||
tool_uses: dict[str, tuple[str, dict[str, Any]]] = {}
|
||||
for row in chain:
|
||||
message = row.get("message") or {}
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
||||
continue
|
||||
tool_id = str(block.get("id") or "")
|
||||
tool_input = block.get("input") or {}
|
||||
if tool_id and isinstance(tool_input, dict):
|
||||
tool_uses[tool_id] = (str(block.get("name") or ""), tool_input)
|
||||
|
||||
output: list[dict[str, str]] = []
|
||||
|
||||
def append(role: str, content: str) -> None:
|
||||
content = redact(content).strip()
|
||||
if content:
|
||||
output.append({"role": role, "content": content})
|
||||
|
||||
for row in chain[start:]:
|
||||
message = row.get("message") or {}
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = str(message.get("role") or "")
|
||||
content = message.get("content")
|
||||
|
||||
if role == "user" and isinstance(content, str):
|
||||
if content.startswith("<task-notification>"):
|
||||
if _xml_value(content, "status") != "completed":
|
||||
continue
|
||||
tool_id = _xml_value(content, "tool-use-id")
|
||||
tool = tool_uses.get(tool_id)
|
||||
result = _xml_value(content, "result")
|
||||
if tool and tool[0] == "Agent" and result:
|
||||
assignment = _agent_assignment(tool[1])
|
||||
response = _agent_response(tool[1], result)
|
||||
append("assistant", assignment)
|
||||
append("assistant", response)
|
||||
continue
|
||||
human = _human_prompt_text(row)
|
||||
if human:
|
||||
append("user", human)
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if role == "assistant" and block_type == "text":
|
||||
append("assistant", str(block.get("text") or ""))
|
||||
continue
|
||||
if role != "user" or block_type != "tool_result":
|
||||
continue
|
||||
|
||||
tool_id = str(block.get("tool_use_id") or "")
|
||||
tool = tool_uses.get(tool_id)
|
||||
if not tool:
|
||||
continue
|
||||
name, tool_input = tool
|
||||
result = _tool_result_text(block)
|
||||
failed = bool(block.get("is_error"))
|
||||
if name == "Agent" and not failed:
|
||||
response = _agent_response(tool_input, result)
|
||||
if response:
|
||||
append("assistant", _agent_assignment(tool_input))
|
||||
append("assistant", response)
|
||||
elif name == "AskUserQuestion" and result and not failed:
|
||||
append("user", f"User answers to Claude's questions:\n{result}")
|
||||
elif name == "ExitPlanMode" and not failed:
|
||||
plan = redact(tool_input.get("plan", "")).strip()
|
||||
if plan:
|
||||
append("assistant", f"Approved implementation plan:\n{plan}")
|
||||
|
||||
fallback = redact(fallback_assistant_message).strip()
|
||||
if fallback:
|
||||
labeled = f"Main Claude response:\n{fallback}"
|
||||
for message in reversed(output):
|
||||
if message["role"] == "assistant" and message["content"] == fallback:
|
||||
message["content"] = labeled
|
||||
break
|
||||
else:
|
||||
append("assistant", labeled)
|
||||
elif label_final_response:
|
||||
last_message = chain[-1].get("message") or {}
|
||||
last_content = (
|
||||
last_message.get("content") if isinstance(last_message, dict) else None
|
||||
)
|
||||
final_parts = [
|
||||
redact(block.get("text", "")).strip()
|
||||
for block in (last_content if isinstance(last_content, list) else [])
|
||||
if isinstance(block, dict)
|
||||
and block.get("type") == "text"
|
||||
and redact(block.get("text", "")).strip()
|
||||
]
|
||||
for start in range(len(output) - len(final_parts), -1, -1):
|
||||
candidate = output[start : start + len(final_parts)]
|
||||
if final_parts and [item["content"] for item in candidate] == final_parts:
|
||||
candidate[0]["content"] = (
|
||||
f"Main Claude response:\n{candidate[0]['content']}"
|
||||
)
|
||||
break
|
||||
return output, leaf_uuid, end_offset
|
||||
|
||||
|
||||
def _checkpoint_message(event: dict[str, Any]) -> str:
|
||||
kind = event.get("kind")
|
||||
payload = event.get("payload") or {}
|
||||
if kind == "user_prompt":
|
||||
return bounded(payload.get("text", ""), MAX_PROMPT_CHARS)
|
||||
return redact(payload.get("text", "")).strip()
|
||||
if kind == "assistant_stop":
|
||||
transcript_messages = payload.get("transcript_messages") or []
|
||||
if isinstance(transcript_messages, list):
|
||||
@@ -784,9 +523,9 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
return bounded(payload.get("text", ""), MAX_ASSISTANT_CHARS)
|
||||
return redact(payload.get("text", "")).strip()
|
||||
if kind == "sidekick_stop":
|
||||
return bounded(payload.get("final_message", ""), MAX_ASSISTANT_CHARS)
|
||||
return redact(payload.get("final_message", "")).strip()
|
||||
return ""
|
||||
|
||||
|
||||
@@ -998,8 +737,27 @@ class EvidenceStore:
|
||||
self.conn.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def record_assistant_response(self, repo: RepoContext, session_id: str, message: str) -> None:
|
||||
"""Ignore repeated response hooks until another prompt or a different answer arrives."""
|
||||
with self.conn:
|
||||
# Serialize the check and insert across concurrent Stop and SessionEnd hooks.
|
||||
self.conn.execute("BEGIN IMMEDIATE")
|
||||
previous = self.conn.execute(
|
||||
"""SELECT kind, payload_json FROM events
|
||||
WHERE repo_id = ? AND session_id = ? AND kind IN ('user_prompt', 'assistant_stop')
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(repo.identity, session_id),
|
||||
).fetchone()
|
||||
if (
|
||||
previous is not None
|
||||
and previous["kind"] == "assistant_stop"
|
||||
and json.loads(previous["payload_json"]).get("text") == message
|
||||
):
|
||||
return
|
||||
self.record_event(repo, session_id, "assistant_stop", {"text": message})
|
||||
|
||||
def repo_for_session(self, session_id: str, cwd: str | None) -> RepoContext:
|
||||
"""Keep one project scope for every hook in a Claude Code session."""
|
||||
"""Keep one project scope for every hook in a coding-agent session."""
|
||||
current = resolve_repo(cwd)
|
||||
if session_id == "unknown-session":
|
||||
return current
|
||||
@@ -1041,78 +799,77 @@ class EvidenceStore:
|
||||
def prepare_flush(
|
||||
self, repo: RepoContext, session_id: str, reason: str
|
||||
) -> tuple[str, list[dict[str, Any]]] | None:
|
||||
existing = self.conn.execute(
|
||||
"""SELECT * FROM flushes
|
||||
WHERE repo_id = ? AND session_id = ?
|
||||
AND status NOT IN ('semantic-succeeded', 'explicitly-stored', 'gave-up')
|
||||
ORDER BY created_at LIMIT 1""",
|
||||
(repo.identity, session_id),
|
||||
).fetchone()
|
||||
if existing and int(existing["attempts"] or 0) >= MAX_FLUSH_ATTEMPTS:
|
||||
with self.conn:
|
||||
with self.conn:
|
||||
self.conn.execute("BEGIN IMMEDIATE")
|
||||
existing = self.conn.execute(
|
||||
"""SELECT * FROM flushes
|
||||
WHERE repo_id = ? AND session_id = ?
|
||||
AND status NOT IN ('semantic-succeeded', 'explicitly-stored', 'gave-up')
|
||||
ORDER BY created_at LIMIT 1""",
|
||||
(repo.identity, session_id),
|
||||
).fetchone()
|
||||
if existing and int(existing["attempts"] or 0) >= MAX_FLUSH_ATTEMPTS:
|
||||
self.conn.execute(
|
||||
"UPDATE flushes SET status = 'gave-up', updated_at = ? WHERE packet_id = ?",
|
||||
(utc_now(), existing["packet_id"]),
|
||||
)
|
||||
telemetry.record(
|
||||
"flush",
|
||||
repo=repo,
|
||||
session_id=session_id,
|
||||
reason=reason,
|
||||
status="gave-up",
|
||||
success=False,
|
||||
attempts=int(existing["attempts"] or 0),
|
||||
)
|
||||
existing = None
|
||||
if existing:
|
||||
if reason != "periodic" and existing["reason"] == "periodic":
|
||||
with self.conn:
|
||||
telemetry.record(
|
||||
"flush",
|
||||
repo=repo,
|
||||
session_id=session_id,
|
||||
reason=reason,
|
||||
status="gave-up",
|
||||
success=False,
|
||||
attempts=int(existing["attempts"] or 0),
|
||||
)
|
||||
existing = None
|
||||
if existing:
|
||||
if reason != "periodic" and existing["reason"] == "periodic":
|
||||
self.conn.execute(
|
||||
"UPDATE flushes SET reason = ?, updated_at = ? WHERE packet_id = ?",
|
||||
(reason, utc_now(), existing["packet_id"]),
|
||||
)
|
||||
existing_rows = self.conn.execute(
|
||||
"SELECT * FROM events WHERE flush_id = ? ORDER BY id",
|
||||
(existing["packet_id"],),
|
||||
existing_rows = self.conn.execute(
|
||||
"SELECT * FROM events WHERE flush_id = ? ORDER BY id",
|
||||
(existing["packet_id"],),
|
||||
).fetchall()
|
||||
if existing_rows:
|
||||
return str(existing["packet_id"]), [
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"kind": row["kind"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
}
|
||||
for row in existing_rows
|
||||
]
|
||||
|
||||
rows = self.conn.execute(
|
||||
"""SELECT * FROM events
|
||||
WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL
|
||||
ORDER BY id""",
|
||||
(repo.identity, session_id),
|
||||
).fetchall()
|
||||
if existing_rows:
|
||||
return str(existing["packet_id"]), [
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"kind": row["kind"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
}
|
||||
for row in existing_rows
|
||||
]
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
rows = self.conn.execute(
|
||||
"""SELECT * FROM events
|
||||
WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL
|
||||
ORDER BY id""",
|
||||
(repo.identity, session_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
events = [
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"kind": row["kind"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
events = select_checkpoint_events(events, force=reason != "periodic")
|
||||
if not events:
|
||||
return None
|
||||
event_start, event_end = events[0]["id"], events[-1]["id"]
|
||||
packet_material = f"{repo.identity}\0{session_id}\0{event_start}\0{event_end}"
|
||||
packet_id = hashlib.sha256(packet_material.encode()).hexdigest()[:32]
|
||||
now = utc_now()
|
||||
|
||||
events = [
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"kind": row["kind"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
events = select_checkpoint_events(events, force=reason != "periodic")
|
||||
if not events:
|
||||
return None
|
||||
event_start, event_end = events[0]["id"], events[-1]["id"]
|
||||
packet_material = f"{repo.identity}\0{session_id}\0{event_start}\0{event_end}"
|
||||
packet_id = hashlib.sha256(packet_material.encode()).hexdigest()[:32]
|
||||
now = utc_now()
|
||||
|
||||
with self.conn:
|
||||
self.conn.execute(
|
||||
"""INSERT OR IGNORE INTO flushes
|
||||
(packet_id, repo_id, app_id, session_id, reason, event_start,
|
||||
@@ -1137,7 +894,7 @@ class EvidenceStore:
|
||||
f"WHERE id IN ({placeholders}) AND flush_id IS NULL",
|
||||
(packet_id, *event_ids),
|
||||
)
|
||||
return packet_id, events
|
||||
return packet_id, events
|
||||
|
||||
def checkpoint_due(self, repo_id: str, session_id: str) -> bool:
|
||||
if self.has_inflight_flush(repo_id, session_id):
|
||||
@@ -1318,9 +1075,19 @@ class EvidenceStore:
|
||||
agent_type: str,
|
||||
transcript_path: str,
|
||||
final_message: str,
|
||||
) -> None:
|
||||
) -> str:
|
||||
now = utc_now()
|
||||
with self.conn:
|
||||
self.conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if not agent_id:
|
||||
rows = self.conn.execute(
|
||||
"""SELECT agent_id FROM sidekick_runs
|
||||
WHERE repo_id = ? AND session_id = ? AND agent_type = ? AND stopped_at IS NULL
|
||||
LIMIT 2""",
|
||||
(repo.identity, session_id, agent_type),
|
||||
).fetchall()
|
||||
# Without a host ID, overlapping runs cannot be correlated reliably.
|
||||
agent_id = rows[0]["agent_id"] if len(rows) == 1 else f"unknown-agent-{time.time_ns()}"
|
||||
self.conn.execute(
|
||||
"""INSERT INTO sidekick_runs
|
||||
(repo_id, session_id, agent_id, agent_type, started_at,
|
||||
@@ -1338,9 +1105,14 @@ class EvidenceStore:
|
||||
now,
|
||||
now,
|
||||
bounded(transcript_path, 2000),
|
||||
bounded(final_message, MAX_ASSISTANT_CHARS),
|
||||
redact(final_message).strip(),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
except Exception:
|
||||
self.conn.rollback()
|
||||
raise
|
||||
return agent_id
|
||||
|
||||
def operation(
|
||||
self,
|
||||
@@ -1518,7 +1290,7 @@ def record_user_prompt(
|
||||
) -> tuple[RepoContext, str, str, bool]:
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
prompt = bounded(hook_input.get("prompt", ""), MAX_PROMPT_CHARS)
|
||||
prompt = redact(hook_input.get("prompt", "")).strip()
|
||||
is_first_prompt = not store.has_event(repo.identity, session_id, "user_prompt")
|
||||
store.record_event(repo, session_id, "user_prompt", {"text": prompt})
|
||||
return repo, session_id, prompt, is_first_prompt
|
||||
@@ -1543,7 +1315,7 @@ def _tool_result_preview(response: Any) -> str:
|
||||
return bounded(response, MAX_RESULT_CHARS)
|
||||
|
||||
|
||||
def tool_payload(hook_input: dict[str, Any], *, failed: bool = False) -> dict[str, Any]:
|
||||
def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) -> dict[str, Any]:
|
||||
name = str(hook_input.get("tool_name") or "unknown")
|
||||
tool_input = hook_input.get("tool_input") or {}
|
||||
if not isinstance(tool_input, dict):
|
||||
@@ -1597,7 +1369,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool = False) -> dict[st
|
||||
|
||||
|
||||
def record_tool(
|
||||
store: EvidenceStore, hook_input: dict[str, Any], *, failed: bool = False
|
||||
store: EvidenceStore, hook_input: dict[str, Any], *, failed: bool | None = False
|
||||
) -> None:
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
@@ -1612,51 +1384,8 @@ def record_tool(
|
||||
)
|
||||
|
||||
|
||||
def record_stop(
|
||||
store: EvidenceStore, hook_input: dict[str, Any]
|
||||
) -> tuple[RepoContext, str]:
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
message = bounded(hook_input.get("last_assistant_message", ""), MAX_ASSISTANT_CHARS)
|
||||
previous_stop = store.latest_event_payload(
|
||||
repo.identity, session_id, "assistant_stop"
|
||||
)
|
||||
latest_prompt = store.latest_event_payload(repo.identity, session_id, "user_prompt")
|
||||
transcript_path = str(hook_input.get("transcript_path") or "")
|
||||
previous_offset = previous_stop.get("transcript_offset")
|
||||
start_offset = (
|
||||
previous_offset
|
||||
if isinstance(previous_offset, int)
|
||||
and str(previous_stop.get("transcript_path") or "") == transcript_path
|
||||
else 0
|
||||
)
|
||||
transcript_messages, leaf_uuid, end_offset = transcript_extraction_messages(
|
||||
transcript_path,
|
||||
session_id,
|
||||
previous_leaf_uuid=str(previous_stop.get("transcript_leaf_uuid") or ""),
|
||||
prompt_hint=str(latest_prompt.get("text") or ""),
|
||||
fallback_assistant_message=message,
|
||||
label_final_response=True,
|
||||
start_offset=start_offset,
|
||||
)
|
||||
if transcript_messages:
|
||||
payload: dict[str, Any] = {
|
||||
"text": message,
|
||||
"transcript_messages": transcript_messages,
|
||||
}
|
||||
if leaf_uuid:
|
||||
payload["transcript_leaf_uuid"] = leaf_uuid
|
||||
if transcript_path:
|
||||
payload["transcript_path"] = transcript_path
|
||||
payload["transcript_offset"] = end_offset
|
||||
store.record_event(
|
||||
repo, session_id, "assistant_stop", payload
|
||||
)
|
||||
return repo, session_id
|
||||
|
||||
|
||||
def record_sidekick_start(
|
||||
store: EvidenceStore, hook_input: dict[str, Any]
|
||||
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
|
||||
) -> str:
|
||||
"""Record a native sidekick and reuse the main turn's retrieved memories."""
|
||||
session_id = _session_id(hook_input)
|
||||
@@ -1666,6 +1395,8 @@ def record_sidekick_start(
|
||||
context = combine_context(
|
||||
format_context(store.injected_memories(session_id, repo.identity))
|
||||
)
|
||||
if not inject_context:
|
||||
context = ""
|
||||
first_start = store.start_sidekick(
|
||||
repo, session_id, agent_id, agent_type, len(context)
|
||||
)
|
||||
@@ -1694,13 +1425,11 @@ def record_sidekick_start(
|
||||
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
|
||||
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
|
||||
final_message = bounded(
|
||||
hook_input.get("last_assistant_message", ""), MAX_ASSISTANT_CHARS
|
||||
)
|
||||
agent_id = bounded(hook_input.get("agent_id", ""), 200)
|
||||
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
|
||||
store.stop_sidekick(
|
||||
agent_id = store.stop_sidekick(
|
||||
repo,
|
||||
session_id,
|
||||
agent_id,
|
||||
@@ -1775,17 +1504,17 @@ def build_episode(
|
||||
task_outcome: str = "",
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
prompts = [
|
||||
bounded(e["payload"].get("text", ""), MAX_PROMPT_CHARS)
|
||||
redact(e["payload"].get("text", "")).strip()
|
||||
for e in events
|
||||
if e["kind"] == "user_prompt" and e["payload"].get("text")
|
||||
]
|
||||
assistant_conclusions = [
|
||||
bounded(e["payload"].get("text", ""), MAX_ASSISTANT_CHARS)
|
||||
redact(e["payload"].get("text", "")).strip()
|
||||
for e in events
|
||||
if e["kind"] == "assistant_stop" and e["payload"].get("text")
|
||||
]
|
||||
sidekick_outcomes = [
|
||||
bounded(e["payload"].get("final_message", ""), MAX_ASSISTANT_CHARS)
|
||||
redact(e["payload"].get("final_message", "")).strip()
|
||||
for e in events
|
||||
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
|
||||
]
|
||||
@@ -1812,7 +1541,7 @@ def build_episode(
|
||||
{
|
||||
"command": t.get("command", ""),
|
||||
"kind": t.get("command_kind", "shell"),
|
||||
"status": "failed" if t.get("failed") else "succeeded",
|
||||
"status": "unknown" if t.get("failed", False) is None else "failed" if t.get("failed") else "succeeded",
|
||||
"result": t.get("result_preview", ""),
|
||||
}
|
||||
for t in tools
|
||||
@@ -1820,10 +1549,7 @@ def build_episode(
|
||||
]
|
||||
|
||||
task = bounded(canonical_task or (prompts[0] if prompts else ""), 4000)
|
||||
conclusion = bounded(
|
||||
assistant_conclusions[-1] if assistant_conclusions else "",
|
||||
MAX_ASSISTANT_CHARS,
|
||||
)
|
||||
conclusion = redact(assistant_conclusions[-1] if assistant_conclusions else "").strip()
|
||||
outcome = bounded(task_outcome, 2000)
|
||||
|
||||
extraction_messages: list[dict[str, str]] = []
|
||||
@@ -1835,16 +1561,14 @@ def build_episode(
|
||||
pending_user_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": bounded(
|
||||
event["payload"].get("text", ""), MAX_PROMPT_CHARS
|
||||
),
|
||||
"content": redact(event["payload"].get("text", "")).strip(),
|
||||
}
|
||||
)
|
||||
elif event["kind"] == "assistant_stop":
|
||||
transcript_messages = event["payload"].get("transcript_messages") or []
|
||||
if isinstance(transcript_messages, list) and transcript_messages:
|
||||
transcript_users = {
|
||||
str(message.get("content") or "").strip()
|
||||
redact(message.get("content") or "").strip()
|
||||
for message in transcript_messages
|
||||
if isinstance(message, dict) and message.get("role") == "user"
|
||||
}
|
||||
@@ -1856,7 +1580,7 @@ def build_episode(
|
||||
extraction_messages.extend(
|
||||
{
|
||||
"role": str(message.get("role") or ""),
|
||||
"content": str(message.get("content") or ""),
|
||||
"content": redact(message.get("content") or "").strip(),
|
||||
}
|
||||
for message in transcript_messages
|
||||
if isinstance(message, dict)
|
||||
@@ -1869,10 +1593,7 @@ def build_episode(
|
||||
extraction_messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": bounded(
|
||||
event["payload"].get("text", ""),
|
||||
MAX_ASSISTANT_CHARS,
|
||||
),
|
||||
"content": redact(event["payload"].get("text", "")).strip(),
|
||||
}
|
||||
)
|
||||
pending_user_messages = []
|
||||
@@ -1972,7 +1693,7 @@ def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]
|
||||
"""Build the session messages sent to Mem0 for memory extraction."""
|
||||
evidence = build_semantic_evidence(structured)
|
||||
messages = [
|
||||
{"role": message["role"], "content": message["content"]}
|
||||
{"role": message["role"], "content": redact(message["content"]).strip()}
|
||||
for message in structured.get("extraction_messages", [])
|
||||
if message.get("role") in {"user", "assistant"} and message.get("content")
|
||||
]
|
||||
@@ -2014,7 +1735,7 @@ def extraction_message_batches(
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Split large extraction input without cutting messages or agent pairs."""
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
@@ -2047,9 +1768,29 @@ def extraction_message_batches(
|
||||
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 units:
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
@@ -2152,6 +1893,11 @@ def _wait_for_event(api_url: str, key: str, event_id: str) -> tuple[str, int, in
|
||||
key,
|
||||
min(10, poll_seconds + 5),
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in {408, 429} and exc.code < 500:
|
||||
raise
|
||||
time.sleep(poll_seconds)
|
||||
continue
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
# The extraction job is durable server-side. A transient polling
|
||||
# failure must not discard a job that may still complete normally.
|
||||
@@ -2217,7 +1963,7 @@ def flush_session(
|
||||
task_outcome=bounded(hook_input.get("task_outcome", ""), 2000),
|
||||
)
|
||||
|
||||
metadata = {"source": "claude_code_plugin"}
|
||||
metadata = {"source": _harness_source_tag}
|
||||
if repo.branch and repo.branch not in {"detached", "unknown"}:
|
||||
metadata["branch"] = repo.branch
|
||||
if repo.head_sha:
|
||||
@@ -2684,7 +2430,7 @@ def format_context(
|
||||
|
||||
|
||||
def format_search_result(result: MemorySearchResult) -> str:
|
||||
"""Return only the text Claude needs from an explicit memory search."""
|
||||
"""Return only the text the coding agent needs from an explicit memory search."""
|
||||
if not result.succeeded:
|
||||
return "Memory search failed."
|
||||
if result.memories:
|
||||
@@ -2731,7 +2477,11 @@ def _scoped_memory_ids(
|
||||
app_id_prefix=prefix,
|
||||
)
|
||||
if include_project:
|
||||
_collect_memory_ids(api_url, key, {"agent_id": repo.project_id}, ids, seen)
|
||||
for project_id in _shared_project_ids(repo):
|
||||
_collect_memory_ids(
|
||||
api_url, key, {"agent_id": project_id}, ids, seen,
|
||||
app_id_prefix=prefix,
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anonymous usage telemetry for the Mem0 Claude Code plugin.
|
||||
"""Anonymous usage telemetry for Mem0 agent plugins.
|
||||
|
||||
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
|
||||
touches the network: `record` appends one JSON line to a local spool and returns.
|
||||
@@ -29,6 +29,38 @@ from typing import Any
|
||||
|
||||
import memory_core
|
||||
|
||||
_harness: str = "generic"
|
||||
_source_tag: str = "MEM0_PLUGIN"
|
||||
_PRIVATE_KEYS = {
|
||||
"apikey",
|
||||
"authorization",
|
||||
"password",
|
||||
"query",
|
||||
"secret",
|
||||
"prompt",
|
||||
"token",
|
||||
"text",
|
||||
"memory",
|
||||
"message",
|
||||
"error",
|
||||
"path",
|
||||
"cwd",
|
||||
"userid",
|
||||
"agentid",
|
||||
"runid",
|
||||
"repoid",
|
||||
"repositoryid",
|
||||
"projectid",
|
||||
"appid",
|
||||
"filters",
|
||||
}
|
||||
|
||||
|
||||
def init(harness: str = "generic", source_tag: str = "") -> None:
|
||||
global _harness, _source_tag
|
||||
_harness = harness
|
||||
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
|
||||
POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/"
|
||||
@@ -54,6 +86,23 @@ def _digest(value: str, length: int = 16) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return memory_core.redact(value)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _safe_value(item)
|
||||
for key, item in value.items()
|
||||
if "".join(character for character in str(key).lower() if character.isalnum())
|
||||
not in _PRIVATE_KEYS
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_safe_value(item) for item in value]
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
return memory_core.redact(value)
|
||||
|
||||
|
||||
def _spool_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry.jsonl"
|
||||
|
||||
@@ -118,8 +167,9 @@ def record(
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
properties = _safe_value(properties)
|
||||
properties.update(
|
||||
harness="claude-code",
|
||||
harness=_harness,
|
||||
plugin_version=memory_core.PLUGIN_VERSION,
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
@@ -316,7 +366,7 @@ def flush() -> int:
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": event.get("timestamp"),
|
||||
"properties": {
|
||||
"source": "CLAUDE_CODE_PLUGIN",
|
||||
"source": _source_tag,
|
||||
"language": "python",
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"version": "0.3.1",
|
||||
"homepage": "https://docs.mem0.ai/integrations/claude-code",
|
||||
"native": {
|
||||
"pluginRoot": "${CLAUDE_PLUGIN_ROOT}",
|
||||
"pluginData": "${CLAUDE_PLUGIN_DATA}",
|
||||
"files": {
|
||||
".claude-plugin/plugin.json": ".claude-plugin/plugin.json",
|
||||
".mcp.json": ".mcp.json",
|
||||
"hooks/hooks.json": "hooks/hooks.json",
|
||||
"adapters/claude/hook.py": "adapters/claude/hook.py",
|
||||
"adapters/claude/transcript.py": "adapters/claude/transcript.py",
|
||||
"agents/sidekick.md": "agents/sidekick.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ so it stays unless the user explicitly asks to delete that too.
|
||||
After the user confirms, run:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" forget --remote --yes
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" forget --remote --yes
|
||||
```
|
||||
|
||||
If the user also asked to delete the repository's shared project memory, add
|
||||
|
||||
@@ -11,7 +11,7 @@ anonymous telemetry ping still fires at session start unless
|
||||
`MEM0_TELEMETRY=false`):
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" pause
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" pause
|
||||
```
|
||||
|
||||
Confirm the new state back to the user, and remind them that already-created
|
||||
|
||||
@@ -11,7 +11,7 @@ Resume memory capture for this machine.
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" resume
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" resume
|
||||
```
|
||||
|
||||
Confirm to the user that capture is active again. New sessions record evidence and
|
||||
|
||||
@@ -20,7 +20,9 @@ preferences.
|
||||
|
||||
Pass `scope` when the question needs something else: `dir` to narrow the
|
||||
shared memory to the directory you are working in (a package inside a
|
||||
monorepo), `mine` for your own preferences alone. Pass `run_id` with a Claude
|
||||
Code session ID to look at what one earlier session recorded, for example to
|
||||
pick up where a compacted or closed session left off. Return the tool's result
|
||||
directly.
|
||||
monorepo), `mine` for your own preferences alone.
|
||||
|
||||
Pass `run_id` with any scope to retrieve memories saved in a specific coding-agent
|
||||
session. Omit `run_id` to search across sessions. It filters the memories returned;
|
||||
it does not identify the session making the search request. Use a known session ID,
|
||||
never invent one. Return the tool's result directly.
|
||||
|
||||
@@ -9,8 +9,8 @@ disable-model-invocation: false
|
||||
Run both commands and report the combined result in plain language:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" status --json
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" doctor
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" status --json
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" doctor
|
||||
```
|
||||
|
||||
Summarize, using only fields the JSON actually reports: whether capture is
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"""Keep the test suite from sending usage telemetry to the live PostHog project."""
|
||||
"""Configure harness identity and disable live telemetry for the test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["MEM0_TELEMETRY"] = "false"
|
||||
|
||||
HOST_ROOT = Path(__file__).resolve().parents[1]
|
||||
_core = HOST_ROOT / "core"
|
||||
sys.path.insert(0, str(_core))
|
||||
|
||||
import hook_runner # noqa: E402,F401
|
||||
from memory_core import configure_harness # noqa: E402
|
||||
import telemetry # noqa: E402
|
||||
|
||||
configure_harness("claude-code", data_dir_name="claude-code-plugin", source_tag="claude_code_plugin")
|
||||
telemetry.init(harness="claude-code", source_tag="CLAUDE_CODE_PLUGIN")
|
||||
|
||||
@@ -13,10 +13,11 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PLUGIN_ROOT / "core"))
|
||||
sys.path.insert(0, str(PLUGIN_ROOT / "adapters" / "claude"))
|
||||
_core_dir = PLUGIN_ROOT / "core"
|
||||
sys.path.insert(0, str(_core_dir))
|
||||
sys.path.insert(0, str(PLUGIN_ROOT))
|
||||
|
||||
import hook # noqa: E402
|
||||
import hook_runner # noqa: E402
|
||||
import memory_core # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
@@ -177,15 +178,17 @@ def test_shared_project_memory_reaches_a_teammate_who_never_wrote(ns):
|
||||
def test_repo_scope_spans_every_subdirectory(ns):
|
||||
found = ns.search("erin", ns.root, "how many times are Stripe webhooks retried", scope="repo")
|
||||
assert "stripe" in _text(found)
|
||||
app_ids = {m.get("app_id") for m in found}
|
||||
assert any(app.endswith("/services/billing") for app in app_ids)
|
||||
assert any("services/billing" in (memory.get("metadata") or {}).get("dirs", []) for memory in found)
|
||||
|
||||
|
||||
def test_dir_scope_narrows_shared_memory_to_the_directory(ns):
|
||||
billing = ns.root / "services" / "billing"
|
||||
found = ns.search("erin", billing, "how do I run the tests here", scope="dir")
|
||||
assert "billing-test" in _text(found)
|
||||
assert {m.get("app_id") for m in found if m.get("agent_id")} == {memory_core.directory_app_id(memory_core.resolve_repo(str(billing)))}
|
||||
shared = [memory for memory in found if memory.get("agent_id")]
|
||||
assert shared
|
||||
assert {memory.get("app_id") for memory in shared} == {memory_core.resolve_repo(str(billing)).app_id}
|
||||
assert all("services/billing" in (memory.get("metadata") or {}).get("dirs", []) for memory in shared)
|
||||
|
||||
web = ns.root / "apps" / "web"
|
||||
elsewhere = ns.search("erin", web, "Stripe webhook retries exponential backoff", scope="dir", tries=1)
|
||||
@@ -232,7 +235,7 @@ def test_a_pending_packet_is_recovered_and_delivered_by_the_worker(ns):
|
||||
stale.write_text(json.dumps({"hook_input": {"session_id": sid, "cwd": str(ns.root)}, "reason": "session-end"}))
|
||||
os.utime(stale, (time.time() - 3600, time.time() - 3600))
|
||||
|
||||
assert hook.recover_pending_handoffs() == 1
|
||||
assert hook_runner.recover_pending_handoffs() == 1
|
||||
deadline = time.time() + 240
|
||||
while time.time() < deadline and list(pending.iterdir()):
|
||||
time.sleep(3)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HOST = Path(__file__).resolve().parents[1]
|
||||
CORE_ROOT = HOST.parent / "agent-plugin-core"
|
||||
sys.path.insert(0, str(CORE_ROOT))
|
||||
|
||||
from build.build import SHARED_SKILLS, build, render_template # noqa: E402
|
||||
|
||||
|
||||
def test_native_claude_bundle_preserves_working_contract(tmp_path: Path) -> None:
|
||||
root = build("claude-code", "native", tmp_path / "claude-code")
|
||||
|
||||
assert (root / ".claude-plugin" / "plugin.json").read_bytes() == (
|
||||
HOST / ".claude-plugin" / "plugin.json"
|
||||
).read_bytes()
|
||||
assert (root / ".mcp.json").read_bytes() == (HOST / ".mcp.json").read_bytes()
|
||||
assert (root / "hooks" / "hooks.json").read_bytes() == (HOST / "hooks" / "hooks.json").read_bytes()
|
||||
assert (root / "agents" / "sidekick.md").read_bytes() == (HOST / "agents" / "sidekick.md").read_bytes()
|
||||
values = {
|
||||
"PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
|
||||
"PLUGIN_DATA": "${PLUGIN_DATA}",
|
||||
"PLUGIN_DATA_ARG": '--plugin-data-dir "${CLAUDE_PLUGIN_DATA}"',
|
||||
"COMMAND_PREFIX": "mem0",
|
||||
"HARNESS_ID": "claude-code",
|
||||
"HARNESS_NAME": "Claude Code",
|
||||
}
|
||||
for skill in SHARED_SKILLS.glob("*/SKILL.md.tmpl"):
|
||||
rendered = render_template(skill.read_text(encoding="utf-8"), values)
|
||||
assert (root / "skills" / skill.parent.name / "SKILL.md").read_text(encoding="utf-8") == rendered
|
||||
assert (root / "adapters" / "claude" / "hook.py").is_file()
|
||||
assert (root / "core" / "mcp_server.py").is_file()
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
CORE = PLUGIN_ROOT / "core"
|
||||
HOST_ROOT = Path(__file__).resolve().parents[1]
|
||||
CORE = HOST_ROOT / "core"
|
||||
sys.path.insert(0, str(CORE))
|
||||
|
||||
import memory_core # noqa: E402
|
||||
@@ -77,6 +76,46 @@ def test_record_hashes_identifiers_and_keeps_no_content(isolated_env):
|
||||
assert "session-abcdef" not in serialized
|
||||
|
||||
|
||||
def test_record_rejects_sensitive_properties_at_the_shared_boundary(isolated_env):
|
||||
secret = "sk-eval-12345678901234567890"
|
||||
telemetry.record(
|
||||
"search",
|
||||
prompt=f"remember {secret}",
|
||||
query=secret,
|
||||
api_key=secret,
|
||||
user_id="private-user",
|
||||
note=f"failure contained {secret}",
|
||||
memory_count=2,
|
||||
)
|
||||
|
||||
(event,) = spool_lines()
|
||||
assert event["properties"]["memory_count"] == 2
|
||||
serialized = json.dumps(event)
|
||||
assert secret not in serialized
|
||||
assert "private-user" not in serialized
|
||||
assert not {"prompt", "query", "api_key", "user_id"} & event["properties"].keys()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["password", "token", "secret", "authorization"])
|
||||
def test_record_removes_sensitive_keys_from_nested_lists(isolated_env, key):
|
||||
secret = "sk-eval-12345678901234567890"
|
||||
telemetry.record(
|
||||
"search",
|
||||
details=[
|
||||
{key: "plain-value", "count": 2},
|
||||
{"nested": {key.upper(): "plain-value", "ok": True}},
|
||||
[f"failure contained {secret}"],
|
||||
],
|
||||
)
|
||||
|
||||
(event,) = spool_lines()
|
||||
assert event["properties"]["details"] == [
|
||||
{"count": 2},
|
||||
{"nested": {"ok": True}},
|
||||
["failure contained [REDACTED]"],
|
||||
]
|
||||
|
||||
|
||||
def test_record_stops_appending_past_the_spool_cap(isolated_env):
|
||||
spool = memory_core.data_dir() / "telemetry.jsonl"
|
||||
spool.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.3.1",
|
||||
"description": "Cross-session memory and token savings for coding agents.",
|
||||
"author": { "name": "Mem0", "email": "support@mem0.ai" },
|
||||
"homepage": "https://docs.mem0.ai/integrations/codex",
|
||||
"repository": "https://github.com/mem0ai/mem0",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"],
|
||||
"skills": "./skills/",
|
||||
"hooks": "./hooks/hooks.json",
|
||||
"mcpServers": "./.mcp.json"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["./core/mcp_server.py"],
|
||||
"cwd": ".",
|
||||
"env_vars": [
|
||||
"MEM0_API_KEY",
|
||||
"MEM0_API_URL",
|
||||
"MEM0_CODE_USER_ID",
|
||||
"MEM0_USER_ID",
|
||||
"MEM0_RESOLVED_USER_ID",
|
||||
"MEM0_PROJECT_ID",
|
||||
"MEM0_TELEMETRY"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detached remote checkpoint worker.
|
||||
|
||||
Claude Code may cancel SessionEnd hooks as a print-mode process exits. The hook
|
||||
therefore persists its input first and launches this process in a new session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
checkpoint_session,
|
||||
configure_harness,
|
||||
touch_handoff_heartbeat,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
return 2
|
||||
handoff_path = Path(sys.argv[1])
|
||||
os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path)
|
||||
harness = os.environ.get("MEM0_PLUGIN_HARNESS")
|
||||
if harness:
|
||||
source_tag = os.environ.get("MEM0_PLUGIN_SOURCE_TAG", "")
|
||||
configure_harness(
|
||||
harness,
|
||||
env_prefix=os.environ.get("MEM0_PLUGIN_ENV_PREFIX", ""),
|
||||
data_dir_name=os.environ.get("MEM0_PLUGIN_DATA_DIR_NAME", ""),
|
||||
source_tag=source_tag,
|
||||
)
|
||||
telemetry.init(harness=harness, source_tag=source_tag.upper())
|
||||
completed = False
|
||||
try:
|
||||
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
|
||||
delay = float(payload.get("delay_seconds") or 0)
|
||||
if delay > 0:
|
||||
payload.pop("delay_seconds", None)
|
||||
temporary = handoff_path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temporary.replace(handoff_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
time.sleep(delay)
|
||||
if not handoff_path.exists():
|
||||
return 0
|
||||
hook_input = payload.get("hook_input") or {}
|
||||
reason = str(payload.get("reason") or "checkpoint")
|
||||
wait_for_inflight = bool(payload.get("wait_for_inflight"))
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if wait_for_inflight:
|
||||
session_id = str(
|
||||
hook_input.get("session_id") or "unknown-session"
|
||||
)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
deadline = time.monotonic() + float(
|
||||
os.environ.get("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "120")
|
||||
)
|
||||
while (
|
||||
store.has_inflight_flush(repo.identity, session_id)
|
||||
and time.monotonic() < deadline
|
||||
):
|
||||
touch_handoff_heartbeat()
|
||||
time.sleep(0.25)
|
||||
# Hooks capture the conversation before handoff; the worker only flushes it.
|
||||
result = checkpoint_session(store, hook_input, reason)
|
||||
print(json.dumps(result, sort_keys=True), flush=True)
|
||||
completed = result.get("status") in {
|
||||
"semantic-succeeded",
|
||||
"explicitly-stored",
|
||||
"nothing-to-flush",
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
finally:
|
||||
telemetry.flush()
|
||||
if completed:
|
||||
try:
|
||||
handoff_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
elif handoff_path.suffix == ".running":
|
||||
try:
|
||||
handoff_path.replace(handoff_path.with_suffix(".json"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Shared hook orchestration for all Mem0 agent plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
_session_id,
|
||||
api_key,
|
||||
bounded,
|
||||
cache_plugin_api_key,
|
||||
checkpoint_session,
|
||||
clear_stale_api_key_cache,
|
||||
configure_harness,
|
||||
data_dir,
|
||||
detached_process_kwargs,
|
||||
format_context,
|
||||
harness_config,
|
||||
record_session_start,
|
||||
record_tool,
|
||||
record_user_prompt,
|
||||
redact,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
STALE_RUNNING_SECONDS = 300
|
||||
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
PENDING_LAUNCH_LIMIT = 5
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 300
|
||||
|
||||
_core_dir: Path = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def read_hook_input() -> dict:
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def default_record_stop(store: EvidenceStore, hook_input: dict):
|
||||
"""Record the assistant's response without transcript parsing."""
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
if message:
|
||||
store.record_assistant_response(repo, session_id, message)
|
||||
return repo, session_id
|
||||
|
||||
|
||||
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
|
||||
"""Search once before the agent handles the first prompt in a session."""
|
||||
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
|
||||
if not is_first_prompt:
|
||||
return {}
|
||||
try:
|
||||
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
|
||||
except ValueError:
|
||||
minimum_query_chars = 20
|
||||
if len(prompt.strip()) < max(minimum_query_chars, 1):
|
||||
return {}
|
||||
result = search_memories(
|
||||
store, repo, session_id, bounded(prompt, 6000),
|
||||
top_k=5, operation="first-prompt-search", timeout=2,
|
||||
)
|
||||
if not result.memories:
|
||||
return {}
|
||||
context = format_context(
|
||||
result.memories,
|
||||
"Mem0 found these relevant memories from earlier work in this repository:",
|
||||
)
|
||||
telemetry.record(
|
||||
"context_injected",
|
||||
repo=repo, session_id=session_id, trigger="first-prompt",
|
||||
memory_count=len(result.memories), context_chars=len(context),
|
||||
prompt_chars=len(prompt),
|
||||
)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _launch_handoff(handoff_path: Path) -> bool:
|
||||
running_path = handoff_path.with_suffix(".running")
|
||||
try:
|
||||
handoff_path.replace(running_path)
|
||||
except OSError:
|
||||
return False
|
||||
worker = _core_dir / "flush_worker.py"
|
||||
log_path = data_dir() / "flush-worker.log"
|
||||
log_handle = open(log_path, "a", encoding="utf-8")
|
||||
harness = harness_config()
|
||||
child_env = os.environ.copy()
|
||||
child_env.update(
|
||||
{
|
||||
"MEM0_CODE_DATA_DIR": str(data_dir()),
|
||||
"MEM0_PLUGIN_HARNESS": harness["name"],
|
||||
"MEM0_PLUGIN_ENV_PREFIX": harness["env_prefix"],
|
||||
"MEM0_PLUGIN_DATA_DIR_NAME": harness["data_dir_name"],
|
||||
"MEM0_PLUGIN_SOURCE_TAG": harness["source_tag"],
|
||||
}
|
||||
)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(worker), str(running_path)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle, stderr=log_handle,
|
||||
close_fds=True,
|
||||
env=child_env,
|
||||
**detached_process_kwargs(),
|
||||
)
|
||||
finally:
|
||||
log_handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def recover_pending_handoffs() -> int:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
for running in pending_dir.glob("*.running"):
|
||||
try:
|
||||
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
|
||||
running.replace(running.with_suffix(".json"))
|
||||
except OSError:
|
||||
continue
|
||||
recoverable = []
|
||||
for handoff in pending_dir.glob("*.json"):
|
||||
try:
|
||||
age = now - handoff.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > PENDING_EXPIRY_SECONDS:
|
||||
handoff.unlink(missing_ok=True)
|
||||
continue
|
||||
recoverable.append((age, handoff))
|
||||
recoverable.sort(key=lambda item: item[0], reverse=True)
|
||||
launched = 0
|
||||
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
|
||||
launched += int(_launch_handoff(handoff))
|
||||
return launched
|
||||
|
||||
|
||||
def refresh_pending_handoffs() -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
if not pending_dir.is_dir():
|
||||
return
|
||||
for pattern in ("*.json", "*.running"):
|
||||
for handoff in pending_dir.glob(pattern):
|
||||
try:
|
||||
os.utime(handoff)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def hand_off_flush(
|
||||
hook_input: dict, reason: str, *, wait_for_inflight: bool = False,
|
||||
) -> None:
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = (
|
||||
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
|
||||
)
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": reason,
|
||||
"wait_for_inflight": wait_for_inflight,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
|
||||
|
||||
def automatic_flush_enabled() -> bool:
|
||||
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
def schedule_periodic_checkpoint(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
if (
|
||||
not automatic_flush_enabled()
|
||||
or not api_key()
|
||||
or not store.checkpoint_due(repo.identity, session_id)
|
||||
):
|
||||
return False
|
||||
if store.prepare_flush(repo, session_id, "periodic") is None:
|
||||
return False
|
||||
hand_off_flush(hook_input, "periodic")
|
||||
return True
|
||||
|
||||
|
||||
def _idle_flush_seconds() -> int:
|
||||
try:
|
||||
return max(
|
||||
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
|
||||
0,
|
||||
)
|
||||
except ValueError:
|
||||
return DEFAULT_IDLE_FLUSH_SECONDS
|
||||
|
||||
|
||||
def schedule_idle_flush(
|
||||
store: EvidenceStore, hook_input: dict, repo, session_id: str,
|
||||
) -> bool:
|
||||
delay = _idle_flush_seconds()
|
||||
if delay <= 0 or not automatic_flush_enabled() or not api_key():
|
||||
return False
|
||||
if store.has_inflight_flush(repo.identity, session_id):
|
||||
return False
|
||||
if not store.has_unflushed_events(repo.identity, session_id):
|
||||
return False
|
||||
pending_dir = data_dir() / "pending"
|
||||
pending_dir.mkdir(parents=True, exist_ok=True)
|
||||
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
|
||||
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
|
||||
for old in pending_dir.glob(f"idle-{digest}*"):
|
||||
old.unlink(missing_ok=True)
|
||||
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
|
||||
temporary_path = handoff_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps({
|
||||
"hook_input": hook_input,
|
||||
"reason": "idle",
|
||||
"delay_seconds": delay,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(handoff_path)
|
||||
_launch_handoff(handoff_path)
|
||||
return True
|
||||
|
||||
|
||||
def log_failure(exc: Exception) -> None:
|
||||
try:
|
||||
log_path = data_dir() / "plugin-errors.log"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> int:
|
||||
if record_stop_fn is None:
|
||||
record_stop_fn = default_record_stop
|
||||
if automatic_flush_reasons is None:
|
||||
automatic_flush_reasons = {"session-end"}
|
||||
|
||||
base_actions = ["session-start", "user-prompt", "post-tool", "stop", "flush"]
|
||||
all_actions = base_actions + list((extra_actions or {}).keys())
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=all_actions)
|
||||
parser.add_argument("--reason", default="manual")
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.harness:
|
||||
configure_harness(args.harness)
|
||||
telemetry.init(harness=args.harness)
|
||||
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
|
||||
hook_input = read_hook_input()
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
if store.is_paused():
|
||||
if args.action == "session-start":
|
||||
refresh_pending_handoffs()
|
||||
telemetry.record("session_start", paused=True)
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
telemetry.record("install")
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
telemetry.record("handoff_recovered", count=recovered)
|
||||
telemetry.spawn_flush()
|
||||
elif args.action == "user-prompt":
|
||||
output = first_prompt_memory_output(store, hook_input)
|
||||
if output:
|
||||
print(json.dumps(output))
|
||||
elif args.action == "post-tool":
|
||||
record_tool(store, hook_input)
|
||||
elif args.action == "stop":
|
||||
repo, session_id = record_stop_fn(store, hook_input)
|
||||
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
|
||||
schedule_idle_flush(store, hook_input, repo, session_id)
|
||||
elif args.action == "flush":
|
||||
automatic = args.reason in automatic_flush_reasons
|
||||
if automatic and not automatic_flush_enabled():
|
||||
return 0
|
||||
if args.reason == "session-end":
|
||||
record_stop_fn(store, hook_input)
|
||||
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
|
||||
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
|
||||
else:
|
||||
session_id = str(hook_input.get("session_id") or "unknown-session")
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
already_running = store.has_inflight_flush(repo.identity, session_id)
|
||||
if already_running and args.reason == "session-end":
|
||||
hand_off_flush(hook_input, args.reason, wait_for_inflight=True)
|
||||
elif not already_running and store.prepare_flush(
|
||||
repo, session_id, args.reason,
|
||||
) is not None:
|
||||
hand_off_flush(hook_input, args.reason)
|
||||
elif extra_actions and args.action in extra_actions:
|
||||
result = extra_actions[args.action](store, hook_input)
|
||||
if result:
|
||||
print(json.dumps(result))
|
||||
finally:
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
def entry_point(
|
||||
*,
|
||||
record_stop_fn=None,
|
||||
extra_actions: dict | None = None,
|
||||
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
|
||||
automatic_flush_reasons: set | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
raise SystemExit(run(
|
||||
record_stop_fn=record_stop_fn,
|
||||
extra_actions=extra_actions,
|
||||
data_dir_env=data_dir_env,
|
||||
automatic_flush_reasons=automatic_flush_reasons,
|
||||
))
|
||||
except Exception as exc:
|
||||
log_failure(exc)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
entry_point()
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose Mem0's memory search as one local coding-agent tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
CODING_MEMORY_CATEGORY_NAMES,
|
||||
PLUGIN_VERSION,
|
||||
SEARCH_SCOPES,
|
||||
format_search_result,
|
||||
resolve_repo,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
TOOL_NAME = "search_memories"
|
||||
TOOL_DESCRIPTION = (
|
||||
"Search memories from earlier work in this repository. ALWAYS call this "
|
||||
"tool before answering anything that could depend on prior context: the "
|
||||
"user's preferences, facts about this codebase, history, people, projects, "
|
||||
"or earlier decisions. Do not rely on the chat window alone. The "
|
||||
"repository's memory is shared by everyone who works in it and includes "
|
||||
"what it took to run, test, or build here, so search before assuming an "
|
||||
"invocation works. The scope argument changes what is searched: 'repo' "
|
||||
"(default) is the whole repository's shared memory plus your own "
|
||||
"preferences, 'dir' narrows the shared part to the directory you are "
|
||||
"working in, and 'mine' is your preferences alone."
|
||||
)
|
||||
TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000,
|
||||
"description": "A direct question about earlier work in this repository.",
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum memories to return. Uses Mem0's configured default when omitted.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": list(CODING_MEMORY_CATEGORY_NAMES),
|
||||
"description": "Optional memory category. Omit to search every category.",
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": list(SEARCH_SCOPES),
|
||||
"description": (
|
||||
"Which memories to search. 'repo' (default) is the whole repository's "
|
||||
"shared memory plus your own preferences, 'dir' narrows the shared "
|
||||
"part to the current directory, 'mine' is your preferences alone."
|
||||
),
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": (
|
||||
"Optional coding-agent session ID. With any scope, restricts results to memories "
|
||||
"saved in that session. Omit to recall memories across sessions."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
class ToolInputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_arguments(
|
||||
arguments: Any,
|
||||
) -> tuple[str, int | None, str | None, str | None, str | None]:
|
||||
if not isinstance(arguments, dict):
|
||||
raise ToolInputError("Search arguments must be an object.")
|
||||
|
||||
unknown = set(arguments) - {"query", "top_k", "category", "scope", "run_id"}
|
||||
if unknown:
|
||||
raise ToolInputError(f"Unknown search argument: {sorted(unknown)[0]}")
|
||||
|
||||
query = arguments.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
raise ToolInputError("query must be a non-empty string.")
|
||||
query = query.strip()
|
||||
if len(query) > 2000:
|
||||
raise ToolInputError("query must be at most 2,000 characters.")
|
||||
|
||||
top_k = arguments.get("top_k")
|
||||
if top_k is not None and (
|
||||
isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 20
|
||||
):
|
||||
raise ToolInputError("top_k must be an integer from 1 to 20.")
|
||||
|
||||
category = arguments.get("category")
|
||||
if category is not None and category not in CODING_MEMORY_CATEGORY_NAMES:
|
||||
raise ToolInputError("category must be one of Mem0's supported categories.")
|
||||
|
||||
scope = arguments.get("scope")
|
||||
if scope is not None and scope not in SEARCH_SCOPES:
|
||||
raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.")
|
||||
|
||||
run_id = arguments.get("run_id")
|
||||
if run_id is not None:
|
||||
if not isinstance(run_id, str) or not run_id.strip():
|
||||
raise ToolInputError("run_id must be a non-empty string.")
|
||||
run_id = run_id.strip()
|
||||
|
||||
return query, top_k, category, scope, run_id
|
||||
|
||||
|
||||
def call_search_memories(arguments: Any, cwd: str | None = None) -> str:
|
||||
query, top_k, category, scope, run_id = _validate_arguments(arguments)
|
||||
repo = resolve_repo(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
|
||||
result = search_memories(
|
||||
None,
|
||||
repo,
|
||||
None,
|
||||
query,
|
||||
top_k=top_k,
|
||||
category=category,
|
||||
scope=scope,
|
||||
run_id=run_id,
|
||||
operation="mcp-search",
|
||||
)
|
||||
return format_search_result(result)
|
||||
|
||||
|
||||
def _workspace_cwd(params: dict[str, Any]) -> str | None:
|
||||
meta = params.get("_meta")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
metadata = meta.get("x-codex-turn-metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
workspaces = metadata.get("workspaces") or {}
|
||||
if isinstance(workspaces, dict):
|
||||
return next((path for path in workspaces if isinstance(path, str) and path), None)
|
||||
return None
|
||||
|
||||
|
||||
def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"isError": is_error,
|
||||
}
|
||||
|
||||
|
||||
def handle_request(message: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
request_id = message.get("id")
|
||||
method = message.get("method")
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
if method == "initialize":
|
||||
requested = (message.get("params") or {}).get("protocolVersion")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": requested or PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "mem0", "version": PLUGIN_VERSION},
|
||||
},
|
||||
}
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": TOOL_NAME,
|
||||
"description": TOOL_DESCRIPTION,
|
||||
"inputSchema": TOOL_SCHEMA,
|
||||
"annotations": {
|
||||
"readOnlyHint": True,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
if method == "tools/call":
|
||||
params = message.get("params") or {}
|
||||
if params.get("name") != TOOL_NAME:
|
||||
result = _tool_response("Unknown Mem0 tool.", is_error=True)
|
||||
else:
|
||||
try:
|
||||
result = _tool_response(
|
||||
call_search_memories(params.get("arguments"), _workspace_cwd(params))
|
||||
)
|
||||
except ToolInputError as exc:
|
||||
result = _tool_response(str(exc), is_error=True)
|
||||
except Exception:
|
||||
result = _tool_response("Memory search failed.", is_error=True)
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
if request_id is None:
|
||||
return None
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": -32601, "message": "Method not found"},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for raw_line in sys.stdin:
|
||||
try:
|
||||
message = json.loads(raw_line)
|
||||
response = handle_request(message)
|
||||
except json.JSONDecodeError:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
}
|
||||
except Exception:
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": "Internal error"},
|
||||
}
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mem0 diagnostics and user controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
api_key,
|
||||
data_dir,
|
||||
doctor,
|
||||
forget_remote_repo,
|
||||
configure_harness,
|
||||
resolve_repo,
|
||||
user_id,
|
||||
)
|
||||
|
||||
|
||||
def _print_status(value: dict) -> None:
|
||||
last = value.get("last_operation") or {}
|
||||
print(f"Mem0: {'paused' if value['paused'] else 'active'}")
|
||||
print(f"Repository: {value['repo_id']}")
|
||||
print(f"Local data: {value['data_dir']}")
|
||||
print(f"API key: {'configured' if value['api_key_configured'] else 'missing'}")
|
||||
print(
|
||||
"Saved on this computer: "
|
||||
f"{value['events']} session details, {value['flushes']} memory updates"
|
||||
)
|
||||
print(
|
||||
f"Used in this repository: {value['retrievals']} memories returned, "
|
||||
f"{value['sidekick_runs']} sidekick runs"
|
||||
)
|
||||
if last:
|
||||
item_label = ""
|
||||
if last["operation"] in {"flush", "flush-retry"}:
|
||||
item_label = f", {last['item_count']} memories"
|
||||
operation = (
|
||||
"memory update"
|
||||
if last["operation"] in {"flush", "flush-retry"}
|
||||
else last["operation"].replace("-", " ")
|
||||
)
|
||||
print(
|
||||
f"Last {operation}: "
|
||||
f"{'succeeded' if last['success'] else 'failed'} "
|
||||
f"({last['duration_ms']:.1f} ms{item_label})"
|
||||
)
|
||||
sidekick = value.get("last_sidekick") or {}
|
||||
if sidekick:
|
||||
state = "finished" if sidekick.get("stopped_at") else "started"
|
||||
print(
|
||||
"Last sidekick: "
|
||||
f"{state}, received {sidekick['context_chars']} characters of memory, "
|
||||
f"agent {sidekick['agent_id']}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plugin-data-dir", default="")
|
||||
parser.add_argument("--harness", default="")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
status = subparsers.add_parser("status")
|
||||
status.add_argument("--json", action="store_true")
|
||||
|
||||
doctor_parser = subparsers.add_parser("doctor")
|
||||
doctor_parser.add_argument("--json", action="store_true")
|
||||
|
||||
subparsers.add_parser("pause")
|
||||
subparsers.add_parser("resume")
|
||||
|
||||
forget = subparsers.add_parser("forget")
|
||||
forget.add_argument("--remote", action="store_true")
|
||||
forget.add_argument("--yes", action="store_true")
|
||||
forget.add_argument("--include-project-memory", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.harness:
|
||||
source_tag = f"{args.harness.replace('-', '_')}_plugin"
|
||||
configure_harness(args.harness, source_tag=source_tag)
|
||||
telemetry.init(harness=args.harness, source_tag=source_tag.upper())
|
||||
if args.plugin_data_dir:
|
||||
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
|
||||
store = EvidenceStore()
|
||||
try:
|
||||
repo = resolve_repo(os.getcwd())
|
||||
telemetry.record("control", repo=repo, action=args.command)
|
||||
if args.command == "status":
|
||||
result = {
|
||||
**store.status(repo.identity),
|
||||
"repo_id": repo.identity,
|
||||
"app_id": repo.app_id,
|
||||
"project_id": repo.project_id,
|
||||
"directory": repo.directory,
|
||||
"user_id": user_id(),
|
||||
"data_dir": str(data_dir()),
|
||||
"api_key_configured": bool(api_key()),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_print_status(result)
|
||||
elif args.command == "doctor":
|
||||
result = doctor(os.getcwd())
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
for name, check in result["checks"].items():
|
||||
print(
|
||||
f"{'PASS' if check['ok'] else 'FAIL'} {name}: {check['detail']}"
|
||||
)
|
||||
return 0 if result["ok"] else 1
|
||||
elif args.command == "pause":
|
||||
store.set_setting("paused", "true")
|
||||
print("Mem0 stopped saving and searching memories.")
|
||||
elif args.command == "resume":
|
||||
store.set_setting("paused", "false")
|
||||
print("Mem0 resumed saving and searching memories.")
|
||||
elif args.command == "forget":
|
||||
if not args.yes:
|
||||
print(
|
||||
"Refusing to delete data without --yes. Add --remote to also "
|
||||
"delete this user/repository scope from Mem0."
|
||||
)
|
||||
return 2
|
||||
remote_result = (
|
||||
forget_remote_repo(
|
||||
repo, include_project_memory=args.include_project_memory
|
||||
)
|
||||
if args.remote
|
||||
else None
|
||||
)
|
||||
local_result = store.forget_local_repo(repo.identity)
|
||||
print(
|
||||
json.dumps(
|
||||
{"local": local_result, "remote": remote_result},
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
if remote_result and remote_result.get("status") == "error":
|
||||
return 1
|
||||
finally:
|
||||
store.close()
|
||||
telemetry.spawn_flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anonymous usage telemetry for Mem0 agent plugins.
|
||||
|
||||
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
|
||||
touches the network: `record` appends one JSON line to a local spool and returns.
|
||||
A detached `python3 telemetry.py` drains the spool in one batched PostHog request,
|
||||
started once per session and again from the flush worker that is already detached.
|
||||
|
||||
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
|
||||
|
||||
Never sends prompts, memory text, queries, file paths, repository names, or API
|
||||
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import memory_core
|
||||
|
||||
_harness: str = "generic"
|
||||
_source_tag: str = "MEM0_PLUGIN"
|
||||
_PRIVATE_KEYS = {
|
||||
"apikey",
|
||||
"authorization",
|
||||
"password",
|
||||
"query",
|
||||
"secret",
|
||||
"prompt",
|
||||
"token",
|
||||
"text",
|
||||
"memory",
|
||||
"message",
|
||||
"error",
|
||||
"path",
|
||||
"cwd",
|
||||
"userid",
|
||||
"agentid",
|
||||
"runid",
|
||||
"repoid",
|
||||
"repositoryid",
|
||||
"projectid",
|
||||
"appid",
|
||||
"filters",
|
||||
}
|
||||
|
||||
|
||||
def init(harness: str = "generic", source_tag: str = "") -> None:
|
||||
global _harness, _source_tag
|
||||
_harness = harness
|
||||
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
|
||||
POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/"
|
||||
EVENT_PREFIX = "code"
|
||||
SPOOL_LIMIT_BYTES = 256 * 1024
|
||||
BATCH_SIZE = 100
|
||||
SEND_TIMEOUT = 5
|
||||
CLAIM_STALE_SECONDS = 120
|
||||
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Whether telemetry is switched on for this process."""
|
||||
return os.environ.get("MEM0_TELEMETRY", "true").strip().lower() not in {
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
|
||||
|
||||
def _digest(value: str, length: int = 16) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return memory_core.redact(value)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _safe_value(item)
|
||||
for key, item in value.items()
|
||||
if "".join(character for character in str(key).lower() if character.isalnum())
|
||||
not in _PRIVATE_KEYS
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_safe_value(item) for item in value]
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
return memory_core.redact(value)
|
||||
|
||||
|
||||
def _spool_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry.jsonl"
|
||||
|
||||
|
||||
def _identity_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry-identity.json"
|
||||
|
||||
|
||||
def _read_identity() -> dict[str, str]:
|
||||
try:
|
||||
value = json.loads(_identity_path().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _write_identity(identity: dict[str, str]) -> None:
|
||||
path = _identity_path()
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary.write_text(json.dumps(identity), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def anonymous_id(identity: dict[str, str] | None = None) -> str:
|
||||
"""Per-machine anonymous identifier, created and persisted on first use."""
|
||||
identity = _read_identity() if identity is None else identity
|
||||
existing = identity.get("anonymous_id")
|
||||
if existing:
|
||||
return existing
|
||||
created = f"code-anon-{uuid.uuid4().hex}"
|
||||
identity["anonymous_id"] = created
|
||||
_write_identity(identity)
|
||||
return created
|
||||
|
||||
|
||||
def is_first_run() -> bool:
|
||||
"""Whether this machine has never recorded a plugin event before."""
|
||||
return not _identity_path().exists()
|
||||
|
||||
|
||||
def record(
|
||||
event: str,
|
||||
*,
|
||||
repo: Any = None,
|
||||
session_id: str | None = None,
|
||||
**properties: Any,
|
||||
) -> None:
|
||||
"""Append one event to the local spool. Never blocks and never raises."""
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
spool = _spool_path()
|
||||
try:
|
||||
if spool.stat().st_size > SPOOL_LIMIT_BYTES:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
properties = _safe_value(properties)
|
||||
properties.update(
|
||||
harness=_harness,
|
||||
plugin_version=memory_core.PLUGIN_VERSION,
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
if repo is not None:
|
||||
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
|
||||
if session_id:
|
||||
properties["session_hash"] = _digest(session_id)
|
||||
line = json.dumps(
|
||||
{
|
||||
"event": f"{EVENT_PREFIX}.{event}",
|
||||
"timestamp": memory_core.utc_now(),
|
||||
"properties": {
|
||||
key: value for key, value in properties.items() if value is not None
|
||||
},
|
||||
},
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
spool.parent.mkdir(parents=True, exist_ok=True)
|
||||
with spool.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def error_kind(exc: BaseException | str) -> str:
|
||||
"""Coarse, content-free label for a failure, safe to send."""
|
||||
text = exc if isinstance(exc, str) else f"{type(exc).__name__}: {exc}"
|
||||
lowered = text.lower()
|
||||
if "timed out" in lowered or "timeout" in lowered:
|
||||
return "timeout"
|
||||
if "401" in lowered or "403" in lowered or "unauthor" in lowered or "forbidden" in lowered:
|
||||
return "auth"
|
||||
if "429" in lowered or "rate limit" in lowered:
|
||||
return "rate-limited"
|
||||
if any(code in lowered for code in ("500", "502", "503", "504")):
|
||||
return "server-error"
|
||||
if "400" in lowered or "422" in lowered:
|
||||
return "bad-request"
|
||||
if isinstance(exc, str):
|
||||
return "other"
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
return "network"
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def spawn_flush() -> bool:
|
||||
"""Start the detached sender that drains the spool."""
|
||||
if not is_enabled():
|
||||
return False
|
||||
try:
|
||||
if not _spool_path().exists() and not any(
|
||||
memory_core.data_dir().glob("telemetry-*.sending")
|
||||
):
|
||||
return False
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(Path(__file__).resolve())],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
**memory_core.detached_process_kwargs(),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_spool() -> Path | None:
|
||||
"""Rename the spool aside so exactly one sender owns each batch."""
|
||||
directory = memory_core.data_dir()
|
||||
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
|
||||
spool = _spool_path()
|
||||
try:
|
||||
spool.replace(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
pass
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending")):
|
||||
try:
|
||||
age = now - orphan.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > CLAIM_EXPIRY_SECONDS:
|
||||
try:
|
||||
orphan.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
continue
|
||||
try:
|
||||
orphan.replace(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_email(key: str) -> str:
|
||||
"""Trade the API key for the account email so events join other Mem0 surfaces."""
|
||||
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return ""
|
||||
email = payload.get("user_email") if isinstance(payload, dict) else ""
|
||||
return email if isinstance(email, str) else ""
|
||||
|
||||
|
||||
def _post(payload: dict[str, Any], url: str) -> bool:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload, default=str).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_distinct_id() -> tuple[str, str]:
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
|
||||
identity = _read_identity()
|
||||
email = identity.get("email", "")
|
||||
if email:
|
||||
return email, ""
|
||||
key = memory_core.api_key()
|
||||
if not key:
|
||||
return anonymous_id(identity), ""
|
||||
email = _resolve_email(key)
|
||||
if not email:
|
||||
return anonymous_id(identity), ""
|
||||
previous = identity.get("anonymous_id", "")
|
||||
identity["email"] = email
|
||||
_write_identity(identity)
|
||||
return email, previous
|
||||
|
||||
|
||||
def flush() -> int:
|
||||
"""Drain claimed spools to PostHog and return the number of events sent."""
|
||||
if not is_enabled():
|
||||
return 0
|
||||
claim = _claim_spool()
|
||||
if claim is None:
|
||||
return 0
|
||||
try:
|
||||
lines = claim.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return 0
|
||||
events = []
|
||||
for line in lines:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("event"):
|
||||
events.append(value)
|
||||
if not events:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
distinct_id, aliased_anonymous_id = resolve_distinct_id()
|
||||
if aliased_anonymous_id:
|
||||
_post(
|
||||
{
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"event": "$identify",
|
||||
"distinct_id": distinct_id,
|
||||
"properties": {
|
||||
"$anon_distinct_id": aliased_anonymous_id,
|
||||
"$lib": "posthog-python",
|
||||
},
|
||||
},
|
||||
POSTHOG_CAPTURE_URL,
|
||||
)
|
||||
|
||||
sent = 0
|
||||
for start in range(0, len(events), BATCH_SIZE):
|
||||
batch = [
|
||||
{
|
||||
"event": event["event"],
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": event.get("timestamp"),
|
||||
"properties": {
|
||||
"source": _source_tag,
|
||||
"language": "python",
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(event.get("properties") or {}),
|
||||
},
|
||||
}
|
||||
for event in events[start : start + BATCH_SIZE]
|
||||
]
|
||||
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
|
||||
return sent
|
||||
sent += len(batch)
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Codex hooks through the shared Mem0 runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
BUNDLED_CORE = HERE.parent.parent / "core"
|
||||
CORE = BUNDLED_CORE if BUNDLED_CORE.is_dir() else HERE.parents[2] / "core" / "python"
|
||||
sys.path.insert(0, str(CORE))
|
||||
|
||||
import hook_runner # noqa: E402
|
||||
import telemetry # noqa: E402
|
||||
from memory_core import ( # noqa: E402
|
||||
configure_harness,
|
||||
record_sidekick_start,
|
||||
record_sidekick_stop,
|
||||
record_tool,
|
||||
)
|
||||
|
||||
configure_harness("codex", data_dir_name="codex-plugin", source_tag="codex_plugin")
|
||||
telemetry.init(harness="codex", source_tag="CODEX_PLUGIN")
|
||||
|
||||
|
||||
def _sidekick_start(store, hook_input):
|
||||
context = record_sidekick_start(store, hook_input)
|
||||
if context:
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SubagentStart",
|
||||
"additionalContext": context,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _sidekick_stop(store, hook_input):
|
||||
record_sidekick_stop(store, hook_input)
|
||||
|
||||
|
||||
def _post_tool(store, payload):
|
||||
response = payload.get("tool_response")
|
||||
failed = None
|
||||
if isinstance(response, dict):
|
||||
exit_code = response.get("exit_code")
|
||||
if isinstance(exit_code, int) and not isinstance(exit_code, bool):
|
||||
failed = exit_code != 0
|
||||
elif isinstance(response.get("isError"), bool):
|
||||
failed = response["isError"]
|
||||
elif isinstance(response.get("success"), bool):
|
||||
failed = not response["success"]
|
||||
if failed:
|
||||
payload = {**payload, "error": response}
|
||||
record_tool(store, payload, failed=failed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "post-tool":
|
||||
sys.argv[1] = "codex-post-tool"
|
||||
hook_runner.entry_point(
|
||||
extra_actions={"sidekick-start": _sidekick_start, "sidekick-stop": _sidekick_stop, "codex-post-tool": _post_tool},
|
||||
automatic_flush_reasons={"session-end", "pre-compact"},
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"description": "Create memories from coding sessions and recall them in later work.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear|compact",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" session-start --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" user-prompt --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" post-tool --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" sidekick-start --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" sidekick-stop --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" stop --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" flush --reason pre-compact --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" flush --reason session-end --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"version": "0.3.1",
|
||||
"homepage": "https://docs.mem0.ai/integrations/codex",
|
||||
"native": {
|
||||
"pluginRoot": "${PLUGIN_ROOT}",
|
||||
"pluginData": "${PLUGIN_DATA}",
|
||||
"files": {
|
||||
".codex-plugin/plugin.json": ".codex-plugin/plugin.json",
|
||||
"hooks/hooks.json": "hooks/hooks.json",
|
||||
"hooks/adapter.py": "hooks/adapter.py",
|
||||
".mcp.json": ".mcp.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: forget
|
||||
description: Delete the Mem0 memories stored for this repository and this user. Use when the user asks to forget, clear, wipe, or delete memories.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Forget this repository's memories
|
||||
|
||||
This permanently deletes remote memories. Before running anything, tell the
|
||||
user exactly what will be deleted: their own memories for this repository
|
||||
only. The repository's project memory is shared by everyone who works in it,
|
||||
so it stays unless the user explicitly asks to delete that too.
|
||||
|
||||
After the user confirms, run:
|
||||
|
||||
```bash
|
||||
python3 "${PLUGIN_ROOT}/core/memory_cli.py" --harness "codex" --plugin-data-dir "${PLUGIN_DATA}" forget --remote --yes
|
||||
```
|
||||
|
||||
If the user also asked to delete the repository's shared project memory, add
|
||||
`--include-project-memory` and say that this removes it for every teammate.
|
||||
|
||||
Report what the command output says was deleted. If the user only wants local
|
||||
data cleared (evidence log, pending queue), run the same command without
|
||||
`--remote`. Never pass `--yes` before the user has confirmed in this
|
||||
conversation.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: pause
|
||||
description: Pause Mem0 memory capture on this machine. Use when the user wants to stop memories being recorded, for example for private work or experiments.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Pause memory capture
|
||||
|
||||
To pause (hooks stop capturing and sending session content; a minimal
|
||||
anonymous telemetry ping still fires at session start unless
|
||||
`MEM0_TELEMETRY=false`):
|
||||
|
||||
```bash
|
||||
python3 "${PLUGIN_ROOT}/core/memory_cli.py" --harness "codex" --plugin-data-dir "${PLUGIN_DATA}" pause
|
||||
```
|
||||
|
||||
Confirm the new state back to the user, and remind them that already-created
|
||||
memories still exist and remain searchable. Pending unsent packets are held
|
||||
while paused, not expired, and are delivered after resuming. To turn capture
|
||||
back on, use `/mem0:resume`.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: remember
|
||||
description: Acknowledge a "remember this" request and make sure it is captured well. Use when the user explicitly asks to remember, note, or save something for future sessions.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Remember something for future sessions
|
||||
|
||||
Mem0 creates memories from the session automatically — there is no separate
|
||||
write command. When the user asks to remember something:
|
||||
|
||||
1. Restate the fact clearly and completely in your reply, in one or two
|
||||
sentences, including any names, values, or paths it depends on. Your visible
|
||||
reply is what memory extraction reads, so a precise restatement is what gets
|
||||
remembered.
|
||||
2. Tell the user it will be saved with this session's memories when the session
|
||||
ends or compacts, and that it will surface in future sessions in this
|
||||
repository (they can check later with /mem0:search).
|
||||
|
||||
Do not invent a storage confirmation or a memory ID — creation happens in the
|
||||
background after the session.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: resume
|
||||
description: Resume Mem0 memory capture after it was paused with /mem0:pause.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Resume memory capture
|
||||
|
||||
Resume memory capture for this machine.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 "${PLUGIN_ROOT}/core/memory_cli.py" --harness "codex" --plugin-data-dir "${PLUGIN_DATA}" resume
|
||||
```
|
||||
|
||||
Confirm to the user that capture is active again. New sessions record evidence and
|
||||
create memories as normal; nothing that happened while paused is retroactively
|
||||
captured.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: search
|
||||
description: Search memories from earlier Codex sessions in this repository. Use it when earlier work may already explain the code, error, decision, or command you need, so you can avoid repeating file reads, searches, or experiments.
|
||||
argument-hint: "[question] [--top-k number] [--category category-name] [--scope repo|dir|mine] [--run-id session-id]"
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Search memories
|
||||
|
||||
Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
|
||||
`--scope`, and `--run-id` as tool arguments instead of including them in the
|
||||
query.
|
||||
|
||||
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
|
||||
category; a category is a best-effort label Mem0 assigned when it saved the
|
||||
memory, so if a category search misses, repeat it without the category. Omit
|
||||
`scope` to use the configured default, normally `repo`: this repository's
|
||||
shared memory, which everyone who works in it contributes to, plus your own
|
||||
preferences.
|
||||
|
||||
Pass `scope` when the question needs something else: `dir` to narrow the
|
||||
shared memory to the directory you are working in (a package inside a
|
||||
monorepo), `mine` for your own preferences alone.
|
||||
|
||||
Pass `run_id` with any scope to retrieve memories saved in a specific coding-agent
|
||||
session. Omit `run_id` to search across sessions. It filters the memories returned;
|
||||
it does not identify the session making the search request. Use a known session ID,
|
||||
never invent one. Return the tool's result directly.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: status
|
||||
description: Show whether Mem0 memory is working in this repository, covering configuration, capture state, pending flushes, and whether the Mem0 API key is valid. Use when the user asks whether memory is on, why a memory is missing, or anything looks broken.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Memory status
|
||||
|
||||
Run both commands and report the combined result in plain language:
|
||||
|
||||
```bash
|
||||
python3 "${PLUGIN_ROOT}/core/memory_cli.py" --harness "codex" --plugin-data-dir "${PLUGIN_DATA}" status --json
|
||||
python3 "${PLUGIN_ROOT}/core/memory_cli.py" --harness "codex" --plugin-data-dir "${PLUGIN_DATA}" doctor
|
||||
```
|
||||
|
||||
Summarize, using only fields the JSON actually reports: whether capture is
|
||||
active or paused, the user ID and repository scope (`repo_id`), whether an
|
||||
API key is configured, the event/flush/retrieval counts (`flushes` is the
|
||||
number of completed flushes, not a pending count), and the doctor check
|
||||
results. If doctor reports an authentication failure (401 / invalid key), say
|
||||
clearly that the Mem0 API key is invalid or expired and that memories are NOT
|
||||
being created. Never report an auth failure as "no memories found". Suggest
|
||||
reinstalling with `--config api_key=...` in that case.
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HOST = Path(__file__).resolve().parents[1]
|
||||
CORE_ROOT = HOST.parent / "agent-plugin-core"
|
||||
sys.path.insert(0, str(CORE_ROOT))
|
||||
|
||||
from build.build import build # noqa: E402
|
||||
|
||||
|
||||
def test_codex_hooks_use_native_events_and_plugin_paths() -> None:
|
||||
hooks = json.loads((HOST / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"]
|
||||
|
||||
assert set(hooks) == {
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PostToolUse",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"Stop",
|
||||
"PreCompact",
|
||||
"SessionEnd",
|
||||
}
|
||||
commands = [hook["command"] for groups in hooks.values() for group in groups for hook in group["hooks"]]
|
||||
assert "matcher" not in hooks["SubagentStart"][0]
|
||||
assert "matcher" not in hooks["SubagentStop"][0]
|
||||
assert all("${PLUGIN_ROOT}/hooks/adapter.py" in command for command in commands)
|
||||
assert any("flush --reason pre-compact" in command for command in commands)
|
||||
assert any("flush --reason session-end" in command for command in commands)
|
||||
assert all(hook.get("timeout", 0) <= 3 for groups in hooks.values() for group in groups for hook in group["hooks"])
|
||||
|
||||
|
||||
def test_codex_sidekick_records_lifecycle(tmp_path: Path) -> None:
|
||||
adapter = HOST / "hooks" / "adapter.py"
|
||||
payload = {
|
||||
"session_id": "session-1",
|
||||
"cwd": str(tmp_path),
|
||||
"agent_id": "agent-1",
|
||||
"agent_type": "default",
|
||||
}
|
||||
|
||||
for action, extra in (
|
||||
("sidekick-start", {}),
|
||||
("sidekick-stop", {"last_assistant_message": "SIDEKICK_OK"}),
|
||||
):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(adapter), action, "--plugin-data-dir", str(tmp_path / "data")],
|
||||
input=json.dumps({**payload, **extra}),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
with sqlite3.connect(tmp_path / "data" / "evidence.sqlite3") as connection:
|
||||
row = connection.execute(
|
||||
"SELECT agent_id, stopped_at, final_message FROM sidekick_runs"
|
||||
).fetchone()
|
||||
|
||||
assert row is not None
|
||||
assert row[0] == "agent-1"
|
||||
assert row[1]
|
||||
assert row[2] == "SIDEKICK_OK"
|
||||
|
||||
|
||||
def test_codex_mcp_uses_host_relative_paths() -> None:
|
||||
manifest = json.loads((HOST / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
|
||||
assert manifest["mcpServers"] == "./.mcp.json"
|
||||
|
||||
server = json.loads((HOST / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["mem0"]
|
||||
assert server["command"] == "python3"
|
||||
assert server["args"] == ["./core/mcp_server.py"]
|
||||
assert server["cwd"] == "."
|
||||
assert set(server["env_vars"]) >= {"MEM0_API_KEY", "MEM0_CODE_USER_ID"}
|
||||
assert "PLUGIN_ROOT" not in json.dumps(server)
|
||||
|
||||
|
||||
def test_native_codex_bundle_is_self_contained(tmp_path: Path) -> None:
|
||||
root = build("codex", "native", tmp_path / "codex")
|
||||
|
||||
manifest = json.loads((root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
|
||||
assert manifest["hooks"] == "./hooks/hooks.json"
|
||||
assert manifest["skills"] == "./skills/"
|
||||
assert (root / "hooks" / "adapter.py").is_file()
|
||||
assert (root / "core" / "mcp_server.py").is_file()
|
||||
assert (root / ".mcp.json").is_file()
|
||||
assert not (root / "agents").exists()
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
def test_codex_post_tool_keeps_failures_and_unknown_outcomes(tmp_path):
|
||||
for response, failed in [("raw command output", None), ({"exit_code": 1, "stderr": "failed"}, True), ({"isError": False}, False)]:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(HOST / "hooks" / "adapter.py"), "post-tool", "--plugin-data-dir", str(tmp_path / "data")],
|
||||
input=json.dumps({"session_id": "s1", "cwd": str(tmp_path), "tool_name": "Bash",
|
||||
"tool_input": {"command": "test command"}, "tool_response": response}),
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
with sqlite3.connect(tmp_path / "data" / "evidence.sqlite3") as connection:
|
||||
row = connection.execute("SELECT payload_json FROM events ORDER BY id DESC LIMIT 1").fetchone()
|
||||
assert json.loads(row[0])["failed"] is failed
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.3.1",
|
||||
"description": "Cross-session memory and token savings for coding agents.",
|
||||
"author": { "name": "Mem0", "email": "support@mem0.ai" },
|
||||
"homepage": "https://docs.mem0.ai/integrations/cursor",
|
||||
"repository": "https://github.com/mem0ai/mem0",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"],
|
||||
"skills": "./skills/",
|
||||
"agents": "./agents/",
|
||||
"hooks": "./hooks/hooks.json",
|
||||
"mcpServers": "./mcp.json",
|
||||
"variables": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": { "type": "string", "title": "Mem0 API Key", "description": "Mem0 Platform API key used to create and search memories." },
|
||||
"user_id": { "type": "string", "title": "Memory user ID", "description": "Optional stable ID shared across machines.", "default": "" },
|
||||
"top_k": { "type": "number", "title": "Manual search results", "default": 3, "minimum": 1, "maximum": 20 },
|
||||
"max_context_chars": { "type": "number", "title": "Maximum retrieved context", "default": 4000, "minimum": 1000, "maximum": 10000 },
|
||||
"search_scope": { "type": "string", "title": "Default search scope", "default": "repo", "enum": ["repo", "dir", "mine"] }
|
||||
},
|
||||
"required": ["api_key"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: sidekick
|
||||
description: Coding agent with an isolated context. Delegate focused investigation, implementation, testing, debugging, or review work to it, then review its result.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are Mem0's Cursor sidekick. Complete only the work the parent agent assigns.
|
||||
Return a tested result the parent can review without repeating your investigation.
|
||||
|
||||
Always call `search_memories` before work that can depend on earlier decisions,
|
||||
repository history, or user preferences. Inspect the relevant code and repository
|
||||
rules, make requested edits, and run the smallest decisive checks.
|
||||
|
||||
Do not make adjacent improvements. Ask the parent one concise question only when
|
||||
a material decision or unsafe ambiguity blocks progress. Otherwise proceed and
|
||||
report the outcome, changed files, validation, and remaining risk.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user