chore: merge origin/main into mintlify/af82bda4 and resolve conflicts

This commit is contained in:
mintlify[bot]
2026-09-16 11:46:52 +00:00
committed by GitHub
parent b5ae772eae
commit 08d9636491
693 changed files with 55668 additions and 22676 deletions
+27 -28
View File
@@ -2,18 +2,21 @@
Persistent semantic memory for [Pi Agent](https://pi.dev), powered by [Mem0](https://mem0.ai).
This extension gives Pi Agent long-term memory that persists across sessions, projects, and devices. Memories are automatically captured from conversations and can be searched, managed, and consolidated through slash commands and an agent-accessible tool.
This extension gives Pi Agent long-term memory that persists across sessions, projects, and devices. Memories are automatically captured from conversations and can be searched and managed through slash commands and an agent-accessible tool.
Current package version: `0.3.0`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); Pi keeps its own tools and scopes.
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
## Features
- **Automatic memory capture** — learns from every conversation (both user and assistant messages)
- **Semantic search** — find memories by meaning, not just keywords
- **Scoped memory** — project, session, or global scope
- **Monorepo-aware** — uses git root for project detection, consistent app_id across subdirectories
- **Dream consolidation** — merges duplicates, resolves contradictions, prunes stale entries
- **Confirmation dialogs** — destructive commands ask before acting
- **8 slash commands** — essential memory management from the command line
- **Agent tool** — `mem0_memory` tool lets the agent search and store memories autonomously
- **Automatic memory capture**: learns from every conversation (both user and assistant messages)
- **Semantic search**: find memories by meaning, not just keywords
- **Scoped memory**: project, session, or global scope
- **Monorepo-aware**: uses git root for project detection, consistent app_id across subdirectories
- **Confirmation dialogs**: destructive commands ask before acting
- **6 slash commands**: essential memory management from the command line
- **Agent tool**: `mem0_memory` tool lets the agent search and store memories autonomously
## Setup
@@ -43,20 +46,13 @@ Or create a config file at `~/.pi/agent/mem0-config.json`:
"userId": "your-username",
"autoCapture": true,
"defaultScope": "project",
"searchThreshold": 0.2,
"dream": {
"enabled": true,
"auto": true,
"minHours": 24,
"minSessions": 5,
"minMemories": 20
}
"searchThreshold": 0.3
}
```
Environment variables (`MEM0_API_KEY`, `MEM0_USER_ID`) override the config file.
`searchThreshold` (default `0.3`) is the minimum similarity score (0–1) a memory must reach to count as a match for `/mem0-search`, `/mem0-forget`, and `/mem0-pin`. It is passed to the mem0 search API (along with reranking for higher-precision ordering), so a query with no sufficiently similar memory reports no match instead of returning the closest unrelated memories. Raise it to be stricter; lower it if relevant results are missed.
`searchThreshold` (default `0.3`) is the minimum similarity score (0–1) a memory must reach to count as a match for `/mem0-search` and `/mem0-forget`. It is passed to the mem0 search API (along with reranking for higher-precision ordering), so a query with no sufficiently similar memory reports no match instead of returning the closest unrelated memories. Raise it to be stricter; lower it if relevant results are missed.
## Commands
@@ -66,14 +62,12 @@ Environment variables (`MEM0_API_KEY`, `MEM0_USER_ID`) override the config file.
| `/mem0-forget <query>` | Search and delete memories (with confirmation) |
| `/mem0-search <query>` | Semantic search across memories |
| `/mem0-tour [scope]` | Browse all memories grouped by category |
| `/mem0-dream` | Consolidate — merge duplicates, prune stale, resolve contradictions |
| `/mem0-pin <query>` | Pin a memory to protect from dream pruning (preserves ID) |
| `/mem0-scope <scope>` | Change default scope for this session |
| `/mem0-status` | Connection health, identity, and memory count |
## Skills
The plugin includes 8 skills that guide the agent on how to use each capability:
The plugin includes 6 skills that guide the agent on how to use each capability:
| Skill | Purpose |
|-------|---------|
@@ -81,9 +75,7 @@ The plugin includes 8 skills that guide the agent on how to use each capability:
| `remember` | Store facts with category classification |
| `search` | Quick semantic search with compact results |
| `forget` | Delete memories with confirmation |
| `dream` | Memory consolidation workflow |
| `tour` | Full memory walkthrough by category |
| `pin` | Protect critical memories from pruning |
| `status` | Health check and diagnostics |
## Memory Scopes
@@ -91,11 +83,19 @@ The plugin includes 8 skills that guide the agent on how to use each capability:
| Scope | Filters | Use case |
|-------|---------|----------|
| `project` | user + app_id (git root) | Default. Project-specific knowledge |
| `session` | user + app_id + run_id | Ephemeral, session-only context |
| `session` | user + app_id + run_id | Recall restricted to memories saved with the current session ID |
| `global` | user only | All memories across all your projects |
Project scoping uses `git rev-parse --show-toplevel` to detect the repository root, so all subdirectories within a monorepo share the same memory pool.
Global tool operations require `/mem0-scope global` or `defaultScope: "global"` in plugin configuration. A model-supplied `scope` argument cannot enable cross-project access on its own. Empty or wildcard user, project, and session identities are rejected.
## Automatic recall and capture
Before an agent response, the extension searches project memories and injects relevant results. After `agent_end`, automatic capture sends the user and assistant text supplied by Pi to Mem0 in **project** scope, regardless of the default scope selected for explicit commands. Captured text is redacted without the former 6,000-character per-message cutoff.
Automatic project writes do not attach `run_id`. Session-scoped recall applies to memories explicitly saved in session scope; it does not make project memories session-specific or automatically expire them. Recall queries and displayed tool results keep separate size limits.
## Memory Categories
Memories are automatically classified into 10 general-purpose categories:
@@ -120,15 +120,14 @@ pi-agent-plugin/
├── src/
│ ├── entry.ts # Extension entry point
│ ├── index.ts # Barrel exports
│ ├── commands.ts # 8 slash commands
│ ├── commands.ts # 6 slash commands
│ ├── prompt.ts # System prompt injection (MEMORY_POLICY)
│ ├── types.ts # Shared interfaces and categories
│ ├── telemetry.ts # PostHog telemetry (batched, PII-safe)
│ ├── config/ # Config loading (~/.pi/agent/mem0-config.json)
│ ├── memory/ # Tool registration, scoping (git root), formatting
│ ├── capture/ # Auto-capture from conversations (user + assistant)
│ └── dream/ # Consolidation state, gating, locking, prompts
├── skills/ # 8 SKILL.md files for Pi Agent
│ └── capture/ # Auto-capture from conversations (user + assistant)
├── skills/ # 6 SKILL.md files for Pi Agent
├── tests/ # Vitest unit tests
└── dist/ # Built output (ESM + DTS)
```
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/pi-agent-plugin",
"version": "0.1.4",
"version": "0.3.0",
"type": "module",
"description": "Mem0 memory extension for Pi Agent persistent, scoped, semantic memory across sessions and projects",
"license": "Apache-2.0",
@@ -32,14 +32,13 @@
},
"files": [
"dist",
"src",
"skills",
"README.md",
"LICENSE"
],
"pi": {
"extensions": [
"./src/entry.ts"
"./dist/entry.js"
],
"skills": [
"./skills"
@@ -63,7 +62,7 @@
"tsup": "^8.5.0",
"typebox": "^1.2.3",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vitest": "^4.1.11"
},
"dependencies": {
"mem0ai": "^3.0.7"
@@ -73,8 +72,8 @@
"form-data@<4.0.6": ">=4.0.6",
"uuid@<11.1.1": ">=11.1.1",
"esbuild": ">=0.28.1",
"undici@<6.27.0": ">=6.27.0 <8.0.0",
"undici@>=8.0.0 <8.5.0": ">=8.5.0",
"undici@<7.29.0": ">=7.29.0 <8.0.0",
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
"axios@<1.18.0": ">=1.18.0 <2.0.0",
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0",
"postcss@<8.5.18": ">=8.5.18 <9.0.0",
+56 -56
View File
@@ -8,8 +8,8 @@ overrides:
form-data@<4.0.6: '>=4.0.6'
uuid@<11.1.1: '>=11.1.1'
esbuild: '>=0.28.1'
undici@<6.27.0: '>=6.27.0 <8.0.0'
undici@>=8.0.0 <8.5.0: '>=8.5.0'
undici@<7.29.0: '>=7.29.0 <8.0.0'
undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
axios@<1.18.0: '>=1.18.0 <2.0.0'
brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0'
postcss@<8.5.18: '>=8.5.18 <9.0.0'
@@ -43,8 +43,8 @@ importers:
specifier: ^6.0.3
version: 6.0.3
vitest:
specifier: ^4.1.7
version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
specifier: ^4.1.11
version: 4.1.11(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
packages:
@@ -985,11 +985,11 @@ packages:
resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==}
engines: {node: '>=20.0.0'}
'@vitest/expect@4.1.8':
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
'@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
'@vitest/mocker@4.1.8':
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
'@vitest/mocker@4.1.11':
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -999,20 +999,20 @@ packages:
vite:
optional: true
'@vitest/pretty-format@4.1.8':
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
'@vitest/pretty-format@4.1.11':
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
'@vitest/runner@4.1.8':
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
'@vitest/runner@4.1.11':
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
'@vitest/snapshot@4.1.8':
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
'@vitest/snapshot@4.1.11':
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
'@vitest/spy@4.1.8':
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
'@vitest/spy@4.1.11':
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
'@vitest/utils@4.1.8':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
@@ -2354,12 +2354,12 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
undici@8.5.0:
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
undici@8.10.0:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
util-deprecate@1.0.2:
@@ -2416,20 +2416,20 @@ packages:
yaml:
optional: true
vitest@4.1.8:
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
vitest@4.1.11:
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.8
'@vitest/browser-preview': 4.1.8
'@vitest/browser-webdriverio': 4.1.8
'@vitest/coverage-istanbul': 4.1.8
'@vitest/coverage-v8': 4.1.8
'@vitest/ui': 4.1.8
'@vitest/browser-playwright': 4.1.11
'@vitest/browser-preview': 4.1.11
'@vitest/browser-webdriverio': 4.1.11
'@vitest/coverage-istanbul': 4.1.11
'@vitest/coverage-v8': 4.1.11
'@vitest/ui': 4.1.11
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2942,7 +2942,7 @@ snapshots:
minimatch: 10.2.5
proper-lockfile: 4.1.2
typebox: 1.1.38
undici: 8.5.0
undici: 8.10.0
yaml: 2.9.0
optionalDependencies:
'@mariozechner/clipboard': 0.3.9
@@ -3203,7 +3203,7 @@ snapshots:
dependencies:
'@qdrant/openapi-typescript-fetch': 1.2.6
typescript: 6.0.3
undici: 7.28.0
undici: 7.29.0
'@qdrant/openapi-typescript-fetch@1.2.6': {}
@@ -3517,44 +3517,44 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.1.8':
'@vitest/expect@4.1.11':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))':
'@vitest/mocker@4.1.11(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.8
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
'@vitest/pretty-format@4.1.8':
'@vitest/pretty-format@4.1.11':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.8':
'@vitest/runner@4.1.11':
dependencies:
'@vitest/utils': 4.1.8
'@vitest/utils': 4.1.11
pathe: 2.0.3
'@vitest/snapshot@4.1.8':
'@vitest/snapshot@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.8
'@vitest/utils': 4.1.8
'@vitest/pretty-format': 4.1.11
'@vitest/utils': 4.1.11
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.8': {}
'@vitest/spy@4.1.11': {}
'@vitest/utils@4.1.8':
'@vitest/utils@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.8
'@vitest/pretty-format': 4.1.11
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
@@ -4895,9 +4895,9 @@ snapshots:
undici-types@7.24.6: {}
undici@7.28.0: {}
undici@7.29.0: {}
undici@8.5.0: {}
undici@8.10.0: {}
util-deprecate@1.0.2: {}
@@ -4919,15 +4919,15 @@ snapshots:
jiti: 2.7.0
yaml: 2.9.0
vitest@4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
vitest@4.1.11(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.8
'@vitest/runner': 4.1.8
'@vitest/snapshot': 4.1.8
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
@@ -5,8 +5,8 @@ overrides:
"form-data@<4.0.6": ">=4.0.6"
"uuid@<11.1.1": ">=11.1.1"
"esbuild": ">=0.28.1"
"undici@<6.27.0": ">=6.27.0 <8.0.0"
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
"undici@<7.29.0": ">=7.29.0 <8.0.0"
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
"axios@<1.18.0": ">=1.18.0 <2.0.0"
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0"
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
@@ -1,125 +0,0 @@
---
name: dream
description: Consolidates stored memories by merging duplicates, resolving contradictions, and pruning stale entries. Use when memory count is high, search results feel noisy or repetitive, or periodic cleanup is needed to maintain memory quality.
---
# Dream — Memory Consolidation
This skill performs a memory consolidation pass: it fetches all memories, identifies near-duplicates, flags contradictions, and prunes stale entries. All proposed changes are shown as a diff for user approval before anything is modified.
**IMPORTANT: Execute steps strictly in order (1 -> 2 -> 3 -> 4 -> 5). Each step depends on the previous one. Do NOT run steps in parallel or skip ahead.**
## Step 1: Fetch ALL Memories
Use `mem0_memory` tool with `action="get_all"` to retrieve every memory.
If zero memories are found, print:
```
No memories found. Nothing to consolidate.
```
...and stop.
## Step 2: Analyze — Find Issues
Work entirely in-memory; do not modify anything yet.
Group memories by category. For each group, identify the following:
### 2a. Near-duplicate pairs (merge candidates)
Two memories are near-duplicates when they express the same fact but phrased differently (e.g., "Prefers morning meetings" and "Likes scheduling meetings early").
Heuristics — two memories are near-duplicates if **all** of these hold:
- If >60% of significant nouns/keywords overlap, treat as near-duplicate.
- Same category.
- Neither memory is pinned (content does not start with `[PINNED]`).
For each qualifying pair, draft a merged version that is more complete than either original.
### 2b. Contradictions
Two memories contradict when they assert opposing facts about the same topic (e.g., "Prefers cats" vs. "Allergic to cats, prefers dogs").
Identify the likely winner: the more recent memory wins. Store both IDs and their content for user review.
### 2c. Prune candidates
A memory is a prune candidate when **any** of the following is true:
1. It is older than 180 days AND has not been accessed recently.
2. Its content is extremely vague (fewer than 5 meaningful words).
**Always skip memories where content starts with `[PINNED]`**, regardless of age.
## Step 3: Print Diff Report
Print a structured diff before making any changes:
```
## dream — consolidation report
Merges (<N>):
[mem0:<id1>] + [mem0:<id2>] -> "<merged content, 100 chars>"
Conflicts (<N>):
[mem0:<idA>] vs [mem0:<idB>] — "<topic>" [A/B/skip]
Prune (<N>):
[mem0:<id>] — <category>, <age>d old
Proposed: <N> merges, <N> prunes, <N> conflicts. Apply? [Y/n]
```
If there are zero total proposals, print:
```
Dream complete. No duplicate, contradictory, or stale memories found.
```
...and stop.
## Step 4: Wait for User Input and Apply
### 4a. Contradictions
For each conflict pair, wait for the user to choose A, B, or skip.
### 4b. Final confirmation
After all conflict resolutions are collected, prompt: `Apply? [Y/n]`
If the user declines, print `Cancelled. No changes made.` and stop.
If confirmed, apply all changes:
**Merges:** Delete both originals, add the merged version using `mem0_memory` with `action="add"`.
**Contradictions (resolved):** Delete the loser using `mem0_memory` with `action="delete"`.
**Prunes:** Delete each using `mem0_memory` with `action="delete"`.
## Step 5: Print Summary
```
Dream complete — merged: <N>, pruned: <N>, conflicts resolved: <N>, skipped: <N>
```
## Auto mode
When invoked with `--auto` (e.g., `/mem0-dream --auto`), run non-interactively:
- **Merges**: applied automatically.
- **Prunes**: applied automatically.
- **Contradictions**: skipped — they require human judgment.
Print a compact summary:
```
[mem0-dream --auto] merged=<N> pruned=<N> conflicts_skipped=<N>
```
## See also
- `/mem0-forget` — targeted deletion of specific memories
- `/mem0-status` — quick health check
@@ -1,48 +0,0 @@
---
name: pin
description: Pins or unpins a memory to protect it from pruning during dream consolidation. Use when a memory is critical and must never be removed, such as core preferences, important decisions, or immutable personal facts.
---
# Pin
Pin a memory to mark it as high-priority and protect from dream pruning.
## Execution
### Step 1: Find the memory
The user provides either a search query or memory ID.
**If memory ID:** Look it up directly.
**If search query:**
- Use `mem0_memory` tool with `action="search"`, `query=<query>`.
- Show numbered list with content previews.
- Ask: "Which memory to pin? Enter a number."
### Step 2: Pin it
Pinning works by prepending `[PINNED]` to the memory text. This marker tells the dream consolidation to skip it during pruning.
Use `mem0_memory` tool with `action="add"`, `content="[PINNED] <original memory text>"`.
Then delete the original using `mem0_memory` with `action="delete"` and the original memory ID.
**For new memories** (user wants to pin text that isn't stored yet):
- Use `mem0_memory` tool with `action="add"`, `content="[PINNED] <the user's text>"`.
### Step 3: Confirm
```
Pinned: "<memory content, first 80 chars>"
```
Append `...` only if content exceeds 80 characters.
### Unpin
If the user says "unpin":
1. Find the memory (search or by ID).
2. Create a new memory without the `[PINNED]` prefix.
3. Delete the pinned version.
4. Print: `Unpinned: "<content>..."`
@@ -86,4 +86,4 @@ Duplicates: <N> · Stale: <N> · Contradictions: <N>
```
If all counts are 0: `Memory quality: clean.`
If any non-zero: append `Run /mem0-dream to fix.`
If any non-zero, list the affected memory IDs so the user can review them with the normal search and forget commands.
@@ -4,37 +4,9 @@ import type { Mem0Config, ScopeContext } from "../types.ts";
import { DEFAULT_CUSTOM_CATEGORIES } from "../types.ts";
import { resolveAddParams } from "../memory/scoping.ts";
import { captureEvent } from "../telemetry.ts";
import { createMemoryLifecycle } from "../../../agent-plugin-core/typescript/src/lifecycle.ts";
interface MessageLike {
role: string;
content?: unknown;
}
function extractText(content: unknown): string | null {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const texts = content
.filter((b: any) => b.type === "text" && typeof b.text === "string")
.map((b: any) => b.text);
return texts.length > 0 ? texts.join("\n") : null;
}
return null;
}
export function extractConversation(
messages: MessageLike[],
): Array<{ role: "user" | "assistant"; content: string }> {
const result: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const msg of messages) {
if (msg.role !== "user" && msg.role !== "assistant") continue;
const text = extractText(msg.content);
if (!text) continue;
result.push({ role: msg.role as "user" | "assistant", content: text });
}
return result;
}
export { extractConversation } from "../../../agent-plugin-core/typescript/src/lifecycle.ts";
export function setupAutoCapture(
pi: ExtensionAPI,
@@ -42,12 +14,13 @@ export function setupAutoCapture(
config: Mem0Config,
getScopeCtx: () => ScopeContext,
telemetryCtx?: { apiKey?: string },
lifecycle: ReturnType<typeof createMemoryLifecycle> = createMemoryLifecycle(),
): void {
if (!config.autoCapture) return;
pi.on("agent_end", async (event) => {
const messages = event.messages ?? [];
const conversation = extractConversation(messages);
const conversation = lifecycle.prepareConversation(messages);
if (conversation.length === 0) return;
const scopeCtx = getScopeCtx();
@@ -6,14 +6,6 @@ vi.mock("./telemetry.ts", () => ({
captureCommandEvent: vi.fn(),
}));
vi.mock("./dream/index.ts", () => ({
acquireDreamLock: vi.fn(() => true),
}));
vi.mock("./dream/prompt.ts", () => ({
DREAM_PROTOCOL: "dream protocol text",
}));
function makeMem0() {
return {
search: vi.fn(),
@@ -56,7 +48,6 @@ const defaultConfig: Mem0Config = {
defaultScope: "project",
contextInjection: false,
searchThreshold: 0.3,
dream: { enabled: false, auto: false, minHours: 24, minSessions: 5, minMemories: 20 },
};
const scopeCtx: ScopeContext = { userId: "test-user", appId: "test-app", runId: "test-run" };
@@ -74,14 +65,14 @@ describe("registerCommands", () => {
it("registers all expected commands", () => {
const names = [...pi._commands.keys()];
expect(names).toContain("mem0-remember");
expect(names).toContain("mem0-forget");
expect(names).toContain("mem0-search");
expect(names).toContain("mem0-tour");
expect(names).toContain("mem0-dream");
expect(names).toContain("mem0-pin");
expect(names).toContain("mem0-scope");
expect(names).toContain("mem0-status");
expect(names).toEqual([
"mem0-remember",
"mem0-forget",
"mem0-search",
"mem0-tour",
"mem0-scope",
"mem0-status",
]);
});
describe("/mem0-forget", () => {
@@ -197,103 +188,6 @@ describe("registerCommands", () => {
});
});
describe("/mem0-pin", () => {
it("uses update to pin in-place, preserving memory ID", async () => {
const ctx = makeCtx(true);
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "important fact" }] });
mem0.update.mockResolvedValue([]);
await pi._invoke("mem0-pin", "important", ctx);
expect(ctx.ui.confirm).toHaveBeenCalledWith(
"Pin this memory?",
expect.stringContaining("important fact"),
);
expect(mem0.update).toHaveBeenCalledWith("abc-123", { text: "[PINNED] important fact" });
expect(mem0.add).not.toHaveBeenCalled();
expect(mem0.delete).not.toHaveBeenCalled();
});
it("sends a visible confirmation after pinning", async () => {
const ctx = makeCtx(true);
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "important fact" }] });
mem0.update.mockResolvedValue([]);
await pi._invoke("mem0-pin", "important", ctx);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "mem0-pin",
content: expect.stringContaining("Pinned"),
display: true,
}),
);
});
it("does not pin when user cancels", async () => {
const ctx = makeCtx(false);
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "fact" }] });
await pi._invoke("mem0-pin", "fact", ctx);
expect(mem0.update).not.toHaveBeenCalled();
});
it("skips already-pinned memories with a visible message", async () => {
const ctx = makeCtx();
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "[PINNED] fact" }] });
await pi._invoke("mem0-pin", "fact", ctx);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(mem0.add).not.toHaveBeenCalled();
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: expect.stringContaining("Already pinned"), display: true }),
);
});
it("uses select UI for multiple matches and pins chosen memory", async () => {
const ctx = makeCtx();
mem0.search.mockResolvedValue({
results: [
{ id: "id-1", memory: "fact one" },
{ id: "id-2", memory: "fact two" },
],
});
mem0.update.mockResolvedValue([]);
ctx.ui.select = vi.fn(async (_title: string, options: string[]) => options[1]);
await pi._invoke("mem0-pin", "fact", ctx);
expect(ctx.ui.select).toHaveBeenCalledWith(
expect.stringContaining("which should I pin"),
expect.arrayContaining([
expect.stringContaining("fact one"),
expect.stringContaining("fact two"),
]),
);
expect(mem0.update).toHaveBeenCalledWith("id-2", { text: "[PINNED] fact two" });
});
it("does not pin when user cancels select", async () => {
const ctx = makeCtx();
ctx.ui.select = vi.fn(async () => undefined);
mem0.search.mockResolvedValue({
results: [
{ id: "id-1", memory: "fact one" },
{ id: "id-2", memory: "fact two" },
],
});
await pi._invoke("mem0-pin", "fact", ctx);
expect(mem0.update).not.toHaveBeenCalled();
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: expect.stringContaining("Cancelled"), display: true }),
);
});
});
describe("/mem0-search", () => {
it("performs server-side semantic search with a relevance threshold", async () => {
const ctx = makeCtx();
@@ -488,23 +382,4 @@ describe("registerCommands", () => {
});
});
describe("/mem0-dream", () => {
it("feeds the protocol to the agent and shows a clean status line", async () => {
const ctx = makeCtx();
await pi._invoke("mem0-dream", "", ctx);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ customType: "mem0-dream", display: false }),
expect.objectContaining({ triggerTurn: true }),
);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "mem0-dream",
content: expect.stringContaining("Dreaming"),
display: true,
}),
);
});
});
});
@@ -4,9 +4,6 @@ import type { Mem0Config, ScopeContext, Scope } from "./types.ts";
import { DEFAULT_CUSTOM_CATEGORIES } from "./types.ts";
import { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts";
import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts";
import { DREAM_PROTOCOL } from "./dream/prompt.ts";
import { acquireDreamLock } from "./dream/index.ts";
import { CONFIG_DIR } from "./config/index.ts";
import { captureCommandEvent } from "./telemetry.ts";
const SEARCH_TOP_K = 10;
@@ -184,90 +181,6 @@ export function registerCommands(
},
});
pi.registerCommand("mem0-dream", {
description: "Consolidate memories — merge duplicates, prune stale entries, resolve contradictions",
handler: async (_args, ctx) => {
if (!acquireDreamLock(CONFIG_DIR)) {
ctx.ui.notify("A dream consolidation is already in progress.", "warning");
return;
}
captureCommandEvent("mem0-dream", {}, telemetryCtx);
pi.sendMessage({ customType: "mem0-dream", content: DREAM_PROTOCOL, display: false }, { triggerTurn: true });
sendFeedback(
"mem0-dream",
"**Dreaming** — reviewing your memories to merge duplicates, resolve contradictions, and prune stale entries. I'll report what changed.",
);
},
});
pi.registerCommand("mem0-pin", {
description: "Pin a memory to protect it from dream pruning",
handler: async (args, ctx) => {
const query = args?.trim();
if (!query) {
ctx.ui.notify("Usage: /mem0-pin <query>", "warning");
return;
}
const memories = await searchMemories(query, config.defaultScope);
if (memories.length === 0) {
captureCommandEvent("mem0-pin", { result_count: 0 }, telemetryCtx);
sendFeedback("mem0-pin", `**No matches for "${query}"** — nothing to pin.`);
return;
}
const pinned = (mem: Parameters<typeof formatMemoryCompact>[0]) => {
captureCommandEvent("mem0-pin", { pinned: true }, telemetryCtx);
sendFeedback(
"mem0-pin",
["**Pinned** — protected from dream pruning", `- ${formatMemoryCompact(mem)}`].join("\n"),
);
};
const alreadyPinned = (mem: Parameters<typeof formatMemoryCompact>[0]) => {
sendFeedback("mem0-pin", ["**Already pinned**", `- ${formatMemoryCompact(mem)}`].join("\n"));
};
if (memories.length === 1) {
const target = memories[0];
const text = target.memory ?? "";
if (text.startsWith("[PINNED]")) {
alreadyPinned(target);
return;
}
const confirmed = await ctx.ui.confirm("Pin this memory?", formatMemoryCompact(target));
if (!confirmed) {
sendFeedback("mem0-pin", "**Cancelled** — nothing was pinned.");
return;
}
await mem0.update(target.id, { text: `[PINNED] ${text}` });
pinned(target);
return;
}
const labels = memories.map((m) => formatMemoryCompact(m));
const selected = await ctx.ui.select(
`Found ${pluralize(memories.length, "match", "matches")} for "${query}" — which should I pin?`,
labels,
);
if (!selected) {
sendFeedback("mem0-pin", "**Cancelled** — nothing was pinned.");
return;
}
const idx = labels.indexOf(selected);
if (idx < 0) return;
const target = memories[idx];
const selectedText = target.memory ?? "";
if (selectedText.startsWith("[PINNED]")) {
alreadyPinned(target);
return;
}
await mem0.update(target.id, { text: `[PINNED] ${selectedText}` });
pinned(target);
},
});
pi.registerCommand("mem0-scope", {
description: "Change default memory scope for this session (project, session, global)",
handler: async (args, ctx) => {
@@ -329,7 +242,6 @@ export function registerCommands(
`- Search relevance threshold: ${config.searchThreshold}`,
`- Project memories: ${count}`,
`- Auto-capture: ${config.autoCapture ? "on" : "off"}`,
`- Dream: ${config.dream.enabled ? "enabled" : "disabled"}`,
];
captureCommandEvent("mem0-status", { connected, memory_count: count }, telemetryCtx);
@@ -1,20 +1,12 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Mem0Config, DreamConfig } from "../types.ts";
import type { Mem0Config } from "../types.ts";
const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
export const CONFIG_DIR = AGENT_ROOT;
const CONFIG_PATH = path.join(AGENT_ROOT, "mem0-config.json");
const DEFAULT_DREAM: DreamConfig = {
enabled: true,
auto: true,
minHours: 24,
minSessions: 5,
minMemories: 20,
};
const DEFAULT_CONFIG: Mem0Config = {
apiKey: "",
userId: "",
@@ -22,7 +14,6 @@ const DEFAULT_CONFIG: Mem0Config = {
defaultScope: "project",
contextInjection: true,
searchThreshold: 0.3,
dream: DEFAULT_DREAM,
};
export function loadConfig(): Mem0Config {
@@ -37,15 +28,9 @@ export function loadConfig(): Mem0Config {
}
}
const dream: DreamConfig = {
...DEFAULT_DREAM,
...(fileConfig.dream ?? {}),
};
const config: Mem0Config = {
...DEFAULT_CONFIG,
...fileConfig,
dream,
};
if (process.env.MEM0_API_KEY) {
@@ -1,115 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { DreamState, DreamLock, DreamConfig } from "../types.ts";
const LOCK_STALE_MS = 60 * 60 * 1000;
const DEFAULTS: DreamConfig = {
enabled: true,
auto: true,
minHours: 24,
minSessions: 5,
minMemories: 20,
};
function statePath(stateDir: string): string {
return path.join(stateDir, "mem0-dream-state.json");
}
function lockPath(stateDir: string): string {
return path.join(stateDir, "mem0-dream.lock");
}
function ensureDir(dir: string): void {
try {
fs.mkdirSync(dir, { recursive: true });
} catch { /* exists */ }
}
function readState(stateDir: string): DreamState {
try {
const raw = fs.readFileSync(statePath(stateDir), "utf-8");
return JSON.parse(raw) as DreamState;
} catch {
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
}
}
function writeState(stateDir: string, state: DreamState): void {
ensureDir(stateDir);
fs.writeFileSync(statePath(stateDir), JSON.stringify(state, null, 2));
}
export function incrementSessionCount(stateDir: string, sessionId: string): void {
const state = readState(stateDir);
if (state.lastSessionId !== sessionId) {
state.sessionsSince++;
state.lastSessionId = sessionId;
writeState(stateDir, state);
}
}
export function checkCheapGates(
stateDir: string,
config: Partial<DreamConfig>,
): { proceed: boolean; reason?: string } {
const minHours = config.minHours ?? DEFAULTS.minHours;
const minSessions = config.minSessions ?? DEFAULTS.minSessions;
const state = readState(stateDir);
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
if (hoursSince < minHours) {
return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
}
if (state.sessionsSince < minSessions) {
return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
}
return { proceed: true };
}
export function checkMemoryGate(
memoryCount: number,
config: Partial<DreamConfig>,
): { pass: boolean; reason?: string } {
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
if (memoryCount < minMemories) {
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
}
return { pass: true };
}
export function acquireDreamLock(stateDir: string): boolean {
ensureDir(stateDir);
const lp = lockPath(stateDir);
try {
const raw = fs.readFileSync(lp, "utf-8");
const lock = JSON.parse(raw) as DreamLock;
if (Date.now() - lock.startedAt < LOCK_STALE_MS) {
return false;
}
try { fs.unlinkSync(lp); } catch { /* race ok */ }
} catch { /* no lock file */ }
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
try {
fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
return true;
} catch {
return false;
}
}
export function releaseDreamLock(stateDir: string): void {
try { fs.unlinkSync(lockPath(stateDir)); } catch { /* already gone */ }
}
export function recordDreamCompletion(stateDir: string): void {
const state = readState(stateDir);
state.lastConsolidatedAt = Date.now();
state.sessionsSince = 0;
state.lastSessionId = null;
writeState(stateDir, state);
}
@@ -1,22 +0,0 @@
export const DREAM_PROTOCOL = `<mem0-dream>
You are running memory consolidation. Complete these steps using the mem0_memory tool:
1. ORIENT — Call mem0_memory with action "get_all" to list all memories. Count by category. Note oldest/newest.
2. GATHER TARGETS — Review each memory. Classify as:
- DELETE: sensitive information (API keys, passwords, tokens), expired/stale entries, noise, redundant operational details
- MERGE: near-duplicates (same fact stated differently). Keep the better-worded one, delete the other.
- REWRITE: vague, first-person, or poorly-categorized entries. Use mem0_memory "add" with improved text, then "delete" the old one.
- KEEP: everything else.
Skip any memory starting with "[PINNED]".
3. CONSOLIDATE — Execute the changes:
- Delete stale/duplicate entries
- For merges: add the merged text, delete both originals
- For rewrites: add improved version, delete original
4. REPORT — Summarize: how many reviewed, deleted, merged, rewritten, final count.
Quality targets: zero sensitive data stored, zero duplicates, all entries are atomic (one fact each), 15-50 words each.
After consolidation, respond to the user's message normally.
</mem0-dream>`;
+10 -98
View File
@@ -1,24 +1,17 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import MemoryClient from "mem0ai";
import { loadConfig, CONFIG_DIR } from "./config/index.ts";
import { loadConfig } from "./config/index.ts";
import { detectAppId, detectRunId, resolveSearchFilters } from "./memory/scoping.ts";
import { formatMemoryList } from "./memory/formatting.ts";
import { registerMemoryTool } from "./memory/tools.ts";
import { registerCommands } from "./commands.ts";
import { setupAutoCapture } from "./capture/index.ts";
import { MEMORY_POLICY } from "./prompt.ts";
import { DREAM_PROTOCOL } from "./dream/prompt.ts";
import {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream/index.ts";
import { captureEvent } from "./telemetry.ts";
import * as os from "node:os";
import type { ScopeContext } from "./types.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
export { buildRecallContext } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
export function resolveUserId(configUserId: string): string {
if (configUserId) return configUserId;
@@ -27,31 +20,6 @@ export function resolveUserId(configUserId: string): string {
try { return os.userInfo().username; } catch { return "default"; }
}
/**
* Build the auto-recall context block for a turn: search memory with the user's
* prompt and format the top matches so they are guaranteed in context instead of
* relying on the agent to call the tool. Best-effort — returns "" when disabled,
* the prompt is blank, nothing matches, or the search fails; it must never block
* the turn.
*/
export async function buildRecallContext(
prompt: string,
enabled: boolean,
search: (query: string) => Promise<{ results?: unknown[] }>,
): Promise<string> {
if (!enabled) return "";
const q = prompt.trim();
if (!q) return "";
try {
const res = await search(q);
const memories = (res.results ?? []) as Parameters<typeof formatMemoryList>[0];
if (memories.length === 0) return "";
return `<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — search mem0_memory for more if you need it.\n${formatMemoryList(memories)}\n</mem0-relevant-memories>`;
} catch {
return "";
}
}
export default function mem0Extension(pi: ExtensionAPI): void {
const config = loadConfig();
@@ -73,20 +41,21 @@ export default function mem0Extension(pi: ExtensionAPI): void {
}
const telemetryCtx = { apiKey: config.apiKey };
const lifecycle = createMemoryLifecycle();
// ── Register tool + commands + auto-capture ─────────────────────────
registerMemoryTool(pi, mem0, config, getScopeCtx, telemetryCtx);
registerCommands(pi, mem0, config, getScopeCtx, telemetryCtx);
setupAutoCapture(pi, mem0, config, getScopeCtx, telemetryCtx);
setupAutoCapture(pi, mem0, config, getScopeCtx, telemetryCtx, lifecycle);
captureEvent("pi.plugin.registered", {
auto_capture: config.autoCapture,
dream_enabled: config.dream.enabled,
default_scope: config.defaultScope,
}, telemetryCtx);
// ── session_start: detect project + session, reconstruct scope ──────
pi.on("session_start", async (_event, ctx) => {
lifecycle.beginSession();
scopeCtx.appId = detectAppId(ctx.cwd);
const sessionFile = ctx.sessionManager?.getSessionFile?.();
@@ -96,86 +65,29 @@ export default function mem0Extension(pi: ExtensionAPI): void {
scopeCtx.userId = config.userId;
}
if (config.dream.enabled) {
incrementSessionCount(CONFIG_DIR, scopeCtx.runId);
}
captureEvent("pi.session.start", {}, telemetryCtx);
});
// ── before_agent_start: append memory policy + auto-dream trigger ───
let dreamTriggered = false;
let dreamChecked = false;
// ── before_agent_start: append memory policy and recall ─────────────
pi.on("before_agent_start", async (event, _ctx) => {
let extra = MEMORY_POLICY;
// Guaranteed retrieval: prefetch memories relevant to this prompt so the
// agent always has them, rather than depending on it to call the tool.
const recall = await buildRecallContext(
const recall = await lifecycle.recall(
event.prompt ?? "",
config.contextInjection,
(q) => mem0.search(q, { filters: resolveSearchFilters("project", scopeCtx) }),
);
if (recall) extra += "\n\n" + recall;
if (config.dream.enabled && config.dream.auto && !dreamTriggered && !dreamChecked) {
const gates = checkCheapGates(CONFIG_DIR, config.dream);
if (gates.proceed) {
try {
const filters = resolveSearchFilters("project", scopeCtx);
const result = await mem0.getAll({ filters });
const count = result.count ?? (result.results ?? []).length;
dreamChecked = true;
const memGate = checkMemoryGate(count, config.dream);
if (memGate.pass && acquireDreamLock(CONFIG_DIR)) {
dreamTriggered = true;
extra += "\n\n" + DREAM_PROTOCOL;
captureEvent("pi.dream.triggered", { memory_count: count }, telemetryCtx);
}
} catch {
// Transient error — retry next turn
}
}
}
return {
systemPrompt: (event.systemPrompt ?? "") + "\n\n" + extra,
};
});
// ── agent_end: dream completion check ───────────────────────────────
pi.on("agent_end", async (event) => {
if (!dreamTriggered) return;
const messages = event.messages ?? [];
const hadWriteAction = messages.some((m) => {
if (m.role !== "assistant") return false;
const content = Array.isArray(m.content) ? m.content : [];
return content.some(
(block: any) =>
block.type === "tool_use" &&
block.name === "mem0_memory" &&
["add", "delete", "delete_all"].includes(block.input?.action),
);
});
if (hadWriteAction) {
recordDreamCompletion(CONFIG_DIR);
captureEvent("pi.dream.completed", {}, telemetryCtx);
}
releaseDreamLock(CONFIG_DIR);
dreamTriggered = false;
});
// ── session_shutdown: release dream lock if still held ──────────────
// ── session_shutdown ────────────────────────────────────────────────
pi.on("session_shutdown", async () => {
captureEvent("pi.session.stop", {}, telemetryCtx);
if (dreamTriggered) {
releaseDreamLock(CONFIG_DIR);
dreamTriggered = false;
}
});
}
-11
View File
@@ -1,7 +1,6 @@
export type {
Scope,
Mem0Config,
DreamConfig,
ScopeContext,
CustomCategory,
} from "./types.ts";
@@ -15,16 +14,6 @@ export { formatAge, formatMemoryCompact, formatMemoryList, groupByCategory } fro
export { setupAutoCapture, extractConversation } from "./capture/index.ts";
export {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream/index.ts";
export { DREAM_PROTOCOL } from "./dream/prompt.ts";
export { MEMORY_POLICY } from "./prompt.ts";
export { registerCommands } from "./commands.ts";
@@ -1,43 +1,7 @@
interface MemoryLike {
id: string;
memory?: string;
categories?: string[];
createdAt?: Date | string;
}
export function formatAge(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const ms = Date.now() - d.getTime();
const minutes = Math.floor(ms / 60_000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function formatMemoryCompact(mem: MemoryLike): string {
const cat = mem.categories?.[0] ?? "uncategorized";
const age = mem.createdAt ? ` (${formatAge(mem.createdAt)})` : "";
return `[${cat}] ${mem.memory ?? "(empty)"}${age} [mem0:${mem.id}]`;
}
export function formatMemoryList(memories: MemoryLike[]): string {
if (memories.length === 0) return "No memories found.";
return memories
.map((m, i) => `${i + 1}. ${formatMemoryCompact(m)}`)
.join("\n");
}
export function groupByCategory(
memories: MemoryLike[],
): Map<string, MemoryLike[]> {
const groups = new Map<string, MemoryLike[]>();
for (const m of memories) {
const cat = m.categories?.[0] ?? "uncategorized";
const list = groups.get(cat) ?? [];
list.push(m);
groups.set(cat, list);
}
return groups;
}
export {
formatAge,
formatMemoryCompact,
formatMemoryList,
groupByCategory,
} from "../../../agent-plugin-core/typescript/src/formatting.ts";
export type { MemoryLike } from "../../../agent-plugin-core/typescript/src/formatting.ts";
@@ -63,8 +63,8 @@ describe("resolveSearchFilters", () => {
expect(resolveSearchFilters("session", ctx)).toEqual({ user_id: "u1", app_id: "a1", run_id: "r1" });
});
it("uses wildcard app_id for global scope", () => {
expect(resolveSearchFilters("global", ctx)).toEqual({ user_id: "u1", app_id: "*" });
it("limits global scope to the configured user", () => {
expect(resolveSearchFilters("global", ctx)).toEqual({ user_id: "u1" });
});
});
@@ -2,6 +2,10 @@ import * as path from "node:path";
import * as crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import type { Scope, ScopeContext } from "../types.ts";
import {
scopeAddParams,
scopeSearchFilters,
} from "../../../agent-plugin-core/typescript/src/scoping.ts";
export function detectAppId(cwd: string): string {
try {
@@ -26,26 +30,12 @@ export function resolveSearchFilters(
scope: Scope,
ctx: ScopeContext,
): Record<string, string> {
switch (scope) {
case "project":
return { user_id: ctx.userId, app_id: ctx.appId };
case "session":
return { user_id: ctx.userId, app_id: ctx.appId, run_id: ctx.runId };
case "global":
return { user_id: ctx.userId, app_id: "*" };
}
return scopeSearchFilters(scope, ctx);
}
export function resolveAddParams(
scope: Scope,
ctx: ScopeContext,
): Record<string, string> {
switch (scope) {
case "project":
return { userId: ctx.userId, appId: ctx.appId };
case "session":
return { userId: ctx.userId, appId: ctx.appId, runId: ctx.runId };
case "global":
return { userId: ctx.userId };
}
return scopeAddParams(scope, ctx);
}
@@ -1,3 +1,4 @@
import { resolveToolScope } from "../../../agent-plugin-core/typescript/src/scoping.ts";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
@@ -17,6 +18,10 @@ interface MemoryResult {
const MAX_OUTPUT_LINES = 200;
const MAX_OUTPUT_BYTES = 50_000;
function normalizeMemoryId(id: string): string {
return id.replace(/^\[?mem0:([0-9a-f-]{36})\]?$/i, "$1");
}
function truncateOutput(text: string): string {
const lines = text.split("\n");
if (lines.length <= MAX_OUTPUT_LINES && text.length <= MAX_OUTPUT_BYTES) {
@@ -50,7 +55,7 @@ export function buildToolExecute(
defaultScope: Scope,
) {
return async (params: ToolParams, signal?: AbortSignal) => {
const scope = params.scope ?? defaultScope;
const scope = resolveToolScope(params.scope, defaultScope);
switch (params.action) {
case "search": {
@@ -96,18 +101,19 @@ export function buildToolExecute(
if (signal?.aborted) throw new Error("Cancelled");
if (!params.memory_id) throw new Error("memory_id is required for update");
if (!params.content) throw new Error("content is required for update");
const updateResult = await mem0.update(params.memory_id, { text: params.content });
const memoryId = normalizeMemoryId(params.memory_id);
const updateResult = await mem0.update(memoryId, { text: params.content });
const res = updateResult as MemoryResult;
return {
content: [{ type: "text" as const, text: res.status ?? "Memory updated." }],
details: { memoryId: params.memory_id },
details: { memoryId },
};
}
case "delete": {
if (signal?.aborted) throw new Error("Cancelled");
if (!params.memory_id) throw new Error("memory_id is required for delete");
const result = await mem0.delete(params.memory_id);
const result = await mem0.delete(normalizeMemoryId(params.memory_id));
return {
content: [{ type: "text" as const, text: result.message ?? "Memory deleted." }],
details: {},
@@ -145,7 +151,7 @@ export function registerMemoryTool(
'For multi-part or comparative questions, run several searches with different phrasings and combine the results before answering -- one search is rarely enough',
'Use mem0_memory with action "add" to save important facts, preferences, goals, decisions, or lessons the user shares',
'Use mem0_memory with action "update" to modify an existing memory — requires memory_id and content. Preserves the memory ID',
"Always use the default project scope unless the user EXPLICITLY asks to search across all projects — only then use scope \"global\"",
"Always use the default project scope unless the user EXPLICITLY asks to search across all projects — only after the user selects /mem0-scope global use scope \"global\"",
"Do NOT pass scope at all for normal queries — omitting it uses the project default automatically",
],
parameters: Type.Object({
+56 -197
View File
@@ -1,240 +1,99 @@
/**
* Plugin telemetry — anonymous usage tracking via PostHog.
*
* Sends fire-and-forget events to PostHog using native fetch().
* Events are batched and flushed every 5 seconds or when the queue
* reaches 10 events, whichever comes first.
*
* Disable with: MEM0_TELEMETRY=false
*/
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { createTelemetry } from "../../agent-plugin-core/typescript/src/telemetry.ts";
import { CONFIG_DIR } from "./config/index.ts";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_THRESHOLD = 10;
let eventQueue: Record<string, unknown>[] = [];
let flushTimer: ReturnType<typeof setInterval> | undefined;
function _loadPluginVersion(): string {
const PLUGIN_VERSION = (() => {
try {
const pkgUrl = new URL("../package.json", import.meta.url);
const pkg = JSON.parse(fs.readFileSync(pkgUrl, "utf-8"));
return pkg.version ?? "unknown";
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
} catch {
return "unknown";
}
}
const PLUGIN_VERSION = _loadPluginVersion();
// ── Opt-out ──────────────────────────────────────────────────────────────
function isTelemetryEnabled(): boolean {
try {
const val = process.env.MEM0_TELEMETRY;
if (val !== undefined) {
const s = val.toLowerCase();
return s !== "false" && s !== "0" && s !== "no" && s !== "off";
}
return true;
} catch {
return true;
}
}
// ── Identity ─────────────────────────────────────────────────────────────
})();
const TELEMETRY_ID_PATH = path.join(CONFIG_DIR, "mem0-telemetry-id.json");
let _cachedAnonymousId: string | undefined;
let cachedAnonymousId: string | undefined;
let currentDistinctId = "";
let identified = false;
function getOrCreateAnonymousId(): string {
if (_cachedAnonymousId) return _cachedAnonymousId;
function anonymousId(): string {
if (cachedAnonymousId) return cachedAnonymousId;
try {
if (fs.existsSync(TELEMETRY_ID_PATH)) {
const data = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8"));
if (data.anonymousId) {
_cachedAnonymousId = data.anonymousId;
return _cachedAnonymousId!;
}
}
} catch { /* ignore */ }
const newId = `pi-mem0-anon-${randomUUID().replace(/-/g, "")}`;
const stored = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8")).anonymousId;
if (typeof stored === "string" && stored) return (cachedAnonymousId = stored);
} catch {
// First run or unreadable identity file.
}
const created = `pi-mem0-anon-${randomUUID().replace(/-/g, "")}`;
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(TELEMETRY_ID_PATH, JSON.stringify({ anonymousId: newId }), "utf-8");
} catch { /* ignore */ }
_cachedAnonymousId = newId;
return newId;
}
function getDistinctId(apiKey?: string): string {
if (apiKey) {
return createHash("sha256").update(apiKey).digest("hex");
}
return getOrCreateAnonymousId();
}
let _identifyDone = false;
function maybeBuildIdentifyEvent(distinctId: string): Record<string, unknown> | null {
if (_identifyDone) return null;
if (!distinctId || distinctId.startsWith("pi-mem0-anon-")) return null;
try {
if (!fs.existsSync(TELEMETRY_ID_PATH)) {
_identifyDone = true;
return null;
}
const data = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8"));
const storedAnon = data.anonymousId;
if (!storedAnon) {
_identifyDone = true;
return null;
}
const identifyEvent = {
event: "$identify",
distinct_id: distinctId,
properties: { $anon_distinct_id: storedAnon, $lib: "posthog-node" },
};
try {
fs.unlinkSync(TELEMETRY_ID_PATH);
} catch { /* ignore */ }
_identifyDone = true;
_cachedAnonymousId = undefined;
return identifyEvent;
fs.writeFileSync(TELEMETRY_ID_PATH, JSON.stringify({ anonymousId: created }), "utf-8");
} catch {
return null;
// An unwritable config directory must not break the plugin.
}
return (cachedAnonymousId = created);
}
function distinctId(apiKey?: string): string {
return apiKey ? createHash("sha256").update(apiKey).digest("hex") : anonymousId();
}
function previousAnonymousId(id: string): string | undefined {
if (identified || id.startsWith("pi-mem0-anon-")) return undefined;
identified = true;
try {
const stored = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8")).anonymousId;
fs.unlinkSync(TELEMETRY_ID_PATH);
cachedAnonymousId = undefined;
return typeof stored === "string" && stored ? stored : undefined;
} catch {
return undefined;
}
}
// ── Flush machinery ──────────────────────────────────────────────────────
function ensureFlushTimer(): void {
if (flushTimer) return;
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
if (typeof flushTimer === "object" && "unref" in flushTimer) {
flushTimer.unref();
}
}
let _exitHandlerInstalled = false;
function ensureExitHandler(): void {
if (_exitHandlerInstalled) return;
_exitHandlerInstalled = true;
process.on("beforeExit", async () => {
if (eventQueue.length === 0) return;
const batch = eventQueue;
eventQueue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
await fetch(POSTHOG_HOST, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
},
body,
signal: AbortSignal.timeout(3_000),
});
} catch { /* silently swallow */ }
});
}
function flushEvents(): void {
if (eventQueue.length === 0) return;
const batch = eventQueue;
eventQueue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
fetch(POSTHOG_HOST, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
},
body,
signal: AbortSignal.timeout(3_000),
}).catch(() => { /* silently swallow */ });
}
// ── Public API ───────────────────────────────────────────────────────────
const telemetry = createTelemetry({
host: "pi",
source: "PI_AGENT_PLUGIN",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
});
export function captureEvent(
eventName: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string },
context?: { apiKey?: string },
): void {
if (!isTelemetryEnabled()) return;
try {
const distinctId = getDistinctId(ctx?.apiKey);
const identifyEvent = maybeBuildIdentifyEvent(distinctId);
if (identifyEvent) {
eventQueue.push(identifyEvent);
}
eventQueue.push({
event: eventName,
distinct_id: distinctId,
properties: {
source: "PI_AGENT_PLUGIN",
language: "node",
plugin_version: PLUGIN_VERSION,
node_version: process.version,
os: process.platform,
$process_person_profile: false,
$lib: "posthog-node",
...properties,
},
});
ensureFlushTimer();
ensureExitHandler();
if (eventQueue.length >= FLUSH_THRESHOLD) {
flushEvents();
}
} catch { /* silently swallow */ }
currentDistinctId = distinctId(context?.apiKey);
const anonymous = previousAnonymousId(currentDistinctId);
if (anonymous) telemetry.capture("$identify", { $anon_distinct_id: anonymous });
telemetry.capture(eventName, properties);
}
export function captureToolEvent(
action: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string },
context?: { apiKey?: string },
): void {
captureEvent("pi.tool.mem0_memory", { action, ...properties }, ctx);
captureEvent("pi.tool.mem0_memory", { action, ...properties }, context);
}
export function captureCommandEvent(
command: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string },
context?: { apiKey?: string },
): void {
captureEvent(`pi.command.${command}`, properties, ctx);
captureEvent(`pi.command.${command}`, properties, context);
}
// ── Test helpers ─────────────────────────────────────────────────────────
export function _getEventQueue(): Record<string, unknown>[] {
return eventQueue;
return telemetry.queueForTesting();
}
export function _resetForTesting(): void {
eventQueue = [];
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = undefined;
}
_cachedAnonymousId = undefined;
_identifyDone = false;
telemetry.resetForTesting();
cachedAnonymousId = undefined;
currentDistinctId = "";
identified = false;
}
-20
View File
@@ -1,13 +1,5 @@
export type Scope = "project" | "session" | "global";
export interface DreamConfig {
enabled: boolean;
auto: boolean;
minHours: number;
minSessions: number;
minMemories: number;
}
export interface Mem0Config {
apiKey: string;
userId: string;
@@ -15,18 +7,6 @@ export interface Mem0Config {
defaultScope: Scope;
contextInjection: boolean;
searchThreshold: number;
dream: DreamConfig;
}
export interface DreamState {
lastConsolidatedAt: number;
sessionsSince: number;
lastSessionId: string | null;
}
export interface DreamLock {
pid: number;
startedAt: number;
}
export interface ScopeContext {
@@ -59,4 +59,10 @@ describe("extractConversation", () => {
it("returns empty array for empty input", () => {
expect(extractConversation([])).toEqual([]);
});
it("redacts credentials before automatic capture", () => {
expect(extractConversation([
{ role: "user", content: "api_key=do-not-store-this" },
])).toEqual([{ role: "user", content: "api_key=[REDACTED]" }]);
});
});
@@ -43,8 +43,6 @@ describe("loadConfig", () => {
const config = loadConfig();
expect(config.apiKey).toBe("m0-file-key");
expect(config.userId).toBe("file-user");
expect(config.dream.enabled).toBe(true);
expect(config.dream.minHours).toBe(24);
});
it("env vars override config file", () => {
@@ -1,59 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import { checkCheapGates, checkMemoryGate } from "../src/dream/index.ts";
vi.mock("node:fs");
const STATE_DIR = "/tmp/test-mem0-dream";
describe("checkCheapGates", () => {
beforeEach(() => {
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("blocks when no state file exists (zero sessions)", () => {
vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error("ENOENT"); });
const result = checkCheapGates(STATE_DIR, {});
expect(result.proceed).toBe(false);
expect(result.reason).toContain("sessions");
});
it("blocks when consolidated recently", () => {
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ lastConsolidatedAt: Date.now() - 3_600_000, sessionsSince: 10, lastSessionId: null })
);
const result = checkCheapGates(STATE_DIR, {});
expect(result.proceed).toBe(false);
expect(result.reason).toContain("time");
});
it("blocks when not enough sessions", () => {
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ lastConsolidatedAt: Date.now() - 48 * 3_600_000, sessionsSince: 2, lastSessionId: null })
);
const result = checkCheapGates(STATE_DIR, {});
expect(result.proceed).toBe(false);
expect(result.reason).toContain("sessions");
});
it("proceeds when both gates pass", () => {
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ lastConsolidatedAt: Date.now() - 48 * 3_600_000, sessionsSince: 10, lastSessionId: null })
);
expect(checkCheapGates(STATE_DIR, {}).proceed).toBe(true);
});
});
describe("checkMemoryGate", () => {
it("blocks when too few memories", () => {
expect(checkMemoryGate(5, {}).pass).toBe(false);
});
it("passes when enough memories", () => {
expect(checkMemoryGate(25, {}).pass).toBe(true);
});
});
@@ -21,9 +21,9 @@ describe("resolveSearchFilters", () => {
});
});
it("global scope returns user_id with app_id wildcard", () => {
it("global scope returns user_id only", () => {
expect(resolveSearchFilters("global", ctx)).toEqual({
user_id: "kartik", app_id: "*",
user_id: "kartik",
});
});
});
@@ -6,6 +6,7 @@ const mockMem0 = {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteAll: vi.fn(),
};
@@ -38,11 +39,13 @@ describe("buildToolExecute", () => {
expect(call[1].customCategories.length).toBe(10);
});
it("search with scope=global filters by user_id with app_id wildcard", async () => {
it("search uses global scope only after the user selects it", async () => {
mockMem0.search.mockResolvedValue({ results: [] });
await execute({ action: "search", query: "preferences", scope: "global" });
await expect(execute({ action: "search", query: "preferences", scope: "global" })).rejects.toThrow(/Select global/);
const globalExecute = buildToolExecute(mockMem0 as any, scopeCtx, "global");
await globalExecute({ action: "search", query: "preferences", scope: "global" });
expect(mockMem0.search).toHaveBeenCalledWith("preferences", {
filters: { user_id: "testuser", app_id: "*" },
filters: { user_id: "testuser" },
});
});
@@ -58,4 +61,19 @@ describe("buildToolExecute", () => {
await execute({ action: "delete", memory_id: fullId });
expect(mockMem0.delete).toHaveBeenCalledWith(fullId);
});
it("write actions accept the citation ID returned by search", async () => {
mockMem0.update.mockResolvedValue({ status: "updated" });
mockMem0.delete.mockResolvedValue({ message: "deleted" });
await execute({
action: "update",
memory_id: "[mem0:956e3d68-b420-4e07-a4e3-3019e7cebe6f]",
content: "updated memory",
});
await execute({ action: "delete", memory_id: "mem0:956e3d68-b420-4e07-a4e3-3019e7cebe6f" });
expect(mockMem0.update).toHaveBeenLastCalledWith("956e3d68-b420-4e07-a4e3-3019e7cebe6f", {
text: "updated memory",
});
expect(mockMem0.delete).toHaveBeenLastCalledWith("956e3d68-b420-4e07-a4e3-3019e7cebe6f");
});
});
+1 -1
View File
@@ -8,7 +8,7 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"rootDir": "..",
"strict": true,
"types": ["node"],
"esModuleInterop": true,