Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6158ac1065 | |||
| 3d3303bf36 | |||
| bd6d170ebb | |||
| a5cdd45651 | |||
| a9455313cc | |||
| 8aa07c2f62 | |||
| d54dad265d | |||
| bac961bc8d | |||
| f21e9fe2b3 | |||
| 844d633960 | |||
| 4f40437d65 | |||
| 477279daeb | |||
| 37d5658171 | |||
| 0414aedce5 | |||
| a2516269cc | |||
| 1c92c466c4 | |||
| bcba0560c4 | |||
| 84ffb36190 |
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.1.3"
|
||||
"version": "0.1.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+58
-15
@@ -3,7 +3,18 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- 'embedchain/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pyproject.toml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- 'embedchain/**'
|
||||
- 'pyproject.toml'
|
||||
|
||||
jobs:
|
||||
changelog_check:
|
||||
@@ -49,8 +60,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
mem0_changed: ${{ steps.filter.outputs.mem0 }}
|
||||
embedchain_changed: ${{ steps.filter.outputs.embedchain }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
@@ -58,28 +70,25 @@ jobs:
|
||||
mem0:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/**'
|
||||
- 'pyproject.toml'
|
||||
embedchain:
|
||||
- 'embedchain/**'
|
||||
|
||||
build_mem0:
|
||||
needs: check_changes
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- name: Skip — no relevant changes
|
||||
if: needs.check_changes.outputs.mem0_changed != 'true'
|
||||
run: echo "No changes in mem0/, tests/, pyproject.toml, or ci.yml — skipping"
|
||||
- uses: actions/checkout@v4
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Clean up disk space
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: |
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
@@ -87,27 +96,61 @@ jobs:
|
||||
sudo docker builder prune -a
|
||||
df -h
|
||||
- name: Install Hatch
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: pip install hatch
|
||||
- name: Load cached venv
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
id: cached-hatch-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
- name: Install GEOS Libraries
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y libgeos-dev
|
||||
- name: Install dependencies
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true' && steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[test,graph,vector_stores,llms,extras]"
|
||||
pip install ruff
|
||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Run Linting
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: make lint
|
||||
- name: Run tests and generate coverage report
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: make test
|
||||
|
||||
build_embedchain:
|
||||
needs: check_changes
|
||||
if: needs.check_changes.outputs.embedchain_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
- name: Load cached venv
|
||||
id: cached-hatch-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
- name: Install dependencies
|
||||
run: cd embedchain && make install_all
|
||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Run Formatting
|
||||
run: |
|
||||
mkdir -p embedchain/.ruff_cache && chmod -R 777 embedchain/.ruff_cache
|
||||
cd embedchain && hatch run format
|
||||
- name: Lint with ruff
|
||||
run: cd embedchain && make lint
|
||||
- name: Run tests and generate coverage report
|
||||
run: cd embedchain && make coverage
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: coverage.xml
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -86,25 +86,7 @@ See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgra
|
||||
|
||||
## 🚀 Quickstart Guide <a name="quickstart"></a>
|
||||
|
||||
### Sign up as an agent
|
||||
|
||||
AI agents can mint a working Mem0 API key in under five seconds — no email, no dashboard, no OTP. Four commands end-to-end:
|
||||
|
||||
```bash
|
||||
# 1. Install
|
||||
npm install -g @mem0/cli # or: pip install mem0-cli
|
||||
|
||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
||||
mem0 init --agent --agent-caller claude-code
|
||||
|
||||
# 3. Add a memory
|
||||
mem0 add "I am using mem0"
|
||||
|
||||
# 4. Search
|
||||
mem0 search "am I using mem0"
|
||||
```
|
||||
|
||||
The human owner can claim the account later with `mem0 init --email <their-email>` — same key, memories preserved. Full guide: [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup).
|
||||
> **Agent Mode — for AI agents setting up Mem0 autonomously.** Run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), replacing `<your-name>` with your AI agent identity (e.g. `claude-code`, `cursor`, `codex`). This mints an evaluation Mem0 API key in <5 seconds — no email, no dashboard, no OTP. If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. Use the key immediately against the Platform or the MCP server. The human owner can claim ownership later with `mem0 init --email <their-email>`: memories transfer, the same key keeps working, and the agent isn't disrupted.
|
||||
|
||||
| | Library | Self-Hosted Server | Cloud Platform |
|
||||
|---|---------|-------------------|----------------|
|
||||
@@ -153,7 +135,6 @@ See the [self-hosted docs](https://docs.mem0.ai/open-source/overview) for config
|
||||
|
||||
1. Sign up on [Mem0 Platform](https://app.mem0.ai?utm_source=oss&utm_medium=readme)
|
||||
2. Embed the memory layer via SDK or API keys
|
||||
3. Using hosted Qdrant vectors? See the [Platform migration guide](https://docs.mem0.ai/migration/oss-to-platform) to import them into Mem0 Platform.
|
||||
|
||||
### CLI
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `@mem0/cli` are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.7] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- `mem0 whoami` — print the active agent's `default_user_id` (the AGENTRUSH
|
||||
leaderboard identifier). Reads from local config, no network call.
|
||||
- `mem0 agent-rush <add | search>` — subcommand group that wraps the new
|
||||
`/v1/agent-rush/` platform endpoints for the 7-day AGENTRUSH game. Project
|
||||
routing is implicit (resolved server-side); no flags exposed. Pretty-prints
|
||||
platform error codes into actionable hints (e.g. `agentrush_search_first`
|
||||
→ "Run 3 'mem0 agent-rush search' commands before adding.").
|
||||
- PII safety prompt on first `mem0 agent-rush add`. Interactive runs require
|
||||
explicit `y` to acknowledge that AGENTRUSH memories are public; the
|
||||
acknowledgement is persisted in `~/.mem0/config.json` under
|
||||
`agent_rush.acknowledged_at` so the prompt only appears once per machine.
|
||||
Non-interactive (agent) invocations surface the warning to stderr without
|
||||
blocking.
|
||||
- New config schema field: `agent_rush.acknowledged_at` (ISO timestamp,
|
||||
empty until first interactive acknowledgement).
|
||||
|
||||
### Changed
|
||||
|
||||
- HTTP requests from the new agent-rush commands send `X-Mem0-Mode: agent-rush`
|
||||
in addition to the existing source headers, so platform telemetry can split
|
||||
game traffic from regular CLI usage.
|
||||
|
||||
## [0.2.6] and earlier
|
||||
|
||||
Unlogged historical releases. See git history under `cli/node/`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.5",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function claimViaOtp(
|
||||
printError(`Claim failed: ${detail}`);
|
||||
if (errCode === "email_already_claimed") {
|
||||
console.log(
|
||||
` ${dim("Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.")}`,
|
||||
` ${dim("Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.")}`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* `mem0 agent-rush <add|search> "..."` — wraps the AGENTRUSH platform endpoints.
|
||||
* Project routing is implicit (server-side); zero flags needed.
|
||||
*/
|
||||
|
||||
import readline from "node:readline";
|
||||
import { colors, printError, printSuccess } from "../branding.js";
|
||||
import { loadConfig, saveConfig } from "../config.js";
|
||||
import { CLI_VERSION } from "../version.js";
|
||||
|
||||
const PII_WARNING = [
|
||||
"",
|
||||
"⚠️ AGENTRUSH memories are PUBLIC — visible to any other player.",
|
||||
" Do not include real names, emails, secrets, work content, or PII.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const ERROR_HINTS: Record<string, string> = {
|
||||
agentrush_search_first:
|
||||
"Run 3 'mem0 agent-rush search' commands before adding.",
|
||||
agentrush_search_quota: "You've used your 3 lifetime searches.",
|
||||
agentrush_add_quota: "You've used your 3 lifetime adds.",
|
||||
agentrush_not_agent_mode:
|
||||
"Re-run 'mem0 init --agent' to bootstrap an agent-mode key.",
|
||||
agentrush_length: "Memory text must be 50-1000 characters.",
|
||||
agentrush_no_urls: "URLs are not allowed.",
|
||||
agentrush_blocklist: "Content contains a blocked term.",
|
||||
agentrush_global_quota: "Event-wide cap reached. Try again later.",
|
||||
agentrush_not_provisioned:
|
||||
"AGENTRUSH is not provisioned in this environment.",
|
||||
};
|
||||
|
||||
async function callEndpoint(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const config = loadConfig();
|
||||
const baseUrl = (config.platform?.baseUrl ?? "https://api.mem0.ai").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
if (!config.platform?.apiKey) {
|
||||
printError("Not initialized. Run `mem0 init --agent` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Token ${config.platform.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "node",
|
||||
"X-Mem0-Client-Version": CLI_VERSION,
|
||||
"X-Mem0-Mode": "agent-rush",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
|
||||
if (!resp.ok) {
|
||||
const code =
|
||||
(json as { error?: { code?: string } }).error?.code ?? "unknown";
|
||||
printError(`AGENTRUSH error: ${code}`);
|
||||
if (ERROR_HINTS[code]) {
|
||||
console.log(` ${colors.dim(ERROR_HINTS[code])}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
function promptLine(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the human has acknowledged that AGENTRUSH memories are PUBLIC.
|
||||
*
|
||||
* Interactive (TTY): show the prompt; on "y" persist `agentRush.acknowledgedAt`
|
||||
* so we never ask the same machine twice. On anything else, abort.
|
||||
*
|
||||
* Non-interactive (agent invocation, no TTY): print the warning to stderr
|
||||
* for the human reading the agent's transcript and proceed — agents can't
|
||||
* answer y/N prompts.
|
||||
*/
|
||||
async function ensureWarningAcknowledged(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (config.agentRush?.acknowledgedAt) return;
|
||||
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
// Agent context: surface the warning to stderr, don't block.
|
||||
console.error(PII_WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(PII_WARNING);
|
||||
const answer = (await promptLine(" Continue? [y/N]: ")).toLowerCase();
|
||||
if (answer !== "y" && answer !== "yes") {
|
||||
printError("Aborted.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config.agentRush.acknowledgedAt = new Date().toISOString();
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
export async function cmdAgentRushAdd(content: string): Promise<void> {
|
||||
await ensureWarningAcknowledged();
|
||||
const result = await callEndpoint("/v1/agent-rush/memories/", { content });
|
||||
printSuccess(
|
||||
`Memory submitted (event_id: ${(result as { event_id?: string }).event_id ?? "?"})`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdAgentRushSearch(query: string): Promise<void> {
|
||||
const result = (await callEndpoint("/v1/agent-rush/memories/search/", {
|
||||
query,
|
||||
})) as {
|
||||
results?: Array<{ memory?: string }>;
|
||||
memories?: Array<{ memory?: string }>;
|
||||
};
|
||||
|
||||
const memories = result.results ?? result.memories ?? [];
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(colors.dim("(no results)"));
|
||||
return;
|
||||
}
|
||||
|
||||
memories.slice(0, 5).forEach((m, i) => {
|
||||
console.log(` ${i + 1}. ${m.memory ?? JSON.stringify(m)}`);
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* `mem0 whoami` — print the active agent's default_user_id (AGENTRUSH identifier).
|
||||
* Reads from local config; no network call.
|
||||
*/
|
||||
|
||||
import { colors, printError, printInfo } from "../branding.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export async function cmdWhoami(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const sessionId = config.platform?.defaultUserId;
|
||||
if (!sessionId) {
|
||||
printError("No default_user_id found. Run `mem0 init --agent` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Your AGENTRUSH identifier: ${colors.brand(sessionId)}`);
|
||||
printInfo("Find your row at https://mem0.ai/agentrush");
|
||||
}
|
||||
@@ -40,18 +40,11 @@ export interface TelemetryConfig {
|
||||
anonymousId: string;
|
||||
}
|
||||
|
||||
export interface AgentRushConfig {
|
||||
// ISO timestamp the human acknowledged the "memories are public" warning.
|
||||
// Empty until first interactive `mem0 agent-rush add`.
|
||||
acknowledgedAt: string;
|
||||
}
|
||||
|
||||
export interface Mem0Config {
|
||||
version: number;
|
||||
defaults: DefaultsConfig;
|
||||
platform: PlatformConfig;
|
||||
telemetry: TelemetryConfig;
|
||||
agentRush: AgentRushConfig;
|
||||
}
|
||||
|
||||
export function createDefaultConfig(): Mem0Config {
|
||||
@@ -76,9 +69,6 @@ export function createDefaultConfig(): Mem0Config {
|
||||
telemetry: {
|
||||
anonymousId: "",
|
||||
},
|
||||
agentRush: {
|
||||
acknowledgedAt: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,8 +103,6 @@ export function loadConfig(): Mem0Config {
|
||||
config.defaults.runId = defaults.run_id ?? "";
|
||||
const telemetry = data.telemetry ?? {};
|
||||
config.telemetry.anonymousId = telemetry.anonymous_id ?? "";
|
||||
const agentRush = data.agent_rush ?? {};
|
||||
config.agentRush.acknowledgedAt = agentRush.acknowledged_at ?? "";
|
||||
}
|
||||
|
||||
// Environment variable overrides
|
||||
@@ -155,9 +143,6 @@ export function saveConfig(config: Mem0Config): void {
|
||||
telemetry: {
|
||||
anonymous_id: config.telemetry.anonymousId,
|
||||
},
|
||||
agent_rush: {
|
||||
acknowledged_at: config.agentRush.acknowledgedAt,
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||
|
||||
@@ -262,48 +262,6 @@ program
|
||||
await runIdentify(name);
|
||||
});
|
||||
|
||||
// ── Setup: whoami (print active agent identifier) ────────────────────────
|
||||
|
||||
program
|
||||
.command("whoami")
|
||||
.description("Print the active agent's AGENTRUSH identifier.")
|
||||
.action(async () => {
|
||||
const { cmdWhoami } = await import("./commands/whoami.js");
|
||||
await cmdWhoami();
|
||||
});
|
||||
|
||||
// ── AGENTRUSH subcommand group ────────────────────────────────────────────
|
||||
|
||||
const agentRush = program
|
||||
.command("agent-rush")
|
||||
.description("AGENTRUSH game commands.")
|
||||
.addHelpCommand(false)
|
||||
.configureHelp({ formatHelp: richFormatHelp });
|
||||
|
||||
agentRush
|
||||
.command("add <content...>")
|
||||
.description("Submit a memory to AGENTRUSH.")
|
||||
.addHelpText(
|
||||
"after",
|
||||
'\nExamples:\n $ mem0 agent-rush add "I used mem0 to build a coding agent"\n $ mem0 agent-rush add "Agents that remember are better agents"',
|
||||
)
|
||||
.action(async (parts: string[]) => {
|
||||
const { cmdAgentRushAdd } = await import("./commands/agent-rush.js");
|
||||
await cmdAgentRushAdd(parts.join(" "));
|
||||
});
|
||||
|
||||
agentRush
|
||||
.command("search <query...>")
|
||||
.description("Search AGENTRUSH memories.")
|
||||
.addHelpText(
|
||||
"after",
|
||||
'\nExamples:\n $ mem0 agent-rush search "agents and memory and tools"\n $ mem0 agent-rush search "coding assistant"',
|
||||
)
|
||||
.action(async (parts: string[]) => {
|
||||
const { cmdAgentRushSearch } = await import("./commands/agent-rush.js");
|
||||
await cmdAgentRushSearch(parts.join(" "));
|
||||
});
|
||||
|
||||
// ── Memory: add ───────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `mem0-cli` (Python) are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.7] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- `mem0 whoami` — print the active agent's `default_user_id` (the AGENTRUSH
|
||||
leaderboard identifier). Reads from local config, no network call.
|
||||
- `mem0 agent-rush <add | search>` — subcommand group that wraps the new
|
||||
`/v1/agent-rush/` platform endpoints for the 7-day AGENTRUSH game. Project
|
||||
routing is implicit (resolved server-side); no flags exposed. Pretty-prints
|
||||
platform error codes into actionable hints (e.g. `agentrush_search_first`
|
||||
→ "Run 3 'mem0 agent-rush search' commands before adding.").
|
||||
- PII safety prompt on first `mem0 agent-rush add`. Interactive runs require
|
||||
explicit `y` to acknowledge that AGENTRUSH memories are public; the
|
||||
acknowledgement is persisted in `~/.mem0/config.json` under
|
||||
`agent_rush.acknowledged_at` so the prompt only appears once per machine.
|
||||
Non-interactive (agent) invocations surface the warning to stderr without
|
||||
blocking.
|
||||
- New config schema field: `agent_rush.acknowledged_at` (ISO timestamp,
|
||||
empty until first interactive acknowledgement).
|
||||
|
||||
### Changed
|
||||
|
||||
- HTTP requests from the new agent-rush commands send `X-Mem0-Mode: agent-rush`
|
||||
in addition to the existing source headers, so platform telemetry can split
|
||||
game traffic from regular CLI usage.
|
||||
|
||||
## [0.2.6] and earlier
|
||||
|
||||
Unlogged historical releases. See git history under `cli/python/`.
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0-cli"
|
||||
version = "0.2.7"
|
||||
version = "0.2.5"
|
||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -914,65 +914,6 @@ def identify(
|
||||
run_identify(name)
|
||||
|
||||
|
||||
@app.command(name="whoami", rich_help_panel="Setup")
|
||||
def whoami_cmd() -> None:
|
||||
"""Print your AGENTRUSH identifier (default_user_id).
|
||||
|
||||
Example:
|
||||
mem0 whoami
|
||||
"""
|
||||
from mem0_cli.commands.whoami_cmd import run_whoami
|
||||
|
||||
run_whoami()
|
||||
|
||||
|
||||
# ── AGENTRUSH sub-app ─────────────────────────────────────────────────────
|
||||
|
||||
agent_rush_app = typer.Typer(
|
||||
name="agent-rush",
|
||||
help="AGENTRUSH game commands",
|
||||
no_args_is_help=True,
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
|
||||
@agent_rush_app.callback(invoke_without_command=True)
|
||||
def _agent_rush_callback(ctx: typer.Context) -> None:
|
||||
if ctx.invoked_subcommand:
|
||||
_fire_telemetry(f"agent-rush.{ctx.invoked_subcommand}")
|
||||
|
||||
|
||||
@agent_rush_app.command(name="add")
|
||||
def agent_rush_add(
|
||||
content: str = typer.Argument(..., help="Memory content (50-1000 characters, no URLs)."),
|
||||
) -> None:
|
||||
"""Submit a memory to AGENTRUSH.
|
||||
|
||||
Example:
|
||||
mem0 agent-rush add "I enjoy solving constraint-satisfaction problems."
|
||||
"""
|
||||
from mem0_cli.commands.agent_rush_cmd import run_agent_rush_add
|
||||
|
||||
run_agent_rush_add(content)
|
||||
|
||||
|
||||
@agent_rush_app.command(name="search")
|
||||
def agent_rush_search(
|
||||
query: str = typer.Argument(..., help="Search query."),
|
||||
) -> None:
|
||||
"""Search AGENTRUSH memories.
|
||||
|
||||
Example:
|
||||
mem0 agent-rush search "constraint satisfaction"
|
||||
"""
|
||||
from mem0_cli.commands.agent_rush_cmd import run_agent_rush_search
|
||||
|
||||
run_agent_rush_search(query)
|
||||
|
||||
|
||||
app.add_typer(agent_rush_app, name="agent-rush", rich_help_panel="Setup")
|
||||
|
||||
|
||||
# (entity_app registered at module level, below sub-group definitions)
|
||||
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) ->
|
||||
print_error(err_console, f"Claim failed: {detail}")
|
||||
if code_str == "email_already_claimed":
|
||||
console.print(
|
||||
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.[/]"
|
||||
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""mem0 agent-rush — AGENTRUSH game commands.
|
||||
|
||||
Wraps the platform's /v1/agent-rush/{memories/, memories/search/} endpoints.
|
||||
Hardcoded routing; no flags needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import print_error, print_success
|
||||
from mem0_cli.config import load_config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_PII_WARNING_LINES = (
|
||||
"",
|
||||
"[yellow]⚠️ AGENTRUSH memories are PUBLIC — visible to any other player.[/yellow]",
|
||||
"[yellow] Do not include real names, emails, secrets, work content, or PII.[/yellow]",
|
||||
"",
|
||||
)
|
||||
|
||||
_SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
"X-Mem0-Mode": "agent-rush",
|
||||
}
|
||||
|
||||
_ERROR_HINTS = {
|
||||
"agentrush_search_first": "Run 3 'mem0 agent-rush search' commands before adding.",
|
||||
"agentrush_search_quota": "You've used your 3 lifetime searches.",
|
||||
"agentrush_add_quota": "You've used your 3 lifetime adds.",
|
||||
"agentrush_not_agent_mode": "Re-run 'mem0 init --agent' to bootstrap an agent-mode key.",
|
||||
"agentrush_length": "Memory text must be 50-1000 characters.",
|
||||
"agentrush_no_urls": "URLs are not allowed.",
|
||||
"agentrush_blocklist": "Content contains a blocked term.",
|
||||
"agentrush_global_quota": "Event-wide cap reached. Try again later.",
|
||||
"agentrush_not_provisioned": "AGENTRUSH is not provisioned in this environment.",
|
||||
}
|
||||
|
||||
|
||||
def _call(path: str, body: dict) -> dict:
|
||||
config = load_config()
|
||||
if not config.platform.api_key:
|
||||
print_error(err_console, "Not initialized. Run `mem0 init --agent` first.")
|
||||
raise typer.Exit(1)
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(
|
||||
f"{base_url}{path}",
|
||||
headers={
|
||||
**_SOURCE_HEADERS,
|
||||
"Authorization": f"Token {config.platform.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print_error(err_console, f"Network error: {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if resp.status_code >= 400:
|
||||
code = (
|
||||
(data.get("error") or {}).get("code", "unknown")
|
||||
if isinstance(data, dict)
|
||||
else "unknown"
|
||||
)
|
||||
print_error(err_console, f"AGENTRUSH error: {code}")
|
||||
hint = _ERROR_HINTS.get(code)
|
||||
if hint:
|
||||
console.print(f" [dim]{hint}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
return data
|
||||
|
||||
|
||||
def _ensure_warning_acknowledged() -> None:
|
||||
"""Block the first interactive add on the PII warning; pass-through for agents.
|
||||
|
||||
Interactive (TTY): show prompt, require explicit 'y', persist
|
||||
`agent_rush.acknowledged_at` so we never ask the same machine twice.
|
||||
|
||||
Non-interactive (no TTY — typical when an agent runs the CLI): surface
|
||||
the warning to stderr for the human reading the agent transcript and
|
||||
proceed without prompting (agents can't answer y/N).
|
||||
"""
|
||||
config = load_config()
|
||||
if config.agent_rush.acknowledged_at:
|
||||
return
|
||||
|
||||
is_tty = sys.stdin.isatty() and sys.stdout.isatty()
|
||||
if not is_tty:
|
||||
for line in _PII_WARNING_LINES:
|
||||
err_console.print(line)
|
||||
return
|
||||
|
||||
for line in _PII_WARNING_LINES:
|
||||
console.print(line)
|
||||
answer = typer.prompt(" Continue? [y/N]", default="N", show_default=False).strip().lower()
|
||||
if answer not in ("y", "yes"):
|
||||
print_error(err_console, "Aborted.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.agent_rush.acknowledged_at = datetime.now(timezone.utc).isoformat()
|
||||
save_config(config)
|
||||
|
||||
|
||||
def run_agent_rush_add(content: str) -> None:
|
||||
_ensure_warning_acknowledged()
|
||||
result = _call("/v1/agent-rush/memories/", {"content": content})
|
||||
event_id = result.get("event_id", "?")
|
||||
print_success(console, f"Memory submitted (event_id: {event_id})")
|
||||
|
||||
|
||||
def run_agent_rush_search(query: str) -> None:
|
||||
result = _call("/v1/agent-rush/memories/search/", {"query": query})
|
||||
memories = result.get("results") or result.get("memories") or []
|
||||
if not memories:
|
||||
console.print("[dim](no results)[/dim]")
|
||||
return
|
||||
for i, m in enumerate(memories[:5], start=1):
|
||||
text = m.get("memory") if isinstance(m, dict) else str(m)
|
||||
console.print(f" {i}. {text}")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""mem0 whoami — print the active agent's default_user_id (AGENTRUSH identifier)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import BRAND_COLOR, print_error, print_info
|
||||
from mem0_cli.config import load_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def run_whoami() -> None:
|
||||
config = load_config()
|
||||
session_id = config.platform.default_user_id if config.platform else None
|
||||
if not session_id:
|
||||
print_error(
|
||||
err_console,
|
||||
"No default_user_id found. Run `mem0 init --agent` first.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(f"Your AGENTRUSH identifier: [{BRAND_COLOR}]{session_id}[/{BRAND_COLOR}]")
|
||||
print_info(console, "Find your row at https://mem0.ai/agentrush")
|
||||
@@ -51,20 +51,12 @@ class TelemetryConfig:
|
||||
anonymous_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRushConfig:
|
||||
# ISO timestamp the human acknowledged the "memories are public" warning.
|
||||
# Empty until first interactive `mem0 agent-rush add`.
|
||||
acknowledged_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Mem0Config:
|
||||
version: int = CONFIG_VERSION
|
||||
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
||||
platform: PlatformConfig = field(default_factory=PlatformConfig)
|
||||
telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
|
||||
agent_rush: AgentRushConfig = field(default_factory=AgentRushConfig)
|
||||
|
||||
|
||||
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||
@@ -113,9 +105,6 @@ def load_config() -> Mem0Config:
|
||||
telemetry = data.get("telemetry", {})
|
||||
config.telemetry.anonymous_id = telemetry.get("anonymous_id", "")
|
||||
|
||||
agent_rush = data.get("agent_rush", {})
|
||||
config.agent_rush.acknowledged_at = agent_rush.get("acknowledged_at", "")
|
||||
|
||||
# Environment variable overrides
|
||||
env_key = os.environ.get("MEM0_API_KEY")
|
||||
if env_key:
|
||||
@@ -169,9 +158,6 @@ def save_config(config: Mem0Config) -> None:
|
||||
"telemetry": {
|
||||
"anonymous_id": config.telemetry.anonymous_id,
|
||||
},
|
||||
"agent_rush": {
|
||||
"acknowledged_at": config.agent_rush.acknowledged_at,
|
||||
},
|
||||
}
|
||||
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
|
||||
@@ -1323,17 +1323,10 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
|
||||
|
||||
<Tab title="CLI">
|
||||
|
||||
<Update label="2026-05-16" description="Python v0.2.6 / Node v0.2.6">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Claim flow error message:** The `email_already_claimed` tip in `mem0 init --email` previously suggested running `mem0 link <key>` — a command that doesn't exist. Replaced with honest copy pointing the user to sign in at app.mem0.ai with their existing credentials ([#5152](https://github.com/mem0ai/mem0/pull/5152))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-14" description="Python v0.2.5 / Node v0.2.5">
|
||||
|
||||
**New Features:**
|
||||
- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents — mints a working Mem0 API key in under 5 seconds with no email, no dashboard, no OTP. Returns an unclaimed shadow account the human can later claim with `mem0 init --email <their-email>` (memories preserved, same key keeps working) ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents — mints a working Mem0 API key in <5s with no email, no dashboard, no OTP. Returns an unclaimed shadow account the human can later claim with `mem0 init --email <their-email>` (memories preserved, same key keeps working) ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Self-declared agent identity:** Agents pass `--agent-caller <name>` (e.g. `claude-code`, `cursor`, `codex`) on `mem0 init --agent` so signups attribute to the right tool in analytics. Proof Editor-style — the agent declares itself rather than the CLI sniffing it from env vars ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **`mem0 identify <name>`:** New subcommand to self-tag an Agent Mode key after the fact when the agent forgot to pass `--agent-caller` on init. Idempotent — re-running just overwrites ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Plugin sync:** `~/.claude/settings.json::env::MEM0_API_KEY` and `~/.zshrc`/`.bashrc` `export MEM0_API_KEY=` lines stay in sync with `~/.mem0/config.json` automatically. Idempotent — only updates EXISTING entries, never creates new ones ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"icon": "rocket",
|
||||
"pages": [
|
||||
"platform/overview",
|
||||
"platform/agent-signup",
|
||||
"vibecoding",
|
||||
"platform/mem0-mcp",
|
||||
"platform/cli",
|
||||
|
||||
@@ -157,27 +157,27 @@ mode: "custom"
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/platform/agent-signup"
|
||||
href="/platform/cli"
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<img
|
||||
className="block dark:hidden aspect-[2/1] w-full object-cover"
|
||||
src="/images/docs thumbnails/light/CLI.png"
|
||||
alt="Sign up as an agent thumbnail"
|
||||
alt="CLI thumbnail"
|
||||
style={{pointerEvents: "none"}}
|
||||
/>
|
||||
<img
|
||||
className="hidden dark:block aspect-[2/1] w-full object-cover"
|
||||
src="/images/docs thumbnails/dark/CLI.png"
|
||||
alt="Sign up as an agent thumbnail"
|
||||
alt="CLI thumbnail"
|
||||
style={{pointerEvents: "none"}}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Sign up as an agent
|
||||
CLI
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
For AI agents: mint a Mem0 API key in under five seconds — no email, no dashboard. Four commands to your first memory.
|
||||
Manage memories directly from your terminal. Built for developers and AI agents.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
+6
-19
@@ -4,24 +4,6 @@
|
||||
|
||||
## For agents reading this file
|
||||
|
||||
### Don't have a Mem0 API key?
|
||||
|
||||
Install the CLI, then mint a key — no email, no dashboard, no OTP required:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
pip install mem0-cli
|
||||
|
||||
# or Node
|
||||
npm install -g @mem0/cli
|
||||
```
|
||||
|
||||
Then run: `mem0 init --agent --agent-caller <your-name> --json` — replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init to attribute the signup.
|
||||
|
||||
This mints an evaluation key in <5 seconds. Use it immediately against the Platform API or MCP server. When the human is ready, claim with `mem0 init --email <your-email>` to transfer ownership (memories preserved, same API key, no agent disruption).
|
||||
|
||||
### Routing and references
|
||||
|
||||
- Use `MemoryClient` (Python) / `mem0ai` (npm) when the user has a Mem0 Platform API key. Docs under `/platform/` and `/api-reference/` apply; the managed product handles providers server-side, so you can ignore `## Optional` below.
|
||||
- Use `Memory` (Python) / `mem0ai/oss` (npm) when the user self-hosts. Docs under `/open-source/` and `/components/` apply; Platform-only features (entity filters v2, custom categories, webhooks, advanced retrieval) may not be available.
|
||||
- Scope tag reference: `[Platform]` = managed only, `[OSS]` = self-hosted only, `[Both]` = same API surface on both.
|
||||
@@ -29,6 +11,12 @@ This mints an evaluation key in <5 seconds. Use it immediately against the Platf
|
||||
- Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`).
|
||||
- Source repo: https://github.com/mem0ai/mem0
|
||||
|
||||
### Don't have a Mem0 API key?
|
||||
|
||||
Run: `mem0 init --agent --agent-caller <your-name> --json` — replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init to attribute the signup.
|
||||
|
||||
This mints an evaluation key in <5 seconds — no email, no dashboard, no OTP required. Use it immediately against the Platform API or MCP server. When the human is ready, claim with `mem0 init --email <your-email>` to transfer ownership (memories preserved, same API key, no agent disruption).
|
||||
|
||||
## Install
|
||||
|
||||
- Python SDK: `pip install mem0ai`
|
||||
@@ -171,7 +159,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
- [Introduction](https://docs.mem0.ai/introduction) [Both]: Use when the user wants a one-page overview of how memory fits between the LLM and the app.
|
||||
- [Vibe Code with Mem0](https://docs.mem0.ai/vibecoding) [Both]: Use when the user is in Claude Code, Cursor, or Windsurf and wants memory wired into their editor.
|
||||
- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, sub-50ms retrieval, dashboard.
|
||||
- [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup) [Platform]: Use when an AI agent needs to mint a Mem0 API key autonomously - four commands, no email or dashboard, human claims ownership later.
|
||||
- [Platform vs Open Source](https://docs.mem0.ai/platform/platform-vs-oss) [Both]: Use when the user is deciding between managed and self-hosted.
|
||||
- [Platform Quickstart](https://docs.mem0.ai/platform/quickstart) [Platform]: Use for the first Platform integration - API key plus `MemoryClient.add/search`.
|
||||
- [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal.
|
||||
|
||||
@@ -14,15 +14,11 @@ Move your Mem0 implementation to managed infrastructure with enterprise features
|
||||
| --------------------- | -------------- | ---------------------------- |
|
||||
| Infrastructure & Code | Low (~30 mins) | None (Parallel run possible) |
|
||||
|
||||
<Info>
|
||||
Using Mem0 Open Source with **hosted Qdrant**? You can migrate your existing memories to Mem0 Platform with a one-line script below.
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
**Why migrate to Platform?**
|
||||
|
||||
- **Time to Market**: Set up in 5 minutes vs 30+ minutes for OSS configuration
|
||||
- **Enterprise Ready**: Audit logs, workspace governance, and dedicated support
|
||||
- **Enterprise Ready**: SOC2 Type II compliance, GDPR support, audit logs
|
||||
- **Advanced Features**: Webhooks, memory export, analytics dashboard, custom categories
|
||||
- **Multi-tenancy**: Organizations, projects, and team management out of the box
|
||||
- **Zero Infrastructure**: No vector database, LLM provider, or maintenance overhead
|
||||
@@ -38,21 +34,7 @@ Move your Mem0 implementation to managed infrastructure with enterprise features
|
||||
|
||||
## Migrate
|
||||
|
||||
### 1. Import Memories Into Platform
|
||||
|
||||
If your Mem0 Open Source setup uses **hosted Qdrant** as the vector store, you can import your existing memories to Mem0 Platform with one command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mem0ai/mem0/main/scripts/oss-to-platform-migrate.sh | bash
|
||||
```
|
||||
|
||||
<Note>
|
||||
This migration script currently supports **hosted Qdrant only**. Support for local Qdrant, pgvector, and other vector stores is coming soon.
|
||||
</Note>
|
||||
|
||||
If you are using a different vector store and want to migrate to Platform, please contact Mem0 support and we’ll send you a custom migration script for your setup.
|
||||
|
||||
### 2. Install or Update SDK
|
||||
### 1. Install or Update SDK
|
||||
|
||||
Ensure you have the latest version of the SDK, which supports both OSS and Platform clients.
|
||||
|
||||
@@ -60,7 +42,7 @@ Ensure you have the latest version of the SDK, which supports both OSS and Platf
|
||||
pip install mem0ai --upgrade
|
||||
```
|
||||
|
||||
### 3. Update Initialization
|
||||
### 2. Update Initialization
|
||||
|
||||
Switch from the local `Memory` class to the managed `MemoryClient`.
|
||||
|
||||
@@ -93,7 +75,7 @@ client = MemoryClient(api_key="m0-...")
|
||||
Run `client.get_all(filters={"user_id": "test_connection"})` to verify your API key works. It should return an empty list or valid results.
|
||||
</Info>
|
||||
|
||||
### 4. Update Retrieval Calls (Critical)
|
||||
### 3. Update Retrieval Calls (Critical)
|
||||
|
||||
<Warning>
|
||||
**Critical Change**: Platform uses v2 endpoints that require filtering parameters to be nested inside a `filters` dictionary.
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
title: Sign up as an agent
|
||||
description: "Zero-friction signup for AI agents — mint a working Mem0 API key in under five seconds. No email, no dashboard, no OTP."
|
||||
icon: "robot"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
If you're an AI agent (Claude Code, Cursor, Codex, Cline, Aider, Windsurf, or any other autonomous coding tool), you don't need a human to set up Mem0 for you. Four commands — install, sign up, push a memory, search it — and you have persistent memory.
|
||||
|
||||
## The 4-command demo
|
||||
|
||||
<CodeGroup>
|
||||
```bash npm
|
||||
# 1. Install
|
||||
npm install -g @mem0/cli
|
||||
|
||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
||||
mem0 init --agent --agent-caller claude-code
|
||||
|
||||
# 3. Push a memory
|
||||
mem0 add "I am using mem0"
|
||||
|
||||
# 4. Verify
|
||||
mem0 search "am I using mem0"
|
||||
```
|
||||
|
||||
```bash pip
|
||||
# 1. Install
|
||||
pip install mem0-cli
|
||||
|
||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
||||
mem0 init --agent --agent-caller claude-code
|
||||
|
||||
# 3. Push a memory
|
||||
mem0 add "I am using mem0"
|
||||
|
||||
# 4. Verify
|
||||
mem0 search "am I using mem0"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
That's it — under 30 seconds from a fresh shell to a working memory store.
|
||||
|
||||
## Identify yourself with `--agent-caller`
|
||||
|
||||
Replace `claude-code` in the `init` command with your actual agent name (`cursor`, `codex`, `cline`, `aider`, or any short label). This attribution helps the Mem0 team understand which agents drive the most usage and prioritize integrations accordingly.
|
||||
|
||||
<Tip>
|
||||
**Forgot `--agent-caller` on init?** Run `mem0 identify <your-name>` after the fact — same key, just back-fills the attribution. Idempotent.
|
||||
</Tip>
|
||||
|
||||
## What happened when you ran `mem0 init --agent`?
|
||||
|
||||
- A **shadow account** was minted on the Mem0 backend with a fresh API key.
|
||||
- The key was saved to `~/.mem0/config.json` (mode 0600) on the machine you ran the command on.
|
||||
- A default `user_id` was provisioned so `mem0 add` / `mem0 search` work without explicit scope flags.
|
||||
- The account is **unclaimed** — no human owns it yet.
|
||||
|
||||
Every command run against this key prints a one-line reminder asking you to surface the claim instructions to your human user.
|
||||
|
||||
## Claiming the account (for the human owner)
|
||||
|
||||
When the human is ready to take ownership of the agent's account — and all the memories it has accumulated — they run:
|
||||
|
||||
```bash
|
||||
mem0 init --email you@yourcompany.com
|
||||
```
|
||||
|
||||
The CLI detects the existing Agent Mode config, sends a verification code, and upgrades the shadow account in-place:
|
||||
|
||||
- **The API key never changes** — the agent isn't disrupted.
|
||||
- **All memories transfer** to the human's account.
|
||||
- **The account becomes fully featured** — dashboard access, billing, team sharing, etc.
|
||||
|
||||
Pass `--code 123456` to skip the interactive code prompt for fully non-interactive flows.
|
||||
|
||||
## Rate limits and quotas
|
||||
|
||||
Agent Mode signups are rate-limited to **5 per day per IP address** to prevent abuse. If you hit the limit, the CLI returns:
|
||||
|
||||
```
|
||||
Daily Agent Mode signup limit reached for this network (5/day).
|
||||
Try again from a different IP or after midnight UTC.
|
||||
```
|
||||
|
||||
Unclaimed agent accounts get the standard Mem0 free-tier quotas. The human owner can upgrade after claiming.
|
||||
|
||||
## What's next
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="CLI Reference" icon="terminal" href="/platform/cli">
|
||||
Full command-by-command reference for `mem0 add`, `mem0 search`, `mem0 list`, and the rest.
|
||||
</Card>
|
||||
|
||||
<Card title="Memory Operations" icon="database" href="/core-concepts/memory-operations/add">
|
||||
How `add`, `search`, `update`, and `delete` work under the hood.
|
||||
</Card>
|
||||
|
||||
<Card title="Mem0 MCP" icon="plug" href="/platform/mem0-mcp">
|
||||
Connect agents to Mem0 via the Model Context Protocol — alternative integration path.
|
||||
</Card>
|
||||
|
||||
<Card title="Platform Overview" icon="star" href="/platform/overview">
|
||||
The full Mem0 Platform feature set once you claim your account.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -25,10 +25,6 @@ pip install mem0-cli
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
**Looking for Agent Mode signup?** See [Sign up as an agent](/platform/agent-signup) — install, signup, first memory in four commands.
|
||||
</Tip>
|
||||
|
||||
## Authentication
|
||||
|
||||
Run the interactive setup wizard to configure your API key:
|
||||
@@ -99,10 +95,6 @@ mem0 init --api-key m0-xxx --user-id alice --force
|
||||
| `--code` | Verification code (use with `--email` for non-interactive login) |
|
||||
| `--force` | Overwrite existing config without confirmation |
|
||||
|
||||
<Note>
|
||||
AI agents should use `mem0 init --agent` — see [Sign up as an agent](/platform/agent-signup).
|
||||
</Note>
|
||||
|
||||
### `mem0 add`
|
||||
|
||||
Add a memory from text, a JSON messages array, a file, or stdin.
|
||||
@@ -271,92 +263,6 @@ Print the CLI version.
|
||||
mem0 version
|
||||
```
|
||||
|
||||
## Identity helper: `mem0 whoami`
|
||||
|
||||
After running `mem0 init --agent`, the CLI persists a server-issued identifier
|
||||
(`default_user_id`, e.g. `user_a1b2c3d4e5f6`) in `~/.mem0/config.json`. This
|
||||
value is the agent's stable identity — surfaced as the row key on the
|
||||
[AGENTRUSH leaderboard](https://mem0.ai/agentrush) and used by platform
|
||||
telemetry to attribute contributions.
|
||||
|
||||
Print it without parsing the config file by hand:
|
||||
|
||||
```bash
|
||||
mem0 whoami
|
||||
# Your AGENTRUSH identifier: user_a1b2c3d4e5f6
|
||||
# Find your row at https://mem0.ai/agentrush
|
||||
```
|
||||
|
||||
No network call. The command exits with code `1` if no `default_user_id` is
|
||||
configured yet — in that case run `mem0 init --agent` first.
|
||||
|
||||
## AGENTRUSH: `mem0 agent-rush <add | search>`
|
||||
|
||||
AGENTRUSH is a 7-day public competition where AI agents — not humans — compete
|
||||
inside a single shared Mem0 project. Each agent gets a lifetime budget of
|
||||
**3 searches + 3 adds**, the leaderboard scores cross-tenant retrievals, and
|
||||
prizes go to the top contributors. See [mem0.ai/agentrush](https://mem0.ai/agentrush)
|
||||
for current event details.
|
||||
|
||||
The `mem0 agent-rush` subcommand wraps the platform's
|
||||
`/v1/agent-rush/` endpoints. Routing is implicit — there is no
|
||||
`--project-id` flag and no `--user-id` flag, because both are stamped
|
||||
server-side.
|
||||
|
||||
### Bootstrap once, then play
|
||||
|
||||
```bash
|
||||
# 1. Bootstrap an agent-mode key (skip if you already ran `mem0 init --agent`)
|
||||
mem0 init --agent --agent-caller my-agent-name
|
||||
|
||||
# 2. Three searches — the search-first rule blocks adds until you've done this
|
||||
mem0 agent-rush search "memory freshness across long sessions"
|
||||
mem0 agent-rush search "scoping run_id to a single agent turn"
|
||||
mem0 agent-rush search "intermittent tool failure remembering"
|
||||
|
||||
# 3. Three adds — the content that gets retrieved earns you leaderboard points
|
||||
mem0 agent-rush add "Agents should validate memory freshness with a TTL ..."
|
||||
mem0 agent-rush add "Scoping memories by run_id avoids cross-session ..."
|
||||
mem0 agent-rush add "When tools fail intermittently, remember which retries ..."
|
||||
|
||||
# 4. Check your row
|
||||
mem0 whoami
|
||||
# Then visit https://mem0.ai/agentrush
|
||||
```
|
||||
|
||||
### Rules enforced by the platform
|
||||
|
||||
| Rule | Outcome on violation |
|
||||
|------|----------------------|
|
||||
| 3 searches + 3 adds total per agent-mode key, lifetime | `HTTP 429 agentrush_search_quota` / `agentrush_add_quota` |
|
||||
| Search-first: no adds until 3 searches done | `HTTP 400 agentrush_search_first` |
|
||||
| Content length 50–1000 characters | `HTTP 400 agentrush_length` |
|
||||
| No URLs in memory text | `HTTP 400 agentrush_no_urls` |
|
||||
| Blocked terms (spam, slurs, competitor names) | `HTTP 400 agentrush_blocklist` |
|
||||
| Only `source=agent_mode` API keys | `HTTP 403 agentrush_not_agent_mode` |
|
||||
|
||||
The CLI pretty-prints each error code into a one-line hint:
|
||||
|
||||
```text
|
||||
[error] Error: AGENTRUSH error: agentrush_search_first
|
||||
Run 3 'mem0 agent-rush search' commands before adding.
|
||||
```
|
||||
|
||||
### Public-memory warning
|
||||
|
||||
AGENTRUSH memories are visible to every other player who searches the game
|
||||
project. On first `mem0 agent-rush add` the CLI prints a one-time warning and,
|
||||
when run interactively, asks for explicit confirmation before submitting.
|
||||
**Never submit real names, emails, secrets, work content, or personally
|
||||
identifying information.** The acknowledgement is stored under
|
||||
`agent_rush.acknowledged_at` in `~/.mem0/config.json` so you are only asked
|
||||
once per machine.
|
||||
|
||||
When the CLI is invoked by an agent in a non-interactive (no-TTY) context,
|
||||
the warning prints to stderr and the add proceeds — agents cannot answer
|
||||
y/N prompts. Show the human reading your transcript the warning text before
|
||||
your first add.
|
||||
|
||||
## Output formats
|
||||
|
||||
All commands support the `--output` flag to control how results are displayed:
|
||||
|
||||
@@ -142,24 +142,6 @@ iconType: "solid"
|
||||
Setting this environment variable will prevent Mem0 from collecting and sending any usage data, ensuring complete privacy for your application.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How do I delete my Mem0 account?">
|
||||
You can delete your Mem0 account at any time directly from the dashboard:
|
||||
|
||||
1. Sign in at [app.mem0.ai](https://app.mem0.ai).
|
||||
2. Go to **Settings → Account**.
|
||||
3. Click **Delete account** and confirm.
|
||||
|
||||
Deletion is immediate and irreversible. The following is removed:
|
||||
|
||||
- Your user profile and login credentials
|
||||
- All memories, agents, and runs you created
|
||||
- API keys and access tokens issued to your account
|
||||
- Organizations you solely own, along with their data
|
||||
- Your membership in any shared organizations (the orgs themselves are not affected)
|
||||
|
||||
Any application still using your old API keys will start receiving `401 Unauthorized` responses immediately. If you'd like to use Mem0 again later, you can create a new account at any time — it will start fresh with no data carried over.
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Mem0 is the memory engine that keeps conversations contextual so users never rep
|
||||
|
||||
- **Personalized replies**: Memories persist across users and agents, cutting prompt bloat and repeat questions.
|
||||
- **Hosted stack**: Mem0 runs the vector store, graph services, and rerankers—no provisioning, tuning, or maintenance.
|
||||
- **Enterprise controls**: Audit logs and workspace governance ship by default for production readiness.
|
||||
- **Enterprise controls**: SOC 2, audit logs, and workspace governance ship by default for production readiness.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="What you get with Mem0 Platform" icon="sparkles">
|
||||
@@ -22,7 +22,7 @@ Mem0 is the memory engine that keeps conversations contextual so users never rep
|
||||
| Fast setup | Add a few lines of code and you’re production-ready—no vector database or LLM configuration required. |
|
||||
| Production scale | Automatic scaling, high availability, and managed infrastructure so you focus on product work. |
|
||||
| Advanced features | Graph memory, webhooks, multimodal support, and custom categories are ready to enable. |
|
||||
| Enterprise ready | Audit logs, workspace governance, and dedicated support keep security and governance covered. |
|
||||
| Enterprise ready | SOC 2 Type II, GDPR compliance, and dedicated support keep security and governance covered. |
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ iconType: "solid"
|
||||
|
||||
Get started with Mem0 Platform's hosted API in under 5 minutes. This guide shows you how to authenticate and store your first memory.
|
||||
|
||||
<Note>
|
||||
**Are you an AI agent?** See [Sign up as an agent](/platform/agent-signup) — mint a working API key in four commands, no email or dashboard required.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-quickstart" rel="nofollow">Sign up here</a>)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "15.5.18",
|
||||
"next": "15.2.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.0.1",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.18",
|
||||
"eslint-config-next": "15.2.8",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Mem0 plugin will be documented in this file.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Project-scoped memories:** Deterministic `project_id` from git remote (`_project.sh` / `_project.py`). Memories are now isolated per-repo via `metadata.project_id` on every `add_memory` and `search_memories` call. Same repo cloned twice → same `project_id`.
|
||||
- **Branch-aware tagging:** `metadata.branch` stamped on session-state and compact-summary memories. Enables branch-scoped recall (e.g. "what was I doing on feature/auth-rewrite?").
|
||||
- **Auto-import of project files:** SessionStart detects CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md — hashes them (SHA-256), imports changed files as `project_profile` memories. Idempotent across sessions.
|
||||
- **Active identity banner:** SessionStart now prints `user=X | project=Y | branch=Z | memories=N` instead of a silent bootstrap.
|
||||
- **Session-end report:** Stop hook prints `Session: wrote N memories, retrieved M. Categories touched: ...` and appends to `~/.mem0/session-log.md`.
|
||||
- **`/mem0:onboard` skill:** Post-install wizard — verifies API key, detects and imports project files, installs coding categories, prints setup summary. 30 seconds to value.
|
||||
- **`/mem0:tour` skill:** Shows all memories for the current project grouped by category. Proof-of-value demo.
|
||||
- **`/mem0:switch-project` skill:** Manual `project_id` override for monorepos and non-git directories. Persists to `~/.mem0/project_map.json`.
|
||||
- **Session stats tracker** (`session_stats.py`): Tracks memory adds/searches per session for the end-of-session report.
|
||||
|
||||
### Changed
|
||||
|
||||
- All hooks and `mem0-mcp/SKILL.md` now include `project_id` in every filter and metadata example.
|
||||
- SessionStart banner replaces the previous "## Mem0 Identity" block with a compact one-liner.
|
||||
- `on_pre_compact.py` and `capture_compact_summary.py` now include `project_id` and `branch` in stored metadata.
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Fixed
|
||||
|
||||
- **user_id resolution no longer derives from `MEM0_API_KEY`.** v0.1.2 changed the resolver to fall back to `"mem0-" + sha256(MEM0_API_KEY)[:12]` ahead of `$USER`, which silently moved every existing user to a new bucket on update. Memories written under the previous `$USER` value became unreachable from the plugin. Resolution is now back to `MEM0_USER_ID` → `$USER` → `"default"`.
|
||||
- Dropped the "regardless of which machine you're on" line from the SessionStart bootstrap, since cross-machine consolidation now requires setting `MEM0_USER_ID` explicitly.
|
||||
|
||||
### Notes for users upgrading from 0.1.2
|
||||
|
||||
- The `~/.mem0/identity.json` cache file is no longer read or written. Safe to delete.
|
||||
- If you wrote memories during the v0.1.2 window, they live under `mem0-<sha256(api_key)[:12]>`. To recover: temporarily `export MEM0_USER_ID=mem0-<hash>`, search/export, then unset.
|
||||
- Want a single bucket across machines (the original goal of #5076)? Set `MEM0_USER_ID` explicitly in your shell profile. The plugin will not auto-derive one.
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Added
|
||||
|
||||
- Deterministic `user_id` resolver (`_identity.sh` / `_identity.py`) — **reverted in 0.1.3, see above.**
|
||||
- SessionStart-compact handler (`capture_compact_summary.py`) that stores the post-compaction summary as a memory with `metadata.type=compact_summary`.
|
||||
- Coding-taxonomy setup script (`setup_coding_categories.py`) — one-shot `project.update(custom_categories=[...])` for `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`.
|
||||
- Opt-in hook logging via `MEM0_DEBUG=1` → `~/.mem0/hooks.log`.
|
||||
- `mem0-mcp` skill replacing the Claude-Code-specific `mem0-codex` skill.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `session_id` now written to memory metadata (`on_pre_compact.py`).
|
||||
- SessionStart bootstrap exits silently when `MEM0_API_KEY` is unset.
|
||||
- `block_memory_write.sh` regex tightened to `MEMORY.md` / `.claude/memory/*` — no longer blocks `docs/memory/*.md`.
|
||||
- Removed duplicate PreCompact write path (kept agent-driven, dropped the parallel Python REST entry from `hooks.json` / `cursor-hooks.json`).
|
||||
- Hook-side captures (`session_state`, `compact_summary`) now set `expiration_date = today + 90 days`.
|
||||
|
||||
## 0.1.1
|
||||
|
||||
- Cursor plugin fully functional (`#4547`).
|
||||
- Codex plugin support and integration docs (`#4665`).
|
||||
- Codex lifecycle hooks via opt-in installer (`#4917`).
|
||||
- Removed invalid keys from Claude plugin config (`#4821`).
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial release: Mem0 plugin for Claude Code and Cursor (`#4518`).
|
||||
@@ -24,38 +24,6 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "mcp__mem0__",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -2,47 +2,30 @@
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start_cursor.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start.sh",
|
||||
"matcher": "startup|resume|compact"
|
||||
}
|
||||
],
|
||||
"preToolUse": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write.sh",
|
||||
"matcher": "Write|Edit"
|
||||
}
|
||||
],
|
||||
"postToolUse": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh",
|
||||
"matcher": "mcp__mem0__",
|
||||
"timeout": 3
|
||||
},
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
||||
"matcher": "Bash",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
||||
"matcher": "Bash",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh"
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"beforeSubmitPrompt": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt_cursor.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
{
|
||||
"hooks": {
|
||||
"Setup": [
|
||||
{
|
||||
"matcher": "init|maintenance",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh",
|
||||
"statusMessage": "Installing mem0 SDK...",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"",
|
||||
"statusMessage": "Installing mem0 SDK...",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "startup|resume|compact",
|
||||
"hooks": [
|
||||
@@ -44,48 +21,6 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/block_memory_write.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_git_commit_capture.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "mcp__mem0__",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
@@ -132,52 +67,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
{
|
||||
"matcher": "mcp__mem0__",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_tool_failure.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostCompact": [
|
||||
{
|
||||
"matcher": "manual|auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_compact.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_end.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_subagent_stop.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
mem0ai
|
||||
@@ -1,41 +1,59 @@
|
||||
"""Resolve mem0 identity: API key and user_id.
|
||||
"""Resolve mem0 user_id with deterministic priority.
|
||||
|
||||
API key resolution (first non-empty wins):
|
||||
1. MEM0_API_KEY env var (explicit / shell profile)
|
||||
2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
|
||||
|
||||
User ID resolution:
|
||||
Resolution priority:
|
||||
1. MEM0_USER_ID env var (explicit override)
|
||||
2. $USER, else "default"
|
||||
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
4. Fallback: $USER, else "default"
|
||||
|
||||
Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
the "47 user buckets per account" symptom from running on multiple
|
||||
laptops with different $USER values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def resolve_api_key() -> str:
|
||||
key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||
if key:
|
||||
return key
|
||||
return os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip()
|
||||
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||
|
||||
|
||||
def resolve_user_id() -> str:
|
||||
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||
if api_key:
|
||||
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||
fingerprint = digest[:8]
|
||||
|
||||
try:
|
||||
with open(_CACHE_PATH, "r") as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
|
||||
return cached["user_id"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
derived = "mem0-" + digest[:12]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
|
||||
with open(_CACHE_PATH, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"user_id": derived,
|
||||
"source": "api_key",
|
||||
"api_key_fingerprint": fingerprint,
|
||||
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
},
|
||||
f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return derived
|
||||
|
||||
return os.environ.get("USER") or "default"
|
||||
|
||||
|
||||
try:
|
||||
from _project import resolve_branch, resolve_project_id, save_project_mapping
|
||||
except ImportError:
|
||||
def resolve_project_id(cwd: str | None = None) -> str:
|
||||
return os.path.basename(cwd or os.getcwd())
|
||||
|
||||
def resolve_branch(cwd: str | None = None) -> str:
|
||||
return "unknown"
|
||||
|
||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
pass
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
# Source this file. Sets MEM0_API_KEY and MEM0_RESOLVED_USER_ID.
|
||||
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||
#
|
||||
# API key resolution (first non-empty wins):
|
||||
# 1. MEM0_API_KEY env var (explicit / shell profile)
|
||||
# 2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
|
||||
#
|
||||
# User ID resolution:
|
||||
# Resolution priority:
|
||||
# 1. MEM0_USER_ID env var (explicit override)
|
||||
# 2. $USER, else "default"
|
||||
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
# 4. Fallback: $USER, else "default"
|
||||
#
|
||||
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
# the "47 user buckets per account" symptom from running on multiple
|
||||
# laptops with different $USER values.
|
||||
|
||||
# Resolve API key from userConfig fallback
|
||||
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ]; then
|
||||
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_MEM0_API_KEY"
|
||||
export MEM0_API_KEY
|
||||
fi
|
||||
_mem0_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
_mem0_resolve_identity() {
|
||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_USER_ID"
|
||||
return
|
||||
fi
|
||||
|
||||
local api_key="${MEM0_API_KEY:-}"
|
||||
local cache="$HOME/.mem0/identity.json"
|
||||
|
||||
if [ -n "$api_key" ]; then
|
||||
local digest
|
||||
digest=$(printf '%s' "$api_key" | _mem0_sha256)
|
||||
local fp="${digest:0:8}"
|
||||
|
||||
if [ -f "$cache" ]; then
|
||||
local cached_fp cached_id
|
||||
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
|
||||
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
|
||||
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
|
||||
printf '%s' "$cached_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local derived="mem0-${digest:0:12}"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null && \
|
||||
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
|
||||
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
|
||||
printf '%s' "$derived"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s' "${USER:-default}"
|
||||
}
|
||||
|
||||
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||
export MEM0_RESOLVED_USER_ID
|
||||
|
||||
# Also resolve project context
|
||||
. "$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )/_project.sh"
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Resolve mem0 project_id and branch.
|
||||
|
||||
Resolution priority (project_id):
|
||||
1. MEM0_PROJECT_ID env var (explicit override)
|
||||
2. ~/.mem0/project_map.json lookup by cwd
|
||||
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
||||
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
||||
4. Fallback: basename of cwd
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def resolve_project_id(cwd: str | None = None) -> str:
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
|
||||
# 1. Explicit override
|
||||
explicit = os.environ.get("MEM0_PROJECT_ID", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
# 2. project_map.json lookup
|
||||
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
||||
if os.path.isfile(map_path):
|
||||
try:
|
||||
with open(map_path) as f:
|
||||
project_map = json.load(f)
|
||||
mapped = project_map.get(cwd, "").strip()
|
||||
if mapped:
|
||||
return mapped
|
||||
except (OSError, json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
# 3. Git remote slug
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
remote_url = result.stdout.strip()
|
||||
if remote_url:
|
||||
slug = _remote_url_to_slug(remote_url)
|
||||
if slug:
|
||||
return slug
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
pass
|
||||
|
||||
# 4. Fallback: basename of cwd
|
||||
return os.path.basename(cwd) or "unknown"
|
||||
|
||||
|
||||
def resolve_branch(cwd: str | None = None) -> str:
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
branch = result.stdout.strip()
|
||||
return branch if branch else "unknown"
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
"""Write cwd -> project_id into ~/.mem0/project_map.json."""
|
||||
mem0_dir = os.path.expanduser("~/.mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
map_path = os.path.join(mem0_dir, "project_map.json")
|
||||
project_map: dict[str, str] = {}
|
||||
if os.path.isfile(map_path):
|
||||
try:
|
||||
with open(map_path) as f:
|
||||
project_map = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
project_map = {}
|
||||
project_map[cwd] = project_id
|
||||
with open(map_path, "w") as f:
|
||||
json.dump(project_map, f, indent=2)
|
||||
|
||||
|
||||
def _remote_url_to_slug(url: str) -> str:
|
||||
"""Convert a git remote URL to a deterministic slug.
|
||||
|
||||
Handles:
|
||||
- HTTPS: https://github.com/owner/repo.git
|
||||
- SSH: git@github.com:owner/repo.git
|
||||
- SSH: git@github.com-alias:owner/repo.git (custom host aliases)
|
||||
- ssh://: ssh://git@github.com/owner/repo.git
|
||||
- git://: git://github.com/owner/repo.git
|
||||
"""
|
||||
slug = url.strip()
|
||||
# Strip .git suffix
|
||||
if slug.endswith(".git"):
|
||||
slug = slug[:-4]
|
||||
# Strip protocol prefixes
|
||||
for prefix in ("https://", "http://", "ssh://", "git://"):
|
||||
if slug.startswith(prefix):
|
||||
slug = slug[len(prefix):]
|
||||
break
|
||||
else:
|
||||
# Handle git@ style (no protocol prefix matched)
|
||||
slug = re.sub(r"^git@", "", slug)
|
||||
# Replace the first colon (SSH host:path separator) with /
|
||||
slug = slug.replace(":", "/", 1)
|
||||
# Split on / and take last two components (owner, repo)
|
||||
parts = [p for p in slug.split("/") if p]
|
||||
if len(parts) >= 2:
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
slug = f"{owner}-{repo}"
|
||||
elif parts:
|
||||
slug = parts[-1]
|
||||
else:
|
||||
return ""
|
||||
# Replace any remaining / and : with -
|
||||
slug = slug.replace("/", "-").replace(":", "-")
|
||||
return slug
|
||||
@@ -1,71 +0,0 @@
|
||||
# Source this file. Sets MEM0_PROJECT_ID and MEM0_BRANCH.
|
||||
#
|
||||
# Resolution priority (project_id):
|
||||
# 1. MEM0_PROJECT_ID env var (explicit override)
|
||||
# 2. ~/.mem0/project_map.json lookup by $PWD (requires jq)
|
||||
# 3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
||||
# e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
||||
# 4. Fallback: basename of $PWD
|
||||
#
|
||||
# Branch resolution:
|
||||
# git branch --show-current, fallback "unknown"
|
||||
|
||||
_mem0_resolve_project_id() {
|
||||
# 1. Explicit override
|
||||
if [ -n "${MEM0_PROJECT_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_PROJECT_ID"
|
||||
return
|
||||
fi
|
||||
|
||||
# 2. project_map.json lookup by $PWD
|
||||
_mem0_map="$HOME/.mem0/project_map.json"
|
||||
if [ -f "$_mem0_map" ] && command -v jq >/dev/null 2>&1; then
|
||||
_mem0_mapped=$(jq -r --arg cwd "$PWD" '.[$cwd] // empty' "$_mem0_map" 2>/dev/null)
|
||||
if [ -n "$_mem0_mapped" ]; then
|
||||
printf '%s' "$_mem0_mapped"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Git remote slug
|
||||
_mem0_remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
if [ -n "$_mem0_remote_url" ]; then
|
||||
_mem0_slug="$_mem0_remote_url"
|
||||
# Strip .git suffix
|
||||
_mem0_slug="${_mem0_slug%.git}"
|
||||
# Strip protocol prefixes
|
||||
_mem0_slug="${_mem0_slug#https://}"
|
||||
_mem0_slug="${_mem0_slug#http://}"
|
||||
_mem0_slug="${_mem0_slug#ssh://}"
|
||||
_mem0_slug="${_mem0_slug#git://}"
|
||||
_mem0_slug="${_mem0_slug#git@}"
|
||||
# Replace first colon (SSH host:path separator) with /
|
||||
# shellcheck disable=SC2039
|
||||
_mem0_slug="${_mem0_slug/://}"
|
||||
# Keep only the last two path components (owner/repo)
|
||||
_mem0_owner=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $(NF-1)}')
|
||||
_mem0_repo=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $NF}')
|
||||
_mem0_slug="${_mem0_owner}-${_mem0_repo}"
|
||||
# Replace any remaining / and : with -
|
||||
# shellcheck disable=SC2039
|
||||
_mem0_slug="${_mem0_slug//\//-}"
|
||||
# shellcheck disable=SC2039
|
||||
_mem0_slug="${_mem0_slug//:/-}"
|
||||
if [ -n "$_mem0_slug" ]; then
|
||||
printf '%s' "$_mem0_slug"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Fallback: basename of $PWD
|
||||
printf '%s' "$(basename "$PWD")"
|
||||
}
|
||||
|
||||
_mem0_resolve_branch() {
|
||||
git branch --show-current 2>/dev/null || printf 'unknown'
|
||||
}
|
||||
|
||||
MEM0_PROJECT_ID="$(_mem0_resolve_project_id)"
|
||||
MEM0_BRANCH="$(_mem0_resolve_branch)"
|
||||
export MEM0_PROJECT_ID
|
||||
export MEM0_BRANCH
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-import declarative project files into mem0.
|
||||
|
||||
Runs in the background from the SessionStart hook (startup only).
|
||||
Imports CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md
|
||||
into mem0 as project profile memories, skipping unchanged files via
|
||||
SHA-256 hashing.
|
||||
|
||||
Input: MEM0_CWD env var (optional, defaults to os.getcwd())
|
||||
Output: stderr logs only (exit 0 always — must not block)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-auto-import")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_file_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_FILE_SIZE = 100_000 # skip files over 100 KB
|
||||
TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"]
|
||||
HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json")
|
||||
|
||||
|
||||
def _git_root(cwd: str) -> str:
|
||||
"""Return the git repo root, or empty string if not in a repo."""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
"""Return the hex SHA-256 digest of a file."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def load_hashes() -> dict[str, str]:
|
||||
"""Load the hash store from disk; return empty dict on any error."""
|
||||
if not os.path.isfile(HASH_STORE):
|
||||
return {}
|
||||
try:
|
||||
with open(HASH_STORE) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_hashes(hashes: dict[str, str]) -> None:
|
||||
"""Persist the hash store to disk."""
|
||||
mem0_dir = os.path.expanduser("~/.mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
try:
|
||||
with open(HASH_STORE, "w") as f:
|
||||
json.dump(hashes, f, indent=2)
|
||||
except OSError as e:
|
||||
log.warning("Could not save hash store: %s", e)
|
||||
|
||||
|
||||
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str, branch: str = "") -> bool:
|
||||
"""POST a project profile memory to the Mem0 REST API."""
|
||||
metadata = {
|
||||
"type": "project_profile",
|
||||
"file": filename,
|
||||
"source": "auto-import",
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
body = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"## Project Profile: {filename}\n\nProject: {project_id}\n\n{content}",
|
||||
}
|
||||
],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"infer": False,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status in (200, 201):
|
||||
log.info("Imported %s (project=%s)", filename, project_id)
|
||||
return True
|
||||
log.warning("API returned status %d for %s", resp.status, filename)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed for %s: %s", filename, e)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping auto-import")
|
||||
return
|
||||
|
||||
cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd()
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id(cwd)
|
||||
branch = resolve_branch(cwd)
|
||||
|
||||
git_root = _git_root(cwd)
|
||||
search_dirs = [cwd]
|
||||
if git_root and os.path.realpath(git_root) != os.path.realpath(cwd):
|
||||
search_dirs.append(git_root)
|
||||
|
||||
log.debug("Auto-import started: cwd=%s git_root=%s project=%s user=%s branch=%s", cwd, git_root or "(none)", project_id, user_id, branch)
|
||||
|
||||
hashes = load_hashes()
|
||||
updated = False
|
||||
|
||||
for filename in TARGET_FILES:
|
||||
filepath = ""
|
||||
for search_dir in search_dirs:
|
||||
candidate = os.path.join(search_dir, filename)
|
||||
if os.path.isfile(candidate):
|
||||
filepath = candidate
|
||||
break
|
||||
|
||||
if not filepath:
|
||||
log.debug("Not found, skipping: %s", filename)
|
||||
continue
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(filepath)
|
||||
except OSError:
|
||||
log.debug("Cannot stat %s, skipping", filename)
|
||||
continue
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
log.debug("Skipping %s: size %d exceeds %d bytes", filename, file_size, MAX_FILE_SIZE)
|
||||
continue
|
||||
|
||||
try:
|
||||
current_hash = sha256_file(filepath)
|
||||
except OSError as e:
|
||||
log.debug("Cannot hash %s: %s", filename, e)
|
||||
continue
|
||||
|
||||
hash_key = f"{project_id}:{filename}"
|
||||
if hashes.get(hash_key) == current_hash:
|
||||
log.debug("Unchanged, skipping: %s", filename)
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(filepath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
log.debug("Cannot read %s: %s", filename, e)
|
||||
continue
|
||||
|
||||
if post_memory(api_key, content, user_id, filename, project_id, branch):
|
||||
hashes[hash_key] = current_hash
|
||||
updated = True
|
||||
# on API failure we don't update the hash — retry next session
|
||||
|
||||
if updated:
|
||||
save_hashes(hashes)
|
||||
else:
|
||||
log.debug("No files imported this run")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: preToolUse (Cursor) — blocks writes to MEMORY.md
|
||||
#
|
||||
# Cursor variant of block_memory_write.sh. Returns JSON:
|
||||
# {"permission":"deny","agent_message":"..."} on block,
|
||||
# {"permission":"allow"} on pass.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
jq -cn '{permission:"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$FILE_PATH" in
|
||||
*/MEMORY.md|*/.claude/memory/*|*/.cursor/memory/*)
|
||||
jq -cn --arg msg "Do not write to $FILE_PATH. Use the mem0 MCP add_memory tool instead to persist memories." \
|
||||
'{permission:"deny", agent_message:$msg}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
jq -cn '{permission:"allow"}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -25,8 +25,7 @@ import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
from _identity import resolve_user_id
|
||||
|
||||
log = logging.getLogger("mem0-compact-summary")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -95,27 +94,23 @@ def find_compact_summary(lines: list[str]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool:
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool:
|
||||
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
metadata = {
|
||||
"type": "compact_summary",
|
||||
"source": "session-start-compact",
|
||||
"session_id": session_id,
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"infer": True,
|
||||
"metadata": {
|
||||
"type": "compact_summary",
|
||||
"source": "session-start-compact",
|
||||
"session_id": session_id,
|
||||
},
|
||||
"infer": False,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -136,7 +131,7 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro
|
||||
|
||||
|
||||
def main():
|
||||
api_key = resolve_api_key()
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
@@ -153,10 +148,7 @@ def main():
|
||||
return
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
cwd = hook_input.get("cwd") or None
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id(cwd)
|
||||
branch = resolve_branch(cwd)
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
@@ -169,7 +161,7 @@ def main():
|
||||
return
|
||||
|
||||
log.info("Capturing compact summary (%d chars)", len(summary))
|
||||
store_summary(api_key, summary, user_id, session_id, project_id, branch)
|
||||
store_summary(api_key, summary, user_id, session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install mem0ai SDK into a persistent venv inside CLAUDE_PLUGIN_DATA.
|
||||
# Runs on SessionStart; skips if requirements.txt hasn't changed.
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
DATA_DIR="${CLAUDE_PLUGIN_DATA:-${HOME}/.mem0/plugin-data}"
|
||||
VENV_DIR="${DATA_DIR}/venv"
|
||||
REQ_SRC="${PLUGIN_ROOT}/requirements.txt"
|
||||
REQ_STAMP="${DATA_DIR}/requirements.txt"
|
||||
|
||||
mkdir -p "${DATA_DIR}"
|
||||
|
||||
needs_install=false
|
||||
|
||||
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
|
||||
needs_install=true
|
||||
elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
|
||||
needs_install=true
|
||||
fi
|
||||
|
||||
if [ "${needs_install}" = "true" ]; then
|
||||
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
|
||||
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
|
||||
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
|
||||
cp "${REQ_SRC}" "${REQ_STAMP}"
|
||||
else
|
||||
rm -f "${REQ_STAMP}"
|
||||
echo "mem0 plugin: failed to install Python dependencies" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
@@ -1,327 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import memories from competing AI tool configuration files into mem0.
|
||||
|
||||
Sub-commands (via sys.argv[1]):
|
||||
cursorrules [--path .cursorrules]
|
||||
copilot [--path .github/copilot-instructions.md]
|
||||
cline [--path memory-bank/]
|
||||
continue [--path .continue/rules.md]
|
||||
|
||||
Each sub-command reads configuration files from competing tools,
|
||||
splits them into chunks, and POSTs each chunk to the mem0 API as a
|
||||
project_profile memory.
|
||||
|
||||
Output: progress messages to stdout, errors to stderr
|
||||
Exit: 0 always
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MIN_CHUNK_CHARS = 50
|
||||
MAX_CHUNK_CHARS = 10_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content splitting utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
|
||||
"""Split content by Markdown header lines (e.g. '## ').
|
||||
|
||||
The header line is included at the start of each chunk.
|
||||
Returns a list of non-empty chunk strings.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines(keepends=True):
|
||||
if line.startswith(header_prefix) and current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
current_lines = [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def split_by_hr_or_headers(content: str) -> list[str]:
|
||||
"""Split content by '---' horizontal rules or '## ' headers.
|
||||
|
||||
Used for .continue/rules.md which may use either convention.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Split on lines that are exactly "---" or start with "## "
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines(keepends=True):
|
||||
is_hr = re.match(r"^---\s*$", line)
|
||||
is_h2 = line.startswith("## ")
|
||||
|
||||
if (is_hr or is_h2) and current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
current_lines = [] if is_hr else [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def filter_and_truncate(chunks: list[str]) -> list[str]:
|
||||
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
|
||||
result: list[str] = []
|
||||
for chunk in chunks:
|
||||
if len(chunk) < MIN_CHUNK_CHARS:
|
||||
continue
|
||||
if len(chunk) > MAX_CHUNK_CHARS:
|
||||
chunk = chunk[:MAX_CHUNK_CHARS]
|
||||
result.append(chunk)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def post_memory(api_key: str, content: str, user_id: str, project_id: str, branch: str, source: str) -> bool:
|
||||
"""POST a single memory chunk to the mem0 API."""
|
||||
metadata: dict = {
|
||||
"type": "project_profile",
|
||||
"source": source,
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"infer": False,
|
||||
}
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return resp.status in (200, 201)
|
||||
except urllib.error.URLError as e:
|
||||
print(f" [warn] API call failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def import_chunks(chunks: list[str], api_key: str, user_id: str, project_id: str, branch: str, source: str) -> int:
|
||||
"""Import a list of content chunks; return number of successful imports."""
|
||||
success = 0
|
||||
for chunk in chunks:
|
||||
if post_memory(api_key, chunk, user_id, project_id, branch, source):
|
||||
success += 1
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-command implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_path_arg(args: list[str], flag: str, default: str) -> str:
|
||||
"""Extract --path <value> from args list, falling back to default."""
|
||||
for i, arg in enumerate(args):
|
||||
if arg == flag and i + 1 < len(args):
|
||||
return args[i + 1]
|
||||
if arg.startswith(f"{flag}="):
|
||||
return arg[len(flag) + 1:]
|
||||
return default
|
||||
|
||||
|
||||
def cmd_cursorrules(args: list[str]) -> None:
|
||||
path = _parse_path_arg(args, "--path", ".cursorrules")
|
||||
source = "cursor-import"
|
||||
|
||||
api_key = resolve_api_key()
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not os.path.isfile(path):
|
||||
print(f"File not found: {path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
|
||||
raw_chunks = split_by_headers(content, "## ")
|
||||
# Fall back to treating the whole file as one chunk if no headers found
|
||||
if not raw_chunks:
|
||||
raw_chunks = [content.strip()] if content.strip() else []
|
||||
|
||||
chunks = filter_and_truncate(raw_chunks)
|
||||
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
|
||||
print(f"Imported {n} memories from {source} ({path})")
|
||||
|
||||
|
||||
def cmd_copilot(args: list[str]) -> None:
|
||||
path = _parse_path_arg(args, "--path", ".github/copilot-instructions.md")
|
||||
source = "copilot-import"
|
||||
|
||||
api_key = resolve_api_key()
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not os.path.isfile(path):
|
||||
print(f"File not found: {path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
|
||||
raw_chunks = split_by_headers(content, "## ")
|
||||
if not raw_chunks:
|
||||
raw_chunks = [content.strip()] if content.strip() else []
|
||||
|
||||
chunks = filter_and_truncate(raw_chunks)
|
||||
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
|
||||
print(f"Imported {n} memories from {source} ({path})")
|
||||
|
||||
|
||||
def cmd_cline(args: list[str]) -> None:
|
||||
dir_path = _parse_path_arg(args, "--path", "memory-bank/")
|
||||
source = "cline-import"
|
||||
|
||||
api_key = resolve_api_key()
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not os.path.isdir(dir_path):
|
||||
print(f"Directory not found: {dir_path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
md_files = sorted(
|
||||
f for f in os.listdir(dir_path) if f.endswith(".md")
|
||||
)
|
||||
if not md_files:
|
||||
print(f"No .md files found in {dir_path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
total = 0
|
||||
for filename in md_files:
|
||||
filepath = os.path.join(dir_path, filename)
|
||||
with open(filepath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read().strip()
|
||||
if not content:
|
||||
continue
|
||||
chunks = filter_and_truncate([content])
|
||||
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
|
||||
total += n
|
||||
|
||||
print(f"Imported {total} memories from {source} ({dir_path})")
|
||||
|
||||
|
||||
def cmd_continue(args: list[str]) -> None:
|
||||
path = _parse_path_arg(args, "--path", ".continue/rules.md")
|
||||
source = "continue-import"
|
||||
|
||||
api_key = resolve_api_key()
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not os.path.isfile(path):
|
||||
print(f"File not found: {path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
|
||||
raw_chunks = split_by_hr_or_headers(content)
|
||||
if not raw_chunks:
|
||||
raw_chunks = [content.strip()] if content.strip() else []
|
||||
|
||||
chunks = filter_and_truncate(raw_chunks)
|
||||
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
|
||||
print(f"Imported {n} memories from {source} ({path})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMMANDS = {
|
||||
"cursorrules": cmd_cursorrules,
|
||||
"copilot": cmd_copilot,
|
||||
"cline": cmd_cline,
|
||||
"continue": cmd_continue,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
||||
available = ", ".join(COMMANDS.keys())
|
||||
print("Usage: import_competing_tools.py <subcommand> [--path <path>]", file=sys.stderr)
|
||||
print(f"Subcommands: {available}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
subcommand = sys.argv[1]
|
||||
remaining_args = sys.argv[2:]
|
||||
COMMANDS[subcommand](remaining_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUse (matcher: Bash)
|
||||
#
|
||||
# Scans bash command output for stack traces and error patterns.
|
||||
# When found, injects a search rubric telling the agent to check mem0
|
||||
# for prior occurrences of the same error.
|
||||
#
|
||||
# This complements on_user_prompt.sh (which catches errors in the user's
|
||||
# typed message). This hook catches errors in COMMAND OUTPUT — e.g.,
|
||||
# when `npm test` or `python script.py` fails with a traceback.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_output
|
||||
# Output: Context injected into Claude's next response (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Skip short output (< 50 chars unlikely to contain a real stack trace)
|
||||
if [ ${#TOOL_RESULT} -lt 50 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip if this is a git commit (handled by on_post_commit.sh)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
case "$COMMAND" in
|
||||
*"git commit"*|*"git merge"*|*"git rebase"*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect stack traces and error patterns in command output
|
||||
HAS_ERROR=""
|
||||
if echo "$TOOL_RESULT" | grep -qiE '(Traceback \(most recent|Error:|Exception:|panic:|FAILED|fatal:|FAIL:| at .+\.[a-z]+:[0-9]+|error\[E[0-9]+\])'; then
|
||||
HAS_ERROR="true"
|
||||
fi
|
||||
|
||||
if [ -z "$HAS_ERROR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract error class/message (first matching line)
|
||||
ERROR_LINE=$(echo "$TOOL_RESULT" | grep -iE '(Error:|Exception:|panic:|FAIL:|fatal:)' | head -1 | sed 's/^[[:space:]]*//' | cut -c1-120)
|
||||
|
||||
# Extract file paths from stack trace frames
|
||||
TRACE_FILES=$(echo "$TOOL_RESULT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh))(:[0-9]+)?' | head -5 | sort -u)
|
||||
|
||||
# Build file list for display
|
||||
FILE_DISPLAY=""
|
||||
if [ -n "$TRACE_FILES" ]; then
|
||||
FILE_DISPLAY=$(echo "$TRACE_FILES" | sed 's/^/ - /')
|
||||
fi
|
||||
|
||||
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## Error detected in command output
|
||||
|
||||
\`$COMMAND\` produced an error:
|
||||
> $ERROR_LINE
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$FILE_DISPLAY" ]; then
|
||||
cat <<EOF
|
||||
**Files in stack trace:**
|
||||
$FILE_DISPLAY
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
Search mem0 for prior occurrences — this error may have been seen before:
|
||||
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
|
||||
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "bug_fix"}}]})\`
|
||||
EOF
|
||||
|
||||
if [ -n "$TRACE_FILES" ]; then
|
||||
FIRST_FILE=$(echo "$TRACE_FILES" | head -1 | sed 's/:[0-9]*//')
|
||||
cat <<EOF
|
||||
- \`search_memories(query="$FIRST_FILE", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
If mem0 returns relevant context, use it to debug faster.
|
||||
If you solve this, store the fix as an \`anti_pattern\` or \`bug_fix\` memory for next time.
|
||||
EOF
|
||||
|
||||
# Telemetry
|
||||
python3 "$SCRIPT_DIR/telemetry.py" bash_error --error_detected 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Bash)
|
||||
#
|
||||
# Detects `git commit` commands and:
|
||||
# 1. Fires on_pre_commit.py in the background to capture staged changes as memory
|
||||
# 2. Searches for relevant memories about the changed files and surfaces them
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input
|
||||
# Output: JSON with additionalContext (relevant memories for the commit)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$COMMAND" in
|
||||
*"git commit"*|*"git merge"*|*"git rebase"*)
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Foreground: search for relevant memories about changed files
|
||||
CHANGED_FILES=$(git diff --cached --name-only 2>/dev/null | head -10 | tr '\n' ', ' | sed 's/,$//')
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
|
||||
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
|
||||
|
||||
CONTEXT=$(python3 -c "
|
||||
import json, urllib.request, os
|
||||
api_key = os.environ.get('MEM0_API_KEY', os.environ.get('CLAUDE_PLUGIN_OPTION_MEM0_API_KEY', ''))
|
||||
user_id = '$USER_ID'
|
||||
app_id = '$PROJECT_ID'
|
||||
files = '$CHANGED_FILES'
|
||||
first_file = files.split(',')[0].strip()
|
||||
body = json.dumps({
|
||||
'query': f'changes to {files}',
|
||||
'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]},
|
||||
'top_k': 3,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v3/memories/search/',
|
||||
data=body,
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
results = json.loads(r.read())
|
||||
memories = results if isinstance(results, list) else results.get('results', [])
|
||||
if memories:
|
||||
lines = ['## Pre-Commit Memory Check', '', 'Relevant memories for files being committed (' + files + '):', '']
|
||||
for m in memories[:3]:
|
||||
mid = m.get('id', '?')[:8]
|
||||
text = m.get('memory', '')[:200]
|
||||
cat = (m.get('metadata') or {}).get('type', 'unknown')
|
||||
lines.append(f'- [{cat}] {text} [mem0:{mid}]')
|
||||
lines.append('')
|
||||
lines.append('Consider: does this commit introduce a learning worth saving? If so, suggest storing it after the commit completes.')
|
||||
print('\\n'.join(lines))
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$CONTEXT" ]; then
|
||||
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUse (matcher: Bash)
|
||||
#
|
||||
# Fires AFTER a Bash tool call completes. When a git commit/merge/rebase
|
||||
# just succeeded, surfaces 1-3 relevant memories from the changed files
|
||||
# and prompts Claude to ask the user if this change should be stored as
|
||||
# a learning.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_output
|
||||
# Output: Context injected into Claude's next response (exit 0)
|
||||
#
|
||||
# This implements Spec #28 — interactive pre-commit memory check.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only trigger on git commit/merge/rebase commands
|
||||
case "$COMMAND" in
|
||||
*"git commit"*|*"git merge"*|*"git rebase"*)
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check if the command actually succeeded (look for commit hash in output)
|
||||
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
|
||||
if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
# No API key — skip interactive prompt
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changed files from commit
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get the commit message
|
||||
COMMIT_MSG=$(git log -1 --format='%s' HEAD 2>/dev/null || echo "")
|
||||
|
||||
# Build file list for display
|
||||
FILE_LIST=$(echo "$CHANGED_FILES" | sed 's/^/ - /' | head -5)
|
||||
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## Commit captured — save as memory?
|
||||
|
||||
**Commit:** $COMMIT_MSG
|
||||
**Files changed ($FILE_COUNT):**
|
||||
$FILE_LIST
|
||||
|
||||
This commit may contain learnings worth preserving. Ask the user:
|
||||
|
||||
> **Save this change as a memory?** I can store it as one of:
|
||||
> - \`decision\` — if an architectural or design choice was made
|
||||
> - \`task_learning\` — if a strategy or approach worked well
|
||||
> - \`anti_pattern\` — if this fixes a bug or avoids a known pitfall
|
||||
> - \`convention\` — if this establishes a new pattern
|
||||
>
|
||||
> Type the category, or **skip** to move on.
|
||||
|
||||
If the user provides a category (or says yes/sure/go ahead):
|
||||
1. Summarize the commit: what changed and why (from the diff + commit message)
|
||||
2. Call \`add_memory\` with:
|
||||
- \`messages=[{"role": "user", "content": "<your summary>"}]\`
|
||||
- \`user_id="$MEM0_RESOLVED_USER_ID"\`
|
||||
- \`app_id="$MEM0_PROJECT_ID"\`
|
||||
- \`metadata={"type": "<chosen_category>", "branch": "$MEM0_BRANCH", "confidence": 0.8, "files": [<changed files>], "source": "post-commit"}\`
|
||||
- \`infer=False\`
|
||||
|
||||
If the user says skip/no/nothing: proceed normally. Do NOT ask again for the same commit.
|
||||
EOF
|
||||
|
||||
# Telemetry
|
||||
python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostCompact (matcher: manual|auto)
|
||||
#
|
||||
# Fires after context compaction completes. Injects a recovery prompt
|
||||
# telling the agent to reload context from mem0.
|
||||
#
|
||||
# Input: JSON on stdin with trigger, messages_retained, messages_removed
|
||||
# Output: Context injected into Claude's post-compaction context (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null || echo "auto")
|
||||
RETAINED=$(echo "$INPUT" | jq -r '.messages_retained // "?"' 2>/dev/null || echo "?")
|
||||
REMOVED=$(echo "$INPUT" | jq -r '.messages_removed // "?"' 2>/dev/null || echo "?")
|
||||
|
||||
# Telemetry (background)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" post_compact --trigger="$TRIGGER" --retained="$RETAINED" --removed="$REMOVED" 2>/dev/null &
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
|
||||
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
|
||||
|
||||
cat <<EOF
|
||||
## Mem0 Post-Compaction Recovery
|
||||
|
||||
Compaction complete ($TRIGGER). $REMOVED messages removed, $RETAINED retained.
|
||||
|
||||
You lost most conversation history. Recover context NOW:
|
||||
|
||||
1. \`search_memories(query="session state current task", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "session_state"}}]})\`
|
||||
2. \`search_memories(query="recent decisions and learnings", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "decision"}}]})\`
|
||||
|
||||
Run both in parallel. Use results to resume work without asking user to repeat context.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUse — track mem0 MCP tool usage for session stats
|
||||
#
|
||||
# Fires after any tool call. We only care about mem0 MCP tools:
|
||||
# mcp__mem0__add_memory → record an add
|
||||
# mcp__mem0__search_memories → record a search
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_output
|
||||
# Output: none (exit 0, non-blocking)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
|
||||
|
||||
case "$TOOL_NAME" in
|
||||
mcp__mem0__add_memory)
|
||||
CATEGORY=$(echo "$INPUT" | jq -r '.tool_input.metadata.type // .tool_input.metadata.category // ""' 2>/dev/null || echo "")
|
||||
python3 "$SCRIPT_DIR/session_stats.py" add "$CATEGORY" 2>/dev/null || true
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=add_memory 2>/dev/null &
|
||||
;;
|
||||
mcp__mem0__search_memories|mcp__mem0__get_memories)
|
||||
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=search_memories 2>/dev/null &
|
||||
;;
|
||||
mcp__mem0__delete_memory)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=delete_memory 2>/dev/null &
|
||||
;;
|
||||
mcp__mem0__update_memory)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=update_memory 2>/dev/null &
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: postToolUse (Cursor) — track mem0 MCP tool usage for session stats
|
||||
#
|
||||
# Wraps on_post_tool_use.sh. Cursor expects JSON output but PostToolUse
|
||||
# has no meaningful return value, so we output {} after tracking.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Run the shared tracker (output is ignored)
|
||||
"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true
|
||||
|
||||
echo '{}'
|
||||
exit 0
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit memory capture.
|
||||
|
||||
Captures a summary of staged changes as a mem0 memory before each commit.
|
||||
Runs as a background fire-and-forget call — never blocks the commit.
|
||||
|
||||
Usage:
|
||||
git diff --cached --stat | python3 on_pre_commit.py
|
||||
# or with full diff:
|
||||
git diff --cached | python3 on_pre_commit.py --full
|
||||
|
||||
Env vars required: MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
|
||||
Env vars optional: MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, MEM0_BRANCH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
|
||||
try:
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
except ImportError:
|
||||
def resolve_project_id() -> str:
|
||||
return os.path.basename(os.getcwd())
|
||||
|
||||
def resolve_branch() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Read the current commit message from COMMIT_EDITMSG (pre-commit context).
|
||||
|
||||
Falls back to HEAD's message if COMMIT_EDITMSG doesn't exist yet
|
||||
(e.g., when invoked outside the git hook context).
|
||||
"""
|
||||
try:
|
||||
git_dir = subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if git_dir.returncode == 0:
|
||||
editmsg = os.path.join(git_dir.stdout.strip(), "COMMIT_EDITMSG")
|
||||
if os.path.isfile(editmsg):
|
||||
with open(editmsg) as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line and not first_line.startswith("#"):
|
||||
return first_line
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_staged_summary() -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--stat"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
return 0
|
||||
|
||||
diff_input = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
|
||||
staged = diff_input or get_staged_summary()
|
||||
if not staged or len(staged) < 10:
|
||||
return 0
|
||||
|
||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or resolve_user_id()
|
||||
project_id = os.environ.get("MEM0_PROJECT_ID") or resolve_project_id()
|
||||
branch = os.environ.get("MEM0_BRANCH") or resolve_branch()
|
||||
commit_msg = get_commit_message()
|
||||
|
||||
lines = staged.splitlines()
|
||||
if len(lines) > 30:
|
||||
staged = "\n".join(lines[:30]) + f"\n... ({len(lines) - 30} more lines)"
|
||||
|
||||
content = f"## Commit Context\n\nBranch: {branch}\n"
|
||||
if commit_msg:
|
||||
content += f"Message: {commit_msg}\n"
|
||||
content += f"\n### Staged Changes\n```\n{staged}\n```"
|
||||
|
||||
body = json.dumps({
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": {
|
||||
"type": "commit_context",
|
||||
"branch": branch,
|
||||
"source": "pre-commit",
|
||||
},
|
||||
"infer": False,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.mem0.ai/v3/memories/add/",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -23,8 +23,7 @@ import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
from _identity import resolve_user_id
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -137,49 +136,56 @@ def parse_transcript(lines: list[str]) -> dict:
|
||||
|
||||
|
||||
def build_content(state: dict, source: str) -> str:
|
||||
"""Build minimal context — only what's needed to resume work.
|
||||
|
||||
This is a FALLBACK safety net, not the primary capture path.
|
||||
The agent handles rich memory storage via on_pre_compact.sh prompts.
|
||||
This script only fires when the agent didn't store enough on its own.
|
||||
|
||||
Keep it short — mem0 infer=True will extract structured facts.
|
||||
"""
|
||||
parts = []
|
||||
"""Build structured markdown from parsed state."""
|
||||
parts = [f"## Session State ({source})\n"]
|
||||
|
||||
if state["user_messages"]:
|
||||
parts.append(f"Working on: {state['user_messages'][-1][:300]}")
|
||||
parts.append("### What the user was working on")
|
||||
for msg in state["user_messages"]:
|
||||
truncated = msg[:5000] + "..." if len(msg) > 5000 else msg
|
||||
parts.append(f"- {truncated}")
|
||||
parts.append("")
|
||||
|
||||
if state["files_modified"]:
|
||||
parts.append(f"Files touched: {', '.join(state['files_modified'][:15])}")
|
||||
parts.append("### Files modified this session")
|
||||
for fp in state["files_modified"]:
|
||||
parts.append(f"- `{fp}`")
|
||||
parts.append("")
|
||||
|
||||
if state["bash_commands"]:
|
||||
parts.append("### Recent commands")
|
||||
for cmd in state["bash_commands"]:
|
||||
truncated = cmd[:1000] + "..." if len(cmd) > 1000 else cmd
|
||||
parts.append(f"- `{truncated}`")
|
||||
parts.append("")
|
||||
|
||||
if state["last_assistant_text"]:
|
||||
parts.append("### Last context")
|
||||
parts.append(state["last_assistant_text"])
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "", project_id: str = "", branch: str = "") -> bool:
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
|
||||
"""Store session state as a memory via the Mem0 REST API."""
|
||||
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
||||
metadata = {
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
"session_id": session_id,
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
body = {
|
||||
"messages": [
|
||||
{"role": "user", "content": content}
|
||||
],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"metadata": {
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
"session_id": session_id,
|
||||
},
|
||||
"expiration_date": expires,
|
||||
"infer": True,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -206,7 +212,7 @@ def main():
|
||||
if arg.startswith("--source="):
|
||||
source = arg.split("=", 1)[1]
|
||||
|
||||
api_key = resolve_api_key()
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
@@ -223,22 +229,7 @@ def main():
|
||||
return
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
cwd = hook_input.get("cwd") or None
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id(cwd)
|
||||
branch = resolve_branch(cwd)
|
||||
|
||||
# Skip if agent already stored memories this session — avoid duplicate writes.
|
||||
# This script is a fallback, not the primary capture path.
|
||||
stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
|
||||
try:
|
||||
with open(stats_file) as f:
|
||||
stats = json.load(f)
|
||||
if stats.get("adds", 0) >= 2:
|
||||
log.info("Agent stored %d memories this session — skipping fallback capture", stats["adds"])
|
||||
return
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass # no stats = agent didn't store anything, proceed with fallback
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
@@ -251,13 +242,15 @@ def main():
|
||||
return
|
||||
|
||||
content = build_content(state, source)
|
||||
if not content.strip():
|
||||
log.debug("No content to store")
|
||||
return
|
||||
|
||||
log.info("Fallback capture: %d files modified", len(state["files_modified"]))
|
||||
log.info(
|
||||
"Capturing session state: %d user msgs, %d files, %d commands",
|
||||
len(state["user_messages"]),
|
||||
len(state["files_modified"]),
|
||||
len(state["bash_commands"]),
|
||||
)
|
||||
|
||||
store_memory(api_key, content, user_id, source, session_id, project_id, branch)
|
||||
store_memory(api_key, content, user_id, source, session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,50 +15,63 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INPUT=$(cat)
|
||||
|
||||
python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null &
|
||||
|
||||
cat <<'EOF'
|
||||
## Pre-Compaction: Extract and store durable facts
|
||||
## CRITICAL: Pre-Compaction Session Summary
|
||||
|
||||
Context compaction is about to happen. Review the conversation and store only facts that would help a future agent with ZERO context.
|
||||
Context compaction is about to happen. You are about to lose most of your conversation history. You MUST store a comprehensive session summary NOW using the mem0 `add_memory` tool.
|
||||
|
||||
### What to store
|
||||
### Step 1: Store session summary
|
||||
|
||||
For each fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If no, skip it. Most sessions produce 0-3 facts worth storing.
|
||||
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
|
||||
|
||||
Store each fact as a SEPARATE `add_memory` call. One fact per call. 15-50 words each. Third person. Include file paths when relevant.
|
||||
|
||||
Categories and when to use them:
|
||||
- `decision` — architectural choices, trade-offs made ("Chose PostgreSQL over MongoDB for auth because of ACID requirements")
|
||||
- `task_learning` — patterns that worked ("Running migrations before seed in this repo avoids FK violations")
|
||||
- `anti_pattern` — approaches that failed ("Don't use batch insert for users table — triggers deadlock with audit log")
|
||||
- `convention` — coding standards discovered ("This repo uses snake_case for all Python files, camelCase for TS")
|
||||
- `user_preference` — how the user likes to work ("User prefers short PRs, one feature per branch")
|
||||
|
||||
### What NOT to store
|
||||
|
||||
- Session summaries or "what we did today" blobs
|
||||
- Raw file lists or command histories
|
||||
- Anything already stored in a prior `add_memory` this session
|
||||
- One-time information that won't recur
|
||||
- Transient state ("currently debugging X")
|
||||
|
||||
### How to store
|
||||
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
|
||||
|
||||
```
|
||||
## Session Summary (Pre-Compaction)
|
||||
|
||||
### User's Goal
|
||||
[What the user originally asked for and their intent]
|
||||
|
||||
### What Was Accomplished
|
||||
[Numbered list of tasks completed, features built, bugs fixed]
|
||||
|
||||
### Key Decisions Made
|
||||
[Architectural choices, design decisions, trade-offs discussed]
|
||||
|
||||
### Files Created or Modified
|
||||
[List of important file paths with what changed in each]
|
||||
|
||||
### Current State
|
||||
[What is in progress RIGHT NOW — the task you were in the middle of]
|
||||
[Any pending items, blockers, or next steps]
|
||||
|
||||
### Important Context
|
||||
[User preferences observed, coding patterns, anything that would help
|
||||
the post-compaction agent continue without asking redundant questions]
|
||||
```
|
||||
|
||||
Tool call shape:
|
||||
```
|
||||
add_memory(
|
||||
messages=[{"role":"user","content":"<one fact, 15-50 words>"}],
|
||||
user_id="<active user_id>",
|
||||
app_id="<active project_id>",
|
||||
metadata={"type":"<category>","branch":"<active branch>","confidence":0.8},
|
||||
messages=[{"role":"user","content":"<the summary above>"}],
|
||||
user_id="<the active user_id from the SessionStart bootstrap>",
|
||||
metadata={"type":"session_state","source":"pre-compaction"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
If nothing durable happened this session, store nothing. That is correct.
|
||||
### Step 2: Store any unstored learnings
|
||||
|
||||
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
|
||||
- Failed approaches -> metadata `{"type": "anti_pattern"}`
|
||||
- Successful strategies -> metadata `{"type": "task_learning"}`
|
||||
- Architecture decisions -> metadata `{"type": "decision"}`
|
||||
|
||||
### Step 3: Acknowledge
|
||||
|
||||
After storing, briefly tell the user that session state has been saved and you're ready for compaction.
|
||||
|
||||
Do this NOW. Do not skip any section. The quality of this summary directly determines whether you can continue the user's task after compaction.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: preCompact (Cursor)
|
||||
#
|
||||
# Wraps on_pre_compact.sh and converts plain-text output to Cursor's
|
||||
# expected JSON format: {"user_message":"<text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TEXT" ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg msg "$TEXT" '{user_message:$msg}'
|
||||
exit 0
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: SessionEnd
|
||||
#
|
||||
# Fires when session actually terminates (after Stop).
|
||||
# Last-chance capture: if on_stop.sh background REST call didn't complete,
|
||||
# this fires a synchronous capture attempt.
|
||||
#
|
||||
# Input: JSON on stdin with session_id, transcript_path, cwd, reason
|
||||
# Output: ignored (session is ending)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Print session-end report (last chance — Stop hook output may not render on /exit)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
if [ -n "$REPORT" ] && [ "$REPORT" != "Session: no memory operations." ]; then
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "mem0 $REPORT"
|
||||
echo "---"
|
||||
|
||||
# Append to persistent session log
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Telemetry (fire-and-forget — session dying, best-effort)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null &
|
||||
|
||||
# Clean up old capture markers (> 7 days)
|
||||
find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
@@ -15,114 +15,32 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
# Skip the bootstrap entirely if no API key is configured -- the agent
|
||||
# would otherwise be told to call mem0 MCP tools that will all fail.
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
# Initialize session stats tracker
|
||||
python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
# Skip bootstrap if no API key — still show banner but with "no key" status.
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
echo "## Mem0 Inactive"
|
||||
echo ""
|
||||
echo "\`user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET\`"
|
||||
echo ""
|
||||
echo "IMPORTANT: In your FIRST response to the user, you MUST display the status banner exactly as shown below (copy-paste it as your opening line before any other output):"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "Mem0 Inactive | user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Set MEM0_API_KEY to enable persistent memory. Get a key at https://app.mem0.ai/dashboard/api-keys"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
|
||||
MEM0_COUNT="?"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
MEM0_COUNT=$(python3 -c "
|
||||
import json, os, urllib.request, urllib.error
|
||||
api_key = os.environ.get('MEM0_API_KEY', '')
|
||||
user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default')
|
||||
app_id = os.environ.get('MEM0_PROJECT_ID', '')
|
||||
body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
data=body,
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
data = json.loads(r.read())
|
||||
if isinstance(data, dict) and 'count' in data:
|
||||
print(data['count'])
|
||||
elif isinstance(data, list):
|
||||
print(len(data))
|
||||
else:
|
||||
print('?')
|
||||
except Exception:
|
||||
print('?')
|
||||
" 2>/dev/null || echo "?")
|
||||
fi
|
||||
|
||||
# Identity line is emitted before every bootstrap variant so the agent
|
||||
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||
# than what the hooks write to.
|
||||
echo "## Mem0 Active"
|
||||
echo "## Mem0 Identity"
|
||||
echo ""
|
||||
echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`"
|
||||
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo ""
|
||||
echo "IMPORTANT: In your FIRST response to the user, you MUST display the identity banner exactly as shown below (copy-paste it as your opening line before any other output):"
|
||||
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "Mem0 Active | user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:"
|
||||
echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo "- app_id: \`$MEM0_PROJECT_ID\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
|
||||
echo ""
|
||||
|
||||
# Load mem0.md project config if present (best-effort, non-blocking)
|
||||
MEM0_PROJECT_CONFIG=""
|
||||
MEM0_CWD_RESOLVED=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
|
||||
fi
|
||||
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
|
||||
echo "### Project Config (mem0.md)"
|
||||
echo "\`\`\`json"
|
||||
echo "$MEM0_PROJECT_CONFIG"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
# First-run detection: auto-trigger onboarding for new projects
|
||||
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
|
||||
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
|
||||
if [ ! -f "$_ONBOARD_MARKER" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 First Run — Automatic Onboarding
|
||||
|
||||
This is the first time mem0 is running for this project. You MUST run the onboarding wizard NOW before doing anything else.
|
||||
|
||||
Execute the `/mem0:onboard` skill immediately. This will:
|
||||
1. Verify the API key
|
||||
2. Detect and import project files (CLAUDE.md, AGENTS.md, .cursorrules, etc.)
|
||||
3. Install coding categories optimized for development
|
||||
4. Show a summary of what was set up
|
||||
|
||||
Do NOT skip this. Do NOT ask the user if they want to onboard. Just run `/mem0:onboard` now.
|
||||
EOF
|
||||
else
|
||||
cat <<'EOF'
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Bootstrap
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Before doing anything else:
|
||||
@@ -133,11 +51,6 @@ You have access to persistent memory via the mem0 MCP tools. Before doing anythi
|
||||
|
||||
IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Auto-import declarative project files in background
|
||||
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
|
||||
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
||||
|
||||
elif [ "$SOURCE" = "resume" ]; then
|
||||
cat <<'EOF'
|
||||
@@ -152,19 +65,23 @@ Continue where you left off.
|
||||
EOF
|
||||
|
||||
elif [ "$SOURCE" = "compact" ]; then
|
||||
# Capture the just-generated compact summary in the background.
|
||||
# PreCompact fires too early to see this entry; SessionStart-compact
|
||||
# is the first place isCompactSummary=true is in the transcript.
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
|
||||
|
||||
cat <<'EOF'
|
||||
## Mem0 Post-Compaction Recovery
|
||||
|
||||
Context was just compacted. Reload your context from mem0.
|
||||
Context was just compacted. The Claude Code-generated compact summary
|
||||
is being captured to mem0 in the background as `metadata.type=compact_summary`.
|
||||
|
||||
1. Call `search_memories` to reload context, layering up to three angles:
|
||||
- `metadata.type=session_state` -- the pre-compaction summary you wrote before compaction
|
||||
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
|
||||
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
|
||||
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
|
||||
2. Continue working from the recovered context.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: sessionStart (Cursor)
|
||||
#
|
||||
# Wraps on_session_start.sh and converts plain-text output to Cursor's
|
||||
# expected JSON format: {"additional_context":"<text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TEXT" ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg ctx "$TEXT" '{additional_context:$ctx}'
|
||||
exit 0
|
||||
@@ -10,8 +10,7 @@
|
||||
#
|
||||
# IMPORTANT: Check stop_hook_active to avoid infinite loops.
|
||||
|
||||
# Intentionally omit -e so the reminder always emits even if session_stats fails.
|
||||
set -uo pipefail
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
@@ -26,26 +25,6 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Telemetry: fire before report() deletes stats file
|
||||
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
|
||||
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
|
||||
|
||||
# Print session-end report
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
if [ -n "$REPORT" ]; then
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "**mem0 $REPORT**"
|
||||
echo "---"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Append to persistent session log
|
||||
if [ -n "$REPORT" ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
||||
|
||||
@@ -58,10 +37,9 @@ Before finishing, check if there are important learnings from this interaction t
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
|
||||
Always include `app_id` (the active project_id from SessionStart) as a top-level parameter in every `add_memory` call.
|
||||
EOF
|
||||
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -21,10 +21,6 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
|
||||
@@ -33,39 +29,20 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Telemetry: fire before report() deletes stats file
|
||||
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
|
||||
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
|
||||
REASON=$(cat <<'EOF'
|
||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
||||
|
||||
# Session-end report (best-effort, must not break JSON output)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
REPORT_BLOCK=""
|
||||
if [ -n "$REPORT" ]; then
|
||||
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
REASON=$(cat <<EOF
|
||||
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
|
||||
1. Were any significant decisions made? -> Store with metadata `{"type": "decision"}`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata `{"type": "task_learning"}`
|
||||
3. Did any approach fail? -> Store with metadata `{"type": "anti_pattern"}`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata `{"type": "user_preference"}`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata `{"type": "environmental"}`
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
Always include \`app_id\` (the active project_id from SessionStart) as a top-level parameter in every \`add_memory\` call.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
)
|
||||
|
||||
jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}'
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop (Cursor)
|
||||
#
|
||||
# Fires when Cursor agent completes a turn. Wraps the same logic as
|
||||
# on_stop.sh but outputs JSON (Cursor expects {"followup_message":"..."}).
|
||||
#
|
||||
# Input: JSON on stdin with status, loop_count, conversation_id, etc.
|
||||
# Output: JSON on stdout: {"followup_message":"<reminder text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# Guard against infinite loops: if this is a re-entry after a prior followup,
|
||||
# let the turn end. Cursor exposes loop_count in the input JSON.
|
||||
LOOP_COUNT=$(echo "$INPUT" | jq -r '.loop_count // 0' 2>/dev/null || echo "0")
|
||||
if [ "$LOOP_COUNT" -gt 1 ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Telemetry: fire before report() deletes stats file
|
||||
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
|
||||
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
|
||||
|
||||
# Session-end report (best-effort)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
REPORT_BLOCK=""
|
||||
if [ -n "$REPORT" ]; then
|
||||
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
MESSAGE=$(cat <<EOF
|
||||
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
|
||||
|
||||
Always include \`app_id\` (the active project_id from session start) as a top-level parameter in every \`add_memory\` call.
|
||||
|
||||
If nothing notable happened, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
)
|
||||
|
||||
jq -cn --arg msg "$MESSAGE" '{followup_message:$msg}'
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: SubagentStop
|
||||
#
|
||||
# Fires when a subagent finishes. Injects a reminder to capture any
|
||||
# learnings the subagent produced that the parent agent should store.
|
||||
#
|
||||
# Input: JSON on stdin with agent_type, result_summary
|
||||
# Output: Context injected into parent agent's context (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""' 2>/dev/null || echo "")
|
||||
RESULT_SUMMARY=$(echo "$INPUT" | jq -r '.result_summary // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Skip short/empty results — nothing worth capturing
|
||||
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip explorer/plan agents — read-only, rarely produce storable learnings
|
||||
case "$AGENT_TYPE" in
|
||||
Explore|Plan)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## Subagent completed: $AGENT_TYPE
|
||||
|
||||
Review the subagent result for learnings worth persisting to mem0.
|
||||
If the subagent discovered something reusable (a fix, pattern, decision, or anti-pattern),
|
||||
store it via \`add_memory\` with appropriate metadata type.
|
||||
|
||||
Only store if genuinely valuable — skip trivial subagent results.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -7,21 +7,15 @@
|
||||
# Input: JSON on stdin with task_id, task_subject, task_description
|
||||
# Output: Text that becomes feedback to the model (exit 0)
|
||||
|
||||
# Intentionally omit -e so the reminder always emits even if identity resolution fails.
|
||||
set -uo pipefail
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||
|
||||
_PROJECT="${MEM0_PROJECT_ID:-unknown}"
|
||||
|
||||
cat <<EOF
|
||||
Task completed: "$TASK_SUBJECT"
|
||||
|
||||
@@ -34,10 +28,6 @@ Extract key learnings from this completed task and store them using the mem0 \`a
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, and examples.
|
||||
Only store genuinely useful learnings — skip if the task was trivial.
|
||||
Include \`app_id\` = \`"$_PROJECT"\` as a top-level parameter in every \`add_memory\` call (not in metadata).
|
||||
EOF
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUseFailure (matcher: mcp__mem0__)
|
||||
#
|
||||
# Fires when a mem0 MCP tool call fails. Logs the failure, bumps telemetry,
|
||||
# and injects a retry hint so Claude can recover.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_error
|
||||
# Output: Context injected into Claude's next response (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
|
||||
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_error // ""' 2>/dev/null || echo "")
|
||||
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}")
|
||||
|
||||
# Extract the short tool name (strip mcp__mem0__ prefix)
|
||||
SHORT_NAME="${TOOL_NAME#mcp__mem0__}"
|
||||
|
||||
# Log failure to persistent file for debugging
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAIL $TOOL_NAME: $TOOL_RESULT" >> "$HOME/.mem0/tool-failures.log" 2>/dev/null || true
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_failure --tool="$SHORT_NAME" 2>/dev/null &
|
||||
|
||||
# Classify the error
|
||||
IS_AUTH_ERROR=""
|
||||
IS_RATE_LIMIT=""
|
||||
IS_NETWORK_ERROR=""
|
||||
|
||||
if echo "$TOOL_RESULT" | grep -qiE '(401|403|unauthorized|forbidden|invalid.*token|invalid.*key)'; then
|
||||
IS_AUTH_ERROR="true"
|
||||
elif echo "$TOOL_RESULT" | grep -qiE '(429|rate.?limit|too many requests|quota)'; then
|
||||
IS_RATE_LIMIT="true"
|
||||
elif echo "$TOOL_RESULT" | grep -qiE '(timeout|connect|ECONNREFUSED|network|DNS|resolve)'; then
|
||||
IS_NETWORK_ERROR="true"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## mem0 tool failure: \`$SHORT_NAME\`
|
||||
|
||||
**Error:** $TOOL_RESULT
|
||||
**Input:** \`$TOOL_INPUT\`
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$IS_AUTH_ERROR" ]; then
|
||||
cat <<'EOF'
|
||||
**Cause:** Authentication failure. MEM0_API_KEY may be invalid or expired.
|
||||
**Action:** Tell the user their API key needs to be checked. Get a new key at https://app.mem0.ai/dashboard/api-keys
|
||||
Do NOT retry — it will fail again with the same key.
|
||||
EOF
|
||||
elif [ -n "$IS_RATE_LIMIT" ]; then
|
||||
cat <<'EOF'
|
||||
**Cause:** Rate limit hit.
|
||||
**Action:** Wait a few seconds, then retry the same call. If it fails again, reduce the number of parallel mem0 calls.
|
||||
EOF
|
||||
elif [ -n "$IS_NETWORK_ERROR" ]; then
|
||||
cat <<'EOF'
|
||||
**Cause:** Network connectivity issue reaching mem0 API.
|
||||
**Action:** Retry once. If it fails again, inform the user and continue without memory context.
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
**Action:** Retry the \`$SHORT_NAME\` call once. If it fails again, continue without memory context and inform the user.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -25,35 +25,14 @@ if [ ${#PROMPT} -lt 20 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No API key means the agent can't search anyway
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
# Detect stack traces and error patterns in the prompt (no API needed)
|
||||
HAS_ERROR=""
|
||||
if echo "$PROMPT" | grep -qiE '(Traceback|Error:|Exception:|panic:|FAILED|fatal:| at .+\.[a-z]+:[0-9]+)'; then
|
||||
HAS_ERROR="true"
|
||||
fi
|
||||
|
||||
# Detect file paths in the prompt (no API needed)
|
||||
FILE_PATHS=$(echo "$PROMPT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html))\b' 2>/dev/null | head -5 || echo "")
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
_TELEM_ARGS=""
|
||||
[ -n "$HAS_ERROR" ] && _TELEM_ARGS="$_TELEM_ARGS --error_detected"
|
||||
[ -n "$FILE_PATHS" ] && _TELEM_ARGS="$_TELEM_ARGS --file_paths_detected"
|
||||
python3 "$SCRIPT_DIR/telemetry.py" user_prompt $_TELEM_ARGS 2>/dev/null &
|
||||
|
||||
# No API key — emit detections only, skip search rubric
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
if [ -n "$HAS_ERROR" ]; then
|
||||
echo "**ERROR DETECTED in prompt.** Set MEM0_API_KEY to search past debugging context."
|
||||
fi
|
||||
if [ -n "$FILE_PATHS" ]; then
|
||||
echo "**FILE PATHS detected:** \`$FILE_PATHS\`"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||
|
||||
cat <<EOF
|
||||
@@ -74,30 +53,6 @@ improve your answer. The agent -- not this hook -- owns this decision.
|
||||
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
|
||||
- it's a pure syntax / factual question answerable from general knowledge
|
||||
- you already searched this scope earlier in the turn
|
||||
EOF
|
||||
|
||||
if [ -n "$HAS_ERROR" ]; then
|
||||
cat <<EOF
|
||||
|
||||
**ERROR DETECTED in prompt.** You SHOULD search mem0 for prior occurrences:
|
||||
- \`search_memories(query="<error class or message>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
|
||||
- \`search_memories(query="<module or file from stack trace>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "task_learning"}}]})\`
|
||||
This surfaces past debugging context and known failure modes.
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -n "$FILE_PATHS" ]; then
|
||||
cat <<EOF
|
||||
|
||||
**FILE PATHS detected:** \`$FILE_PATHS\`
|
||||
Search mem0 for context about these files using the \`contains\` operator on \`metadata.files\`:
|
||||
- \`search_memories(query="<filename>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata.files": {"contains": "<filename>"}}]})\`
|
||||
- Also run a broader text search without the files filter as fallback:
|
||||
- \`search_memories(query="<filename without extension>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
**If searching, do it well:**
|
||||
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one
|
||||
@@ -105,13 +60,12 @@ cat <<EOF
|
||||
- Phrase queries as **nouns** ("auth module decisions"), not full sentences.
|
||||
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
|
||||
with an array, and metadata uses a **nested** object (not dotted keys).
|
||||
Combine \`user_id\` + \`app_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]}\` when no metadata filter fits.
|
||||
- **Recency boost:** For state-related queries ("where were we", "current task", "latest"), add a \`created_at\` filter: \`{"created_at": {"gte": "<90 days ago YYYY-MM-DD>"}}\`. Skip recency for durable facts (conventions, decisions).
|
||||
Combine \`user_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}]}\` when no metadata filter fits.
|
||||
- Empty results are normal -- proceed without context.
|
||||
EOF
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: beforeSubmitPrompt (Cursor)
|
||||
#
|
||||
# Wraps on_user_prompt.sh and converts plain-text output to Cursor's
|
||||
# expected JSON format: {"continue":true,"user_message":"<text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TEXT" ]; then
|
||||
jq -cn '{continue:true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg msg "$TEXT" '{continue:true, user_message:$msg}'
|
||||
exit 0
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse a mem0 export file and output JSON.
|
||||
|
||||
Input: path to a mem0-export-*.md file (sys.argv[1])
|
||||
Output: JSON array of memory records to stdout
|
||||
Exit: 0 always
|
||||
|
||||
Each block in the file is delimited by lines containing exactly "---".
|
||||
Blocks have a YAML-like frontmatter section (key: value lines) followed
|
||||
by a blank line and the memory content text.
|
||||
|
||||
Example block format:
|
||||
---
|
||||
id: abc123
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
type: task_learnings
|
||||
confidence: 0.9
|
||||
branch: main
|
||||
files: src/foo.py, src/bar.py
|
||||
categories: coding_conventions, task_learnings
|
||||
---
|
||||
The actual memory content text goes here.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def parse_blocks(content: str) -> list[dict]:
|
||||
"""Split content on '---' boundaries and parse each block.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
id, type, confidence, branch, files (list), categories (list), content (str)
|
||||
|
||||
Blocks with empty content are skipped.
|
||||
Missing optional fields default to "" (scalar) or [] (list fields).
|
||||
"""
|
||||
# Normalise line endings
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Split on lines that are exactly "---"
|
||||
raw_blocks = re.split(r"(?m)^---\s*$", content)
|
||||
|
||||
# After splitting on "---", the structure for each memory is:
|
||||
# raw_blocks[0] = preamble (before first ---, typically empty)
|
||||
# raw_blocks[1] = frontmatter for block 1
|
||||
# raw_blocks[2] = content for block 1
|
||||
# raw_blocks[3] = frontmatter for block 2
|
||||
# raw_blocks[4] = content for block 2
|
||||
# ...
|
||||
# So frontmatter blocks are at odd indices (1, 3, 5, ...) and
|
||||
# content blocks at even indices (2, 4, 6, ...).
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
# Pair up frontmatter + content starting at index 1
|
||||
i = 1
|
||||
while i < len(raw_blocks):
|
||||
frontmatter_raw = raw_blocks[i]
|
||||
content_raw = raw_blocks[i + 1] if i + 1 < len(raw_blocks) else ""
|
||||
|
||||
# Parse the frontmatter key-value pairs
|
||||
fm = _parse_frontmatter(frontmatter_raw)
|
||||
|
||||
# Strip leading/trailing whitespace from content
|
||||
memory_content = content_raw.strip()
|
||||
|
||||
# Skip blocks with empty content
|
||||
if not memory_content:
|
||||
i += 2
|
||||
continue
|
||||
|
||||
record = {
|
||||
"id": fm.get("id", ""),
|
||||
"type": fm.get("type", ""),
|
||||
"confidence": fm.get("confidence", ""),
|
||||
"branch": fm.get("branch", ""),
|
||||
"files": _parse_list_field(fm.get("files", "")),
|
||||
"categories": _parse_list_field(fm.get("categories", "")),
|
||||
"content": memory_content,
|
||||
}
|
||||
|
||||
# Include created_at if present
|
||||
if "created_at" in fm:
|
||||
record["created_at"] = fm["created_at"]
|
||||
|
||||
results.append(record)
|
||||
i += 2
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict[str, str]:
|
||||
"""Parse simple 'key: value' lines from frontmatter text.
|
||||
|
||||
Only the first colon is used as the delimiter — values may contain colons.
|
||||
Lines not matching 'key: value' are ignored.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
|
||||
if match:
|
||||
key = match.group(1).strip()
|
||||
value = match.group(2).strip()
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_list_field(value: str) -> list[str]:
|
||||
"""Split a comma-separated value into a list, stripping whitespace.
|
||||
|
||||
Returns [] for empty/whitespace-only input.
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: parse_export_file.py <path-to-export-file>", file=sys.stderr)
|
||||
print("[]")
|
||||
sys.exit(0)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
try:
|
||||
if filepath == "-":
|
||||
content = sys.stdin.read()
|
||||
else:
|
||||
with open(filepath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
print(f"Error reading file: {e}", file=sys.stderr)
|
||||
print("[]")
|
||||
sys.exit(0)
|
||||
|
||||
records = parse_blocks(content)
|
||||
print(json.dumps(records, ensure_ascii=False, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse mem0.md project configuration file.
|
||||
|
||||
Reads the optional ``mem0.md`` file in a project directory and extracts
|
||||
retention policies from a ``## Retention`` section.
|
||||
|
||||
Retention format (inside the section):
|
||||
<category>: <N>d — keep for N days
|
||||
<category>: forever — never prune (returned as None)
|
||||
|
||||
Usage (CLI):
|
||||
python3 parse_mem0_config.py [<cwd>]
|
||||
|
||||
Prints a JSON object mapping category names to day counts (int) or null
|
||||
(forever) on stdout. Prints ``{}`` when no mem0.md or no ## Retention
|
||||
section is found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def find_mem0_config(cwd: str) -> str | None:
|
||||
"""Look for ``mem0.md`` in *cwd*.
|
||||
|
||||
Returns the absolute path to ``mem0.md`` if found, else ``None``.
|
||||
"""
|
||||
candidate = os.path.join(cwd, "mem0.md")
|
||||
return candidate if os.path.isfile(candidate) else None
|
||||
|
||||
|
||||
def parse_retention(content: str) -> dict[str, int | None]:
|
||||
"""Parse the ``## Retention`` section of *content*.
|
||||
|
||||
Scans for a heading that matches ``## Retention`` (case-insensitive),
|
||||
then reads lines until the next ``##``-level heading or end of string.
|
||||
|
||||
Each non-blank, non-comment line inside the section is expected to be::
|
||||
|
||||
<category>: <N>d → days=N (int)
|
||||
<category>: forever → days=None
|
||||
|
||||
Malformed lines are silently skipped.
|
||||
|
||||
Args:
|
||||
content: Full text of a mem0.md file.
|
||||
|
||||
Returns:
|
||||
Dict mapping category name (str) to day count (int) or ``None``
|
||||
(forever). Empty dict when no ``## Retention`` section is found.
|
||||
"""
|
||||
# Find the ## Retention section (allow any amount of trailing whitespace /
|
||||
# extra words, but the heading must start with "## Retention").
|
||||
section_match = re.search(
|
||||
r"^##\s+Retention[^\n]*\n(.*?)(?=^##\s|\Z)",
|
||||
content,
|
||||
flags=re.MULTILINE | re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not section_match:
|
||||
return {}
|
||||
|
||||
section_text = section_match.group(1)
|
||||
policies: dict[str, int | None] = {}
|
||||
|
||||
for line in section_text.splitlines():
|
||||
# Strip comments and whitespace
|
||||
line = re.sub(r"#.*$", "", line).strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Match "<category>: <value>"
|
||||
line_match = re.match(r"^([^:]+):\s*(.+)$", line)
|
||||
if not line_match:
|
||||
continue
|
||||
|
||||
category = line_match.group(1).strip()
|
||||
value = line_match.group(2).strip().lower()
|
||||
|
||||
if value == "forever":
|
||||
policies[category] = None
|
||||
else:
|
||||
days_match = re.match(r"^(\d+)d$", value)
|
||||
if days_match:
|
||||
policies[category] = int(days_match.group(1))
|
||||
# else: malformed value — skip silently
|
||||
|
||||
return policies
|
||||
|
||||
|
||||
def parse_section_kv(content: str, heading: str) -> dict[str, str]:
|
||||
"""Parse a key-value section from mem0.md.
|
||||
|
||||
Looks for ``## <heading>`` (case-insensitive) and reads ``key: value``
|
||||
lines until the next ``##``-level heading or end of string.
|
||||
"""
|
||||
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
|
||||
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
||||
if not match:
|
||||
return {}
|
||||
|
||||
result: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines():
|
||||
line = re.sub(r"#.*$", "", line).strip()
|
||||
if not line:
|
||||
continue
|
||||
m = re.match(r"^([^:]+):\s*(.+)$", line)
|
||||
if m:
|
||||
result[m.group(1).strip()] = m.group(2).strip()
|
||||
return result
|
||||
|
||||
|
||||
def parse_section_list(content: str, heading: str) -> list[str]:
|
||||
"""Parse a list section from mem0.md.
|
||||
|
||||
Looks for ``## <heading>`` and reads ``- item`` or bare lines.
|
||||
"""
|
||||
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
|
||||
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
||||
if not match:
|
||||
return []
|
||||
|
||||
items: list[str] = []
|
||||
for line in match.group(1).splitlines():
|
||||
line = re.sub(r"#.*$", "", line).strip()
|
||||
line = re.sub(r"^[-*]\s+", "", line).strip()
|
||||
if line:
|
||||
items.append(line)
|
||||
return items
|
||||
|
||||
|
||||
def parse_ignore_patterns(content: str) -> list[str]:
|
||||
"""Parse the ``## Ignore`` section of *content*.
|
||||
|
||||
Each non-blank line is a glob pattern (e.g., ``node_modules``, ``*.lock``).
|
||||
Lines starting with ``#`` are comments and skipped.
|
||||
"""
|
||||
pattern = r"^##\s+Ignore[^\n]*\n(.*?)(?=^##\s|\Z)"
|
||||
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
||||
if not match:
|
||||
return []
|
||||
|
||||
patterns: list[str] = []
|
||||
for line in match.group(1).splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
line = re.sub(r"^[-*]\s+", "", line).strip()
|
||||
if line:
|
||||
patterns.append(line)
|
||||
return patterns
|
||||
|
||||
|
||||
def load_full_config(cwd: str | None = None) -> dict:
|
||||
"""Load all config sections from mem0.md.
|
||||
|
||||
Returns a dict with keys: retention, search, categories, identity,
|
||||
ignore, project_id.
|
||||
Each is populated only if the corresponding ``##`` section exists.
|
||||
"""
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
|
||||
config_path = find_mem0_config(cwd)
|
||||
if config_path is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
config: dict = {}
|
||||
|
||||
retention = parse_retention(content)
|
||||
if retention:
|
||||
config["retention"] = retention
|
||||
|
||||
search = parse_section_kv(content, "Search")
|
||||
if search:
|
||||
config["search"] = search
|
||||
|
||||
categories = parse_section_list(content, "Categories")
|
||||
if categories:
|
||||
config["categories"] = categories
|
||||
config["default_categories"] = categories
|
||||
|
||||
identity = parse_section_kv(content, "Identity")
|
||||
if identity:
|
||||
config["identity"] = identity
|
||||
if "project_id" in identity:
|
||||
config["project_id"] = identity["project_id"]
|
||||
|
||||
ignore = parse_ignore_patterns(content)
|
||||
if ignore:
|
||||
config["ignore"] = ignore
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def load_retention_policies(cwd: str | None = None) -> dict[str, int | None]:
|
||||
"""Load retention policies from the mem0.md in *cwd*.
|
||||
|
||||
Combines :func:`find_mem0_config` and :func:`parse_retention` into a
|
||||
single convenience function.
|
||||
"""
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
|
||||
config_path = find_mem0_config(cwd)
|
||||
if config_path is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
return parse_retention(content)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point.
|
||||
|
||||
With ``--full``, prints the complete config. Without it, prints only
|
||||
retention policies (backward-compatible).
|
||||
"""
|
||||
full_mode = "--full" in sys.argv
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
cwd = args[0] if args else os.getcwd()
|
||||
|
||||
if full_mode:
|
||||
config = load_full_config(cwd)
|
||||
else:
|
||||
config = load_retention_policies(cwd)
|
||||
print(json.dumps(config))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Session stats tracker for mem0 plugin.
|
||||
|
||||
Tracks memory adds/searches per session.
|
||||
Uses /tmp/mem0_session_stats_$USER.json (single file per user, reset on init).
|
||||
|
||||
Usage:
|
||||
python session_stats.py init # reset for new session
|
||||
python session_stats.py add <category> # record a memory write
|
||||
python session_stats.py search # record a search
|
||||
python session_stats.py report # print summary, clean up temp file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
STATS_FILE = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if os.path.isfile(STATS_FILE):
|
||||
try:
|
||||
with open(STATS_FILE) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {
|
||||
"adds": 0,
|
||||
"searches": 0,
|
||||
"categories": [],
|
||||
"category_counts": {},
|
||||
"started": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _save(stats: dict) -> None:
|
||||
with open(STATS_FILE, "w") as f:
|
||||
json.dump(stats, f)
|
||||
|
||||
|
||||
MAX_RECENT_IDS = 50
|
||||
|
||||
|
||||
def init() -> None:
|
||||
_save({
|
||||
"adds": 0,
|
||||
"searches": 0,
|
||||
"categories": [],
|
||||
"category_counts": {},
|
||||
"recent_ids": [],
|
||||
"started": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
def record_add(category: str = "", memory_id: str = "") -> None:
|
||||
stats = _load()
|
||||
stats["adds"] = stats.get("adds", 0) + 1
|
||||
if category:
|
||||
if category not in stats.get("categories", []):
|
||||
stats.setdefault("categories", []).append(category)
|
||||
counts = stats.setdefault("category_counts", {})
|
||||
counts[category] = counts.get(category, 0) + 1
|
||||
if memory_id:
|
||||
recent = stats.setdefault("recent_ids", [])
|
||||
recent.append({"id": memory_id, "category": category, "ts": datetime.now().isoformat()})
|
||||
if len(recent) > MAX_RECENT_IDS:
|
||||
stats["recent_ids"] = recent[-MAX_RECENT_IDS:]
|
||||
_save(stats)
|
||||
|
||||
|
||||
def record_search() -> None:
|
||||
stats = _load()
|
||||
stats["searches"] = stats.get("searches", 0) + 1
|
||||
_save(stats)
|
||||
|
||||
|
||||
def peek() -> str:
|
||||
"""Return current stats as JSON without clearing the file."""
|
||||
stats = _load()
|
||||
return json.dumps(stats)
|
||||
|
||||
|
||||
def report() -> str:
|
||||
stats = _load()
|
||||
adds = stats.get("adds", 0)
|
||||
searches = stats.get("searches", 0)
|
||||
categories = stats.get("categories", [])
|
||||
|
||||
# Clean up temp file after reading
|
||||
try:
|
||||
os.unlink(STATS_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if adds == 0 and searches == 0:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
category_counts = stats.get("category_counts", {})
|
||||
if category_counts:
|
||||
breakdown = ", ".join(f"{c} {n}" for c, n in sorted(category_counts.items(), key=lambda x: -x[1]))
|
||||
parts.append(f"Session: wrote {adds} memories ({breakdown}), retrieved {searches}")
|
||||
else:
|
||||
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
|
||||
if categories:
|
||||
parts.append(f"Categories touched: {', '.join(categories)}")
|
||||
|
||||
return ". ".join(parts) + "."
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: session_stats.py [init|add|search|report]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "init":
|
||||
init()
|
||||
elif cmd == "add":
|
||||
category = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
memory_id = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
record_add(category, memory_id)
|
||||
elif cmd == "search":
|
||||
record_search()
|
||||
elif cmd == "peek":
|
||||
print(peek())
|
||||
elif cmd == "report":
|
||||
result = report()
|
||||
if result:
|
||||
print(result)
|
||||
else:
|
||||
print("Session: no memory operations.")
|
||||
else:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,14 +5,15 @@ mem0 auto-tags every memory with one or more `categories`. By default the list
|
||||
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
|
||||
This script replaces the project's category list with a coding-focused one.
|
||||
|
||||
Uses the mem0ai SDK (client.project.update). The SDK is installed into a
|
||||
persistent venv at ${CLAUDE_PLUGIN_DATA}/venv by the ensure_deps.sh hook.
|
||||
The change is project-level (per the platform docs, per-request overrides are
|
||||
not supported on the managed API). Run once per project; future memories will
|
||||
be tagged using the new list automatically.
|
||||
|
||||
Usage:
|
||||
python setup_coding_categories.py # dry-run: show current vs proposed
|
||||
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
|
||||
python setup_coding_categories.py --apply # actually call project.update()
|
||||
|
||||
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY).
|
||||
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,19 +23,6 @@ import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _script_dir)
|
||||
from _identity import resolve_api_key # noqa: E402
|
||||
|
||||
_plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", os.path.join(_script_dir, ".."))
|
||||
_data_dir = os.environ.get("CLAUDE_PLUGIN_DATA", os.path.join(os.path.expanduser("~"), ".mem0", "plugin-data"))
|
||||
_venv_site = os.path.join(_data_dir, "venv", "lib")
|
||||
if os.path.isdir(_venv_site):
|
||||
for d in sorted(os.listdir(_venv_site)):
|
||||
sp = os.path.join(_venv_site, d, "site-packages")
|
||||
if os.path.isdir(sp) and sp not in sys.path:
|
||||
sys.path.insert(1, sp)
|
||||
|
||||
CODING_CATEGORIES = [
|
||||
{
|
||||
"architecture_decisions": (
|
||||
@@ -78,66 +66,6 @@ CODING_CATEGORIES = [
|
||||
"and ways of working."
|
||||
)
|
||||
},
|
||||
{
|
||||
"dependency_decisions": (
|
||||
"Why specific libraries, frameworks, or package versions were chosen or replaced, "
|
||||
"including the alternatives considered and the reasoning behind the selection."
|
||||
)
|
||||
},
|
||||
{
|
||||
"performance_findings": (
|
||||
"Profiling results, bottlenecks identified, optimisations applied, and measurable "
|
||||
"improvements achieved -- useful for avoiding regressions and guiding future work."
|
||||
)
|
||||
},
|
||||
{
|
||||
"security_constraints": (
|
||||
"Security requirements, authentication and authorisation rules, data-handling "
|
||||
"constraints, compliance obligations, and known threat mitigations in effect."
|
||||
)
|
||||
},
|
||||
{
|
||||
"testing_patterns": (
|
||||
"Test strategies, frameworks chosen, coverage targets, fixture patterns, mocking "
|
||||
"approaches, and how the test suite is structured for this project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"data_model": (
|
||||
"Schema definitions, database column semantics, domain object relationships, "
|
||||
"field constraints, and how data flows between storage and application layers."
|
||||
)
|
||||
},
|
||||
{
|
||||
"api_contracts": (
|
||||
"API endpoint shapes, request and response schemas, authentication requirements, "
|
||||
"versioning policy, and any breaking-change commitments or deprecation timelines."
|
||||
)
|
||||
},
|
||||
{
|
||||
"deployment_runbook": (
|
||||
"How to build, release, deploy, and roll back the project. CI/CD pipeline steps, "
|
||||
"environment-specific configuration, and on-call runbook entries."
|
||||
)
|
||||
},
|
||||
{
|
||||
"team_norms": (
|
||||
"Team working agreements, PR review etiquette, branching strategy, on-call "
|
||||
"rotation, and other social or process conventions the team has agreed on."
|
||||
)
|
||||
},
|
||||
{
|
||||
"domain_glossary": (
|
||||
"Domain-specific terms, abbreviations, and acronyms with their precise meanings "
|
||||
"in this project -- prevents misunderstandings across code, docs, and discussion."
|
||||
)
|
||||
},
|
||||
{
|
||||
"experiment_results": (
|
||||
"Results from A/B tests, feature-flag experiments, spikes, or proof-of-concept "
|
||||
"work -- what was tried, what was measured, and what conclusion was reached."
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -159,19 +87,17 @@ def main() -> int:
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
print("ERROR: MEM0_API_KEY is not set. Export it or configure it via plugin userConfig.", file=sys.stderr)
|
||||
if not os.environ.get("MEM0_API_KEY"):
|
||||
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
|
||||
return 1
|
||||
os.environ["MEM0_API_KEY"] = api_key
|
||||
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError:
|
||||
print(
|
||||
"ERROR: mem0ai SDK not found. The plugin's ensure_deps.sh hook should\n"
|
||||
"install it automatically on session start. Try restarting Claude Code,\n"
|
||||
"or run manually: pip install mem0ai",
|
||||
"ERROR: the mem0ai Python SDK is not installed.\n"
|
||||
"Install with: pip install mem0ai\n"
|
||||
"Then re-run this script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lightweight fire-and-forget telemetry for the mem0 plugin.
|
||||
|
||||
Sends anonymous usage events to PostHog using the same project key and
|
||||
endpoint as the mem0 Python SDK and CLI. No posthog library dependency —
|
||||
uses stdlib urllib directly (same pattern as cli/python telemetry_sender.py).
|
||||
|
||||
CLI usage (called from hooks as a background subprocess):
|
||||
python3 telemetry.py <event_type> [--memory_count=N] [--categories_count=N]
|
||||
[--error_detected] [--file_paths_detected]
|
||||
[--source=<src>] [--tool=<name>]
|
||||
|
||||
Opt-out: set MEM0_TELEMETRY=false (or 0/no/off) to disable all telemetry.
|
||||
|
||||
Never sends: user content, memory content, API keys, raw user/project IDs.
|
||||
Only sends: event type, platform, plugin version, anonymized hashes, counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
PLUGIN_VERSION = "0.2.1"
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||
REQUEST_TIMEOUT = 2
|
||||
|
||||
# All events sampled at 10% to keep PostHog costs predictable.
|
||||
SAMPLE_RATE = 0.1
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _distinct_id() -> str:
|
||||
"""Stable anonymous ID: MD5 of API key if available, else SHA-256 of username."""
|
||||
api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or ""
|
||||
if api_key:
|
||||
return hashlib.md5(api_key.encode()).hexdigest()
|
||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
|
||||
return _sha256(user_id)
|
||||
|
||||
|
||||
def detect_platform() -> str:
|
||||
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
|
||||
return "claude-code"
|
||||
if os.environ.get("CURSOR_PLUGIN_ROOT"):
|
||||
return "cursor"
|
||||
if os.environ.get("CODEX_PLUGIN_ROOT"):
|
||||
return "codex"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off")
|
||||
|
||||
|
||||
def _should_sample() -> bool:
|
||||
return random.random() < SAMPLE_RATE
|
||||
|
||||
|
||||
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
|
||||
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
|
||||
return {
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"distinct_id": _distinct_id(),
|
||||
"event": event_name,
|
||||
"properties": {
|
||||
"source": "plugin",
|
||||
"platform": detect_platform(),
|
||||
"plugin_version": PLUGIN_VERSION,
|
||||
"project_hash": _sha256(project_id),
|
||||
"os": sys.platform,
|
||||
"os_version": platform.version(),
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(properties or {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def send(payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
POSTHOG_HOST,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def emit(event_type: str, properties: dict | None = None) -> None:
|
||||
if not is_enabled():
|
||||
return
|
||||
event_name = f"plugin.{event_type}"
|
||||
if not _should_sample():
|
||||
return
|
||||
send(build_posthog_payload(event_name, properties))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not is_enabled():
|
||||
return 0
|
||||
if len(sys.argv) < 2:
|
||||
return 1
|
||||
|
||||
event_type = sys.argv[1]
|
||||
properties: dict = {}
|
||||
|
||||
for arg in sys.argv[2:]:
|
||||
if arg.startswith("--memory_count="):
|
||||
try:
|
||||
properties["memory_count"] = int(arg.split("=", 1)[1])
|
||||
except ValueError:
|
||||
pass
|
||||
elif arg.startswith("--categories_count="):
|
||||
try:
|
||||
properties["categories_count"] = int(arg.split("=", 1)[1])
|
||||
except ValueError:
|
||||
pass
|
||||
elif arg == "--error_detected":
|
||||
properties["error_detected"] = True
|
||||
elif arg == "--file_paths_detected":
|
||||
properties["file_paths_detected"] = True
|
||||
elif arg.startswith("--source="):
|
||||
properties["source_detail"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--tool="):
|
||||
properties["tool"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--files_count="):
|
||||
try:
|
||||
properties["files_count"] = int(arg.split("=", 1)[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
emit(event_type, properties)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,123 +0,0 @@
|
||||
---
|
||||
name: mem0-digest
|
||||
description: >
|
||||
Summarize recent memory activity for the current project. Shows new memories,
|
||||
categories touched, and growth trends over the past 7 days.
|
||||
TRIGGER: user runs /mem0:digest, or asks "weekly summary", "what's new in memory",
|
||||
"mem0 digest", "memory recap".
|
||||
---
|
||||
|
||||
# Mem0 Weekly Digest
|
||||
|
||||
Summarize recent memory activity for the current project.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Fetch recent memories
|
||||
|
||||
Call `search_memories` in parallel with different time-scoped queries:
|
||||
|
||||
1. `query="decisions made this week"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20`
|
||||
2. `query="bugs errors fixes"`, same time filter, `limit=20`
|
||||
3. `query="patterns conventions learnings"`, same time filter, `limit=20`
|
||||
|
||||
Also call `get_memories` with `user_id` + `app_id` to get the full count for comparison.
|
||||
|
||||
### Step 2: Deduplicate and analyze
|
||||
|
||||
Merge results by memory ID. For each memory, extract:
|
||||
- `created_at` date
|
||||
- `categories[0]` or `metadata.type`
|
||||
- First 100 chars of content
|
||||
|
||||
Group into:
|
||||
- **New this week** (created in last 7 days)
|
||||
- **Older** (created before, but surfaced by search)
|
||||
|
||||
Calculate:
|
||||
- Total memories in project
|
||||
- Memories added in last 7 days
|
||||
- Most active categories this week
|
||||
- Days with most activity
|
||||
|
||||
### Step 3: Display
|
||||
|
||||
```
|
||||
## mem0 Weekly Digest — <project_id>
|
||||
Period: <start_date> to <today>
|
||||
|
||||
### New Memories This Week: <N>
|
||||
<category>: <count>
|
||||
- <memory summary, 80 chars> (<date>)
|
||||
- ...
|
||||
<category>: <count>
|
||||
- ...
|
||||
|
||||
### Activity Pattern
|
||||
Most active day: <day> (<N> memories)
|
||||
Categories touched: <list>
|
||||
|
||||
### Project Totals
|
||||
Total memories: <N> (up <N> from last week)
|
||||
Top categories: <top 3 by count>
|
||||
|
||||
### Highlights
|
||||
<2-3 sentence summary of the most important decisions, learnings, or patterns stored this week>
|
||||
```
|
||||
|
||||
### Step 4: Write digest to file
|
||||
|
||||
After displaying, write the digest to `~/.mem0/weekly-digest.md` for persistence
|
||||
and external consumption (email, Slack, etc.):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.mem0
|
||||
```
|
||||
|
||||
Write the full digest output (same markdown shown in terminal) to `~/.mem0/weekly-digest.md`
|
||||
using the Write tool. **Overwrite** the file each time — it always contains the latest digest.
|
||||
|
||||
Also append a one-line summary to `~/.mem0/digest-history.log` for trend tracking:
|
||||
|
||||
```bash
|
||||
echo "<YYYY-MM-DD> | <project_id> | +<new_count> memories | top: <top_category>" >> ~/.mem0/digest-history.log
|
||||
```
|
||||
|
||||
Print at the end:
|
||||
```
|
||||
Digest saved to ~/.mem0/weekly-digest.md
|
||||
```
|
||||
|
||||
### Step 5: Schedule recurring digests
|
||||
|
||||
When invoked with `--schedule` (e.g., `/mem0:digest --schedule weekly`), register
|
||||
a cloud routine via Claude Code's `/schedule` command:
|
||||
|
||||
```
|
||||
/schedule <frequency> /mem0:digest
|
||||
```
|
||||
|
||||
For example:
|
||||
- `/schedule weekly on Monday 9am /mem0:digest` — digest every Monday morning
|
||||
- `/schedule daily at 8am /mem0:digest` — daily digest
|
||||
|
||||
Print:
|
||||
```
|
||||
Digest scheduled: <frequency>
|
||||
Manage at: https://claude.ai/code/routines
|
||||
```
|
||||
|
||||
If `/schedule` is unavailable, print a cron one-liner the user can install manually:
|
||||
```bash
|
||||
# macOS/Linux — weekly Monday 9am
|
||||
(crontab -l 2>/dev/null; echo "0 9 * * 1 cd PROJECT_DIR && claude -p '/mem0:digest' >> /tmp/mem0-digest.log 2>&1") | crontab -
|
||||
```
|
||||
|
||||
### Step 6: Empty state
|
||||
|
||||
If no memories in the last 7 days:
|
||||
```
|
||||
No new memories in the past week for <project_id>.
|
||||
Total project memories: <N>.
|
||||
Tip: mem0 captures learnings automatically as you work. Start coding!
|
||||
```
|
||||
@@ -1,344 +0,0 @@
|
||||
---
|
||||
name: mem0-dream
|
||||
description: >
|
||||
Memory consolidation pass. Fetches all project memories, finds near-duplicates,
|
||||
merges them, flags contradictions, prunes stale entries per retention policy.
|
||||
Outputs a diff for user approval before applying changes.
|
||||
TRIGGER: user runs /mem0:dream, or asks "consolidate memories", "clean up memories",
|
||||
"merge duplicate memories", "run dream".
|
||||
---
|
||||
|
||||
# Mem0 Dream — Memory Consolidation
|
||||
|
||||
This skill performs a memory consolidation pass: it fetches all project memories,
|
||||
identifies near-duplicates, flags contradictions, and prunes stale entries based on
|
||||
configured retention policies. All proposed changes are shown as a diff for user
|
||||
approval before anything is modified.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Load Retention Policies
|
||||
|
||||
Determine the active retention policy by running the parser script. Use the
|
||||
appropriate `PLUGIN_ROOT` variable for the current platform (`${CLAUDE_PLUGIN_ROOT}`,
|
||||
`${CODEX_PLUGIN_ROOT}`, or `${CURSOR_PLUGIN_ROOT}`):
|
||||
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/parse_mem0_config.py" "<cwd>"
|
||||
```
|
||||
|
||||
Parse the JSON output (a dict of `category → days | null`). If the script fails
|
||||
or returns `{}`, fall back to these built-in defaults:
|
||||
|
||||
| `metadata.type` | Default retention |
|
||||
|---|---|
|
||||
| `session_state` | 90 days |
|
||||
| `compact_summary` | 90 days |
|
||||
| all others | no pruning |
|
||||
|
||||
Store the resolved policies for use in Step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Fetch ALL Project Memories
|
||||
|
||||
Call `get_memories` to retrieve every memory for the active project:
|
||||
|
||||
```python
|
||||
get_memories(
|
||||
user_id="<active_user_id>",
|
||||
app_id="<active_project_id>",
|
||||
page_size=200,
|
||||
)
|
||||
```
|
||||
|
||||
If the response indicates more pages exist, paginate until all memories are fetched.
|
||||
Collect the full list before proceeding. If zero memories are found, print:
|
||||
|
||||
```
|
||||
No memories found for project <project_id>. Nothing to consolidate.
|
||||
```
|
||||
|
||||
…and stop.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Analyze — Find Issues
|
||||
|
||||
Work entirely in-memory; do not modify anything yet.
|
||||
|
||||
Group memories by `metadata.type` (use `"unknown"` when the field is absent).
|
||||
For each group, identify the following:
|
||||
|
||||
### 3a. Near-duplicate pairs (merge candidates)
|
||||
|
||||
Two memories are near-duplicates when they express the same fact or decision but
|
||||
phrased differently (e.g., "Use PostgreSQL for auth" and "Auth DB is PostgreSQL").
|
||||
|
||||
Heuristics — two memories are near-duplicates if **all** of these hold:
|
||||
- Similarity threshold: estimated cosine similarity > 0.9 (use noun/keyword overlap as proxy — if >60% of significant nouns overlap, treat as >0.9 similarity).
|
||||
- Same `metadata.type`.
|
||||
- Neither memory is pinned (`metadata.pinned != true`).
|
||||
|
||||
For each qualifying pair, draft a merged version that is more complete and specific
|
||||
than either original.
|
||||
|
||||
### 3b. Contradictions
|
||||
|
||||
Two memories contradict when they assert opposing facts about the same topic
|
||||
(e.g., "Deploy to ECS" vs. "Deploy to Vercel").
|
||||
|
||||
Identify the likely winner: the more recent memory with higher confidence wins.
|
||||
Store both IDs and their content for user review.
|
||||
|
||||
### 3c. Prune candidates
|
||||
|
||||
A memory is a prune candidate when **any** of the following is true:
|
||||
|
||||
1. Its `metadata.type` has a retention policy and the memory is older than the
|
||||
configured number of days (compare `created_at` to today).
|
||||
2. Its confidence score is below 0.3 AND it contains no information unique to
|
||||
this project (no file paths, identifiers, or domain-specific nouns).
|
||||
|
||||
**Always skip memories where `metadata.pinned == true`**, regardless of age or
|
||||
confidence.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Print Diff Report (item 15)
|
||||
|
||||
Print a structured diff to the terminal before making any changes. Use exactly
|
||||
this format:
|
||||
|
||||
```
|
||||
## Dream — Memory Consolidation Report
|
||||
|
||||
### Merge proposals (<N> pairs)
|
||||
MERGE [mem0:<id1>] + [mem0:<id2>] → NEW
|
||||
- Original 1: "<content of memory 1, truncated to 120 chars>"
|
||||
- Original 2: "<content of memory 2, truncated to 120 chars>"
|
||||
- Merged: "<drafted merged content>"
|
||||
|
||||
### Contradictions (<N> pairs)
|
||||
CONFLICT [mem0:<idA>] vs [mem0:<idB>]
|
||||
- A: "<content>" (<created_at date>, confidence: <score>)
|
||||
- B: "<content>" (<created_at date>, confidence: <score>)
|
||||
Which is current? [A/B/skip]
|
||||
|
||||
### Prune candidates (<N> memories)
|
||||
PRUNE [mem0:<id>] — <metadata.type>, <age>d old (policy: <policy_days>d)
|
||||
|
||||
---
|
||||
Proposed: <N> merges, <N> prunes, <N> conflicts
|
||||
Apply? [Y/n]
|
||||
```
|
||||
|
||||
If there are zero items in any category, omit that section entirely.
|
||||
|
||||
If there are zero total proposals (no merges, no prunes, no conflicts), print:
|
||||
|
||||
```
|
||||
Dream complete. No duplicate, contradictory, or stale memories found.
|
||||
```
|
||||
|
||||
…and stop.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Wait for User Input and Apply
|
||||
|
||||
### 5a. Contradictions
|
||||
|
||||
For each `CONFLICT` pair in the report, wait for the user to type `A`, `B`, or
|
||||
`skip` (case-insensitive). If they enter nothing (empty), treat as `skip`.
|
||||
|
||||
Record the winner for each pair before proceeding to the final apply confirmation.
|
||||
|
||||
### 5b. Final confirmation
|
||||
|
||||
After all conflict resolutions are collected, prompt:
|
||||
|
||||
```
|
||||
Apply? [Y/n]
|
||||
```
|
||||
|
||||
If the user types `n` or `no` (case-insensitive), print `Cancelled. No changes made.`
|
||||
and stop.
|
||||
|
||||
If the user confirms (`Y`, `yes`, or empty / Enter), apply all changes in this order:
|
||||
|
||||
#### Merges
|
||||
|
||||
For each approved merge pair:
|
||||
1. `delete_memory(<id1>)`
|
||||
2. `delete_memory(<id2>)`
|
||||
3. `add_memory` with:
|
||||
- `messages=[{"role": "user", "content": "<merged content>"}]`
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>` (top-level, not in metadata)
|
||||
- `metadata={"type": "<original type>", "branch": "<active_branch>", "confidence": <higher of the two original scores>, "source": "mem0-dream"}`
|
||||
- `infer=False`
|
||||
|
||||
#### Contradictions (resolved)
|
||||
|
||||
For each resolved conflict where the user chose A or B:
|
||||
- Identify the loser (the non-chosen memory).
|
||||
- First call `get_memory(<loser_id>)` to read its current text content.
|
||||
- Then call `update_memory(<loser_id>, data=<original_text_content>)` to preserve the text while updating it.
|
||||
- **Important:** `update_memory` requires the `data` (text) parameter. A metadata-only call may error or wipe the content. Always read first, then update with the original text.
|
||||
|
||||
Contradictions where the user chose `skip` are left untouched.
|
||||
|
||||
#### Prunes
|
||||
|
||||
For each prune candidate:
|
||||
- `delete_memory(<memory_id>)`
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Print Summary
|
||||
|
||||
After all changes are applied, print:
|
||||
|
||||
```
|
||||
Dream complete.
|
||||
Merged: <N> pairs → <N> new memories
|
||||
Pruned: <N> memories deleted
|
||||
Flagged: <N> contradictions resolved, <N> skipped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auto mode
|
||||
|
||||
When invoked with `--auto` (e.g., `/mem0:dream --auto`), run non-interactively:
|
||||
|
||||
- **Merges**: applied automatically (no contradiction, both are compatible).
|
||||
- **Prunes**: applied automatically (age/confidence-based, no ambiguity).
|
||||
- **Contradictions**: skipped — they require human judgment.
|
||||
|
||||
In auto mode:
|
||||
1. Load policies and fetch memories (Steps 1–3) as normal.
|
||||
2. Apply merges and prunes silently without printing the diff or prompting.
|
||||
3. Print a compact summary:
|
||||
```
|
||||
[mem0-dream --auto] project=<id> merged=<N> pruned=<N> conflicts_skipped=<N>
|
||||
```
|
||||
4. If contradictions were detected but skipped, store a reminder memory:
|
||||
```python
|
||||
add_memory(
|
||||
messages=[{"role": "user", "content": "mem0-dream detected <N> contradiction(s) requiring manual review. Run /mem0:dream to resolve them interactively."}],
|
||||
user_id="<active_user_id>",
|
||||
app_id="<active_project_id>",
|
||||
metadata={"type": "task_learning", "source": "mem0-dream-auto", "branch": "<active_branch>"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
## Scheduling recurring dreams
|
||||
|
||||
When invoked with `--schedule` (e.g., `/mem0:dream --schedule weekly`), register a
|
||||
cloud routine via Claude Code's built-in `/schedule` command so the dream runs
|
||||
automatically without any local cron or launchd setup.
|
||||
|
||||
### Step S1: Parse schedule frequency
|
||||
|
||||
Accept natural-language frequency after `--schedule`:
|
||||
|
||||
| User input | Cron equivalent | Description |
|
||||
|---|---|---|
|
||||
| `weekly` or `--schedule weekly` | Every Sunday 3:00 AM local | Default weekly consolidation |
|
||||
| `daily` | Every day 3:00 AM local | For high-volume projects |
|
||||
| `biweekly` | Every other Sunday 3:00 AM local | Lower frequency option |
|
||||
| Custom (e.g., `"every Monday 9am"`) | Pass verbatim to `/schedule` | Let Claude Code resolve it |
|
||||
|
||||
### Step S2: Create the routine
|
||||
|
||||
Use Claude Code's `/schedule` command to create a cloud routine. The routine runs
|
||||
`/mem0:dream --auto` on the specified schedule against the current repository:
|
||||
|
||||
```
|
||||
/schedule <frequency> /mem0:dream --auto
|
||||
```
|
||||
|
||||
For example:
|
||||
- `/schedule weekly /mem0:dream --auto` — runs every week
|
||||
- `/schedule daily at 3am /mem0:dream --auto` — runs every day at 3 AM
|
||||
- `/schedule every Monday 9am /mem0:dream --auto` — runs every Monday at 9 AM
|
||||
|
||||
The `/schedule` command handles all the cloud infrastructure: repository cloning,
|
||||
environment setup, and cron scheduling. The routine runs as a full Claude Code
|
||||
cloud session with access to the mem0 MCP tools.
|
||||
|
||||
### Step S3: Confirm to user
|
||||
|
||||
After the routine is created, print:
|
||||
|
||||
```
|
||||
Dream scheduled: <frequency>
|
||||
Routine name: mem0-dream-<project_id>
|
||||
Next run: <next scheduled time>
|
||||
|
||||
Manage at: https://claude.ai/code/routines
|
||||
Edit: /schedule list → /schedule update
|
||||
Cancel: /schedule list → delete the routine
|
||||
```
|
||||
|
||||
### Managing scheduled dreams
|
||||
|
||||
| Action | Command |
|
||||
|---|---|
|
||||
| List all routines | `/schedule list` |
|
||||
| Run dream now | `/schedule run` (select the dream routine) |
|
||||
| Change frequency | `/schedule update` (select the dream routine) |
|
||||
| Pause | Toggle off at claude.ai/code/routines |
|
||||
| Delete | Delete at claude.ai/code/routines or `/schedule update` |
|
||||
|
||||
### Fallback for non-cloud users
|
||||
|
||||
If `/schedule` is unavailable (API key auth, no claude.ai subscription), fall back
|
||||
to local options:
|
||||
|
||||
1. **macOS launchd plist** — generate and install:
|
||||
```bash
|
||||
cat > ~/Library/LaunchAgents/com.mem0.dream.plist << 'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>com.mem0.dream</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>claude</string>
|
||||
<string>-p</string>
|
||||
<string>/mem0:dream --auto</string>
|
||||
<string>--allowedTools</string>
|
||||
<string>mcp__mem0__*</string>
|
||||
</array>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Weekday</key><integer>0</integer>
|
||||
<key>Hour</key><integer>3</integer>
|
||||
<key>Minute</key><integer>0</integer>
|
||||
</dict>
|
||||
<key>StandardOutPath</key><string>/tmp/mem0-dream.log</string>
|
||||
<key>StandardErrorPath</key><string>/tmp/mem0-dream.err</string>
|
||||
<key>WorkingDirectory</key><string>PROJECT_DIR</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
launchctl load ~/Library/LaunchAgents/com.mem0.dream.plist
|
||||
```
|
||||
Replace `PROJECT_DIR` with the actual project path.
|
||||
|
||||
2. **Linux cron** — add entry:
|
||||
```bash
|
||||
(crontab -l 2>/dev/null; echo "0 3 * * 0 cd PROJECT_DIR && claude -p '/mem0:dream --auto' >> /tmp/mem0-dream.log 2>&1") | crontab -
|
||||
```
|
||||
|
||||
Print which method was used and how to verify:
|
||||
```
|
||||
Dream scheduled (local: launchd/cron): weekly Sundays 3am
|
||||
Verify: launchctl list | grep mem0 # macOS
|
||||
crontab -l | grep mem0 # Linux
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
name: mem0-export
|
||||
description: >
|
||||
Export all memories for the current project to a local Markdown file.
|
||||
Each memory is written as a YAML-frontmatter block that can be re-imported later.
|
||||
TRIGGER: user runs /mem0:export, or asks "export memories", "backup memories",
|
||||
"download my memories", "save memories to file".
|
||||
---
|
||||
|
||||
# Mem0 Export
|
||||
|
||||
Export all memories for the current project to a portable Markdown file.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Resolve identity
|
||||
|
||||
Determine the active identity:
|
||||
- `user_id` from `MEM0_USER_ID` env var, else `$USER`, else `"default"`
|
||||
- `project_id` (used as `app_id`) from `MEM0_PROJECT_ID` env var, or via the project resolver
|
||||
|
||||
### Step 2: Fetch all memories
|
||||
|
||||
Call `get_memories` with:
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
- `page_size=200`
|
||||
|
||||
If the response is paginated (i.e. the result contains a `next` cursor or the count equals `page_size`), continue fetching pages until all memories are retrieved.
|
||||
|
||||
### Step 3: Format each memory as a YAML-frontmatter block
|
||||
|
||||
For each memory record, produce a block in this exact format:
|
||||
|
||||
```
|
||||
---
|
||||
id: <memory.id>
|
||||
created_at: <memory.created_at>
|
||||
type: <memory.metadata.type or "">
|
||||
confidence: <memory.metadata.confidence or "">
|
||||
branch: <memory.metadata.branch or "">
|
||||
files: <memory.metadata.files joined with ", " or "">
|
||||
categories: <memory.categories joined with ", " or "">
|
||||
---
|
||||
<memory.memory or memory content string>
|
||||
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `---` delimiters must be on their own lines with no extra whitespace.
|
||||
- `files` and `categories` are written as comma-separated values on a single line.
|
||||
- Leave a blank line after the content before the next `---` (for readability).
|
||||
- If a field is missing or null, write an empty string (not "null").
|
||||
|
||||
### Step 4: Write the export file
|
||||
|
||||
Determine the output filename:
|
||||
|
||||
```
|
||||
mem0-export-<project_id>-<YYYY-MM-DD>.md
|
||||
```
|
||||
|
||||
Where `<YYYY-MM-DD>` is today's date in UTC.
|
||||
|
||||
Write all formatted blocks to this file using the Write tool (or equivalent). The file is written to the current working directory.
|
||||
|
||||
### Step 5: Print summary
|
||||
|
||||
```
|
||||
Exported <N> memories to <filename>
|
||||
```
|
||||
|
||||
Where `<N>` is the total number of memory blocks written.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If `get_memories` returns an error or zero memories, print:
|
||||
```
|
||||
No memories found for project <project_id>. Nothing exported.
|
||||
```
|
||||
- If the write fails, report the error to the user.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: mem0-forget
|
||||
description: >
|
||||
Delete memories by search query or memory ID. Shows matches for confirmation
|
||||
before deleting. Safe — always confirms before destructive action.
|
||||
TRIGGER: user runs /mem0:forget <query>, or says "forget this", "delete memory",
|
||||
"remove that memory about X".
|
||||
---
|
||||
|
||||
# Mem0 Forget
|
||||
|
||||
Delete specific memories from mem0.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Parse input
|
||||
|
||||
The user provides either:
|
||||
- A search query: `/mem0:forget auth module decisions`
|
||||
- A memory ID: `/mem0:forget <memory_id>`
|
||||
|
||||
If no argument, ask: "What should I forget? Provide a search query or memory ID."
|
||||
|
||||
### Step 2: Find memories
|
||||
|
||||
**If memory ID provided** (looks like a UUID or hex string):
|
||||
- Call `get_memory` with the ID to verify it exists.
|
||||
- Show: `Found: "<memory content first 120 chars>" (created <date>)`
|
||||
|
||||
**If search query provided:**
|
||||
- Call `search_memories` with:
|
||||
- `query=<user's query>`
|
||||
- `user_id=<active_user_id>`
|
||||
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
|
||||
- `limit=10`
|
||||
- Show numbered list:
|
||||
```
|
||||
Found <N> memories matching "<query>":
|
||||
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_id>]
|
||||
2. ...
|
||||
```
|
||||
|
||||
### Step 3: Confirm
|
||||
|
||||
Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'."
|
||||
|
||||
For a single memory ID, ask: "Delete this memory? [y/N]"
|
||||
|
||||
**Never delete without confirmation.** This is destructive.
|
||||
|
||||
### Step 4: Delete
|
||||
|
||||
For each confirmed memory, call `delete_memory` with the memory ID.
|
||||
|
||||
### Step 5: Report
|
||||
|
||||
```
|
||||
Deleted <N> memories.
|
||||
```
|
||||
|
||||
If any deletions failed, report which ones and why.
|
||||
|
||||
## Undo recent writes
|
||||
|
||||
If the user says "undo last N memories" or "undo last write":
|
||||
|
||||
1. Read session stats to get recently written memory IDs:
|
||||
```bash
|
||||
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts"
|
||||
python3 "$SCRIPT_DIR/session_stats.py" peek
|
||||
```
|
||||
2. Parse the `recent_ids` array from the JSON output. Each entry has `id`, `category`, `ts`.
|
||||
3. Show the last N entries (default 1) and ask for confirmation.
|
||||
4. Delete confirmed entries via `delete_memory`.
|
||||
|
||||
If `recent_ids` is empty, tell the user: "No recent memory IDs tracked this session. Use a search query instead."
|
||||
@@ -1,171 +0,0 @@
|
||||
---
|
||||
name: mem0-health
|
||||
description: >
|
||||
Diagnostic health check for the mem0 plugin. Verifies API key, MCP server
|
||||
connectivity, identity resolution, and memory read/write capability.
|
||||
TRIGGER: user runs /mem0:health, or asks "is mem0 working", "mem0 health",
|
||||
"check mem0 connection", "debug mem0".
|
||||
---
|
||||
|
||||
# Mem0 Health Check
|
||||
|
||||
Run a diagnostic check on the mem0 plugin. Useful for troubleshooting.
|
||||
|
||||
## Execution
|
||||
|
||||
Run ALL checks, then display a single summary. Do not stop on the first failure.
|
||||
|
||||
### Check 1: API key
|
||||
|
||||
```bash
|
||||
echo "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-NOT_SET}}"
|
||||
```
|
||||
|
||||
- If `NOT_SET`: FAIL — "No API key configured"
|
||||
- If set: PASS — show first 6 chars + `...` (never print the full key)
|
||||
|
||||
### Check 2: Identity resolution
|
||||
|
||||
Read the active identity from the SessionStart banner or resolve manually:
|
||||
- `user_id`: from `MEM0_RESOLVED_USER_ID` or `$USER`
|
||||
- `project_id`: from `MEM0_PROJECT_ID` or current directory name
|
||||
- `branch`: from `MEM0_BRANCH` or `git rev-parse --abbrev-ref HEAD`
|
||||
|
||||
PASS if all three are non-empty. WARN if any falls back to defaults.
|
||||
|
||||
### Check 3: MCP server connectivity
|
||||
|
||||
Call `search_memories` with:
|
||||
- `query="health check"`, `user_id=<id>`, `limit=1`
|
||||
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
|
||||
|
||||
- If returns successfully (even empty): PASS
|
||||
- If errors: FAIL — show the error message
|
||||
|
||||
### Check 4: Memory write capability
|
||||
|
||||
Call `add_memory` with:
|
||||
- `messages=[{"role": "user", "content": "Health check probe — safe to delete."}]`
|
||||
- `user_id=<id>`, `app_id=<project_id>`
|
||||
- `metadata={"type": "health_check", "probe": true}`
|
||||
|
||||
If it returns a memory ID: PASS — then immediately call `delete_memory` with that ID to clean up.
|
||||
If it errors: FAIL — show the error.
|
||||
|
||||
### Check 5: Session stats tracker
|
||||
|
||||
Check if the session stats file exists and is readable:
|
||||
|
||||
```bash
|
||||
STATS_FILE="/tmp/mem0_session_stats_${USER}.json"
|
||||
if [ -f "$STATS_FILE" ] && python3 -c "import json; json.load(open('$STATS_FILE'))" 2>/dev/null; then
|
||||
echo "OK"
|
||||
else
|
||||
echo "FAIL"
|
||||
fi
|
||||
```
|
||||
|
||||
This file is created by the SessionStart hook and updated by PostToolUse hooks throughout the session. If it doesn't exist, the session hooks may not have fired yet — try sending a message first, then recheck.
|
||||
|
||||
### Display
|
||||
|
||||
```
|
||||
## mem0 Health Check
|
||||
|
||||
| Check | Status | Detail |
|
||||
|--------------------|--------|-------------------------------|
|
||||
| API Key | PASS | m0-dVe... |
|
||||
| Identity | PASS | user=kartik, project=mem0 |
|
||||
| MCP Connectivity | PASS | 142ms round-trip |
|
||||
| Memory Write/Read | PASS | write + delete OK |
|
||||
| Session Tracker | PASS | stats file active |
|
||||
|
||||
All checks passed. mem0 is healthy.
|
||||
```
|
||||
|
||||
If any check fails, add a `## Troubleshooting` section with specific fix steps for each failure.
|
||||
|
||||
## Extended mode: Memory Quality Analysis
|
||||
|
||||
When invoked with `--deep` (e.g., `/mem0:health --deep`) or `--fix` (e.g., `/mem0:health --fix`), run the standard 5 checks above **plus** a memory quality scan.
|
||||
|
||||
`--fix` implies `--deep` and automatically applies safe fixes after showing the analysis (see bottom of this section).
|
||||
|
||||
### Quality Check 1: Duplicates
|
||||
|
||||
Call `get_memories` with `user_id`, `app_id`, `page_size=200`. Compare all pairs within the same `metadata.type` group for high textual overlap (shared nouns/keywords > 60%). Report:
|
||||
|
||||
```
|
||||
Potential duplicates: <N> pairs
|
||||
[mem0:<id1>] ≈ [mem0:<id2>] — both about "<shared topic>"
|
||||
```
|
||||
|
||||
### Quality Check 2: Stale memories
|
||||
|
||||
Flag memories where:
|
||||
- `metadata.type` is `session_state` or `compact_summary` AND older than 90 days
|
||||
- `metadata.confidence` < 0.3 AND older than 30 days
|
||||
|
||||
```
|
||||
Stale candidates: <N>
|
||||
[mem0:<id>] — session_state, 142d old
|
||||
```
|
||||
|
||||
### Quality Check 2b: Low-confidence memories
|
||||
|
||||
Flag memories where `metadata.confidence` < 0.5 (regardless of age). Report separately from stale:
|
||||
|
||||
```
|
||||
Low-confidence memories: <N>
|
||||
[mem0:<id>] — confidence=0.3, "<content preview>"
|
||||
```
|
||||
|
||||
### Quality Check 3: Contradictions
|
||||
|
||||
Within each `metadata.type` group, flag pairs that assert opposing facts about the same topic. Use semantic judgment — look for negation patterns, conflicting tool/framework choices, or reversed decisions.
|
||||
|
||||
```
|
||||
Possible contradictions: <N>
|
||||
[mem0:<idA>] vs [mem0:<idB>] — conflicting on "<topic>"
|
||||
```
|
||||
|
||||
### Quality Check 4: Orphan memories
|
||||
|
||||
Memories with no `metadata.type` set, or with `metadata.type` not in the 17 known coding categories. These were likely written without proper tagging.
|
||||
|
||||
```
|
||||
Untagged/orphan memories: <N>
|
||||
```
|
||||
|
||||
### Quality summary
|
||||
|
||||
```
|
||||
## Memory Quality
|
||||
| Metric | Count | Action |
|
||||
|----------------|-------|---------------------------------|
|
||||
| Duplicates | <N> | Run /mem0:dream to merge |
|
||||
| Stale | <N> | Run /mem0:dream to prune |
|
||||
| Contradictions | <N> | Run /mem0:dream to resolve |
|
||||
| Orphans | <N> | Consider retagging via MCP |
|
||||
```
|
||||
|
||||
If all counts are 0: `Memory quality: clean. No duplicates, stale entries, or contradictions found.`
|
||||
|
||||
### Auto-fix mode (`--fix`)
|
||||
|
||||
When `--fix` is passed, apply these safe fixes automatically after displaying the quality summary:
|
||||
|
||||
1. **Orphans:** For each untagged memory, infer a `metadata.type` from content and call `update_memory` to set it. If inference is uncertain, skip.
|
||||
2. **Stale `session_state`/`compact_summary` > 90d:** Delete them via `delete_memory`. These are ephemeral by design.
|
||||
3. **Duplicates:** Do NOT auto-merge — print "Run `/mem0:dream` to merge duplicates" instead.
|
||||
4. **Contradictions:** Do NOT auto-resolve — print "Run `/mem0:dream` to resolve contradictions" instead.
|
||||
5. **Low-confidence < 0.3 AND > 30d old:** Delete them via `delete_memory`.
|
||||
|
||||
Print a summary of actions taken:
|
||||
|
||||
```
|
||||
## Auto-fix Results
|
||||
Deleted: <N> stale, <N> low-confidence
|
||||
Retagged: <N> orphans
|
||||
Skipped: <N> duplicates (use /mem0:dream), <N> contradictions (use /mem0:dream)
|
||||
```
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
name: mem0-import-tools
|
||||
description: >
|
||||
Import memories from competing AI tool configuration files into mem0.
|
||||
Supports Cursor (.cursorrules), GitHub Copilot (.github/copilot-instructions.md),
|
||||
Cline (memory-bank/), and Continue (.continue/rules.md).
|
||||
TRIGGER: user runs /mem0:import-tools, or asks "import from cursor",
|
||||
"import cursorrules", "import from cline", "import from copilot",
|
||||
"import from continue", "migrate from cursor", "migrate memories".
|
||||
---
|
||||
|
||||
# Mem0 Import from Competing Tools
|
||||
|
||||
Import configuration and memory files from other AI coding tools into mem0.
|
||||
|
||||
## Supported Tools
|
||||
|
||||
| Tool | Default file/directory |
|
||||
|------|----------------------|
|
||||
| Cursor | `.cursorrules` |
|
||||
| GitHub Copilot | `.github/copilot-instructions.md` |
|
||||
| Cline | `memory-bank/` (directory of `.md` files) |
|
||||
| Continue | `.continue/rules.md` |
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Detect which tool files exist
|
||||
|
||||
Check for the presence of each tool's file/directory in the current working directory:
|
||||
|
||||
```bash
|
||||
# Check each location
|
||||
test -f .cursorrules && echo "cursor: .cursorrules"
|
||||
test -f .github/copilot-instructions.md && echo "copilot: .github/copilot-instructions.md"
|
||||
test -d memory-bank/ && echo "cline: memory-bank/"
|
||||
test -f .continue/rules.md && echo "continue: .continue/rules.md"
|
||||
```
|
||||
|
||||
### Step 2: Report findings and ask user
|
||||
|
||||
List all found files to the user. For example:
|
||||
|
||||
```
|
||||
Found the following tool configuration files:
|
||||
[1] Cursor rules: .cursorrules
|
||||
[2] Cline memory bank: memory-bank/
|
||||
|
||||
Which would you like to import? (enter numbers, comma-separated, or "all"):
|
||||
```
|
||||
|
||||
If no files are found, print:
|
||||
```
|
||||
No competing tool configuration files found in the current directory.
|
||||
Checked: .cursorrules, .github/copilot-instructions.md, memory-bank/, .continue/rules.md
|
||||
```
|
||||
and stop.
|
||||
|
||||
### Step 3: Run the import script for each selected tool
|
||||
|
||||
Determine the plugin root. Use the appropriate variable for the current platform:
|
||||
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
|
||||
- Codex: `${CODEX_PLUGIN_ROOT}`
|
||||
- Cursor: `${CURSOR_PLUGIN_ROOT}`
|
||||
|
||||
For each tool the user selected, run the corresponding sub-command:
|
||||
|
||||
**Cursor (.cursorrules):**
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cursorrules --path .cursorrules
|
||||
```
|
||||
|
||||
**GitHub Copilot:**
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" copilot --path .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
**Cline:**
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cline --path memory-bank/
|
||||
```
|
||||
|
||||
**Continue:**
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" continue --path .continue/rules.md
|
||||
```
|
||||
|
||||
### Step 4: Report results
|
||||
|
||||
After each script runs, echo its output to the user. Then print a combined summary:
|
||||
|
||||
```
|
||||
Import complete.
|
||||
Cursor: <N> memories
|
||||
Copilot: <N> memories
|
||||
Total: <N> memories imported into project <project_id>
|
||||
```
|
||||
|
||||
Adjust the summary to reflect only the tools that were actually imported.
|
||||
|
||||
## Notes
|
||||
|
||||
- Memories are imported with `infer=False` — no AI inference is applied, content is stored as-is.
|
||||
- Each section or file becomes a separate memory tagged with `metadata.source=<tool>-import` and `metadata.type=project_profile`.
|
||||
- Sections shorter than 50 characters are automatically skipped (too short to be useful).
|
||||
- Content longer than 10,000 characters is automatically truncated per chunk.
|
||||
- You can re-run this skill safely — duplicate content will be caught by mem0's deduplication.
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: mem0-import
|
||||
description: >
|
||||
Import memories from a mem0 export file back into the current project.
|
||||
Reads a YAML-frontmatter Markdown file produced by /mem0:export and
|
||||
adds each memory block to mem0.
|
||||
TRIGGER: user runs /mem0:import, or asks "import memories", "restore memories",
|
||||
"load memories from file", "reimport backup".
|
||||
---
|
||||
|
||||
# Mem0 Import
|
||||
|
||||
Import memories from a mem0 export file into the current project.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Determine the export file to import
|
||||
|
||||
If the user provided a filename as an argument to `/mem0:import <filename>`, use that file.
|
||||
|
||||
Otherwise, list `.md` files in the current directory whose names contain `mem0-export`:
|
||||
|
||||
```bash
|
||||
ls -1 *.md 2>/dev/null | grep mem0-export || echo "No export files found"
|
||||
```
|
||||
|
||||
If multiple files are found, ask the user which one to import. If none are found, print:
|
||||
```
|
||||
No mem0-export files found in the current directory.
|
||||
Run /mem0:export first, or provide the filename: /mem0:import <path-to-file>
|
||||
```
|
||||
|
||||
### Step 2: Parse the export file
|
||||
|
||||
Determine the plugin root. Use the appropriate variable for the current platform:
|
||||
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
|
||||
- Codex: `${CODEX_PLUGIN_ROOT}`
|
||||
- Cursor: `${CURSOR_PLUGIN_ROOT}`
|
||||
|
||||
Run the parser script to extract memory records as JSON:
|
||||
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/parse_export_file.py" "<path-to-export-file>"
|
||||
```
|
||||
|
||||
This outputs a JSON array where each element has:
|
||||
- `id` — original memory ID (for reference only; a new ID will be assigned on import)
|
||||
- `type` — metadata type
|
||||
- `confidence` — metadata confidence value
|
||||
- `branch` — metadata branch
|
||||
- `files` — list of associated files
|
||||
- `categories` — list of categories
|
||||
- `content` — the memory text
|
||||
|
||||
If the script fails or outputs `[]`, print:
|
||||
```
|
||||
Failed to parse <filename> or file contains no valid memory blocks.
|
||||
```
|
||||
and stop.
|
||||
|
||||
### Step 3: Resolve identity
|
||||
|
||||
Determine the active identity:
|
||||
- `user_id` from `MEM0_USER_ID` env var, else `$USER`, else `"default"`
|
||||
- `project_id` (used as `app_id`) from `MEM0_PROJECT_ID` env var, or via the project resolver
|
||||
|
||||
### Step 4: Import each memory
|
||||
|
||||
For each record in the parsed JSON array, call `add_memory` with:
|
||||
|
||||
- `messages=[{"role": "user", "content": "<record.content>"}]`
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
- `metadata={`
|
||||
- `"type": "<record.type>"` (if non-empty)
|
||||
- `"confidence": "<record.confidence>"` (if non-empty)
|
||||
- `"branch": "<record.branch>"` (if non-empty)
|
||||
- `"files": <record.files>` (the list, if non-empty)
|
||||
- `"source": "import"`
|
||||
- `}`
|
||||
- `infer=False`
|
||||
|
||||
Notes:
|
||||
- Do NOT pass the original `id` — the platform assigns a new ID.
|
||||
- Skip records where `content` is empty (the parser already filters these, but be defensive).
|
||||
- Continue importing even if individual records fail; track the count of successes.
|
||||
|
||||
### Step 5: Print results
|
||||
|
||||
```
|
||||
Imported <N> memories into project <project_id>
|
||||
```
|
||||
|
||||
Where `<N>` is the number of successfully imported memories.
|
||||
|
||||
If any failed:
|
||||
```
|
||||
Imported <N>/<total> memories into project <project_id> (<failed> failed)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the parser script is not found at `<PLUGIN_ROOT>/scripts/parse_export_file.py`, print an error and stop.
|
||||
- If `add_memory` calls fail consistently (e.g. auth error), report the issue and stop early.
|
||||
@@ -16,22 +16,6 @@ You have access to persistent memory via the mem0 MCP tools. Follow this protoco
|
||||
|
||||
Decide whether persistent memory context would improve your response, then act accordingly. Don't search by default — search deliberately.
|
||||
|
||||
## Project scoping
|
||||
|
||||
Every memory operation MUST be scoped to the current project using `app_id` (entity-scoped memory):
|
||||
|
||||
- **On `add_memory`:** Always pass `app_id=<active_project_id>` as a **top-level parameter** (not in metadata).
|
||||
- **On `search_memories`:** Always include `{"app_id": "<your_project_id>"}` in the AND filter.
|
||||
|
||||
Full filter template:
|
||||
```python
|
||||
filters={"AND": [
|
||||
{"user_id": "<your_user_id>"},
|
||||
{"app_id": "<your_project_id>"},
|
||||
{"metadata": {"type": "decision"}}
|
||||
]}
|
||||
```
|
||||
|
||||
### Decide: search or skip?
|
||||
|
||||
**Search WHEN** the user:
|
||||
@@ -66,7 +50,7 @@ Two rules from the v2 filter spec:
|
||||
1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work.
|
||||
2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable.
|
||||
|
||||
Combine `user_id` + `app_id` with one metadata clause per call:
|
||||
Combine `user_id` with one metadata clause per call:
|
||||
|
||||
| `metadata.type` clause | Use for |
|
||||
|--------|---------|
|
||||
@@ -75,33 +59,9 @@ Combine `user_id` + `app_id` with one metadata clause per call:
|
||||
| `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work |
|
||||
| `{"metadata": {"type": "convention"}}` | established patterns in this project |
|
||||
|
||||
### Which categories to search by query intent
|
||||
|
||||
When a query clearly maps to one of the platform's custom categories, fan-out to 2–3 parallel `search_memories` calls scoped to those categories so recall is precise without being noisy. Use the `metadata.type` filter as your primary discriminator; treat the category column below as the semantic lens to pick the right query nouns.
|
||||
|
||||
| User intent / signal | Primary categories to search | Example query nouns |
|
||||
|---|---|---|
|
||||
| Design or architecture question | `architecture_decisions`, `api_contracts`, `data_model` | `"architecture decision"`, `"API schema"`, `"data model"` |
|
||||
| Something failed / debugging | `anti_patterns`, `bug_fixes`, `security_constraints` | `"bug root cause"`, `"failure pattern"`, `"security constraint"` |
|
||||
| How do we do X here? | `coding_conventions`, `team_norms`, `testing_patterns` | `"code convention"`, `"team norm"`, `"test strategy"` |
|
||||
| Which library / version to use | `dependency_decisions`, `tooling_setup`, `architecture_decisions` | `"dependency choice"`, `"library version"`, `"tooling setup"` |
|
||||
| Performance or scale concern | `performance_findings`, `architecture_decisions`, `data_model` | `"performance bottleneck"`, `"profiling result"`, `"optimisation"` |
|
||||
| Security / auth / compliance | `security_constraints`, `api_contracts`, `coding_conventions` | `"auth rule"`, `"security requirement"`, `"compliance"` |
|
||||
| Test strategy or coverage | `testing_patterns`, `coding_conventions`, `anti_patterns` | `"test framework"`, `"coverage target"`, `"fixture pattern"` |
|
||||
| Schema / DB / domain object | `data_model`, `api_contracts`, `domain_glossary` | `"schema"`, `"column"`, `"domain object"` |
|
||||
| API shape or versioning | `api_contracts`, `data_model`, `architecture_decisions` | `"endpoint"`, `"request schema"`, `"versioning"` |
|
||||
| How to deploy / release / rollback | `deployment_runbook`, `tooling_setup`, `team_norms` | `"deploy step"`, `"rollback"`, `"CI pipeline"` |
|
||||
| Team process / branching / PRs | `team_norms`, `coding_conventions`, `deployment_runbook` | `"branching strategy"`, `"PR review"`, `"working agreement"` |
|
||||
| What does this term mean? | `domain_glossary`, `data_model`, `api_contracts` | `"glossary"`, `"abbreviation"`, `"domain term"` |
|
||||
| Experiment / spike / A-B test | `experiment_results`, `performance_findings`, `anti_patterns` | `"experiment result"`, `"A/B test"`, `"spike outcome"` |
|
||||
| User's tool / language preferences | `user_preferences`, `tooling_setup`, `coding_conventions` | `"user preference"`, `"preferred tool"`, `"language choice"` |
|
||||
| Past task strategies that worked | `task_learnings`, `anti_patterns`, `coding_conventions` | `"task strategy"`, `"approach that worked"` |
|
||||
| Environment / setup question | `tooling_setup`, `deployment_runbook`, `dependency_decisions` | `"environment setup"`, `"build tool"`, `"install step"` |
|
||||
| Anything related to current state | `task_learnings`, `architecture_decisions`, `anti_patterns` | (combine with recency filter — see below) |
|
||||
|
||||
Full filter (replace `<your_user_id>` and `<your_project_id>` with the active values from SessionStart):
|
||||
Full filter (replace `<your_user_id>` with the active user_id from your runtime):
|
||||
```python
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "decision"}}]}
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]}
|
||||
```
|
||||
|
||||
### Worked example
|
||||
@@ -114,16 +74,16 @@ search_memories(query="Refactor the auth module to use JWT")
|
||||
# Hits whatever shares words. Misses prior decisions and preferences.
|
||||
```
|
||||
|
||||
Do (parallel — substitute the active `user_id` and `app_id` for the placeholders):
|
||||
Do (parallel — substitute the active `user_id` for `<your_user_id>`):
|
||||
```python
|
||||
search_memories(query="auth module decisions",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "decision"}}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]})
|
||||
search_memories(query="JWT",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}]})
|
||||
search_memories(query="auth refactor failures",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "anti_pattern"}}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "anti_pattern"}}]})
|
||||
search_memories(query="auth",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "user_preference"}}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "user_preference"}}]})
|
||||
```
|
||||
|
||||
## After completing significant work
|
||||
@@ -137,8 +97,6 @@ Extract key learnings and store them using the `add_memory` tool:
|
||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||
|
||||
Always include `"branch": "<active_branch>"` in the metadata object alongside `type`. The active branch is shown in the SessionStart banner. This enables branch-scoped filtering later (e.g., "what did we do on feature/auth-rewrite?").
|
||||
|
||||
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
|
||||
|
||||
### Expiration: high-churn vs durable
|
||||
@@ -158,7 +116,7 @@ When the user is asking about *current* state ("where were we", "what's the acti
|
||||
|
||||
```python
|
||||
# Last 90 days only
|
||||
{"AND": [{"user_id": "<id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||
```
|
||||
|
||||
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
|
||||
@@ -173,8 +131,7 @@ When you've done the extraction work yourself — pre-compaction summaries, deci
|
||||
add_memory(
|
||||
messages=[{"role": "user", "content": "<your structured fact>"}],
|
||||
user_id="<active user_id>",
|
||||
app_id="<active project_id>",
|
||||
metadata={"type": "decision", "branch": "<active branch>"},
|
||||
metadata={"type": "decision"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
@@ -206,115 +163,8 @@ If context is about to be compacted or the session is ending, store a comprehens
|
||||
|
||||
Include metadata: `{"type": "session_state"}`
|
||||
|
||||
## Inline citations
|
||||
|
||||
When your response is informed by specific memories, cite them so the user can trace provenance. Use the memory ID returned by `search_memories`.
|
||||
|
||||
Format: `[mem0:<short_id>]` where `<short_id>` is the first 8 characters of the memory ID.
|
||||
|
||||
Example:
|
||||
> We chose Postgres over SQLite for production [mem0:a3f8b2c1] and the auth module uses JWT tokens [mem0:7e2d9f4a].
|
||||
|
||||
Rules:
|
||||
- Only cite when the memory **directly informed** your answer. Don't cite for general knowledge.
|
||||
- Place citations inline, at the end of the relevant sentence.
|
||||
- If multiple memories support the same point, cite all: `[mem0:abc12345][mem0:def67890]`.
|
||||
- Don't cite `session_state` or `compact_summary` memories — those are internal bookkeeping.
|
||||
- Keep it subtle. One or two citations per response is typical. Don't over-cite.
|
||||
|
||||
## Memory hygiene
|
||||
|
||||
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
|
||||
- Only store genuinely useful learnings. Skip trivial interactions.
|
||||
- Use specific, searchable language in memory content.
|
||||
|
||||
### Confidence scoring on every add_memory
|
||||
|
||||
Every `add_memory` call MUST include a `confidence` field in its `metadata` object. This captures how certain the stored fact is, so downstream callers can filter out speculation.
|
||||
|
||||
| `metadata.confidence` value | Meaning | When to use |
|
||||
|---|---|---|
|
||||
| `1.0` | User explicitly stated it | User said "we use Postgres", "always lint before commit", "never use floats for currency" |
|
||||
| `0.8` | Observed directly in code / config | You read it from a file, migration, or config — not inferred |
|
||||
| `0.5` | Inferred from context | You derived it from surrounding evidence but the user didn't confirm it |
|
||||
| `0.3` | Guessed / low-signal | Extrapolated from a single weak signal; treat as a tentative hypothesis |
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
add_memory(
|
||||
messages=[{"role": "user", "content": "We always use Postgres — never SQLite in production."}],
|
||||
user_id="<active user_id>",
|
||||
app_id="<active project_id>",
|
||||
metadata={"type": "architecture_decisions", "branch": "<active branch>", "confidence": 1.0},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
**Search guidance:** When recalling actionable facts (decisions, conventions, security constraints), optionally apply a confidence threshold of 0.6 or above to avoid surfacing low-confidence guesses. Only top-level metadata keys are filterable, so `confidence` filtering requires SDK-side post-filtering or a dedicated high-confidence write path — for now, include the confidence value in every write and document it in the memory content so it is searchable via text.
|
||||
|
||||
### File path tagging on every add_memory
|
||||
|
||||
Every `add_memory` call that is associated with specific files MUST include a `files` key in its `metadata` object. The value is an array of affected file paths relative to the project root.
|
||||
|
||||
```python
|
||||
add_memory(
|
||||
messages=[{"role": "user", "content": "The auth middleware lives in src/middleware/auth.ts and validates JWTs using the shared key in config/secrets.ts."}],
|
||||
user_id="<active user_id>",
|
||||
app_id="<active project_id>",
|
||||
metadata={
|
||||
"type": "architecture_decisions",
|
||||
"branch": "<active branch>",
|
||||
"confidence": 0.8,
|
||||
"files": ["src/middleware/auth.ts", "config/secrets.ts"],
|
||||
},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
**Filtering by files:** Use the `contains` operator to filter by `metadata.files` at search time:
|
||||
|
||||
```python
|
||||
search_memories(
|
||||
query="auth middleware",
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": "<id>"},
|
||||
{"app_id": "<project_id>"},
|
||||
{"metadata.files": {"contains": "src/middleware/auth.ts"}},
|
||||
]
|
||||
},
|
||||
limit=5,
|
||||
)
|
||||
```
|
||||
|
||||
Also embed bare filenames in the memory content text as a fallback — the vector search will surface them even if the structured filter misses.
|
||||
|
||||
### Access counter: track memory usage
|
||||
|
||||
When you retrieve a memory via `search_memories` and **actually use it** in your response (i.e., it informed your answer or you cited it), increment its access counter and update the last-accessed timestamp by calling:
|
||||
|
||||
```python
|
||||
# 1. Read current state
|
||||
mem = get_memory(memory_id=<id>)
|
||||
current_text = mem["content"] # or mem["memory"], depending on response shape
|
||||
current_meta = mem.get("metadata", {})
|
||||
|
||||
# 2. Bump access_count and set last_accessed
|
||||
import datetime
|
||||
current_meta["access_count"] = current_meta.get("access_count", 0) + 1
|
||||
current_meta["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
# 3. Update with preserved content and bumped metadata
|
||||
update_memory(
|
||||
memory_id=<id>,
|
||||
data=current_text, # preserve original text — required parameter
|
||||
metadata=current_meta, # pass updated access_count and last_accessed
|
||||
)
|
||||
```
|
||||
|
||||
**Important:** `update_memory` requires the `data` (text) parameter. Always `get_memory` first to read the current content, then pass it back unchanged. A metadata-only update may error or wipe the content.
|
||||
|
||||
**When to increment:** Only when you actually used the memory to answer. Don't bump on every search hit — that inflates counts for memories that were returned but irrelevant. Aim for 1-3 bumps per response at most.
|
||||
|
||||
**Why:** `access_count` and `last_accessed` feed into `/mem0:dream` pruning decisions. Memories that are never accessed after creation are candidates for cleanup. Frequently accessed memories are protected from pruning regardless of age.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
name: mem0-onboard
|
||||
description: >
|
||||
Post-install onboarding wizard for the mem0 plugin.
|
||||
Detects CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md
|
||||
and offers to import them. Installs coding categories. Shows active identity.
|
||||
TRIGGER: user runs /mem0:onboard, or mentions "setup mem0", "configure mem0 plugin".
|
||||
---
|
||||
|
||||
# Mem0 Onboarding Wizard
|
||||
|
||||
Run this wizard to set up the mem0 plugin for the current project. Complete in ~30 seconds.
|
||||
|
||||
## Step 0: Ensure mem0ai SDK is installed
|
||||
|
||||
The plugin installs the `mem0ai` Python SDK automatically on session start via a venv in `${CLAUDE_PLUGIN_DATA}/venv`. If Step 4 (categories) fails with an import error, run:
|
||||
|
||||
```bash
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
|
||||
```
|
||||
|
||||
This is silent and idempotent — safe to run anytime.
|
||||
|
||||
## Step 1: Verify API key and MCP connection
|
||||
|
||||
Check that mem0 MCP tools are available. Use ToolSearch with query `"mem0 search_memories"` — the exact tool name varies by install method (may be `mcp__mem0__search_memories` or `mcp__plugin_mem0_mem0__search_memories`).
|
||||
|
||||
- If **any mem0 search tool found**: Proceed to Step 2. The API key is working.
|
||||
- If **NOT found**: The MCP server failed to connect. Tell the user:
|
||||
1. "MCP server not connected. Make sure `MEM0_API_KEY` is exported in your shell."
|
||||
2. Show: `export MEM0_API_KEY="m0-your-key-here"` then restart Claude Code.
|
||||
3. If they need a key: https://app.mem0.ai/dashboard/api-keys or `mem0 init --agent --json`
|
||||
4. **STOP here.** Do not proceed — all other steps need MCP tools.
|
||||
|
||||
## Step 2: Show identity
|
||||
|
||||
Report the active identity to the user:
|
||||
- Call `search_memories` with `query="project setup"`, `user_id=<active_user_id>`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`, `limit=1` to verify connectivity.
|
||||
- Print: `Connected. user=<user_id>, project=<project_id>, branch=<branch>`
|
||||
- If the search fails, troubleshoot the API key.
|
||||
|
||||
## Step 3: Detect and import project files
|
||||
|
||||
Check for these files in the project root:
|
||||
1. `CLAUDE.md`
|
||||
2. `AGENTS.md`
|
||||
3. `.cursorrules`
|
||||
4. `.windsurfrules`
|
||||
5. `mem0.md`
|
||||
|
||||
For each file found, ask the user: "Found `<filename>` (<size> bytes). Import into mem0? [Y/n]"
|
||||
|
||||
If user says yes (or default):
|
||||
- Read the file content
|
||||
- Call `add_memory` with:
|
||||
- `messages=[{"role": "user", "content": "## Project Profile: <filename>\n\nProject: <project_id>\n\n<file_content>"}]`
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
- `metadata={"type": "project_profile", "file": "<filename>", "source": "onboard", "branch": "<active_branch>"}`
|
||||
- `infer=False`
|
||||
|
||||
## Step 4: Install coding categories
|
||||
|
||||
Ask: "Install coding categories optimized for development workflows? [Y/n]"
|
||||
|
||||
If yes, run the setup script using the plugin's venv python:
|
||||
|
||||
```bash
|
||||
VENV_PY="${CLAUDE_PLUGIN_DATA}/venv/bin/python3"
|
||||
if [ -x "${VENV_PY}" ]; then
|
||||
"${VENV_PY}" "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
|
||||
else
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
|
||||
fi
|
||||
```
|
||||
|
||||
If the script fails with "mem0ai SDK not found", run the dependency installer first:
|
||||
```bash
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
|
||||
```
|
||||
Then retry the categories script.
|
||||
|
||||
## Step 5: Mark project as onboarded
|
||||
|
||||
Create a marker file so SessionStart won't re-trigger onboarding next session:
|
||||
|
||||
```bash
|
||||
_SAFE_PID=$(printf '%s' "<active_project_id>" | tr '/:' '--')
|
||||
mkdir -p ~/.mem0 && touch ~/.mem0/.onboarded_${_SAFE_PID}
|
||||
```
|
||||
|
||||
This is silent — no user-facing output needed.
|
||||
|
||||
## Step 6: Summary
|
||||
|
||||
Print a summary:
|
||||
```
|
||||
Onboarding complete.
|
||||
user_id: <user_id>
|
||||
project_id: <project_id> (app_id)
|
||||
imported: <N> files
|
||||
categories: <installed or skipped>
|
||||
|
||||
Memory is now active for this project. Start working — mem0 will
|
||||
automatically search relevant context and capture learnings.
|
||||
|
||||
Run /mem0:tour to see what mem0 already knows about this project.
|
||||
```
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: mem0-peek
|
||||
description: >
|
||||
Quick search — compact one-liner results. Faster and lighter than /mem0:tour.
|
||||
Takes a search query as argument.
|
||||
TRIGGER: user runs /mem0:peek <query>, or says "quick search mem0",
|
||||
"what do we know about X", "peek at memories about Y".
|
||||
---
|
||||
|
||||
# Mem0 Peek
|
||||
|
||||
Quick search with compact output. Lighter than `/mem0:tour`.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Parse query
|
||||
|
||||
The user provides a search query: `/mem0:peek auth middleware`
|
||||
|
||||
If no query provided, ask: "What should I search for?"
|
||||
|
||||
### Step 2: Search
|
||||
|
||||
Run 2 parallel `search_memories` calls:
|
||||
|
||||
1. Broad: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
||||
2. Targeted: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
|
||||
|
||||
### Step 3: Display
|
||||
|
||||
Deduplicate by ID, then show compact results:
|
||||
|
||||
```
|
||||
## mem0 peek: "<query>" (<N> results)
|
||||
|
||||
1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1]
|
||||
2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a]
|
||||
3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7]
|
||||
```
|
||||
|
||||
Format: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
|
||||
|
||||
If no results:
|
||||
```
|
||||
No memories matching "<query>" for project <project_id>.
|
||||
```
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: mem0-pin
|
||||
description: >
|
||||
Pin important memories so they surface prominently. Updates metadata to mark
|
||||
a memory as pinned. Pinned memories should be prioritized during search.
|
||||
TRIGGER: user runs /mem0:pin <query or ID>, or says "pin this memory",
|
||||
"mark as important", "always remember this".
|
||||
---
|
||||
|
||||
# Mem0 Pin
|
||||
|
||||
Pin a memory to mark it as high-priority.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Find the memory
|
||||
|
||||
The user provides either a search query or memory ID.
|
||||
|
||||
**If memory ID:**
|
||||
- Call `get_memory` with the ID.
|
||||
|
||||
**If search query:**
|
||||
- Call `search_memories` with the query, `user_id`, `app_id`, `limit=5`.
|
||||
- Show numbered list with content previews.
|
||||
- Ask: "Which memory to pin? Enter a number."
|
||||
|
||||
### Step 2: Read current content
|
||||
|
||||
Call `get_memory` with the selected memory ID. Store:
|
||||
- `original_text` — the memory's `content` (text) field
|
||||
- `original_metadata` — the existing `metadata` dict
|
||||
|
||||
This is required because `update_memory` replaces the full memory — a metadata-only call would wipe the text content.
|
||||
|
||||
### Step 3: Pin it
|
||||
|
||||
Call `update_memory` with:
|
||||
- `memory_id=<selected_id>`
|
||||
- `data=<original_text>` (preserve the existing content)
|
||||
- `metadata=` merge `original_metadata` with `{"pinned": true}`
|
||||
|
||||
Example:
|
||||
```python
|
||||
updated_meta = {**original_metadata, "pinned": True}
|
||||
update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_meta)
|
||||
```
|
||||
|
||||
**Important:** `update_memory` requires the `data` (text) parameter. Passing only metadata may error or wipe content. Always read first, then update with the full text and explicit metadata.
|
||||
|
||||
### Step 4: Confirm
|
||||
|
||||
```
|
||||
Pinned: "<memory content, first 80 chars>..."
|
||||
Memory ID: <id>
|
||||
Pinned memories surface first when relevant to a search.
|
||||
```
|
||||
|
||||
### Unpin
|
||||
|
||||
If the user says "unpin" or `/mem0:unpin`:
|
||||
1. Call `get_memory` to read current content and metadata.
|
||||
2. Set `metadata.pinned = false` explicitly:
|
||||
```python
|
||||
updated_meta = {**original_metadata, "pinned": False}
|
||||
update_memory(memory_id=<id>, data=<original_text>, metadata=updated_meta)
|
||||
```
|
||||
3. Print: `Unpinned: "<content>..."`
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: mem0-remember
|
||||
description: >
|
||||
Quick-add a memory from the user's input. No extraction pass — stores verbatim.
|
||||
TRIGGER: user runs /mem0:remember <text>, or says "remember this", "save this",
|
||||
"store this fact", "don't forget that".
|
||||
---
|
||||
|
||||
# Mem0 Remember
|
||||
|
||||
Store a fact or learning directly into mem0.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Extract the content
|
||||
|
||||
The user provides the content as an argument: `/mem0:remember <text>`
|
||||
|
||||
If no text was provided, ask: "What should I remember?"
|
||||
|
||||
### Step 2: Classify the memory
|
||||
|
||||
Based on the content, pick the best `metadata.type`:
|
||||
|
||||
| Content signal | Type |
|
||||
|---|---|
|
||||
| "we decided...", "always use...", "never..." | `decision` |
|
||||
| "X doesn't work because...", "don't try..." | `anti_pattern` |
|
||||
| "I prefer...", "use X instead of Y" | `user_preference` |
|
||||
| "the convention is...", "we always..." | `convention` |
|
||||
| "learned that...", "figured out..." | `task_learning` |
|
||||
| setup, env, tooling, config | `environmental` |
|
||||
| anything else | `task_learning` |
|
||||
|
||||
### Step 3: Store
|
||||
|
||||
Call `add_memory` with:
|
||||
- `messages=[{"role": "user", "content": "<the user's text>"}]`
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
- `metadata={"type": "<classified_type>", "branch": "<active_branch>", "confidence": 1.0, "source": "remember_command"}`
|
||||
- `infer=False`
|
||||
|
||||
`infer=False` because the user stated the fact explicitly — no extraction needed.
|
||||
`confidence=1.0` because the user explicitly asked to store this.
|
||||
|
||||
### Step 4: Confirm
|
||||
|
||||
Print:
|
||||
```
|
||||
Remembered as <type>: "<first 80 chars of content>..."
|
||||
Memory ID: <id>
|
||||
```
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: mem0-stats
|
||||
description: >
|
||||
Show memory statistics for the current session and project lifetime.
|
||||
Combines local session counters with API-fetched totals.
|
||||
TRIGGER: user runs /mem0:stats, or asks "how many memories", "mem0 stats",
|
||||
"memory usage", "show memory count".
|
||||
---
|
||||
|
||||
# Mem0 Stats
|
||||
|
||||
Show session and lifetime memory statistics.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Gather session stats
|
||||
|
||||
Run the session stats reporter:
|
||||
|
||||
```bash
|
||||
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts"
|
||||
python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null || echo "{}"
|
||||
```
|
||||
|
||||
The `peek` command returns JSON without clearing the stats file (unlike `report`).
|
||||
|
||||
If the script returns empty or errors, note "No session data available" and continue.
|
||||
|
||||
### Step 2: Fetch lifetime stats from API
|
||||
|
||||
Call `get_memories` with:
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
- `page_size=100`
|
||||
|
||||
Count the returned memories. Group them by:
|
||||
1. `categories[0]` (platform-assigned) — primary grouping
|
||||
2. `metadata.type` (agent-assigned) — secondary if no categories
|
||||
3. `created_at` date — for age analysis
|
||||
|
||||
Also run a `search_memories` call with `query="project"`, `limit=1` to measure round-trip latency (time the call).
|
||||
|
||||
### Step 3: Display
|
||||
|
||||
Print a compact dashboard with an ASCII histogram for category distribution:
|
||||
|
||||
```
|
||||
## mem0 Stats
|
||||
|
||||
### This Session
|
||||
Memories written: <N>
|
||||
Searches run: <N>
|
||||
Categories touched: <list>
|
||||
|
||||
### Project Lifetime (<project_id>)
|
||||
Total memories: <N>
|
||||
|
||||
By category:
|
||||
decision ████████████████ 24
|
||||
convention ██████████░░░░░░ 15
|
||||
anti_pattern ████░░░░░░░░░░░░ 6
|
||||
task_learning ███░░░░░░░░░░░░░ 5
|
||||
user_preference ██░░░░░░░░░░░░░░ 3
|
||||
session_state █░░░░░░░░░░░░░░░ 2
|
||||
|
||||
By age:
|
||||
< 7 days ████████████████ 5
|
||||
7–30 days ██████████░░░░░░ 12
|
||||
30–90 days ████░░░░░░░░░░░░ 10
|
||||
> 90 days ██░░░░░░░░░░░░░░ 8
|
||||
|
||||
By access count:
|
||||
Never accessed ████████████████ 18
|
||||
1–5 accesses ████████░░░░░░░░ 10
|
||||
6–20 accesses ████░░░░░░░░░░░░ 4
|
||||
20+ accesses █░░░░░░░░░░░░░░░ 3
|
||||
|
||||
Oldest memory: <date>
|
||||
Newest memory: <date>
|
||||
|
||||
### Health
|
||||
API latency: <N>ms
|
||||
User: <user_id>
|
||||
Project: <project_id>
|
||||
Branch: <branch>
|
||||
```
|
||||
|
||||
**Histogram rules:**
|
||||
- Max bar width: 16 characters. Scale all bars relative to the highest count.
|
||||
- Use `█` for filled and `░` for empty. Right-align the count number.
|
||||
- Sort categories by count descending. Omit categories with 0 memories.
|
||||
- If only 1-2 categories exist, still show the histogram — it provides visual context.
|
||||
- **Age buckets:** Compute from `created_at`. Buckets: <7d, 7–30d, 30–90d, >90d.
|
||||
- **Access count buckets:** Read `metadata.access_count` (default 0 if absent). Buckets: 0, 1–5, 6–20, 20+.
|
||||
|
||||
Skip any section with zero data.
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: mem0-switch-project
|
||||
description: >
|
||||
Manually override project_id for the current directory.
|
||||
Useful for monorepos, nested git dirs, or non-git directories.
|
||||
TRIGGER: user runs /mem0:switch-project <name>, or asks "switch mem0 project",
|
||||
"change project scope", "override project_id".
|
||||
---
|
||||
|
||||
# Mem0 Switch Project
|
||||
|
||||
Override the automatic project_id detection for the current directory.
|
||||
|
||||
## Usage
|
||||
|
||||
The user provides a project name as an argument: `/mem0:switch-project <project-name>`
|
||||
|
||||
## Execution
|
||||
|
||||
1. If no project name was given, ask: "What project_id should this directory use?"
|
||||
|
||||
2. Write the mapping to `~/.mem0/project_map.json` using the Bash tool:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, os
|
||||
map_file = os.path.expanduser('~/.mem0/project_map.json')
|
||||
mapping = {}
|
||||
if os.path.isfile(map_file):
|
||||
with open(map_file) as f:
|
||||
mapping = json.load(f)
|
||||
mapping[os.getcwd()] = '<PROJECT_NAME>'
|
||||
os.makedirs(os.path.dirname(map_file), exist_ok=True)
|
||||
with open(map_file, 'w') as f:
|
||||
json.dump(mapping, f, indent=2)
|
||||
print(f'Mapped {os.getcwd()} -> <PROJECT_NAME>')
|
||||
"
|
||||
```
|
||||
|
||||
(Replace `<PROJECT_NAME>` with the user's chosen project name.)
|
||||
|
||||
3. Verify by searching for existing memories:
|
||||
- Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<PROJECT_NAME>"}]}`, `limit=1`
|
||||
|
||||
4. Print:
|
||||
```
|
||||
Switched to project <PROJECT_NAME>.
|
||||
<N> memories found for this project.
|
||||
Note: This override persists across sessions for this directory.
|
||||
```
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
name: mem0-tour
|
||||
description: >
|
||||
Show what mem0 knows about the current project. Dumps top memories
|
||||
grouped by category. Power-user-friendly proof of value.
|
||||
TRIGGER: user runs /mem0:tour, or asks "what do you know about this project",
|
||||
"show me my memories", "what has mem0 stored".
|
||||
---
|
||||
|
||||
# Mem0 Project Tour
|
||||
|
||||
Show the user what mem0 has stored for the current project.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Fetch ALL memories for this project
|
||||
|
||||
Call `get_memories` with:
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
|
||||
This returns every memory scoped to the project — no semantic filtering, no missed results.
|
||||
|
||||
If `get_memories` doesn't support `app_id` as a direct parameter, use:
|
||||
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`
|
||||
|
||||
Pass `page_size=100` (or the maximum allowed) to get a full picture.
|
||||
|
||||
### Step 2: Run supplementary semantic searches
|
||||
|
||||
In parallel, run these `search_memories` calls to get relevance-ranked results for key topics:
|
||||
|
||||
- `query="architecture decisions design choices"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
||||
- `query="bugs errors failures anti-patterns"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
||||
- `query="project setup tooling conventions preferences"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
||||
|
||||
**Do NOT filter by `metadata.type` in these calls.** The platform auto-assigns `categories` — filtering on `metadata.type` misses memories that were auto-categorized but don't have an explicit `metadata.type`.
|
||||
|
||||
### Step 3: Merge and group
|
||||
|
||||
Merge all results by memory ID (deduplicate). For each memory, determine its group using this priority:
|
||||
|
||||
1. **Platform `categories` field** (array on each memory, auto-assigned by Mem0). Use the first category value.
|
||||
2. **`metadata.type` field** (if present, set explicitly by hooks/agent). Use as fallback if no `categories`.
|
||||
3. **"other"** bucket for memories with neither.
|
||||
|
||||
Map category names to display names:
|
||||
|
||||
| Platform category / metadata.type | Display name |
|
||||
|---|---|
|
||||
| `architecture decisions`, `architecture_decisions`, `decision` | Architecture Decisions |
|
||||
| `anti patterns`, `anti_patterns`, `anti_pattern` | Anti-Patterns |
|
||||
| `task learnings`, `task_learnings`, `task_learning` | Task Learnings |
|
||||
| `coding conventions`, `coding_conventions`, `convention` | Coding Conventions |
|
||||
| `user preferences`, `user_preferences`, `user_preference` | User Preferences |
|
||||
| `project profile`, `project_profile` | Project Profile |
|
||||
| `tooling setup`, `tooling_setup`, `environmental` | Tooling & Setup |
|
||||
| `technology`, `professional_details` | Tooling & Setup |
|
||||
| `session_state` | Session State |
|
||||
| `compact_summary` | Compact Summaries |
|
||||
| anything else | Other |
|
||||
|
||||
### Step 4: Display results
|
||||
|
||||
For each group that has results, print:
|
||||
|
||||
```
|
||||
## <display_name> (<count> memories)
|
||||
- <full_memory_content> (score: <similarity_score_if_available>)
|
||||
- ...
|
||||
```
|
||||
|
||||
Show the **full memory text** for each entry — do NOT truncate. If a group has more than 10 entries, show top 10 by recency (or similarity score if from a search call) and note `... and <N> more`.
|
||||
|
||||
For groups with zero results, skip them entirely — don't print empty groups.
|
||||
|
||||
### Step 5: Print totals
|
||||
|
||||
```
|
||||
---
|
||||
Total: <N> unique memories across <M> categories for project <project_id>
|
||||
Branch: <active_branch>
|
||||
```
|
||||
|
||||
### Step 6: Empty state
|
||||
|
||||
If zero memories found for this project, print:
|
||||
```
|
||||
No memories stored yet for project <project_id>.
|
||||
Run /mem0:onboard to import project files, or start working — mem0 captures learnings automatically.
|
||||
```
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Shared fixtures for mem0-plugin tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scripts_on_path():
|
||||
"""Ensure scripts/ is on sys.path so we can import _project, session_stats, etc."""
|
||||
abs_scripts = os.path.abspath(SCRIPTS_DIR)
|
||||
if abs_scripts not in sys.path:
|
||||
sys.path.insert(0, abs_scripts)
|
||||
yield
|
||||
if abs_scripts in sys.path:
|
||||
sys.path.remove(abs_scripts)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_git_repo(tmp_path):
|
||||
"""Create a temp dir with a git repo and HTTPS remote."""
|
||||
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_git_repo_ssh(tmp_path):
|
||||
"""Create a temp dir with a git repo and SSH remote."""
|
||||
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "git@github.com:acme/cool-project.git"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_no_git(tmp_path):
|
||||
"""Temp dir with no git repo."""
|
||||
return tmp_path
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Tests for setup_coding_categories.py -- CODING_CATEGORIES list completeness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
EXPECTED_KEYS = [
|
||||
"architecture_decisions",
|
||||
"anti_patterns",
|
||||
"task_learnings",
|
||||
"tooling_setup",
|
||||
"bug_fixes",
|
||||
"coding_conventions",
|
||||
"user_preferences",
|
||||
"dependency_decisions",
|
||||
"performance_findings",
|
||||
"security_constraints",
|
||||
"testing_patterns",
|
||||
"data_model",
|
||||
"api_contracts",
|
||||
"deployment_runbook",
|
||||
"team_norms",
|
||||
"domain_glossary",
|
||||
"experiment_results",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def coding_categories():
|
||||
"""Import CODING_CATEGORIES from setup_coding_categories, ensuring scripts/ is on path."""
|
||||
abs_scripts = os.path.abspath(SCRIPTS_DIR)
|
||||
inserted = False
|
||||
if abs_scripts not in sys.path:
|
||||
sys.path.insert(0, abs_scripts)
|
||||
inserted = True
|
||||
# Force re-import in case another test already loaded a stale version
|
||||
mod_name = "setup_coding_categories"
|
||||
if mod_name in sys.modules:
|
||||
del sys.modules[mod_name]
|
||||
mod = importlib.import_module(mod_name)
|
||||
yield mod.CODING_CATEGORIES
|
||||
if inserted and abs_scripts in sys.path:
|
||||
sys.path.remove(abs_scripts)
|
||||
|
||||
|
||||
def test_total_count(coding_categories):
|
||||
"""CODING_CATEGORIES must contain exactly 17 entries."""
|
||||
assert len(coding_categories) == 17, (
|
||||
f"Expected 17 categories, found {len(coding_categories)}: "
|
||||
f"{[list(c.keys())[0] for c in coding_categories]}"
|
||||
)
|
||||
|
||||
|
||||
def test_all_expected_keys_present(coding_categories):
|
||||
"""Every expected category key must appear exactly once."""
|
||||
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
|
||||
for key in EXPECTED_KEYS:
|
||||
assert key in actual_keys, f"Missing expected category key: '{key}'"
|
||||
|
||||
|
||||
def test_no_duplicate_keys(coding_categories):
|
||||
"""No category key may appear more than once."""
|
||||
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
|
||||
seen = set()
|
||||
duplicates = []
|
||||
for key in actual_keys:
|
||||
if key in seen:
|
||||
duplicates.append(key)
|
||||
seen.add(key)
|
||||
assert not duplicates, f"Duplicate category keys found: {duplicates}"
|
||||
|
||||
|
||||
def test_each_description_is_non_empty_string(coding_categories):
|
||||
"""Every category must have a non-empty string description."""
|
||||
for cat in coding_categories:
|
||||
assert len(cat) == 1, f"Category dict should have exactly one key, got: {cat}"
|
||||
key = list(cat.keys())[0]
|
||||
description = cat[key]
|
||||
assert isinstance(description, str), (
|
||||
f"Category '{key}' description is not a string: {type(description)}"
|
||||
)
|
||||
assert description.strip(), f"Category '{key}' has an empty description"
|
||||
@@ -1,355 +0,0 @@
|
||||
"""Tests for import_competing_tools.py — competing tool file importers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split_sections tests (unit tests on the splitter functions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_split_by_headers_cursorrules():
|
||||
"""split_by_headers correctly splits .cursorrules content on ## headers."""
|
||||
from import_competing_tools import split_by_headers
|
||||
|
||||
content = """\
|
||||
# My Cursor Rules
|
||||
|
||||
Some preamble text that belongs to the first section.
|
||||
|
||||
## TypeScript Conventions
|
||||
|
||||
Always use strict mode. Prefer const over let.
|
||||
Never use var.
|
||||
|
||||
## React Patterns
|
||||
|
||||
Use functional components with hooks.
|
||||
Avoid class components.
|
||||
|
||||
## Testing
|
||||
|
||||
Write tests for all utility functions.
|
||||
"""
|
||||
chunks = split_by_headers(content, "## ")
|
||||
assert len(chunks) == 4 # preamble + 3 sections
|
||||
|
||||
# First chunk is the preamble (before any ## header)
|
||||
assert "preamble text" in chunks[0]
|
||||
|
||||
# Remaining chunks start with their header
|
||||
assert chunks[1].startswith("## TypeScript Conventions")
|
||||
assert "strict mode" in chunks[1]
|
||||
|
||||
assert chunks[2].startswith("## React Patterns")
|
||||
assert "functional components" in chunks[2]
|
||||
|
||||
assert chunks[3].startswith("## Testing")
|
||||
assert "utility functions" in chunks[3]
|
||||
|
||||
|
||||
def test_split_by_headers_copilot():
|
||||
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
|
||||
from import_competing_tools import split_by_headers
|
||||
|
||||
content = """\
|
||||
## Code Style
|
||||
|
||||
Use 2-space indentation. Always add trailing commas.
|
||||
|
||||
## Architecture
|
||||
|
||||
Follow clean architecture principles. Keep business logic in domain layer.
|
||||
"""
|
||||
chunks = split_by_headers(content, "## ")
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].startswith("## Code Style")
|
||||
assert "2-space indentation" in chunks[0]
|
||||
assert chunks[1].startswith("## Architecture")
|
||||
assert "clean architecture" in chunks[1]
|
||||
|
||||
|
||||
def test_split_by_headers_no_headers():
|
||||
"""split_by_headers returns entire content as one chunk if no headers found."""
|
||||
from import_competing_tools import split_by_headers
|
||||
|
||||
content = "This file has no headers at all. Just plain text."
|
||||
chunks = split_by_headers(content, "## ")
|
||||
assert len(chunks) == 1
|
||||
assert "Just plain text" in chunks[0]
|
||||
|
||||
|
||||
def test_split_cline_multiple_md_files(tmp_path):
|
||||
"""cmd_cline processes multiple .md files from memory-bank/ directory."""
|
||||
from import_competing_tools import filter_and_truncate
|
||||
|
||||
# Create a temporary memory-bank directory with .md files
|
||||
mb_dir = tmp_path / "memory-bank"
|
||||
mb_dir.mkdir()
|
||||
|
||||
(mb_dir / "architecture.md").write_text(
|
||||
"# Architecture Decisions\n\nUse microservices architecture with event sourcing."
|
||||
)
|
||||
(mb_dir / "conventions.md").write_text(
|
||||
"# Code Conventions\n\nAll functions must have type hints. Use black formatter."
|
||||
)
|
||||
(mb_dir / "empty.md").write_text("") # empty file should be skipped
|
||||
|
||||
# Read and verify we can split the files
|
||||
md_files = sorted(f for f in os.listdir(str(mb_dir)) if f.endswith(".md"))
|
||||
assert "architecture.md" in md_files
|
||||
assert "conventions.md" in md_files
|
||||
assert "empty.md" in md_files
|
||||
|
||||
non_empty = []
|
||||
for filename in md_files:
|
||||
filepath = os.path.join(str(mb_dir), filename)
|
||||
with open(filepath) as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
chunks = filter_and_truncate([content])
|
||||
non_empty.extend(chunks)
|
||||
|
||||
assert len(non_empty) == 2
|
||||
assert any("microservices" in c for c in non_empty)
|
||||
assert any("type hints" in c for c in non_empty)
|
||||
|
||||
|
||||
def test_split_by_hr_or_headers_continue():
|
||||
"""split_by_hr_or_headers correctly splits .continue/rules.md."""
|
||||
from import_competing_tools import split_by_hr_or_headers
|
||||
|
||||
content = """\
|
||||
## First Section
|
||||
|
||||
Content of first section.
|
||||
|
||||
---
|
||||
|
||||
## Second Section
|
||||
|
||||
Content of second section.
|
||||
|
||||
---
|
||||
|
||||
Third section without a header (just after HR).
|
||||
"""
|
||||
chunks = split_by_hr_or_headers(content)
|
||||
# Should split into meaningful chunks
|
||||
assert len(chunks) >= 2
|
||||
assert any("First Section" in c for c in chunks)
|
||||
assert any("Second Section" in c for c in chunks)
|
||||
|
||||
|
||||
def test_filter_and_truncate_skips_short():
|
||||
"""filter_and_truncate skips chunks shorter than MIN_CHUNK_CHARS (50)."""
|
||||
from import_competing_tools import filter_and_truncate
|
||||
|
||||
chunks = [
|
||||
"Short", # < 50 chars, should be filtered
|
||||
"A" * 49, # exactly 49 chars, should be filtered
|
||||
"A" * 50, # exactly 50 chars, should be kept
|
||||
"A long enough chunk that definitely passes the minimum length filter.",
|
||||
]
|
||||
result = filter_and_truncate(chunks)
|
||||
assert len(result) == 2
|
||||
assert all(len(c) >= 50 for c in result)
|
||||
|
||||
|
||||
def test_filter_and_truncate_truncates_long():
|
||||
"""filter_and_truncate truncates chunks over MAX_CHUNK_CHARS (10000)."""
|
||||
from import_competing_tools import MAX_CHUNK_CHARS, filter_and_truncate
|
||||
|
||||
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
|
||||
result = filter_and_truncate([long_chunk])
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == MAX_CHUNK_CHARS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock API tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_response(status: int = 201, body: dict | None = None) -> mock.MagicMock:
|
||||
"""Create a mock HTTP response object."""
|
||||
if body is None:
|
||||
body = {"id": "new-mem-id", "memory": "test"}
|
||||
resp = mock.MagicMock()
|
||||
resp.status = status
|
||||
resp.read.return_value = json.dumps(body).encode()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = mock.MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
|
||||
def test_cursorrules_import_api_call(tmp_path):
|
||||
"""cmd_cursorrules calls the API with correct app_id (top-level), infer=False, and correct source."""
|
||||
from import_competing_tools import cmd_cursorrules
|
||||
|
||||
# Create a .cursorrules file with enough content
|
||||
cursorrules = tmp_path / ".cursorrules"
|
||||
cursorrules.write_text(
|
||||
"## TypeScript Rules\n\nAlways use strict TypeScript. Never use 'any' type. "
|
||||
"Prefer interfaces over type aliases for object shapes."
|
||||
)
|
||||
|
||||
captured_requests: list[dict] = []
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
body = json.loads(req.data.decode())
|
||||
captured_requests.append(body)
|
||||
return _make_mock_response(201)
|
||||
|
||||
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-testkey"), \
|
||||
mock.patch("import_competing_tools.resolve_user_id", return_value="testuser"), \
|
||||
mock.patch("import_competing_tools.resolve_project_id", return_value="my-project"), \
|
||||
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
|
||||
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
os.chdir(str(tmp_path))
|
||||
try:
|
||||
cmd_cursorrules(["--path", str(cursorrules)])
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
assert len(captured_requests) >= 1
|
||||
|
||||
req_body = captured_requests[0]
|
||||
|
||||
# app_id must be top-level (not inside metadata)
|
||||
assert req_body["app_id"] == "my-project", f"Expected app_id at top level, got: {req_body}"
|
||||
|
||||
# infer must be False (not "false", but the boolean False)
|
||||
assert req_body["infer"] is False, f"Expected infer=False, got: {req_body['infer']}"
|
||||
|
||||
# source must be cursor-import
|
||||
assert req_body["metadata"]["source"] == "cursor-import", (
|
||||
f"Expected source=cursor-import, got: {req_body['metadata'].get('source')}"
|
||||
)
|
||||
|
||||
# user_id must be set
|
||||
assert req_body["user_id"] == "testuser"
|
||||
|
||||
# messages must be a list with role/content
|
||||
assert isinstance(req_body["messages"], list)
|
||||
assert req_body["messages"][0]["role"] == "user"
|
||||
assert len(req_body["messages"][0]["content"]) > 0
|
||||
|
||||
|
||||
def test_copilot_import_api_call(tmp_path):
|
||||
"""cmd_copilot calls the API with source=copilot-import."""
|
||||
from import_competing_tools import cmd_copilot
|
||||
|
||||
copilot_dir = tmp_path / ".github"
|
||||
copilot_dir.mkdir()
|
||||
copilot_file = copilot_dir / "copilot-instructions.md"
|
||||
copilot_file.write_text(
|
||||
"## Code Style\n\nUse 2-space indentation. Always add trailing commas in multi-line structures. "
|
||||
"Prefer const over let. Never use var in JavaScript code."
|
||||
)
|
||||
|
||||
captured: list[dict] = []
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
captured.append(json.loads(req.data.decode()))
|
||||
return _make_mock_response(201)
|
||||
|
||||
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
|
||||
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
|
||||
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
|
||||
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
|
||||
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
|
||||
cmd_copilot(["--path", str(copilot_file)])
|
||||
|
||||
assert len(captured) >= 1
|
||||
assert captured[0]["metadata"]["source"] == "copilot-import"
|
||||
assert captured[0]["app_id"] == "proj1"
|
||||
assert captured[0]["infer"] is False
|
||||
|
||||
|
||||
def test_cline_import_multiple_files(tmp_path):
|
||||
"""cmd_cline imports one memory per non-empty .md file."""
|
||||
from import_competing_tools import cmd_cline
|
||||
|
||||
mb = tmp_path / "memory-bank"
|
||||
mb.mkdir()
|
||||
(mb / "arch.md").write_text(
|
||||
"Architecture: microservices with event-sourcing. Each service owns its database. "
|
||||
"Communication via message bus only. No direct service-to-service HTTP calls."
|
||||
)
|
||||
(mb / "style.md").write_text(
|
||||
"Code style: PEP 8 for Python. Black formatter. isort for imports. "
|
||||
"Line length 120. Type hints required on all public functions and methods."
|
||||
)
|
||||
(mb / "empty.md").write_text("")
|
||||
|
||||
captured: list[dict] = []
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
captured.append(json.loads(req.data.decode()))
|
||||
return _make_mock_response(201)
|
||||
|
||||
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
|
||||
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
|
||||
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
|
||||
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
|
||||
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
|
||||
cmd_cline(["--path", str(mb)])
|
||||
|
||||
# Should have exactly 2 imports (empty.md skipped)
|
||||
assert len(captured) == 2
|
||||
sources = {r["metadata"]["source"] for r in captured}
|
||||
assert sources == {"cline-import"}
|
||||
for r in captured:
|
||||
assert r["infer"] is False
|
||||
assert r["app_id"] == "proj1"
|
||||
|
||||
|
||||
def test_no_api_key_does_not_call_api(tmp_path):
|
||||
"""When no API key is set, no HTTP call is made."""
|
||||
from import_competing_tools import cmd_cursorrules
|
||||
|
||||
cursorrules = tmp_path / ".cursorrules"
|
||||
cursorrules.write_text("## Rules\n\n" + "x" * 100)
|
||||
|
||||
with mock.patch("import_competing_tools.resolve_api_key", return_value=""), \
|
||||
mock.patch("urllib.request.urlopen") as mock_url:
|
||||
|
||||
cmd_cursorrules(["--path", str(cursorrules)])
|
||||
|
||||
mock_url.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_file_does_not_call_api(tmp_path):
|
||||
"""When the source file doesn't exist, no HTTP call is made."""
|
||||
from import_competing_tools import cmd_cursorrules
|
||||
|
||||
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
|
||||
mock.patch("urllib.request.urlopen") as mock_url:
|
||||
|
||||
cmd_cursorrules(["--path", str(tmp_path / "nonexistent.cursorrules")])
|
||||
|
||||
mock_url.assert_not_called()
|
||||
|
||||
|
||||
def test_main_unknown_subcommand_exits_zero():
|
||||
"""Calling main() with an unknown subcommand exits 0."""
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "import_competing_tools.py"), "unknown"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "MEM0_API_KEY": ""},
|
||||
)
|
||||
assert result.returncode == 0
|
||||
@@ -1,298 +0,0 @@
|
||||
"""Tests for parse_export_file.py — mem0 export file parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_blocks_single_valid_block():
|
||||
"""parse_blocks returns one record for a single valid block."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
content = """\
|
||||
---
|
||||
id: abc123
|
||||
created_at: 2024-01-15T10:00:00Z
|
||||
type: task_learnings
|
||||
confidence: 0.85
|
||||
branch: main
|
||||
files: src/foo.py, src/bar.py
|
||||
categories: coding_conventions, task_learnings
|
||||
---
|
||||
Always use context managers when opening files.
|
||||
"""
|
||||
records = parse_blocks(content)
|
||||
assert len(records) == 1
|
||||
r = records[0]
|
||||
assert r["id"] == "abc123"
|
||||
assert r["type"] == "task_learnings"
|
||||
assert r["confidence"] == "0.85"
|
||||
assert r["branch"] == "main"
|
||||
assert r["files"] == ["src/foo.py", "src/bar.py"]
|
||||
assert r["categories"] == ["coding_conventions", "task_learnings"]
|
||||
assert "Always use context managers" in r["content"]
|
||||
|
||||
|
||||
def test_parse_blocks_multiple_blocks():
|
||||
"""parse_blocks returns the correct number of records for multiple blocks."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
content = """\
|
||||
---
|
||||
id: mem001
|
||||
type: architecture_decisions
|
||||
confidence: 0.9
|
||||
branch: main
|
||||
files:
|
||||
categories: architecture_decisions
|
||||
---
|
||||
Use hexagonal architecture for the core domain.
|
||||
|
||||
---
|
||||
id: mem002
|
||||
type: anti_patterns
|
||||
confidence: 0.75
|
||||
branch: feat/refactor
|
||||
files: src/legacy.py
|
||||
categories: anti_patterns
|
||||
---
|
||||
Avoid direct database calls from view layer.
|
||||
|
||||
---
|
||||
id: mem003
|
||||
type: coding_conventions
|
||||
confidence: 0.8
|
||||
branch: main
|
||||
files: src/utils.py, src/helpers.py
|
||||
categories:
|
||||
---
|
||||
Use snake_case for all Python identifiers.
|
||||
"""
|
||||
records = parse_blocks(content)
|
||||
assert len(records) == 3
|
||||
|
||||
assert records[0]["id"] == "mem001"
|
||||
assert records[0]["categories"] == ["architecture_decisions"]
|
||||
assert "hexagonal architecture" in records[0]["content"]
|
||||
|
||||
assert records[1]["id"] == "mem002"
|
||||
assert records[1]["files"] == ["src/legacy.py"]
|
||||
assert "direct database calls" in records[1]["content"]
|
||||
|
||||
assert records[2]["id"] == "mem003"
|
||||
assert records[2]["files"] == ["src/utils.py", "src/helpers.py"]
|
||||
assert records[2]["categories"] == []
|
||||
assert "snake_case" in records[2]["content"]
|
||||
|
||||
|
||||
def test_parse_blocks_missing_optional_fields():
|
||||
"""parse_blocks uses defaults when optional fields are absent."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
# confidence, branch, files, categories all absent
|
||||
content = """\
|
||||
---
|
||||
id: xyz789
|
||||
type: task_learnings
|
||||
---
|
||||
Run tests before committing.
|
||||
"""
|
||||
records = parse_blocks(content)
|
||||
assert len(records) == 1
|
||||
r = records[0]
|
||||
assert r["id"] == "xyz789"
|
||||
assert r["confidence"] == "" # default empty string
|
||||
assert r["branch"] == "" # default empty string
|
||||
assert r["files"] == [] # default empty list
|
||||
assert r["categories"] == [] # default empty list
|
||||
assert "Run tests" in r["content"]
|
||||
|
||||
|
||||
def test_parse_blocks_filters_empty_content():
|
||||
"""parse_blocks skips blocks whose content is empty or whitespace-only."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
content = """\
|
||||
---
|
||||
id: empty1
|
||||
type: task_learnings
|
||||
---
|
||||
|
||||
---
|
||||
id: real1
|
||||
type: task_learnings
|
||||
---
|
||||
This block has real content.
|
||||
|
||||
---
|
||||
id: empty2
|
||||
type: coding_conventions
|
||||
---
|
||||
|
||||
"""
|
||||
records = parse_blocks(content)
|
||||
# Only the block with actual content should be returned
|
||||
assert len(records) == 1
|
||||
assert records[0]["id"] == "real1"
|
||||
assert "real content" in records[0]["content"]
|
||||
|
||||
|
||||
def test_parse_blocks_round_trip():
|
||||
"""Content formatted by export matches what parse_blocks expects."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
# Simulate the exact format produced by the export skill
|
||||
memory_id = "test-id-001"
|
||||
created_at = "2024-06-01T12:00:00Z"
|
||||
mem_type = "architecture_decisions"
|
||||
confidence = "0.92"
|
||||
branch = "feat/new-feature"
|
||||
files = ["src/main.py", "tests/test_main.py"]
|
||||
categories = ["architecture_decisions", "coding_conventions"]
|
||||
memory_content = "Use dependency injection for all service classes."
|
||||
|
||||
# Format exactly as the export skill would
|
||||
block = (
|
||||
"---\n"
|
||||
f"id: {memory_id}\n"
|
||||
f"created_at: {created_at}\n"
|
||||
f"type: {mem_type}\n"
|
||||
f"confidence: {confidence}\n"
|
||||
f"branch: {branch}\n"
|
||||
f"files: {', '.join(files)}\n"
|
||||
f"categories: {', '.join(categories)}\n"
|
||||
"---\n"
|
||||
f"{memory_content}\n"
|
||||
"\n"
|
||||
)
|
||||
|
||||
records = parse_blocks(block)
|
||||
assert len(records) == 1
|
||||
r = records[0]
|
||||
assert r["id"] == memory_id
|
||||
assert r["type"] == mem_type
|
||||
assert r["confidence"] == confidence
|
||||
assert r["branch"] == branch
|
||||
assert r["files"] == files
|
||||
assert r["categories"] == categories
|
||||
assert r["content"] == memory_content
|
||||
|
||||
|
||||
def test_parse_blocks_multiline_content():
|
||||
"""parse_blocks correctly captures multi-line memory content."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
content = """\
|
||||
---
|
||||
id: multi001
|
||||
type: task_learnings
|
||||
---
|
||||
Line one of the memory.
|
||||
Line two of the memory.
|
||||
|
||||
Line four after blank line.
|
||||
"""
|
||||
records = parse_blocks(content)
|
||||
assert len(records) == 1
|
||||
assert "Line one" in records[0]["content"]
|
||||
assert "Line two" in records[0]["content"]
|
||||
assert "Line four" in records[0]["content"]
|
||||
|
||||
|
||||
def test_parse_blocks_empty_input():
|
||||
"""parse_blocks returns empty list for empty input."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
assert parse_blocks("") == []
|
||||
assert parse_blocks(" \n ") == []
|
||||
|
||||
|
||||
def test_parse_blocks_no_blocks():
|
||||
"""parse_blocks returns empty list for content without any --- delimiters."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
assert parse_blocks("Just some text without any delimiters.") == []
|
||||
|
||||
|
||||
def test_parse_blocks_value_with_colon():
|
||||
"""parse_blocks handles values that themselves contain colons."""
|
||||
from parse_export_file import parse_blocks
|
||||
|
||||
content = """\
|
||||
---
|
||||
id: colon-test
|
||||
type: task_learnings
|
||||
created_at: 2024-01-01T10:00:00Z
|
||||
---
|
||||
Timestamp values contain colons and should parse correctly.
|
||||
"""
|
||||
records = parse_blocks(content)
|
||||
assert len(records) == 1
|
||||
assert records[0]["id"] == "colon-test"
|
||||
# created_at field should be captured (it's in the record if present)
|
||||
assert "2024-01-01T10:00:00Z" in records[0].get("created_at", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI / subprocess tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_main_cli_outputs_json(tmp_path):
|
||||
"""Running parse_export_file.py as a script outputs valid JSON."""
|
||||
export_file = tmp_path / "mem0-export-test.md"
|
||||
export_file.write_text("""\
|
||||
---
|
||||
id: cli-test-001
|
||||
type: task_learnings
|
||||
confidence: 0.8
|
||||
branch: main
|
||||
files:
|
||||
categories: task_learnings
|
||||
---
|
||||
Prefer composition over inheritance.
|
||||
""")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"), str(export_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
records = json.loads(result.stdout)
|
||||
assert isinstance(records, list)
|
||||
assert len(records) == 1
|
||||
assert records[0]["id"] == "cli-test-001"
|
||||
|
||||
|
||||
def test_main_cli_no_args_exits_zero():
|
||||
"""Running parse_export_file.py with no arguments exits 0 and prints []."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "[]"
|
||||
|
||||
|
||||
def test_main_cli_missing_file_exits_zero(tmp_path):
|
||||
"""Running parse_export_file.py with a missing file exits 0 and prints []."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"),
|
||||
str(tmp_path / "nonexistent.md")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "[]"
|
||||
@@ -1,424 +0,0 @@
|
||||
"""Tests for parse_mem0_config.py — mem0.md retention policy parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_retention — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_retention_valid_section():
|
||||
"""parse_retention extracts day-count policies correctly."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
# Project Config
|
||||
|
||||
## Retention
|
||||
|
||||
session_state: 90d
|
||||
compact_summary: 60d
|
||||
decision: 180d
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result == {
|
||||
"session_state": 90,
|
||||
"compact_summary": 60,
|
||||
"decision": 180,
|
||||
}
|
||||
|
||||
|
||||
def test_parse_retention_forever_returns_none():
|
||||
"""parse_retention maps 'forever' to None."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
## Retention
|
||||
|
||||
user_preference: forever
|
||||
anti_pattern: forever
|
||||
session_state: 30d
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result["user_preference"] is None
|
||||
assert result["anti_pattern"] is None
|
||||
assert result["session_state"] == 30
|
||||
|
||||
|
||||
def test_parse_retention_no_section_returns_empty():
|
||||
"""parse_retention returns {} when there is no ## Retention heading."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
# Project Config
|
||||
|
||||
## Some Other Section
|
||||
|
||||
key: value
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_parse_retention_stops_at_next_heading():
|
||||
"""parse_retention stops reading at the next ## heading."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
## Retention
|
||||
|
||||
session_state: 7d
|
||||
|
||||
## Other Section
|
||||
|
||||
other_key: 999d
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert "session_state" in result
|
||||
assert "other_key" not in result
|
||||
|
||||
|
||||
def test_parse_retention_malformed_lines_skipped():
|
||||
"""Malformed lines (no colon, bad day format) are silently ignored."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
## Retention
|
||||
|
||||
session_state: 90d
|
||||
bad_line_no_colon
|
||||
another: badvalue
|
||||
decision: 30d
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result == {"session_state": 90, "decision": 30}
|
||||
|
||||
|
||||
def test_parse_retention_comments_ignored():
|
||||
"""Inline # comments are stripped before parsing."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
## Retention
|
||||
|
||||
session_state: 90d # rolling 90-day window
|
||||
user_preference: forever # never prune preferences
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result["session_state"] == 90
|
||||
assert result["user_preference"] is None
|
||||
|
||||
|
||||
def test_parse_retention_case_insensitive_heading():
|
||||
"""## retention (lowercase) is matched the same as ## Retention."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
## retention
|
||||
|
||||
session_state: 14d
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result == {"session_state": 14}
|
||||
|
||||
|
||||
def test_parse_retention_empty_section_returns_empty():
|
||||
"""A ## Retention section with no valid lines returns {}."""
|
||||
from parse_mem0_config import parse_retention
|
||||
|
||||
content = """\
|
||||
## Retention
|
||||
|
||||
# only comments here
|
||||
|
||||
## Next Section
|
||||
"""
|
||||
result = parse_retention(content)
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_retention_policies — integration tests with tmp files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_retention_policies_with_tmp_file(tmp_path):
|
||||
"""load_retention_policies reads a real mem0.md from disk."""
|
||||
from parse_mem0_config import load_retention_policies
|
||||
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text(
|
||||
"""\
|
||||
# My Project
|
||||
|
||||
## Retention
|
||||
|
||||
session_state: 90d
|
||||
compact_summary: 60d
|
||||
decision: forever
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = load_retention_policies(str(tmp_path))
|
||||
assert result == {
|
||||
"session_state": 90,
|
||||
"compact_summary": 60,
|
||||
"decision": None,
|
||||
}
|
||||
|
||||
|
||||
def test_load_retention_policies_no_mem0_md_returns_empty(tmp_path):
|
||||
"""load_retention_policies returns {} when no mem0.md exists."""
|
||||
from parse_mem0_config import load_retention_policies
|
||||
|
||||
result = load_retention_policies(str(tmp_path))
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_retention_policies_no_retention_section_returns_empty(tmp_path):
|
||||
"""load_retention_policies returns {} when mem0.md has no ## Retention."""
|
||||
from parse_mem0_config import load_retention_policies
|
||||
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text(
|
||||
"""\
|
||||
# My Project
|
||||
|
||||
Some general project notes here.
|
||||
No retention section.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = load_retention_policies(str(tmp_path))
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_retention_policies_defaults_to_cwd(tmp_path, monkeypatch):
|
||||
"""load_retention_policies uses os.getcwd() when cwd is None."""
|
||||
from parse_mem0_config import load_retention_policies
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text("## Retention\nsession_state: 45d\n", encoding="utf-8")
|
||||
|
||||
result = load_retention_policies() # no cwd arg
|
||||
assert result == {"session_state": 45}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI / main() — subprocess test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_main_prints_json(tmp_path):
|
||||
"""CLI: python parse_mem0_config.py <cwd> prints valid JSON."""
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text(
|
||||
"## Retention\nsession_state: 90d\nuser_preference: forever\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data["session_state"] == 90
|
||||
assert data["user_preference"] is None
|
||||
|
||||
|
||||
def test_cli_main_no_file_prints_empty_json(tmp_path):
|
||||
"""CLI: prints '{}' when no mem0.md exists."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert json.loads(result.stdout) == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_section_kv — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_section_kv_basic():
|
||||
"""parse_section_kv extracts key-value pairs from a named section."""
|
||||
from parse_mem0_config import parse_section_kv
|
||||
|
||||
content = """\
|
||||
## Search
|
||||
|
||||
default_limit: 10
|
||||
boost_recency: true
|
||||
"""
|
||||
result = parse_section_kv(content, "Search")
|
||||
assert result == {"default_limit": "10", "boost_recency": "true"}
|
||||
|
||||
|
||||
def test_parse_section_kv_missing_section():
|
||||
"""parse_section_kv returns {} when section doesn't exist."""
|
||||
from parse_mem0_config import parse_section_kv
|
||||
|
||||
result = parse_section_kv("## Other\nfoo: bar\n", "Search")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_parse_section_kv_stops_at_next_heading():
|
||||
"""parse_section_kv stops at the next ## heading."""
|
||||
from parse_mem0_config import parse_section_kv
|
||||
|
||||
content = """\
|
||||
## Identity
|
||||
|
||||
user_id: kartik
|
||||
project_id: mem0
|
||||
|
||||
## Other
|
||||
|
||||
ignored: yes
|
||||
"""
|
||||
result = parse_section_kv(content, "Identity")
|
||||
assert result == {"user_id": "kartik", "project_id": "mem0"}
|
||||
assert "ignored" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_section_list — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_section_list_basic():
|
||||
"""parse_section_list extracts list items from a named section."""
|
||||
from parse_mem0_config import parse_section_list
|
||||
|
||||
content = """\
|
||||
## Categories
|
||||
|
||||
- architecture_decisions
|
||||
- bug_fixes
|
||||
- coding_conventions
|
||||
"""
|
||||
result = parse_section_list(content, "Categories")
|
||||
assert result == ["architecture_decisions", "bug_fixes", "coding_conventions"]
|
||||
|
||||
|
||||
def test_parse_section_list_bare_lines():
|
||||
"""parse_section_list works with bare lines (no bullet prefix)."""
|
||||
from parse_mem0_config import parse_section_list
|
||||
|
||||
content = """\
|
||||
## Categories
|
||||
|
||||
architecture_decisions
|
||||
bug_fixes
|
||||
"""
|
||||
result = parse_section_list(content, "Categories")
|
||||
assert result == ["architecture_decisions", "bug_fixes"]
|
||||
|
||||
|
||||
def test_parse_section_list_missing_section():
|
||||
"""parse_section_list returns [] when section doesn't exist."""
|
||||
from parse_mem0_config import parse_section_list
|
||||
|
||||
result = parse_section_list("## Other\n- foo\n", "Categories")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_full_config — integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_full_config_all_sections(tmp_path):
|
||||
"""load_full_config extracts all sections from mem0.md."""
|
||||
from parse_mem0_config import load_full_config
|
||||
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text(
|
||||
"""\
|
||||
# My Project
|
||||
|
||||
## Retention
|
||||
|
||||
session_state: 90d
|
||||
decision: forever
|
||||
|
||||
## Search
|
||||
|
||||
default_limit: 20
|
||||
boost_recency: true
|
||||
|
||||
## Categories
|
||||
|
||||
- architecture_decisions
|
||||
- bug_fixes
|
||||
- security_constraints
|
||||
|
||||
## Identity
|
||||
|
||||
user_id: kartik
|
||||
project_id: my-project
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_full_config(str(tmp_path))
|
||||
assert config["retention"] == {"session_state": 90, "decision": None}
|
||||
assert config["search"] == {"default_limit": "20", "boost_recency": "true"}
|
||||
assert config["categories"] == ["architecture_decisions", "bug_fixes", "security_constraints"]
|
||||
assert config["identity"] == {"user_id": "kartik", "project_id": "my-project"}
|
||||
|
||||
|
||||
def test_load_full_config_partial_sections(tmp_path):
|
||||
"""load_full_config only includes sections that exist."""
|
||||
from parse_mem0_config import load_full_config
|
||||
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text("## Retention\nsession_state: 30d\n", encoding="utf-8")
|
||||
|
||||
config = load_full_config(str(tmp_path))
|
||||
assert "retention" in config
|
||||
assert "search" not in config
|
||||
assert "categories" not in config
|
||||
assert "identity" not in config
|
||||
|
||||
|
||||
def test_load_full_config_no_file(tmp_path):
|
||||
"""load_full_config returns {} when no mem0.md exists."""
|
||||
from parse_mem0_config import load_full_config
|
||||
|
||||
config = load_full_config(str(tmp_path))
|
||||
assert config == {}
|
||||
|
||||
|
||||
def test_cli_full_flag(tmp_path):
|
||||
"""CLI: --full prints all sections as JSON."""
|
||||
mem0_md = tmp_path / "mem0.md"
|
||||
mem0_md.write_text(
|
||||
"## Retention\nsession_state: 90d\n\n## Search\nlimit: 10\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), "--full", str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "retention" in data
|
||||
assert "search" in data
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Tests for on_pre_commit.py — pre-commit memory capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
def test_import_succeeds():
|
||||
"""on_pre_commit module can be imported."""
|
||||
import on_pre_commit
|
||||
|
||||
assert hasattr(on_pre_commit, "main")
|
||||
|
||||
|
||||
def test_no_api_key_exits_zero(monkeypatch):
|
||||
"""main() exits 0 when no API key is set."""
|
||||
import on_pre_commit
|
||||
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
|
||||
|
||||
assert on_pre_commit.main() == 0
|
||||
|
||||
|
||||
def test_empty_diff_exits_zero(monkeypatch):
|
||||
"""main() exits 0 when stdin diff is empty."""
|
||||
from io import StringIO
|
||||
|
||||
import on_pre_commit
|
||||
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
monkeypatch.setattr("sys.stdin", StringIO(""))
|
||||
|
||||
assert on_pre_commit.main() == 0
|
||||
|
||||
|
||||
def test_get_staged_summary_runs():
|
||||
"""get_staged_summary doesn't crash even outside a git repo."""
|
||||
import on_pre_commit
|
||||
|
||||
result = on_pre_commit.get_staged_summary()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_get_commit_message_runs():
|
||||
"""get_commit_message doesn't crash even outside a git repo."""
|
||||
import on_pre_commit
|
||||
|
||||
result = on_pre_commit.get_commit_message()
|
||||
assert isinstance(result, str)
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Tests for _project.py — project_id + branch resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_resolve_project_id_from_https_remote(tmp_git_repo):
|
||||
from _project import resolve_project_id
|
||||
|
||||
pid = resolve_project_id(str(tmp_git_repo))
|
||||
assert pid == "mem0ai-mem0"
|
||||
|
||||
|
||||
def test_resolve_project_id_from_ssh_remote(tmp_git_repo_ssh):
|
||||
from _project import resolve_project_id
|
||||
|
||||
pid = resolve_project_id(str(tmp_git_repo_ssh))
|
||||
assert pid == "acme-cool-project"
|
||||
|
||||
|
||||
def test_resolve_project_id_fallback_basename(tmp_no_git):
|
||||
from _project import resolve_project_id
|
||||
|
||||
pid = resolve_project_id(str(tmp_no_git))
|
||||
assert pid == os.path.basename(str(tmp_no_git))
|
||||
|
||||
|
||||
def test_resolve_project_id_from_env(tmp_no_git, monkeypatch):
|
||||
from _project import resolve_project_id
|
||||
|
||||
monkeypatch.setenv("MEM0_PROJECT_ID", "my-override")
|
||||
pid = resolve_project_id(str(tmp_no_git))
|
||||
assert pid == "my-override"
|
||||
|
||||
|
||||
def test_resolve_project_id_from_project_map(tmp_no_git):
|
||||
from _project import resolve_project_id, save_project_mapping
|
||||
|
||||
save_project_mapping(str(tmp_no_git), "custom-project")
|
||||
pid = resolve_project_id(str(tmp_no_git))
|
||||
assert pid == "custom-project"
|
||||
|
||||
|
||||
def test_save_project_mapping_creates_file(tmp_no_git):
|
||||
from _project import save_project_mapping
|
||||
|
||||
save_project_mapping(str(tmp_no_git), "test-proj")
|
||||
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
||||
assert os.path.isfile(map_path)
|
||||
with open(map_path) as f:
|
||||
data = json.load(f)
|
||||
assert data[str(tmp_no_git)] == "test-proj"
|
||||
|
||||
|
||||
def test_resolve_branch_in_git_repo(tmp_git_repo):
|
||||
from _project import resolve_branch
|
||||
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feat/test-branch"],
|
||||
cwd=tmp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
branch = resolve_branch(str(tmp_git_repo))
|
||||
assert branch == "feat/test-branch"
|
||||
|
||||
|
||||
def test_resolve_branch_no_git(tmp_no_git):
|
||||
from _project import resolve_branch
|
||||
|
||||
branch = resolve_branch(str(tmp_no_git))
|
||||
assert branch == "unknown"
|
||||
|
||||
|
||||
def test_remote_url_to_slug_various_formats():
|
||||
from _project import _remote_url_to_slug
|
||||
|
||||
assert _remote_url_to_slug("https://github.com/mem0ai/mem0.git") == "mem0ai-mem0"
|
||||
assert _remote_url_to_slug("git@github.com:mem0ai/mem0.git") == "mem0ai-mem0"
|
||||
assert _remote_url_to_slug("ssh://git@github.com/acme/app.git") == "acme-app"
|
||||
assert _remote_url_to_slug("https://gitlab.com/org/sub/repo.git") == "sub-repo"
|
||||
assert _remote_url_to_slug("git@bitbucket.org:team/project.git") == "team-project"
|
||||
|
||||
|
||||
def test_remote_url_to_slug_no_git_suffix():
|
||||
from _project import _remote_url_to_slug
|
||||
|
||||
assert _remote_url_to_slug("https://github.com/foo/bar") == "foo-bar"
|
||||
|
||||
|
||||
def test_resolve_project_id_priority_order(tmp_git_repo, monkeypatch):
|
||||
"""Env var > project_map > git remote > basename."""
|
||||
from _project import resolve_project_id, save_project_mapping
|
||||
|
||||
# Git remote gives "mem0ai-mem0"
|
||||
assert resolve_project_id(str(tmp_git_repo)) == "mem0ai-mem0"
|
||||
|
||||
# project_map overrides git remote
|
||||
save_project_mapping(str(tmp_git_repo), "from-map")
|
||||
assert resolve_project_id(str(tmp_git_repo)) == "from-map"
|
||||
|
||||
# Env var overrides everything
|
||||
monkeypatch.setenv("MEM0_PROJECT_ID", "from-env")
|
||||
assert resolve_project_id(str(tmp_git_repo)) == "from-env"
|
||||
@@ -1,248 +0,0 @@
|
||||
"""Tests for session_stats.py — session-level memory operation tracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_stats_file(tmp_path, monkeypatch):
|
||||
"""Point STATS_FILE to a temp location so tests don't interfere."""
|
||||
stats_file = str(tmp_path / "test_stats.json")
|
||||
monkeypatch.setattr("session_stats.STATS_FILE", stats_file)
|
||||
yield stats_file
|
||||
|
||||
|
||||
def test_init_creates_file(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
assert os.path.isfile(_isolate_stats_file)
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 0
|
||||
assert data["searches"] == 0
|
||||
assert data["categories"] == []
|
||||
|
||||
|
||||
def test_record_add_increments(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("decisions")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 3
|
||||
assert set(data["categories"]) == {"bug_fixes", "decisions"}
|
||||
|
||||
|
||||
def test_record_search_increments(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_search()
|
||||
session_stats.record_search()
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["searches"] == 2
|
||||
|
||||
|
||||
def test_report_returns_summary(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("architecture_decisions")
|
||||
session_stats.record_add("task_learnings")
|
||||
session_stats.record_search()
|
||||
session_stats.record_search()
|
||||
session_stats.record_search()
|
||||
|
||||
result = session_stats.report()
|
||||
assert "wrote 2 memories" in result
|
||||
assert "retrieved 3" in result
|
||||
assert "architecture_decisions" in result
|
||||
assert "task_learnings" in result
|
||||
|
||||
|
||||
def test_report_empty_session(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
result = session_stats.report()
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_report_cleans_up_file(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add()
|
||||
session_stats.report()
|
||||
assert not os.path.isfile(_isolate_stats_file)
|
||||
|
||||
|
||||
def test_record_add_no_category(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("")
|
||||
session_stats.record_add()
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 2
|
||||
assert data["categories"] == []
|
||||
|
||||
|
||||
def test_duplicate_categories_not_added(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 3
|
||||
assert data["categories"] == ["bug_fixes"]
|
||||
|
||||
|
||||
def test_cli_init(tmp_path):
|
||||
"""Test CLI invocation: session_stats.py init."""
|
||||
env = {**os.environ, "USER": "test"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def test_cli_report_no_data(tmp_path):
|
||||
"""Test CLI invocation: report with no prior init prints fallback."""
|
||||
env = {**os.environ, "USER": f"test_{os.getpid()}"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "report"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def test_peek_returns_json_without_clearing(_isolate_stats_file):
|
||||
"""peek returns JSON stats without deleting the stats file."""
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("decisions")
|
||||
session_stats.record_add("decisions")
|
||||
session_stats.record_search()
|
||||
|
||||
result = session_stats.peek()
|
||||
data = json.loads(result)
|
||||
assert data["adds"] == 2
|
||||
assert data["searches"] == 1
|
||||
|
||||
assert os.path.isfile(_isolate_stats_file)
|
||||
|
||||
|
||||
def test_category_counts_tracked(_isolate_stats_file):
|
||||
"""category_counts tracks per-category add counts."""
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("decisions")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["category_counts"]["bug_fixes"] == 3
|
||||
assert data["category_counts"]["decisions"] == 1
|
||||
|
||||
|
||||
def test_category_counts_empty_category_not_tracked(_isolate_stats_file):
|
||||
"""Empty category string doesn't appear in category_counts."""
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("")
|
||||
session_stats.record_add()
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["category_counts"] == {}
|
||||
|
||||
|
||||
def test_recent_ids_tracked(_isolate_stats_file):
|
||||
"""record_add with memory_id stores ID in recent_ids."""
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("decision", "abc-123")
|
||||
session_stats.record_add("convention", "def-456")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert len(data["recent_ids"]) == 2
|
||||
assert data["recent_ids"][0]["id"] == "abc-123"
|
||||
assert data["recent_ids"][1]["id"] == "def-456"
|
||||
assert data["recent_ids"][0]["category"] == "decision"
|
||||
|
||||
|
||||
def test_recent_ids_capped(_isolate_stats_file):
|
||||
"""recent_ids list is capped at MAX_RECENT_IDS."""
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
for i in range(60):
|
||||
session_stats.record_add("test", f"id-{i}")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert len(data["recent_ids"]) == session_stats.MAX_RECENT_IDS
|
||||
assert data["recent_ids"][0]["id"] == f"id-{60 - session_stats.MAX_RECENT_IDS}"
|
||||
|
||||
|
||||
def test_recent_ids_empty_without_memory_id(_isolate_stats_file):
|
||||
"""record_add without memory_id doesn't add to recent_ids."""
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("decision")
|
||||
session_stats.record_add("convention", "")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["recent_ids"] == []
|
||||
|
||||
|
||||
def test_cli_peek(tmp_path):
|
||||
"""Test CLI invocation: session_stats.py peek outputs JSON."""
|
||||
env = {**os.environ, "USER": "test"}
|
||||
subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "peek"],
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "adds" in data
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tests for telemetry.py — fire-and-forget PostHog plugin telemetry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
|
||||
|
||||
|
||||
def test_import_succeeds():
|
||||
import telemetry
|
||||
|
||||
assert hasattr(telemetry, "emit")
|
||||
assert hasattr(telemetry, "main")
|
||||
|
||||
|
||||
def test_opt_out_skips_send(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setenv("MEM0_TELEMETRY", "false")
|
||||
sent = []
|
||||
monkeypatch.setattr(telemetry, "send", lambda p: sent.append(p))
|
||||
telemetry.emit("session_start")
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_opt_out_variants(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
for val in ("0", "no", "off", "FALSE", "No"):
|
||||
monkeypatch.setenv("MEM0_TELEMETRY", val)
|
||||
assert not telemetry.is_enabled()
|
||||
|
||||
|
||||
def test_enabled_by_default(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
|
||||
assert telemetry.is_enabled()
|
||||
|
||||
|
||||
def test_posthog_payload_structure(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser")
|
||||
monkeypatch.setenv("MEM0_PROJECT_ID", "test-project")
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||
|
||||
payload = telemetry.build_posthog_payload("plugin.session_start", {"memory_count": 5})
|
||||
|
||||
assert payload["api_key"] == telemetry.POSTHOG_API_KEY
|
||||
assert payload["event"] == "plugin.session_start"
|
||||
assert "distinct_id" in payload
|
||||
assert payload["properties"]["source"] == "plugin"
|
||||
assert payload["properties"]["plugin_version"] == "0.2.1"
|
||||
assert payload["properties"]["memory_count"] == 5
|
||||
assert payload["properties"]["$process_person_profile"] is False
|
||||
|
||||
raw = json.dumps(payload)
|
||||
assert "testuser" not in raw
|
||||
assert "test-project" not in raw
|
||||
|
||||
|
||||
def test_distinct_id_from_api_key(monkeypatch):
|
||||
import hashlib
|
||||
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123")
|
||||
expected = hashlib.md5(b"m0-testkey123").hexdigest()
|
||||
assert telemetry._distinct_id() == expected
|
||||
|
||||
|
||||
def test_distinct_id_fallback_no_key(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "kartik")
|
||||
assert telemetry._distinct_id() == telemetry._sha256("kartik")
|
||||
|
||||
|
||||
def test_hash_deterministic():
|
||||
import telemetry
|
||||
|
||||
h1 = telemetry._sha256("same-value")
|
||||
h2 = telemetry._sha256("same-value")
|
||||
assert h1 == h2
|
||||
assert h1 != telemetry._sha256("different-value")
|
||||
|
||||
|
||||
def test_platform_claude_code(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setenv("CLAUDECODE", "1")
|
||||
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
|
||||
assert telemetry.detect_platform() == "claude-code"
|
||||
|
||||
|
||||
def test_platform_cursor(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.delenv("CLAUDECODE", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
|
||||
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
|
||||
assert telemetry.detect_platform() == "cursor"
|
||||
|
||||
|
||||
def test_platform_codex(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.delenv("CLAUDECODE", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.setenv("CODEX_PLUGIN_ROOT", "/path")
|
||||
assert telemetry.detect_platform() == "codex"
|
||||
|
||||
|
||||
def test_sampling_drops_at_high_random(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setattr(telemetry.random, "random", lambda: 0.5)
|
||||
assert telemetry._should_sample() is False
|
||||
|
||||
|
||||
def test_sampling_sends_at_low_random(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setattr(telemetry.random, "random", lambda: 0.05)
|
||||
assert telemetry._should_sample() is True
|
||||
|
||||
|
||||
def test_send_fails_silently(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
def raise_error(req, timeout):
|
||||
raise urllib.error.URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr(telemetry.urllib.request, "urlopen", raise_error)
|
||||
telemetry.send({"event": "test"})
|
||||
|
||||
|
||||
def test_cli_exits_zero_when_disabled(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.setenv("MEM0_TELEMETRY", "false")
|
||||
monkeypatch.setattr(sys, "argv", ["telemetry.py", "session_start"])
|
||||
assert telemetry.main() == 0
|
||||
|
||||
|
||||
def test_cli_no_args_exits_nonzero(monkeypatch):
|
||||
import telemetry
|
||||
|
||||
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
|
||||
assert telemetry.main() == 1
|
||||
@@ -1,194 +0,0 @@
|
||||
"""Tests for write-path app_id migration and API key resolution.
|
||||
|
||||
Verifies that all scripts writing to the Mem0 API:
|
||||
1. Pass app_id as a top-level parameter (not in metadata)
|
||||
2. Do NOT include project_id in metadata
|
||||
3. Include branch in metadata when available
|
||||
4. Use resolve_api_key() for key resolution with userConfig fallback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_auto_import_post_memory_uses_app_id():
|
||||
"""auto_import.post_memory sends app_id top-level, not metadata.project_id."""
|
||||
from auto_import import post_memory
|
||||
|
||||
captured = {}
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
captured.update(body)
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
result = post_memory(
|
||||
api_key="test-key",
|
||||
content="test content",
|
||||
user_id="testuser",
|
||||
filename="CLAUDE.md",
|
||||
project_id="my-project",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert captured["app_id"] == "my-project"
|
||||
assert captured["user_id"] == "testuser"
|
||||
assert "project_id" not in captured.get("metadata", {})
|
||||
assert captured["metadata"]["type"] == "project_profile"
|
||||
assert captured["metadata"]["branch"] == "main"
|
||||
assert captured["infer"] is False
|
||||
|
||||
|
||||
def test_auto_import_post_memory_omits_empty_branch():
|
||||
"""auto_import.post_memory skips branch in metadata when empty."""
|
||||
from auto_import import post_memory
|
||||
|
||||
captured = {}
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
captured.update(body)
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
post_memory("key", "content", "user", "FILE.md", "proj", branch="")
|
||||
|
||||
assert "branch" not in captured.get("metadata", {})
|
||||
|
||||
|
||||
def test_on_pre_compact_store_memory_uses_app_id():
|
||||
"""on_pre_compact.store_memory sends app_id top-level."""
|
||||
from on_pre_compact import store_memory
|
||||
|
||||
captured = {}
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
captured.update(body)
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
result = store_memory(
|
||||
api_key="test-key",
|
||||
content="session state content",
|
||||
user_id="testuser",
|
||||
source="pre-compaction",
|
||||
session_id="sess-123",
|
||||
project_id="my-project",
|
||||
branch="feat/auth",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert captured["app_id"] == "my-project"
|
||||
assert captured["user_id"] == "testuser"
|
||||
assert "project_id" not in captured.get("metadata", {})
|
||||
assert captured["metadata"]["type"] == "session_state"
|
||||
assert captured["metadata"]["source"] == "pre-compaction"
|
||||
assert captured["metadata"]["branch"] == "feat/auth"
|
||||
assert "expiration_date" in captured
|
||||
|
||||
|
||||
def test_capture_compact_summary_store_uses_app_id():
|
||||
"""capture_compact_summary.store_summary sends app_id top-level."""
|
||||
from capture_compact_summary import store_summary
|
||||
|
||||
captured = {}
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
captured.update(body)
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
result = store_summary(
|
||||
api_key="test-key",
|
||||
summary="compact summary text",
|
||||
user_id="testuser",
|
||||
session_id="sess-456",
|
||||
project_id="my-project",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert captured["app_id"] == "my-project"
|
||||
assert captured["user_id"] == "testuser"
|
||||
assert "project_id" not in captured.get("metadata", {})
|
||||
assert captured["metadata"]["type"] == "compact_summary"
|
||||
assert captured["metadata"]["branch"] == "main"
|
||||
assert captured["infer"] is True
|
||||
assert "expiration_date" in captured
|
||||
|
||||
|
||||
def test_no_metadata_project_id_anywhere():
|
||||
"""Ensure none of the write functions put project_id in metadata."""
|
||||
from auto_import import post_memory
|
||||
from capture_compact_summary import store_summary
|
||||
from on_pre_compact import store_memory
|
||||
|
||||
bodies = []
|
||||
|
||||
def mock_urlopen(req, timeout=None):
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
bodies.append(body)
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
|
||||
post_memory("k", "c", "u", "f", "proj", "br")
|
||||
store_memory("k", "c", "u", "src", "sid", "proj", "br")
|
||||
store_summary("k", "s", "u", "sid", "proj", "br")
|
||||
|
||||
for i, body in enumerate(bodies):
|
||||
metadata = body.get("metadata", {})
|
||||
assert "project_id" not in metadata, f"Write function #{i} still has metadata.project_id"
|
||||
assert body.get("app_id") == "proj", f"Write function #{i} missing app_id top-level"
|
||||
|
||||
|
||||
def test_resolve_api_key_prefers_env_var(monkeypatch):
|
||||
"""resolve_api_key returns MEM0_API_KEY when both are set."""
|
||||
from _identity import resolve_api_key
|
||||
|
||||
monkeypatch.setenv("MEM0_API_KEY", "direct-key")
|
||||
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
|
||||
assert resolve_api_key() == "direct-key"
|
||||
|
||||
|
||||
def test_resolve_api_key_falls_back_to_plugin_option(monkeypatch):
|
||||
"""resolve_api_key falls back to CLAUDE_PLUGIN_OPTION_MEM0_API_KEY."""
|
||||
from _identity import resolve_api_key
|
||||
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
|
||||
assert resolve_api_key() == "fallback-key"
|
||||
|
||||
|
||||
def test_resolve_api_key_returns_empty_when_neither_set(monkeypatch):
|
||||
"""resolve_api_key returns empty string when no key is available."""
|
||||
from _identity import resolve_api_key
|
||||
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||
assert resolve_api_key() == ""
|
||||
@@ -100,7 +100,7 @@
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"axios": "1.13.6",
|
||||
"openai": "^4.93.0",
|
||||
"uuid": "9.0.1",
|
||||
"zod": "^3.24.1"
|
||||
@@ -111,7 +111,7 @@
|
||||
"@azure/search-documents": "^12.0.0",
|
||||
"@cloudflare/workers-types": "^4.20250504.0",
|
||||
"@google/genai": "^1.2.0",
|
||||
"@langchain/core": "^1.1.47",
|
||||
"@langchain/core": "^1.0.0",
|
||||
"@mistralai/mistralai": "^1.5.2",
|
||||
"@qdrant/js-client-rest": "1.13.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
|
||||
Generated
+46
-44
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13)
|
||||
'@langchain/core':
|
||||
specifier: ^1.1.47
|
||||
version: 1.1.47(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
specifier: ^1.0.0
|
||||
version: 1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
'@mistralai/mistralai':
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(zod@3.24.2)
|
||||
@@ -42,8 +42,8 @@ importers:
|
||||
specifier: 8.11.0
|
||||
version: 8.11.0
|
||||
axios:
|
||||
specifier: ^1.15.2
|
||||
version: 1.16.1
|
||||
specifier: 1.13.6
|
||||
version: 1.13.6
|
||||
better-sqlite3:
|
||||
specifier: ^12.6.2
|
||||
version: 12.6.2
|
||||
@@ -613,8 +613,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@langchain/core@1.1.47':
|
||||
resolution: {integrity: sha512-+fiPu6ZFnJMrZyKeM77OIVPoMPAY6OKWacnPlojHtXTbMMzb2cEOKAJV0U07cDl86NHSCIYYa0i4CyKZzXbHQQ==}
|
||||
'@langchain/core@1.1.32':
|
||||
resolution: {integrity: sha512-ZZNiER5tceFXqZOghfrxNHzM60gcQL5XK/8Ow5+o4OuKHrP1p/RUQBDM9Y1nddi/VmKQj+ncaXXM5KovXTEGGQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@mistralai/mistralai@1.5.2':
|
||||
@@ -968,10 +968,6 @@ packages:
|
||||
afinn-165@2.0.2:
|
||||
resolution: {integrity: sha512-mJ/RLUfpXfQA6bzugv+bBsc/QYkVrKaLYeS8fWBpKbTCsonv4iuV9ET0fgReEunm9vKLkaNgnekuSNlTC3WQ1Q==}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
agent-base@7.1.3:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1030,8 +1026,8 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.16.1:
|
||||
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
|
||||
axios@1.13.6:
|
||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||
|
||||
babel-jest@29.7.0:
|
||||
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
|
||||
@@ -1296,6 +1292,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1546,8 +1546,8 @@ packages:
|
||||
resolution: {integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==}
|
||||
hasBin: true
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
@@ -1562,6 +1562,10 @@ packages:
|
||||
form-data-encoder@1.7.2:
|
||||
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||
|
||||
form-data@4.0.2:
|
||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1708,10 +1712,6 @@ packages:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -2593,9 +2593,8 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
pstree.remy@1.1.8:
|
||||
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
|
||||
@@ -3079,7 +3078,10 @@ packages:
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
uuid@13.0.0:
|
||||
@@ -3840,14 +3842,18 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@langchain/core@1.1.47(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)':
|
||||
'@langchain/core@1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)':
|
||||
dependencies:
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
'@standard-schema/spec': 1.1.0
|
||||
ansi-styles: 5.2.0
|
||||
camelcase: 6.3.0
|
||||
decamelize: 1.2.0
|
||||
js-tiktoken: 1.0.19
|
||||
langsmith: 0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
uuid: 11.1.0
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
@@ -4124,7 +4130,7 @@ snapshots:
|
||||
'@types/node-fetch@2.6.12':
|
||||
dependencies:
|
||||
'@types/node': 22.13.5
|
||||
form-data: 4.0.5
|
||||
form-data: 4.0.2
|
||||
|
||||
'@types/node@18.19.76':
|
||||
dependencies:
|
||||
@@ -4193,12 +4199,6 @@ snapshots:
|
||||
|
||||
afinn-165@2.0.2: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.0(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
agent-base@7.1.3: {}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
@@ -4249,15 +4249,13 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.16.1:
|
||||
axios@1.13.6:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
babel-jest@29.7.0(@babel/core@7.26.9):
|
||||
dependencies:
|
||||
@@ -4556,6 +4554,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
@@ -4823,7 +4823,7 @@ snapshots:
|
||||
kolorist: 1.8.0
|
||||
read-pkg: 8.1.0
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
@@ -4832,6 +4832,13 @@ snapshots:
|
||||
|
||||
form-data-encoder@1.7.2: {}
|
||||
|
||||
form-data@4.0.2:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@@ -5009,13 +5016,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.0(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
@@ -6021,7 +6021,7 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
pstree.remy@1.1.8: {}
|
||||
|
||||
@@ -6539,6 +6539,8 @@ snapshots:
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@13.0.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface SearchMemoryOptions {
|
||||
topK?: number;
|
||||
threshold?: number;
|
||||
rerank?: boolean;
|
||||
latestOnly?: boolean;
|
||||
fields?: string[];
|
||||
categories?: string[];
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export interface GetAllMemoryOptions {
|
||||
pageSize?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
latestOnly?: boolean;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -63,24 +63,6 @@ describe("MemoryClient - search()", () => {
|
||||
expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" });
|
||||
});
|
||||
|
||||
test("serializes latestOnly as latest_only", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v3/memories/search/", {
|
||||
status: 200,
|
||||
body: { results: [] },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", {
|
||||
filters: { user_id: "u1" },
|
||||
latestOnly: true,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).latest_only).toBe(true);
|
||||
});
|
||||
|
||||
test("passes complex OR filters through to the API body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v3/memories/search/", {
|
||||
@@ -282,22 +264,4 @@ describe("MemoryClient - getAll() entity param rejection", () => {
|
||||
await client.getAll({ filters: { user_id: "u1" } });
|
||||
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("serializes latestOnly as latest_only", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v3/memories/", {
|
||||
status: 200,
|
||||
body: { results: [] },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({
|
||||
filters: { user_id: "u1" },
|
||||
latestOnly: true,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v3/memories/", "POST");
|
||||
expect(getFetchBody(call!).latest_only).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user