Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8399b088a5 | ||
|
|
437f0b5495 | ||
|
|
0ffaffa88c | ||
|
|
433ff494f1 | ||
|
|
de03c52ed3 | ||
|
|
b4a50e3dc8 | ||
|
|
b819d95d18 | ||
|
|
3ac1c9452c | ||
|
|
d6347f6660 | ||
|
|
e769502baa | ||
|
|
652193d599 | ||
|
|
a86c87236d | ||
|
|
2274b5acad | ||
|
|
9b0705c345 | ||
|
|
d31fa168eb | ||
|
|
f32eb4406b | ||
|
|
366945965d | ||
|
|
6702fa3e3e | ||
|
|
a44855af9e | ||
|
|
d817aa9c12 | ||
|
|
7ac8ab154b | ||
|
|
b00a1a1065 | ||
|
|
2e90ed4f78 | ||
|
|
069ea0887c | ||
|
|
ae7f406265 | ||
|
|
90f2d24e83 | ||
|
|
64b9646e7d | ||
|
|
866888df41 | ||
|
|
95b6f95f7b | ||
|
|
74771b4e76 | ||
|
|
8e65ce915d | ||
|
|
a3154d59e5 | ||
|
|
1019f0e17c |
@@ -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.2.7"
|
||||
"version": "0.2.9"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||
"version": "0.2.7"
|
||||
"version": "0.2.9"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged v* is
|
||||
# published. Can also be dispatched manually to re-publish a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||
if: startsWith(github.event.release.tag_name, 'v')
|
||||
# Pure SDK version tags only (v1.2.3) — excludes package-prefixed tags
|
||||
# like vercel-ai-v* that also start with 'v'
|
||||
if: startsWith(inputs.tag, 'v') && !contains(inputs.tag, '-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
@@ -39,7 +54,6 @@ jobs:
|
||||
# packages_dir: dist/
|
||||
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages_dir: dist/
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
name: Publish @mem0/cli 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# cli-node-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. cli-node-v0.2.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/cli 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'cli-node-v')
|
||||
if: startsWith(inputs.tag, 'cli-node-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -16,6 +28,8 @@ jobs:
|
||||
working-directory: cli/node
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -38,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
name: Publish mem0-cli 🐍 distributions 📦 to PyPI
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged cli-v* is
|
||||
# published. Can also be dispatched manually to re-publish a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. cli-v0.2.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish mem0-cli 📦 to PyPI
|
||||
if: startsWith(github.event.release.tag_name, 'cli-v')
|
||||
if: startsWith(inputs.tag, 'cli-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -16,6 +27,8 @@ jobs:
|
||||
working-directory: cli/python
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
name: Publish @mem0/openclaw-mem0 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# openclaw-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. openclaw-v0.5.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/openclaw-mem0 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'openclaw-v')
|
||||
if: startsWith(inputs.tag, 'openclaw-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -16,6 +28,8 @@ jobs:
|
||||
working-directory: openclaw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -38,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
name: Publish @mem0/opencode-plugin 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# opencode-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. opencode-v0.2.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/opencode-plugin 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'opencode-v')
|
||||
if: startsWith(inputs.tag, 'opencode-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -16,6 +28,8 @@ jobs:
|
||||
working-directory: mem0-plugin/.opencode-plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -36,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Publish @mem0/pi-agent-plugin 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# pi-agent-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. pi-agent-v0.1.1)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/pi-agent-plugin 📦 to npm
|
||||
if: startsWith(inputs.tag, 'pi-agent-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: pi-agent-plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pi-agent-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
npx npm@latest publish --provenance --access public
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
name: pi-agent-plugin checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pi-agent-plugin/**'
|
||||
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pi-agent-plugin/**'
|
||||
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pi-agent-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd pi-agent-plugin && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd pi-agent-plugin && pnpm exec tsc --noEmit
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pi-agent-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd pi-agent-plugin && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: cd pi-agent-plugin && pnpm exec vitest run
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pi-agent-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd pi-agent-plugin && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd pi-agent-plugin && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f pi-agent-plugin/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f pi-agent-plugin/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
test -f pi-agent-plugin/dist/entry.js || (echo "Build output missing: dist/entry.js" && exit 1)
|
||||
test -f pi-agent-plugin/dist/entry.d.ts || (echo "Build output missing: dist/entry.d.ts" && exit 1)
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Release Router 🚦
|
||||
|
||||
# Single entry point for all release publishing.
|
||||
#
|
||||
# Package CD workflows no longer listen to release events themselves — this
|
||||
# router inspects the release tag and dispatches only the matching pipeline,
|
||||
# so each release produces one routed run instead of one real run plus seven
|
||||
# skipped ones.
|
||||
#
|
||||
# Re-publishing a release (e.g. after fixing registry settings) does NOT
|
||||
# require deleting and recreating it anymore — manually dispatch the
|
||||
# package's CD workflow from the tag instead:
|
||||
#
|
||||
# gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>
|
||||
#
|
||||
# Note: dispatching runs the workflow file as it exists at the given ref, so
|
||||
# this router can only dispatch tags created after the workflow_dispatch
|
||||
# conversion landed on main. For older tags, dispatch manually from main.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
route:
|
||||
name: Route ${{ github.event.release.tag_name }} to its CD pipeline
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Match tag prefix to CD workflow
|
||||
id: match
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
# Specific package prefixes first; the bare v* (Python SDK) arm
|
||||
# must stay last so prefixed tags that also start with 'v'
|
||||
# (vercel-ai-v*) can never be routed to the Python pipeline.
|
||||
case "$TAG" in
|
||||
ts-v*) workflow="ts-sdk-cd.yml" ;;
|
||||
cli-node-v*) workflow="cli-node-cd.yml" ;;
|
||||
cli-v*) workflow="cli-python-cd.yml" ;;
|
||||
vercel-ai-v*) workflow="vercel-ai-cd.yml" ;;
|
||||
openclaw-v*) workflow="openclaw-cd.yml" ;;
|
||||
opencode-v*) workflow="opencode-plugin-cd.yml" ;;
|
||||
pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;;
|
||||
v*) workflow="cd.yml" ;;
|
||||
*)
|
||||
echo "::error::Release tag '$TAG' does not match any known package prefix — nothing will be published. See the tag prefix table in AGENTS.md."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "workflow=$workflow" >> "$GITHUB_OUTPUT"
|
||||
echo ":outbox_tray: Routed \`$TAG\` → \`$workflow\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Dispatch ${{ steps.match.outputs.workflow }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
# --ref points at the tag so the dispatched run builds (and signs
|
||||
# provenance for) the exact tagged commit.
|
||||
gh workflow run "${{ steps.match.outputs.workflow }}" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "refs/tags/$TAG" \
|
||||
-f tag="$TAG" \
|
||||
-f prerelease="${{ github.event.release.prerelease }}"
|
||||
@@ -1,13 +1,24 @@
|
||||
name: Publish mem0ai 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged ts-v* is
|
||||
# published. Can also be dispatched manually to re-publish a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. ts-v2.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish mem0ai 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'ts-v')
|
||||
if: startsWith(inputs.tag, 'ts-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -16,6 +27,8 @@ jobs:
|
||||
working-directory: mem0-ts
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -38,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
name: Publish @mem0/vercel-ai-provider 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# vercel-ai-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. vercel-ai-v2.0.7)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/vercel-ai-provider 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'vercel-ai-v')
|
||||
if: startsWith(inputs.tag, 'vercel-ai-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -16,6 +28,8 @@ jobs:
|
||||
working-directory: vercel-ai-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -38,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
|
||||
@@ -414,11 +414,15 @@ To add a new LLM, embedding, vector store, or reranker provider:
|
||||
| Node CLI | `cli-node-ci.yml` | Push to `cli/node/`, PRs, manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 |
|
||||
| OpenClaw | `openclaw-checks.yml` | Push to `openclaw/`, PRs, manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
|
||||
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to `mem0-plugin/.opencode-plugin/`, PRs, manual | Bun: tsc type-check + build + dist artifact check |
|
||||
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to `pi-agent-plugin/`, PRs, manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 |
|
||||
|
||||
### CD Workflows (automated publishing)
|
||||
|
||||
Publishing is routed through a single entry point: **`release.yml` (Release Router)** is the only workflow that listens to `release: published` events. It matches the release tag prefix and dispatches the corresponding package workflow via `workflow_dispatch`, so each release produces exactly one routed run (no skipped runs from the other pipelines).
|
||||
|
||||
| Workflow | File | Tag Prefix | Target |
|
||||
|----------|------|------------|--------|
|
||||
| Release Router | `release.yml` | (all releases) | dispatches the matching workflow below |
|
||||
| Python SDK | `cd.yml` | `v*` | PyPI (`mem0ai`) |
|
||||
| TypeScript SDK | `ts-sdk-cd.yml` | `ts-v*` | npm (`mem0ai`) |
|
||||
| Python CLI | `cli-python-cd.yml` | `cli-v*` | PyPI (`mem0-cli`) |
|
||||
@@ -426,9 +430,13 @@ To add a new LLM, embedding, vector store, or reranker provider:
|
||||
| Vercel AI SDK | `vercel-ai-cd.yml` | `vercel-ai-v*` | npm (`@mem0/vercel-ai-provider`) |
|
||||
| OpenClaw | `openclaw-cd.yml` | `openclaw-v*` | npm (`@mem0/openclaw-mem0`) |
|
||||
| OpenCode Plugin | `opencode-plugin-cd.yml` | `opencode-v*` | npm (`@mem0/opencode-plugin`) |
|
||||
| Pi Agent Plugin | `pi-agent-plugin-cd.yml` | `pi-agent-v*` | npm (`@mem0/pi-agent-plugin`) |
|
||||
|
||||
- Package CD workflows are `workflow_dispatch`-only (inputs: `tag`, `prerelease`); they check out and build the given tag. Registry trusted-publisher settings stay pinned to each package's own workflow filename.
|
||||
- All publishing uses **OIDC trusted publishing** — no tokens or secrets required.
|
||||
- First publish of a new npm package must be done manually; OIDC works for subsequent versions.
|
||||
- To re-publish a release (e.g. after a registry settings fix), do **not** delete/recreate the GitHub release — manually dispatch the package workflow instead: `gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>`.
|
||||
- When adding a new package: add its CD workflow (`workflow_dispatch` with `tag`/`prerelease` inputs), then register its tag prefix in the `case` block in `release.yml`. Keep the bare `v*` arm last.
|
||||
|
||||
### Utility Workflows
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@ 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.8] — 2026-06-01
|
||||
|
||||
### Security
|
||||
|
||||
- Pinned transitive dependencies via pnpm overrides to remediate high-severity CVEs:
|
||||
- `jws` → 4.0.1 (CVE-2025-65945)
|
||||
- `langsmith` → ^0.6.0 (CVE-2026-45134)
|
||||
- `tar-fs` → ^2.1.4 (CVE-2025-48387, CVE-2025-59343)
|
||||
- `picomatch` → ^2.3.2 (CVE-2026-33671)
|
||||
- `minimatch` → ^3.1.3 / ^5.1.8 / ^9.0.7 (CVE-2026-27903, CVE-2026-27904, CVE-2026-26996)
|
||||
- `path-to-regexp` → ^8.4.0 (CVE-2026-4926)
|
||||
- `rollup` → ^4.59.0 (CVE-2026-27606)
|
||||
- `glob` → ^10.5.0 (CVE-2025-64756)
|
||||
- `@modelcontextprotocol/sdk` → ^1.25.4 (CVE-2025-66414, CVE-2026-0621)
|
||||
|
||||
## [0.2.7] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -40,7 +40,8 @@
|
||||
"typescript": "^5.4.0",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vitest": "^1.5.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"@biomejs/biome": "^1.7.0",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
|
||||
Generated
+347
-477
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- "@biomejs/biome"
|
||||
- esbuild
|
||||
|
||||
overrides:
|
||||
jws@4.0.0: 4.0.1
|
||||
langsmith@<0.6.0: ^0.6.0
|
||||
tar-fs@>=2.0.0 <2.1.4: ^2.1.4
|
||||
picomatch@<2.3.2: ^2.3.2
|
||||
@@ -7,6 +7,26 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-06-10" description="v2.0.5">
|
||||
|
||||
**New Features:**
|
||||
- **Memory:** Warn at init time when hybrid/BM25 search silently degrades to semantic-only because the configured vector store does not implement `keyword_search`. Affected stores: Chroma, FAISS, Cassandra, LangChain, Neptune Analytics, S3 Vectors, Supabase, TurboPuffer, Valkey ([#5444](https://github.com/mem0ai/mem0/pull/5444))
|
||||
- **Memory:** Add opt-in `explain=True` parameter to `Memory.search()` and `AsyncMemory.search()`. When enabled, each result includes a `score_breakdown` dict with `semantic`, `keyword` (normalized BM25), `entity_boost`, and `temporal_boost` signals so callers can understand and tune retrieval ranking ([#5102](https://github.com/mem0ai/mem0/pull/5102))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Vector Stores:** Normalize similarity scores to `[0, 1]` (higher = better) consistently across all backends. 11 adapters previously returned raw distance metrics (lower = better) — FAISS, Chroma, Milvus, Redis, Cassandra, PGVector, S3 Vectors, Supabase, Valkey, Azure MySQL, and Vertex AI Vector Search — causing incorrect ranking in multi-store setups ([#5391](https://github.com/mem0ai/mem0/pull/5391))
|
||||
- **Memory:** Parallelize entity boost searches in `Memory.search()` and `AsyncMemory.search()`. Previously up to 8 entities were embedded and queried sequentially (16 serial round-trips with remote embedders); all entity lookups now run concurrently, eliminating multi-second latency on entity-rich queries ([#5377](https://github.com/mem0ai/mem0/pull/5377))
|
||||
- **Memory:** Reject empty or whitespace-only queries in `Memory.search()`, `AsyncMemory.search()`, `MemoryClient.search()`, and `AsyncMemoryClient.search()` before any embedding or API call is made. Also strips leading/trailing whitespace from valid queries ([#5258](https://github.com/mem0ai/mem0/pull/5258))
|
||||
- **LLMs:** Add `is_reasoning_model: Optional[bool]` override to `BaseLlmConfig` (surfaced on `OpenAILlmConfig` and `AzureOpenAILlmConfig`). Fixes silent zero-extraction when using Azure deployments with versioned `gpt-5.x` names that the automatic name-based heuristic cannot recognize ([#5327](https://github.com/mem0ai/mem0/pull/5327))
|
||||
- **LLMs:** Fix xAI LLM provider: add `XAIConfig` with `xai_base_url`, forward `tools`/`tool_choice` in `generate_response()`, and parse `tool_calls` in the response. Previously the provider raised `AttributeError` at init and silently dropped tool results ([#5190](https://github.com/mem0ai/mem0/pull/5190))
|
||||
- **Vector Stores:** Fix PGVector `ConnectionPool` hang in Docker Compose environments where the app container starts before Postgres is DNS-resolvable — switched to `open=False` to avoid blocking constructor or silent zombie pool ([#5155](https://github.com/mem0ai/mem0/pull/5155))
|
||||
- **Vector Stores:** Fix PGVector `sslmode` handling for PostgreSQL URIs — the `sslmode` query parameter is now correctly extracted and forwarded when building the async connection pool ([#5308](https://github.com/mem0ai/mem0/pull/5308))
|
||||
- **Vector Stores:** Fix S3 Vectors `list()` not applying metadata filters — filtering is now done client-side after fetching, with pagination preserved and `top_k` applied after filtering to prevent pre-truncation of matching rows ([#5018](https://github.com/mem0ai/mem0/pull/5018))
|
||||
- **Vector Stores:** Fix Upstash Vector `search()` routing all queries to the default namespace — `namespace` is now passed as a top-level keyword argument to `query_many()` instead of inside the per-query dict where it was silently ignored ([#5202](https://github.com/mem0ai/mem0/pull/5202))
|
||||
- **Core:** Replace mutable default arguments with `None` sentinels in embedder configs and the proxy module, preventing cross-request state contamination ([#5302](https://github.com/mem0ai/mem0/pull/5302))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-27" description="v2.0.4">
|
||||
|
||||
**New Features:**
|
||||
@@ -939,6 +959,27 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
|
||||
</Tab>
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-06-10" description="v3.0.7">
|
||||
|
||||
**New Features:**
|
||||
- **Embeddings:** Add `LMStudioEmbedding` provider for local embeddings via the LM Studio server ([#5377](https://github.com/mem0ai/mem0/pull/5377))
|
||||
- **Memory:** Add opt-in `explain: true` option to `Memory.search()`. When enabled, each result includes a `scoreBreakdown` object with `semantic`, `keyword`, `entityBoost`, and `temporalBoost` fields so callers can inspect and tune retrieval ranking ([#5102](https://github.com/mem0ai/mem0/pull/5102))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Memory:** Parallelize entity boost searches in `Memory.search()`. All entity embed + store lookups now run concurrently instead of sequentially, eliminating multi-second latency on entity-rich queries with remote embedding providers ([#5377](https://github.com/mem0ai/mem0/pull/5377))
|
||||
- **Vector Stores:** Normalize similarity scores to `[0, 1]` (higher = better) — fixed score inversion in the Redis vector store adapter ([#5391](https://github.com/mem0ai/mem0/pull/5391))
|
||||
- **Embeddings:** Request `encoding_format: "float"` from the OpenAI embedder in both `embed()` and `embedBatch()`. Fixes incorrect vector dimensions when using OpenAI-compatible proxies that default to base64 encoding ([#5170](https://github.com/mem0ai/mem0/pull/5170))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-06-01" description="v3.0.6">
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Bumped `axios` to `^1.16.0` to remediate high-severity prototype-pollution CVEs (credential theft, MITM, DoS). Pinned transitive dependencies via pnpm overrides: `jws` → 4.0.1 (CVE-2025-65945), `langsmith` → ^0.6.0 (CVE-2026-45134), `tar-fs` → ^2.1.4 (CVE-2025-48387, CVE-2025-59343), `picomatch` → ^2.3.2 (CVE-2026-33671), `minimatch` → ^3.1.3 / ^5.1.8 / ^9.0.7 (CVE-2026-27903, CVE-2026-27904, CVE-2026-26996), `path-to-regexp` → ^8.4.0 (CVE-2026-4926), `rollup` → ^4.59.0 (CVE-2026-27606), `glob` → ^10.5.0 (CVE-2025-64756), `@modelcontextprotocol/sdk` → ^1.25.4 (CVE-2025-66414, CVE-2026-0621)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-27" description="v3.0.5">
|
||||
|
||||
**New Features:**
|
||||
@@ -1352,6 +1393,13 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
|
||||
|
||||
<Tab title="CLI">
|
||||
|
||||
<Update label="2026-06-01" description="Node v0.2.8">
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Pinned transitive dependencies via pnpm overrides to remediate high-severity CVEs: `jws` → 4.0.1 (CVE-2025-65945), `langsmith` → ^0.6.0 (CVE-2026-45134), `tar-fs` → ^2.1.4 (CVE-2025-48387, CVE-2025-59343), `picomatch` → ^2.3.2 (CVE-2026-33671), `minimatch` → ^3.1.3 / ^5.1.8 / ^9.0.7 (CVE-2026-27903, CVE-2026-27904, CVE-2026-26996), `path-to-regexp` → ^8.4.0 (CVE-2026-4926), `rollup` → ^4.59.0 (CVE-2026-27606), `glob` → ^10.5.0 (CVE-2025-64756), `@modelcontextprotocol/sdk` → ^1.25.4 (CVE-2025-66414, CVE-2026-0621)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-16" description="Python v0.2.6 / Node v0.2.6">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -1468,6 +1516,43 @@ A full-featured command-line interface for Mem0, available in both Python and No
|
||||
|
||||
<Tab title="Plugins">
|
||||
|
||||
<Update label="2026-06-01" description="openclaw-mem0 v1.0.12">
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Pinned transitive dependencies via pnpm overrides to remediate high-severity CVEs: `protobufjs` → ^7.5.5, `vite` → ^8.0.5, `langsmith` → ^0.6.0 (CVE-2026-45134), `picomatch` → ^2.3.2 (CVE-2026-33671), `@qdrant/js-client-rest` → ^1.18.0
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-06-10" description="Vercel AI SDK v3.0.0">
|
||||
|
||||
**Major Release** — Migrated to Vercel AI SDK v6 (`LanguageModelV3` / `ProviderV3`) and Mem0 v3 API.
|
||||
|
||||
**Breaking Changes:**
|
||||
- **AI SDK v6:** Upgraded from AI SDK v5 (`LanguageModelV2`) to v6 (`LanguageModelV3`). Users must upgrade `ai` to `^6.0.199` and all `@ai-sdk/*` provider packages to `^3.x` ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Mem0 v3 API:** Memory endpoints migrated from `/v1/memories/` and `/v2/memories/search/` to `/v3/memories/add/` and `/v3/memories/search/`. Entity IDs (`user_id`, `agent_id`, `run_id`) now go inside the `filters` object for search requests ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Graph memory removed:** All `enable_graph`, graph prompts, and relation-extraction code removed. Graph memory is now a project-level setting on the Platform ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Deprecated params removed:** `org_id`, `project_id`, `org_name`, `project_name`, `output_format`, `filter_memories`, `async_mode`, `enable_graph`, `version`, `api_version` removed from `Mem0ConfigSettings` ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
|
||||
**New Features:**
|
||||
- **V3 provider contract:** `specificationVersion: 'v3'`, `supportedUrls` property, V3 content array in `doGenerate`, V3 stream lifecycle events in `doStream` ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Mem0 source in responses:** Memories are attached as a `source` in `generateText`/`streamText` responses with `providerMetadata.mem0.memories` for programmatic access ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Async memory storage:** `addMemories` is now properly `await`ed — memories no longer silently fail to store ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Prompt mutation:** Prompt array is now cloned before injecting memory context, preventing side effects on the caller's array ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Null guard on content:** `doGenerate` guards against null `content` from upstream providers ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Stream response:** `doStream` now returns the full `LanguageModelV3StreamResult` object preserving all V3 fields ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
- **Response normalization:** `getMemories` and `retrieveMemories` now handle both array and `{results: [...]}` envelope responses from the v3 API ([#4741](https://github.com/mem0ai/mem0/pull/4741))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-06-01" description="Vercel AI SDK v2.0.6">
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Pinned transitive dependencies via pnpm overrides to remediate high-severity CVEs: `glob` → ^10.5.0 (CVE-2025-64756), `minimatch` → ^3.1.3 / ^5.1.8 / ^9.0.7 (CVE-2026-27903, CVE-2026-27904, CVE-2026-26996), `picomatch` → ^2.3.2 (CVE-2026-33671), `rollup` → ^4.59.0 (CVE-2026-27606)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-02" description="mem0-plugin v1.0.0">
|
||||
|
||||
**Mem0 Plugin for Claude Code, Cursor, and Codex**
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "Neon"
|
||||
description: "Use Neon as a vector store in Mem0, powered by PostgreSQL and pgvector."
|
||||
---
|
||||
|
||||
Use [Neon](https://neon.com/) as a vector store in Mem0, powered by PostgreSQL and the
|
||||
[pgvector extension](https://neon.com/docs/extensions/pgvector).
|
||||
|
||||
Neon is a serverless Postgres platform. Since Mem0 supports Postgres through the
|
||||
`pgvector` provider, Neon can be used with a standard Postgres connection string.
|
||||
|
||||
## Usage
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from mem0 import Memory
|
||||
|
||||
load_dotenv()
|
||||
|
||||
config = {
|
||||
"vector_store": {
|
||||
"provider": "pgvector",
|
||||
"config": {
|
||||
"connection_string": os.environ["DATABASE_URL"],
|
||||
"collection_name": "memories",
|
||||
"embedding_model_dims": 1536,
|
||||
"hnsw": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m = Memory.from_config(config)
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
|
||||
{"role": "assistant", "content": "How about thriller movies? They can be quite engaging."},
|
||||
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
|
||||
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."},
|
||||
]
|
||||
m.add(messages, user_id="alice", metadata={"category": "movies"})
|
||||
|
||||
results = m.search(
|
||||
"What movies should I recommend?",
|
||||
filters={"user_id": "alice"},
|
||||
)
|
||||
|
||||
print(results)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import "dotenv/config";
|
||||
import { Memory } from "mem0ai/oss";
|
||||
|
||||
const databaseUrl = new URL(process.env.DATABASE_URL!);
|
||||
|
||||
const m = new Memory({
|
||||
vectorStore: {
|
||||
provider: "pgvector",
|
||||
config: {
|
||||
user: decodeURIComponent(databaseUrl.username),
|
||||
password: decodeURIComponent(databaseUrl.password),
|
||||
host: databaseUrl.hostname,
|
||||
port: Number(databaseUrl.port || 5432),
|
||||
dbname: databaseUrl.pathname.slice(1) || "neondb",
|
||||
collectionName: "memories",
|
||||
dimension: 1536,
|
||||
embeddingModelDims: 1536,
|
||||
hnsw: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{ role: "user" as const, content: "I'm planning to watch a movie tonight. Any recommendations?" },
|
||||
{ role: "assistant" as const, content: "How about thriller movies? They can be quite engaging." },
|
||||
{ role: "user" as const, content: "I'm not a big fan of thriller movies but I love sci-fi movies." },
|
||||
{ role: "assistant" as const, content: "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future." },
|
||||
];
|
||||
|
||||
await m.add(messages, {
|
||||
userId: "alice",
|
||||
metadata: { category: "movies" },
|
||||
});
|
||||
|
||||
const results = await m.search("What movies should I recommend?", {
|
||||
filters: { user_id: "alice" },
|
||||
});
|
||||
|
||||
console.log(results);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## SQL Migration
|
||||
|
||||
You don't need to run any SQL migrations. Mem0 creates the collection table when it initializes the `pgvector` store.
|
||||
|
||||
## Environment
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-xx...
|
||||
DATABASE_URL=postgresql://user:password@ep-example.us-east-2.aws.neon.tech/neondb?sslmode=require
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
| Parameter | Description | Default Value |
|
||||
| --- | --- | --- |
|
||||
| `connection_string` | Neon Postgres connection string. | Required |
|
||||
| `collection_name` | Name for the vector collection. | `mem0` |
|
||||
| `embedding_model_dims` | Embedding model dimensions. | `1536` |
|
||||
| `hnsw` | Enables HNSW indexing. | `False` |
|
||||
| `sslmode` | PostgreSQL SSL mode. Use `require` for Neon. | Driver default |
|
||||
</Tab>
|
||||
<Tab title="TypeScript">
|
||||
The current Mem0 TypeScript `pgvector` adapter takes individual Postgres fields,
|
||||
so parse `DATABASE_URL` before creating `Memory`.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `user` | Database user. | Required |
|
||||
| `password` | Database password. | Required |
|
||||
| `host` | Database host. | Required |
|
||||
| `port` | Database port. | `5432` |
|
||||
| `dbname` | Database name. | `vector_store` |
|
||||
| `collectionName` | Name for the vector collection. | `memories` |
|
||||
| `dimension` | Vector dimension for Mem0 config. | Auto-detected |
|
||||
| `embeddingModelDims` | Embedding model dimensions for table creation. | Required |
|
||||
| `hnsw` | Enables HNSW indexing. | `false` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Indexing
|
||||
|
||||
The `pgvector` provider can create an HNSW index for faster vector search.
|
||||
|
||||
- Set `hnsw` to `true` to enable a Hierarchical Navigable Small World index.
|
||||
- Leave `hnsw` as `false` if you want to create or manage indexes yourself.
|
||||
|
||||
### Similarity Search
|
||||
|
||||
The `pgvector` provider uses cosine similarity for vector search. Make sure your
|
||||
embedding dimensions match the configured `embedding_model_dims` value.
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Index Selection**:
|
||||
- Use `hnsw` for faster search performance when memory usage is not a constraint
|
||||
- Manage indexes manually if you need a different pgvector index strategy
|
||||
|
||||
2. **Connection String**:
|
||||
- Always use environment variables or even better, a secret manager for sensitive information in the connection string
|
||||
- Format: `postgresql://user:password@host:port/database`
|
||||
@@ -156,6 +156,33 @@ const memories = memory.search("food preferences", {
|
||||
On Mem0 Platform v3, time-aware queries use Temporal Reasoning internally while preserving the normal search response shape. See <Link href="/platform/features/temporal-reasoning">Temporal Reasoning</Link>.
|
||||
</Note>
|
||||
|
||||
### Explain OSS search scores
|
||||
|
||||
OSS search combines semantic similarity with optional keyword and entity signals. Pass `explain=True` when tuning retrieval quality or debugging why a memory ranked where it did:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
results = m.search(
|
||||
"food preferences",
|
||||
filters={"user_id": "alice"},
|
||||
explain=True,
|
||||
)
|
||||
|
||||
print(results["results"][0]["score_details"])
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
const results = await memory.search("food preferences", {
|
||||
filters: { user_id: "alice" },
|
||||
explain: true,
|
||||
});
|
||||
|
||||
console.log(results.results[0].score_details);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Each result includes `score_details` with the semantic score, normalized BM25 score, entity boost, raw combined score, maximum possible score, final score, and threshold used for filtering. The field is omitted unless `explain` is enabled, so existing response shapes stay unchanged.
|
||||
|
||||
## Filter patterns
|
||||
|
||||
Filters help narrow down search results. Common use cases:
|
||||
|
||||
+7
-3
@@ -143,7 +143,8 @@
|
||||
"icon": "robot",
|
||||
"pages": [
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes"
|
||||
"integrations/hermes",
|
||||
"integrations/pi-agent"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -245,6 +246,7 @@
|
||||
"components/vectordbs/dbs/cassandra",
|
||||
"components/vectordbs/dbs/s3_vectors",
|
||||
"components/vectordbs/dbs/databricks",
|
||||
"components/vectordbs/dbs/neon",
|
||||
"components/vectordbs/dbs/neptune_analytics",
|
||||
"components/vectordbs/dbs/turbopuffer"
|
||||
]
|
||||
@@ -302,7 +304,8 @@
|
||||
"group": "Migration",
|
||||
"icon": "arrow-right",
|
||||
"pages": [
|
||||
"migration/oss-v2-to-v3"
|
||||
"migration/oss-v2-to-v3",
|
||||
"migration/server-pgvector-upgrade"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -462,7 +465,8 @@
|
||||
"icon": "robot",
|
||||
"pages": [
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes"
|
||||
"integrations/hermes",
|
||||
"integrations/pi-agent"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+243
-191
@@ -7,285 +7,338 @@ Integrate [**Mem0**](https://github.com/mem0ai/mem0) with [Google ADK (Agent Dev
|
||||
|
||||
## Overview
|
||||
|
||||
1. Store and retrieve memories from Mem0 within Google ADK agents
|
||||
2. Multi-agent workflows with shared memory across hierarchies
|
||||
3. Retrieve relevant memories from past conversations
|
||||
4. Personalized responses based on user history
|
||||
In this guide, we'll create a Google ADK agent that:
|
||||
1. Uses ADK's native `MemoryService` interface to connect Mem0
|
||||
2. Automatically injects relevant memories using ADK's built-in `load_memory` tool
|
||||
3. Persists session history to Mem0 after each turn via an after-agent callback
|
||||
4. Shares memory seamlessly across multi-agent hierarchies
|
||||
|
||||
## Prerequisites
|
||||
## Setup and Configuration
|
||||
|
||||
Before setting up Mem0 with Google ADK, ensure you have:
|
||||
Install the necessary libraries:
|
||||
|
||||
1. Installed the required packages:
|
||||
```bash
|
||||
pip install google-adk mem0ai python-dotenv
|
||||
```
|
||||
|
||||
2. Valid API keys:
|
||||
Set up your API keys:
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-google-ai-adk" rel="nofollow">Mem0 API Key</a>
|
||||
- Google AI Studio API Key
|
||||
|
||||
## Basic Integration Example
|
||||
|
||||
The following example demonstrates how to create a Google ADK agent with Mem0 memory integration:
|
||||
<Note>Remember to get your API key from <a href="https://app.mem0.ai" rel="nofollow">Mem0 Platform</a> and set up a [Google AI Studio API Key](https://aistudio.google.com/apikey).</Note>
|
||||
|
||||
```python
|
||||
import os
|
||||
import asyncio
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.genai import types
|
||||
from mem0 import MemoryClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Set up environment variables
|
||||
# os.environ["GOOGLE_API_KEY"] = "your-google-api-key"
|
||||
# os.environ["MEM0_API_KEY"] = "your-mem0-api-key"
|
||||
```
|
||||
|
||||
# Initialize Mem0 client
|
||||
mem0 = MemoryClient()
|
||||
## Implement Mem0MemoryService
|
||||
|
||||
# Define memory function tools
|
||||
def search_memory(query: str, user_id: str) -> dict:
|
||||
"""Search through past conversations and memories"""
|
||||
# For Platform API, user_id goes in filters
|
||||
filters = {"user_id": user_id}
|
||||
memories = mem0.search(query, filters=filters)
|
||||
if memories.get('results', []):
|
||||
memory_list = memories['results']
|
||||
memory_context = "\n".join([f"- {mem['memory']}" for mem in memory_list])
|
||||
return {"status": "success", "memories": memory_context}
|
||||
return {"status": "no_memories", "message": "No relevant memories found"}
|
||||
Create a custom `MemoryService` by implementing ADK's `BaseMemoryService`. Save the following as **`mem0_memory_service.py`**:
|
||||
|
||||
def save_memory(content: str, user_id: str) -> dict:
|
||||
"""Save important information to memory"""
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing_extensions import override
|
||||
|
||||
from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse
|
||||
from google.adk.memory.memory_entry import MemoryEntry
|
||||
from google.adk.sessions import Session
|
||||
from google.genai.types import Content, Part
|
||||
from mem0 import MemoryClient
|
||||
|
||||
|
||||
class Mem0MemoryService(BaseMemoryService):
|
||||
"""MemoryService implementation backed by the Mem0 Platform."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
super().__init__()
|
||||
api_key = api_key or os.environ.get("MEM0_API_KEY")
|
||||
self._client: Optional[MemoryClient] = MemoryClient(api_key=api_key) if api_key else None
|
||||
|
||||
@override
|
||||
async def search_memory(
|
||||
self, *, app_name: str, user_id: str, query: str
|
||||
) -> SearchMemoryResponse:
|
||||
"""Search for memories relevant to the current user and query."""
|
||||
if not self._client:
|
||||
return SearchMemoryResponse(memories=[])
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(
|
||||
self._client.search,
|
||||
query,
|
||||
filters={"AND": [{"user_id": user_id}, {"app_id": app_name}]},
|
||||
top_k=5,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for mem in results.get("results", []):
|
||||
text = mem.get("memory", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
raw_ts = mem.get("created_at") or mem.get("updated_at")
|
||||
entries.append(
|
||||
MemoryEntry(
|
||||
content=Content(parts=[Part(text=text)]),
|
||||
author=mem.get("metadata", {}).get("author", "user"),
|
||||
timestamp=str(raw_ts) if raw_ts else None,
|
||||
)
|
||||
)
|
||||
|
||||
return SearchMemoryResponse(memories=entries)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Mem0MemoryService] search_memory error: {e}")
|
||||
return SearchMemoryResponse(memories=[])
|
||||
|
||||
@override
|
||||
async def add_session_to_memory(self, session: Session) -> None:
|
||||
"""Persist a completed ADK session into Mem0."""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
user_id = session.user_id
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
app_name = getattr(session, "app_name", None)
|
||||
|
||||
try:
|
||||
messages = []
|
||||
for event in session.events:
|
||||
if not (event.content and event.content.parts):
|
||||
continue
|
||||
role = getattr(event.content, "role", None) or "user"
|
||||
if role == "model":
|
||||
role = "assistant"
|
||||
elif role not in ("user", "assistant"):
|
||||
continue
|
||||
text_parts = [
|
||||
p.text for p in event.content.parts if hasattr(p, "text") and p.text
|
||||
]
|
||||
if text_parts:
|
||||
messages.append({"role": role, "content": " ".join(text_parts)})
|
||||
|
||||
if messages:
|
||||
metadata = {"app_id": app_name} if app_name else {}
|
||||
await asyncio.to_thread(
|
||||
self._client.add, messages, user_id=user_id, metadata=metadata
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Mem0MemoryService] add_session_to_memory error: {e}")
|
||||
```
|
||||
|
||||
## Add Auto-Save Callback
|
||||
|
||||
This after-agent callback fires at the end of every turn and saves the session to Mem0. Save as **`memory_callbacks.py`**:
|
||||
|
||||
```python
|
||||
async def save_session_to_memory(callback_context) -> None:
|
||||
"""Persist the completed session to Mem0 after each agent turn."""
|
||||
try:
|
||||
result = mem0.add([{"role": "user", "content": content}], user_id=user_id)
|
||||
return {"status": "success", "message": "Information saved to memory", "result": result}
|
||||
await callback_context.add_session_to_memory()
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"Failed to save memory: {str(e)}"}
|
||||
print(f"[save_session_to_memory] error: {e}")
|
||||
```
|
||||
|
||||
# Create agent with memory capabilities
|
||||
personal_assistant = Agent(
|
||||
## Basic Integration Example
|
||||
|
||||
The following example demonstrates creating an ADK agent with automatic Mem0 memory:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.adk.tools import load_memory
|
||||
from google.genai.types import Content, Part
|
||||
|
||||
from mem0_memory_service import Mem0MemoryService
|
||||
from memory_callbacks import save_session_to_memory
|
||||
|
||||
memory_service = Mem0MemoryService()
|
||||
session_service = InMemorySessionService()
|
||||
|
||||
agent = LlmAgent(
|
||||
name="personal_assistant",
|
||||
model="gemini-2.0-flash",
|
||||
instruction="""You are a helpful personal assistant with memory capabilities.
|
||||
Use the search_memory function to recall past conversations and user preferences.
|
||||
Use the save_memory function to store important information about the user.
|
||||
Always personalize your responses based on available memory.""",
|
||||
instruction="""You are a helpful personal assistant.
|
||||
Relevant memories from past conversations are provided to you automatically.
|
||||
Use them to personalize your responses.""",
|
||||
description="A personal assistant that remembers user preferences and past interactions",
|
||||
tools=[search_memory, save_memory]
|
||||
tools=[load_memory],
|
||||
after_agent_callback=save_session_to_memory,
|
||||
)
|
||||
|
||||
async def chat_with_agent(user_input: str, user_id: str) -> str:
|
||||
"""
|
||||
Handle user input with automatic memory integration.
|
||||
runner = Runner(
|
||||
agent=agent,
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
app_name="memory_assistant",
|
||||
)
|
||||
|
||||
Args:
|
||||
user_input: The user's message
|
||||
user_id: Unique identifier for the user
|
||||
|
||||
Returns:
|
||||
The agent's response
|
||||
"""
|
||||
# Set up session and runner
|
||||
session_service = InMemorySessionService()
|
||||
async def chat(user_input: str, user_id: str) -> str:
|
||||
session = await session_service.create_session(
|
||||
app_name="memory_assistant",
|
||||
user_id=user_id,
|
||||
session_id=f"session_{user_id}"
|
||||
)
|
||||
runner = Runner(agent=personal_assistant, app_name="memory_assistant", session_service=session_service)
|
||||
|
||||
# Create content and run agent
|
||||
content = types.Content(role='user', parts=[types.Part(text=user_input)])
|
||||
events = runner.run(user_id=user_id, session_id=session.id, new_message=content)
|
||||
|
||||
# Extract final response
|
||||
for event in events:
|
||||
if event.is_final_response():
|
||||
response = event.content.parts[0].text
|
||||
|
||||
return response
|
||||
|
||||
content = Content(role="user", parts=[Part(text=user_input)])
|
||||
async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=content):
|
||||
if event.is_final_response() and event.content and event.content.parts:
|
||||
return event.content.parts[0].text
|
||||
return "No response generated"
|
||||
|
||||
# Example usage
|
||||
|
||||
if __name__ == "__main__":
|
||||
response = asyncio.run(chat_with_agent(
|
||||
print(asyncio.run(chat(
|
||||
"I love Italian food and I'm planning a trip to Rome next month",
|
||||
user_id="alice"
|
||||
))
|
||||
print(response)
|
||||
user_id="alice",
|
||||
)))
|
||||
|
||||
print(asyncio.run(chat(
|
||||
"Any food recommendations for my trip?",
|
||||
user_id="alice",
|
||||
)))
|
||||
```
|
||||
|
||||
## Multi-Agent Hierarchy with Shared Memory
|
||||
|
||||
Create specialized agents in a hierarchy that share memory:
|
||||
Because `memory_service` is passed to the `Runner`, every agent in the hierarchy shares the same memory automatically. Only the root coordinator needs the auto-save callback — ADK fires it once when the full turn completes:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.adk.tools.agent_tool import AgentTool
|
||||
from google.adk.tools import load_memory
|
||||
from google.genai.types import Content, Part
|
||||
|
||||
# Travel specialist agent
|
||||
travel_agent = Agent(
|
||||
from mem0_memory_service import Mem0MemoryService
|
||||
from memory_callbacks import save_session_to_memory
|
||||
|
||||
memory_service = Mem0MemoryService()
|
||||
session_service = InMemorySessionService()
|
||||
|
||||
travel_agent = LlmAgent(
|
||||
name="travel_specialist",
|
||||
model="gemini-2.0-flash",
|
||||
instruction="""You are a travel planning specialist. Use search_memory to
|
||||
understand the user's travel preferences and history before making recommendations.
|
||||
After providing advice, use save_memory to save travel-related information.""",
|
||||
instruction="""You are a travel planning specialist.
|
||||
Relevant memories about the user's travel preferences are provided automatically.
|
||||
Use them to make personalized recommendations.""",
|
||||
description="Specialist in travel planning and recommendations",
|
||||
tools=[search_memory, save_memory]
|
||||
tools=[load_memory],
|
||||
)
|
||||
|
||||
# Health advisor agent
|
||||
health_agent = Agent(
|
||||
health_agent = LlmAgent(
|
||||
name="health_advisor",
|
||||
model="gemini-2.0-flash",
|
||||
instruction="""You are a health and wellness advisor. Use search_memory to
|
||||
understand the user's health goals and dietary preferences.
|
||||
After providing advice, use save_memory to save health-related information.""",
|
||||
instruction="""You are a health and wellness advisor.
|
||||
Relevant memories about the user's health goals are provided automatically.
|
||||
Use them to give personalized advice.""",
|
||||
description="Specialist in health and wellness advice",
|
||||
tools=[search_memory, save_memory]
|
||||
tools=[load_memory],
|
||||
)
|
||||
|
||||
# Coordinator agent that delegates to specialists
|
||||
coordinator_agent = Agent(
|
||||
coordinator = LlmAgent(
|
||||
name="coordinator",
|
||||
model="gemini-2.0-flash",
|
||||
instruction="""You are a coordinator that delegates requests to specialist agents.
|
||||
For travel-related questions (trips, hotels, flights, destinations), delegate to the travel specialist.
|
||||
For health-related questions (fitness, diet, wellness, exercise), delegate to the health advisor.
|
||||
Use search_memory to understand the user before delegation.""",
|
||||
For travel-related questions, delegate to the travel specialist.
|
||||
For health-related questions, delegate to the health advisor.
|
||||
Relevant memories about the user are provided automatically.""",
|
||||
description="Coordinates requests between specialist agents",
|
||||
tools=[
|
||||
load_memory,
|
||||
AgentTool(agent=travel_agent, skip_summarization=False),
|
||||
AgentTool(agent=health_agent, skip_summarization=False)
|
||||
]
|
||||
AgentTool(agent=health_agent, skip_summarization=False),
|
||||
],
|
||||
after_agent_callback=save_session_to_memory,
|
||||
)
|
||||
|
||||
def chat_with_specialists(user_input: str, user_id: str) -> str:
|
||||
"""
|
||||
Handle user input with specialist agent delegation and memory.
|
||||
runner = Runner(
|
||||
agent=coordinator,
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
app_name="specialist_system",
|
||||
)
|
||||
|
||||
Args:
|
||||
user_input: The user's message
|
||||
user_id: Unique identifier for the user
|
||||
|
||||
Returns:
|
||||
The specialist agent's response
|
||||
"""
|
||||
session_service = InMemorySessionService()
|
||||
session = session_service.create_session(
|
||||
async def chat_with_specialists(user_input: str, user_id: str) -> str:
|
||||
session = await session_service.create_session(
|
||||
app_name="specialist_system",
|
||||
user_id=user_id,
|
||||
session_id=f"session_{user_id}"
|
||||
)
|
||||
runner = Runner(agent=coordinator_agent, app_name="specialist_system", session_service=session_service)
|
||||
|
||||
content = types.Content(role='user', parts=[types.Part(text=user_input)])
|
||||
events = runner.run(user_id=user_id, session_id=session.id, new_message=content)
|
||||
|
||||
for event in events:
|
||||
if event.is_final_response():
|
||||
response = event.content.parts[0].text
|
||||
|
||||
# Store the conversation in shared memory
|
||||
conversation = [
|
||||
{"role": "user", "content": user_input},
|
||||
{"role": "assistant", "content": response}
|
||||
]
|
||||
mem0.add(conversation, user_id=user_id)
|
||||
|
||||
return response
|
||||
|
||||
content = Content(role="user", parts=[Part(text=user_input)])
|
||||
async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=content):
|
||||
if event.is_final_response() and event.content and event.content.parts:
|
||||
return event.content.parts[0].text
|
||||
return "No response generated"
|
||||
|
||||
# Example usage
|
||||
response = chat_with_specialists("Plan a healthy meal for my Italy trip", user_id="alice")
|
||||
print(response)
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Quick Start Chat Interface
|
||||
|
||||
Simple interactive chat with memory and Google ADK:
|
||||
|
||||
```python
|
||||
def interactive_chat():
|
||||
"""Interactive chat interface with memory and ADK"""
|
||||
user_id = input("Enter your user ID: ") or "demo_user"
|
||||
print(f"Chat started for user: {user_id}")
|
||||
print("Type 'quit' to exit")
|
||||
print("=" * 50)
|
||||
|
||||
while True:
|
||||
user_input = input("\nYou: ")
|
||||
|
||||
if user_input.lower() == 'quit':
|
||||
print("Goodbye! Your conversation has been saved to memory.")
|
||||
break
|
||||
else:
|
||||
response = chat_with_specialists(user_input, user_id)
|
||||
print(f"Assistant: {response}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
interactive_chat()
|
||||
response = asyncio.run(chat_with_specialists("Plan a healthy meal for my Italy trip", user_id="alice"))
|
||||
print(response)
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Memory-Enhanced Function Tools
|
||||
- **Function Tools**: Standard Python functions that can search and save memories
|
||||
- **Tool Context**: Access to session state and memory through function parameters
|
||||
- **Structured Returns**: Dictionary-based returns with status indicators for better LLM understanding
|
||||
|
||||
### 2. Multi-Agent Memory Sharing
|
||||
- **Agent-as-a-Tool**: Specialists can be called as tools while maintaining shared memory
|
||||
- **Hierarchical Delegation**: Coordinator agents route to specialists based on context
|
||||
- **Memory Categories**: Store interactions with metadata for better organization
|
||||
|
||||
### 3. Flexible Memory Operations
|
||||
- **Search Capabilities**: Retrieve relevant memories through conversation history
|
||||
- **User Segmentation**: Organize memories by user ID
|
||||
- **Memory Management**: Built-in tools for saving and retrieving information
|
||||
1. **Automatic Memory Injection**: ADK's built-in `load_memory` tool searches Mem0 at the start of each turn and injects relevant memories directly into the agent context — no prompt instructions needed.
|
||||
2. **Automatic Session Saving**: The `save_session_to_memory` callback persists every completed turn to Mem0 without any manual calls.
|
||||
3. **Native ADK Integration**: `Mem0MemoryService` implements ADK's `BaseMemoryService` and integrates via the `Runner` — works natively across the entire agent hierarchy.
|
||||
4. **User Scoping**: `user_id` is passed automatically from the ADK session context, ensuring memories are always scoped to the correct user.
|
||||
5. **Multi-Agent Support**: A single `Mem0MemoryService` instance shared through the `Runner` gives all agents — coordinators and specialists — access to the same user memory.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Customize memory behavior and agent setup:
|
||||
### Using Vertex AI
|
||||
|
||||
To use Google Cloud Vertex AI instead of AI Studio, set the following environment variables before creating agents:
|
||||
|
||||
```python
|
||||
# Configure memory search with filters
|
||||
# For Platform API, all filters including user_id go in filters object
|
||||
memories = mem0.search(
|
||||
query="travel preferences",
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"categories": {"contains": "travel"}}
|
||||
]
|
||||
},
|
||||
top_k=5
|
||||
)
|
||||
|
||||
# Configure agent with custom model settings
|
||||
agent = Agent(
|
||||
name="custom_agent",
|
||||
model="gemini-2.0-flash", # or use LiteLLM for other models
|
||||
instruction="Custom agent behavior",
|
||||
tools=[memory_tools],
|
||||
# Additional ADK configurations
|
||||
)
|
||||
|
||||
# Use Google Cloud Vertex AI instead of AI Studio
|
||||
import os
|
||||
os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "True"
|
||||
os.environ["GOOGLE_CLOUD_PROJECT"] = "your-project-id"
|
||||
os.environ["GOOGLE_CLOUD_LOCATION"] = "us-central1"
|
||||
```
|
||||
|
||||
### Advanced Memory Filtering
|
||||
|
||||
You can customize how memories are searched by modifying `Mem0MemoryService.search_memory`. For example, to filter by category:
|
||||
|
||||
```python
|
||||
results = await asyncio.to_thread(
|
||||
self._client.search,
|
||||
query,
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"app_id": app_name},
|
||||
{"categories": {"contains": "travel"}}
|
||||
]
|
||||
},
|
||||
top_k=10,
|
||||
)
|
||||
```
|
||||
|
||||
<Note>`InMemorySessionService` stores sessions in memory and is intended for prototyping. For production, use a persistent session service and clean up sessions when they are no longer needed.</Note>
|
||||
|
||||
## Conclusion
|
||||
|
||||
By implementing `Mem0MemoryService` as an ADK `BaseMemoryService`, you get persistent, user-scoped memory across single agents and complex multi-agent hierarchies with minimal code. Memory injection and session saving happen automatically, keeping your agent prompts clean and your token usage efficient.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Healthcare Agent Cookbook" icon="heart-pulse" href="/cookbooks/integrations/healthcare-google-adk">
|
||||
Build HIPAA-compliant healthcare agents with Google ADK
|
||||
@@ -294,4 +347,3 @@ os.environ["GOOGLE_CLOUD_LOCATION"] = "us-central1"
|
||||
Compare with OpenAI's agent framework
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: Pi Agent
|
||||
description: "Add persistent memory to Pi Agent with the Mem0 plugin semantic search, auto-capture, and dream consolidation."
|
||||
---
|
||||
|
||||
Add persistent memory to [**Pi Agent**](https://pi.dev) with `@mem0/pi-agent-plugin`. Your agent forgets everything between sessions — this plugin fixes that by automatically capturing knowledge from conversations, storing it in Mem0's cloud memory layer, and retrieving relevant context before every response.
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin provides:
|
||||
1. **Auto-capture** — Extracts durable facts from both user and assistant messages automatically
|
||||
2. **Semantic recall** — Retrieves relevant memories via the `mem0_memory` tool before each response
|
||||
3. **Dream consolidation** — Periodic maintenance: merges duplicates, resolves contradictions, prunes stale entries
|
||||
4. **Monorepo-aware scoping** — Uses git root for project detection, consistent across subdirectories
|
||||
5. **Confirmation dialogs** — Destructive commands ask before acting via Pi's built-in UI
|
||||
6. **8 skills + 8 commands** — Essential memory management from slash commands and agent-guided workflows
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Mem0 Platform account and API key:
|
||||
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-pi-agent" rel="nofollow">Sign up at app.mem0.ai</a>
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-pi-agent" rel="nofollow">Get your API key</a> (starts with `m0-`)
|
||||
|
||||
2. Pi Agent installed ([pi.dev](https://pi.dev))
|
||||
|
||||
3. Your API key added to your shell profile:
|
||||
|
||||
<CodeGroup>
|
||||
```bash zsh
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
```bash bash
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pi install npm:@mem0/pi-agent-plugin
|
||||
```
|
||||
|
||||
That's it. The extension loads automatically on every Pi session. No config files needed — `MEM0_API_KEY` from your environment is picked up automatically.
|
||||
|
||||
<Info>
|
||||
Start a new Pi session and run `/mem0-status` to verify the connection. You should see your user ID, detected project, and memory count.
|
||||
</Info>
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
For advanced settings, create `~/.pi/agent/mem0-config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "m0-your-key-here",
|
||||
"userId": "your-username",
|
||||
"autoCapture": true,
|
||||
"defaultScope": "project",
|
||||
"dream": {
|
||||
"enabled": true,
|
||||
"auto": true,
|
||||
"minHours": 24,
|
||||
"minSessions": 5,
|
||||
"minMemories": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. |
|
||||
| `userId` | `string` | `$MEM0_USER_ID` or `"default"` | User identity for memory scoping |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts from conversations automatically |
|
||||
| `defaultScope` | `string` | `"project"` | Default memory scope: `project`, `session`, or `global` |
|
||||
| `dream.enabled` | `boolean` | `true` | Enable dream consolidation |
|
||||
| `dream.auto` | `boolean` | `true` | Auto-trigger dreams when thresholds are met |
|
||||
| `dream.minHours` | `number` | `24` | Minimum hours between auto-dreams |
|
||||
| `dream.minSessions` | `number` | `5` | Minimum sessions before first auto-dream |
|
||||
| `dream.minMemories` | `number` | `20` | Minimum memories before auto-dream triggers |
|
||||
|
||||
## What's Included
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `mem0_memory` tool | Agent-callable tool for search, add, get_all, delete, delete_all |
|
||||
| 8 slash commands | Essential memory management from the command line |
|
||||
| 8 skills | Guide the agent on how to use each capability |
|
||||
| Auto-capture | Extracts and stores facts on every `agent_end` event |
|
||||
| System prompt | Appends memory policy to every agent turn |
|
||||
| Dream consolidation | Automated memory maintenance with session/time/count gates |
|
||||
|
||||
## Agent Tool
|
||||
|
||||
The `mem0_memory` tool is registered with Pi and callable by the agent during conversations:
|
||||
|
||||
| Action | Required Params | Description |
|
||||
|--------|----------------|-------------|
|
||||
| `search` | `query` | Semantic search across memories |
|
||||
| `add` | `content` | Store a new memory |
|
||||
| `get_all` | — | List all memories in scope |
|
||||
| `delete` | `memory_id` | Delete a specific memory |
|
||||
| `delete_all` | — | Delete all memories in scope |
|
||||
|
||||
All actions accept an optional `scope` parameter: `project` (default), `session`, or `global`.
|
||||
|
||||
Tool output is truncated to 200 lines / 50KB to prevent context overflow.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/mem0-remember <text>` | Store a memory verbatim (no inference) |
|
||||
| `/mem0-forget <query>` | Search and delete memories (with confirmation dialog) |
|
||||
| `/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 memory ID) |
|
||||
| `/mem0-scope <scope>` | Change default scope for this session (project, session, global) |
|
||||
| `/mem0-status` | Connection health, identity, and memory count |
|
||||
|
||||
## Memory Scopes
|
||||
|
||||
Memories are scoped using Mem0's `user_id`, `app_id`, and `run_id` parameters:
|
||||
|
||||
| Scope | Filters | Use Case |
|
||||
|-------|---------|----------|
|
||||
| `project` | user_id + app_id (git root) | **Default.** Project-specific knowledge — decisions, architecture, config |
|
||||
| `session` | user_id + app_id + run_id | Ephemeral context for the current session only |
|
||||
| `global` | user_id only | All memories across all your projects |
|
||||
|
||||
The `app_id` is auto-detected from the git repository root (`git rev-parse --show-toplevel`), so all subdirectories within a monorepo share the same memory pool. Falls back to the working directory name for non-git directories. The `run_id` is derived from Pi's session file path.
|
||||
|
||||
## Dream Consolidation
|
||||
|
||||
### Confirmation Dialogs
|
||||
|
||||
Destructive and mutating commands use Pi's built-in `ctx.ui.confirm()` dialog before acting:
|
||||
|
||||
- `/mem0-forget` asks "Delete this memory?" before deleting a single match
|
||||
- `/mem0-pin` asks "Pin this memory?" before modifying it
|
||||
- Cancelling either operation is always safe — no changes are made
|
||||
|
||||
### Pin
|
||||
|
||||
`/mem0-pin` uses Mem0's `update()` API to prepend `[PINNED]` to the memory text. This preserves the original memory ID — no add+delete cycle that would lose history or change the UUID.
|
||||
|
||||
### Dream Consolidation
|
||||
|
||||
The plugin includes automated memory maintenance ("dream") that merges duplicates, resolves contradictions, and prunes stale entries. When enabled, dreams auto-trigger after enough sessions, time, and memories accumulate (configurable via `dream.*` settings). Run `/mem0-dream` to trigger consolidation manually at any time. Pinned memories (via `/mem0-pin`) are protected from pruning.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```text
|
||||
# Session 1
|
||||
You: I prefer dark mode and concise answers.
|
||||
# Mem0 auto-captures preferences
|
||||
|
||||
# Session 2 (days later)
|
||||
You: What do you know about my preferences?
|
||||
# Pi retrieves stored memories — no re-explaining needed
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No API key found"** — Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
|
||||
- **Extension not loading** — Check Pi startup output for errors. Run `pi -e ./src/entry.ts` from the plugin directory for verbose output
|
||||
- **Memories not capturing** — Verify `autoCapture` is `true` (default). Check `/mem0-status` for connection health
|
||||
- **Wrong project detected** — The plugin uses the git repository root as `app_id`. If not in a git repo, it falls back to the working directory name. Run `/mem0-status` to see the detected project
|
||||
- **Dream not triggering** — All three gates must pass (time, sessions, memories). Use `/mem0-dream` to force it manually
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude Code Integration" icon="terminal" href="/integrations/claude-code">
|
||||
Add Mem0 memory to Claude Code
|
||||
</Card>
|
||||
<Card title="OpenClaw Integration" icon="plug" href="/integrations/openclaw">
|
||||
Add Mem0 memory to OpenClaw agents
|
||||
</Card>
|
||||
</CardGroup>
|
||||
+151
-116
@@ -6,25 +6,33 @@ description: "Use the Mem0 AI SDK Provider with Vercel AI SDK for persistent mem
|
||||
The [**Mem0 AI SDK Provider**](https://www.npmjs.com/package/@mem0/vercel-ai-provider) is a library developed by **Mem0** to integrate with the Vercel AI SDK. This library brings enhanced AI interaction capabilities to your applications by introducing persistent memory functionality.
|
||||
|
||||
<Note type="info">
|
||||
Mem0 AI SDK now supports <strong>Vercel AI SDK V5</strong>.
|
||||
Mem0 AI SDK Provider v3.0.0 supports <strong>Vercel AI SDK v6</strong> (<code>LanguageModelV3</code> / <code>ProviderV3</code>). If you are upgrading from v2.x, see the <a href="https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0">AI SDK v6 migration guide</a>.
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
1. Offers persistent memory storage for conversational AI
|
||||
2. Enables smooth integration with the Vercel AI SDK
|
||||
3. Ensures compatibility with multiple LLM providers
|
||||
2. Enables smooth integration with the Vercel AI SDK v6
|
||||
3. Ensures compatibility with multiple LLM providers (OpenAI, Anthropic, Google, Groq, Cohere)
|
||||
4. Supports structured message formats for clarity
|
||||
5. Facilitates streaming response capabilities
|
||||
6. Attaches Mem0 memories as sources in responses for programmatic access
|
||||
|
||||
## Setup and Configuration
|
||||
|
||||
Install the SDK provider using npm:
|
||||
Install the SDK provider and AI SDK:
|
||||
|
||||
```bash
|
||||
npm install @mem0/vercel-ai-provider
|
||||
npm install @mem0/vercel-ai-provider ai@^6
|
||||
```
|
||||
|
||||
### Peer Dependencies
|
||||
|
||||
`@mem0/vercel-ai-provider` v3.0.0 requires:
|
||||
- `ai` v6+ (`^6.0.199`)
|
||||
- `@ai-sdk/provider` v3+ (`^3.0.10`)
|
||||
- Provider packages at v3+: `@ai-sdk/openai@^3`, `@ai-sdk/anthropic@^3`, `@ai-sdk/google@^3`, `@ai-sdk/groq@^3`, `@ai-sdk/cohere@^3`
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Setting Up Mem0
|
||||
@@ -41,7 +49,7 @@ npm install @mem0/vercel-ai-provider
|
||||
mem0ApiKey: "m0-xxx",
|
||||
apiKey: "provider-api-key",
|
||||
config: {
|
||||
// Options for LLM Provider
|
||||
// Options for the upstream LLM provider (e.g. baseURL)
|
||||
},
|
||||
// Optional Mem0 Global Config
|
||||
mem0Config: {
|
||||
@@ -57,154 +65,153 @@ npm install @mem0/vercel-ai-provider
|
||||
3. Add Memories to Enhance Context:
|
||||
|
||||
```typescript
|
||||
import { LanguageModelV2Prompt } from "@ai-sdk/provider";
|
||||
import { addMemories } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const messages: LanguageModelV2Prompt = [
|
||||
const messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "I love red cars." }] },
|
||||
];
|
||||
|
||||
await addMemories(messages, { user_id: "borat" });
|
||||
```
|
||||
|
||||
### Standalone Features:
|
||||
### Standalone Features
|
||||
|
||||
```typescript
|
||||
await addMemories(messages, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
await retrieveMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
await getMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
```
|
||||
> For standalone features, such as `addMemories`, `retrieveMemories`, and `getMemories`, you must either set `MEM0_API_KEY` as an environment variable or pass it directly in the function call.
|
||||
```typescript
|
||||
await addMemories(messages, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
await retrieveMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
await getMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
```
|
||||
|
||||
> `getMemories` will return raw memories in the form of an array of objects, while `retrieveMemories` will return a response in string format with a system prompt ingested with the retrieved memories.
|
||||
> For standalone features, such as `addMemories`, `retrieveMemories`, and `getMemories`, you must either set `MEM0_API_KEY` as an environment variable or pass it directly in the function call.
|
||||
|
||||
> `getMemories` returns an array of memory objects.
|
||||
> `getMemories` will return raw memories in the form of an array of objects, while `retrieveMemories` will return a response in string format with a system prompt ingested with the retrieved memories.
|
||||
|
||||
### 1. Basic Text Generation with Memory Context
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0();
|
||||
const mem0 = createMem0();
|
||||
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
```
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-5-mini", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Combining OpenAI Provider with Memory Utils
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { retrieveMemories } from "@mem0/vercel-ai-provider";
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { retrieveMemories } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const prompt = "Suggest me a good car to buy.";
|
||||
const memories = await retrieveMemories(prompt, { user_id: "borat" });
|
||||
const prompt = "Suggest me a good car to buy.";
|
||||
const memories = await retrieveMemories(prompt, { user_id: "borat" });
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4-turbo"),
|
||||
prompt: prompt,
|
||||
system: memories,
|
||||
});
|
||||
```
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-5-mini"),
|
||||
prompt: prompt,
|
||||
system: memories,
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Structured Message Format with Memory
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0();
|
||||
const mem0 = createMem0();
|
||||
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Suggest me a good car to buy." },
|
||||
{ type: "text", text: "Why is it better than the other cars for me?" },
|
||||
],
|
||||
},
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-5-mini", { user_id: "borat" }),
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Suggest me a good car to buy." },
|
||||
{ type: "text", text: "Why is it better than the other cars for me?" },
|
||||
],
|
||||
});
|
||||
```
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Streaming Responses with Memory Context
|
||||
### 4. Streaming Responses with Memory Context
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0();
|
||||
const mem0 = createMem0();
|
||||
|
||||
const { textStream } = streamText({
|
||||
model: mem0("gpt-4-turbo", {
|
||||
user_id: "borat",
|
||||
}),
|
||||
prompt: "Suggest me a good car to buy! Why is it better than the other cars for me? Give options for every price range.",
|
||||
});
|
||||
const { textStream } = streamText({
|
||||
model: mem0("gpt-5-mini", {
|
||||
user_id: "borat",
|
||||
}),
|
||||
prompt: "Suggest me a good car to buy! Why is it better than the other cars for me? Give options for every price range.",
|
||||
});
|
||||
|
||||
for await (const textPart of textStream) {
|
||||
process.stdout.write(textPart);
|
||||
}
|
||||
```
|
||||
for await (const textPart of textStream) {
|
||||
process.stdout.write(textPart);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Generate Responses with Tools Call
|
||||
### 5. Generate Responses with Tools Call
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
import { z } from "zod";
|
||||
```typescript
|
||||
import { generateText, tool } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
import { z } from "zod";
|
||||
|
||||
const mem0 = createMem0({
|
||||
provider: "anthropic",
|
||||
apiKey: "anthropic-api-key",
|
||||
mem0Config: {
|
||||
// Global User ID
|
||||
user_id: "borat"
|
||||
}
|
||||
});
|
||||
const mem0 = createMem0({
|
||||
provider: "anthropic",
|
||||
apiKey: "anthropic-api-key",
|
||||
mem0Config: {
|
||||
user_id: "borat"
|
||||
}
|
||||
});
|
||||
|
||||
const prompt = "What the temperature in the city that I live in?"
|
||||
const result = await generateText({
|
||||
model: mem0('claude-sonnet-4-20250514'),
|
||||
tools: {
|
||||
weather: tool({
|
||||
description: 'Get the weather in a location',
|
||||
parameters: z.object({
|
||||
location: z.string().describe('The location to get the weather for'),
|
||||
}),
|
||||
execute: async ({ location }) => ({
|
||||
location,
|
||||
temperature: 72 + Math.floor(Math.random() * 21) - 10,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
prompt: "What the temperature in the city that I live in?",
|
||||
});
|
||||
|
||||
const result = await generateText({
|
||||
model: mem0('claude-3-5-sonnet-20240620'),
|
||||
tools: {
|
||||
weather: tool({
|
||||
description: 'Get the weather in a location',
|
||||
parameters: z.object({
|
||||
location: z.string().describe('The location to get the weather for'),
|
||||
}),
|
||||
execute: async ({ location }) => ({
|
||||
location,
|
||||
temperature: 72 + Math.floor(Math.random() * 21) - 10,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
prompt: prompt,
|
||||
});
|
||||
console.log(result);
|
||||
```
|
||||
|
||||
console.log(result);
|
||||
```
|
||||
### 6. Get Sources from Memory
|
||||
|
||||
### 5. Get sources from memory
|
||||
`generateText` and `streamText` responses include Mem0 memories as a source, giving you programmatic access to the memories that influenced the response:
|
||||
|
||||
```typescript
|
||||
const { text, sources } = await generateText({
|
||||
model: mem0("gpt-4-turbo"),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
model: mem0("gpt-5-mini", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
|
||||
// sources[0].title === "Mem0 Memories"
|
||||
// sources[0].providerMetadata.mem0.memories — array of memory objects
|
||||
console.log(sources);
|
||||
```
|
||||
|
||||
The same can be done for `streamText` as well.
|
||||
|
||||
### 6. File Support with Memory Context
|
||||
### 7. File Support with Memory Context
|
||||
|
||||
Mem0 AI SDK supports file processing with memory context. Here's an example of analyzing a PDF file:
|
||||
|
||||
@@ -226,15 +233,11 @@ const mem0 = createMem0({
|
||||
});
|
||||
|
||||
async function main() {
|
||||
// Read the PDF file
|
||||
const filePath = join(process.cwd(), 'my_pdf.pdf');
|
||||
const fileBuffer = readFileSync(filePath);
|
||||
|
||||
// Convert the file's arrayBuffer to a Base64 data URL
|
||||
const arrayBuffer = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// Convert Uint8Array to an array of characters
|
||||
const charArray = Array.from(uint8Array, byte => String.fromCharCode(byte));
|
||||
const binaryString = charArray.join('');
|
||||
const base64Data = Buffer.from(binaryString, 'binary').toString('base64');
|
||||
@@ -274,24 +277,56 @@ main();
|
||||
|
||||
| Provider | Configuration Value |
|
||||
|----------|-------------------|
|
||||
| OpenAI | openai |
|
||||
| Anthropic | anthropic |
|
||||
| Google | google |
|
||||
| Groq | groq |
|
||||
| OpenAI | `openai` |
|
||||
| Anthropic | `anthropic` |
|
||||
| Google / Gemini | `google` or `gemini` |
|
||||
| Groq | `groq` |
|
||||
| Cohere | `cohere` |
|
||||
|
||||
> **Note**: You can use `google` as provider for Gemini (Google) models. They are same and internally they use `@ai-sdk/google` package.
|
||||
> **Note**: You can use either `google` or `gemini` as the provider value for Google Gemini models. Both map to the `@ai-sdk/google` package internally.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Mem0ConfigSettings
|
||||
|
||||
These options can be passed per-request when creating a model instance:
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `user_id` | `string` | User identifier for memory scoping |
|
||||
| `agent_id` | `string` | Agent identifier |
|
||||
| `app_id` | `string` | Application identifier |
|
||||
| `run_id` | `string` | Run/session identifier |
|
||||
| `metadata` | `object` | Custom metadata for memories |
|
||||
| `filters` | `object` | Filters for memory search |
|
||||
| `infer` | `boolean` | Enable inference-based retrieval |
|
||||
| `top_k` | `number` | Number of memories to retrieve (default: 10) |
|
||||
| `threshold` | `number` | Relevance threshold for search |
|
||||
| `rerank` | `boolean` | Enable reranking of results |
|
||||
| `page` | `number` | Page number for pagination |
|
||||
| `page_size` | `number` | Results per page |
|
||||
|
||||
## Key Features
|
||||
|
||||
- `createMem0()`: Initializes a new Mem0 provider instance.
|
||||
- `retrieveMemories()`: Retrieves memory context for prompts.
|
||||
- `createMem0()`: Initializes a new Mem0 provider instance implementing `ProviderV3`.
|
||||
- `retrieveMemories()`: Retrieves memory context for prompts as a formatted system prompt string.
|
||||
- `getMemories()`: Get memories from your profile in array format.
|
||||
- `addMemories()`: Adds user memories to enhance contextual responses.
|
||||
|
||||
## Migrating from v2.x
|
||||
|
||||
If you're upgrading from `@mem0/vercel-ai-provider` v2.x:
|
||||
|
||||
1. **Upgrade AI SDK**: `npm install ai@^6` and update all `@ai-sdk/*` provider packages to `^3.x`
|
||||
2. **Remove deprecated params**: Remove `org_id`, `project_id`, `output_format`, `filter_memories`, `async_mode`, `enable_graph` from your config
|
||||
3. **Remove graph memory**: All graph-related options (`enable_graph`, graph prompts) have been removed. Graph memory is now a project-level setting on the Mem0 Platform
|
||||
4. **Update imports**: `LanguageModelV2Prompt` is now `LanguageModelV3Prompt` if you import types directly
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **User Identification**: Use a unique `user_id` for consistent memory retrieval.
|
||||
2. **Memory Cleanup**: Regularly clean up unused memory data.
|
||||
3. **Sources**: Access `result.sources` to inspect which memories influenced the response.
|
||||
|
||||
> **Note**: We also have support for `agent_id`, `app_id`, and `run_id`. Refer [Docs](/api-reference/memory/add-memories).
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
- [OSS v2 to v3 Migration](https://docs.mem0.ai/migration/oss-v2-to-v3) [OSS]: Use when upgrading a self-hosted deployment across major versions.
|
||||
- [Platform v2 to v3 Migration](https://docs.mem0.ai/migration/platform-v2-to-v3) [Platform]: Use when upgrading a Platform integration across major versions.
|
||||
- [API Changes](https://docs.mem0.ai/migration/api-changes) [Both]: Use when the upgrade involves API surface changes.
|
||||
- [Server pgvector Image Upgrade](https://docs.mem0.ai/migration/server-pgvector-upgrade) [OSS]: Use when upgrading the self-hosted server Docker image from ankane/pgvector to pgvector/pgvector.
|
||||
- [Changelog](https://docs.mem0.ai/changelog/highlights) [Both]: Use when the user asks what shipped recently.
|
||||
|
||||
## Open Source
|
||||
@@ -258,6 +259,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
- [Camel AI](https://docs.mem0.ai/integrations/camel-ai) [Both]: Use when the user is on Camel AI.
|
||||
- [ChatDev](https://docs.mem0.ai/integrations/chatdev) [Both]: Use when the user is on ChatDev.
|
||||
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when the user is on Hermes.
|
||||
- [Pi Agent](https://docs.mem0.ai/integrations/pi-agent) [Platform]: Use when adding persistent memory to Pi Agent with the Mem0 plugin.
|
||||
- [OpenAI Agents SDK](https://docs.mem0.ai/integrations/openai-agents-sdk) [Both]: Use when the user is on the OpenAI Agents SDK.
|
||||
- [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) [Both]: Use when the user is on Google's Agent Development Kit.
|
||||
- [Mastra](https://docs.mem0.ai/integrations/mastra) [Both]: Use when the user is on Mastra (TypeScript).
|
||||
@@ -474,6 +476,7 @@ Everything below is OSS-only provider configuration. Skip this entire section wh
|
||||
- [Elasticsearch](https://docs.mem0.ai/components/vectordbs/dbs/elasticsearch) [OSS]: Use when Elasticsearch is the backing store.
|
||||
- [OpenSearch](https://docs.mem0.ai/components/vectordbs/dbs/opensearch) [OSS]: Use when OpenSearch is the backing store.
|
||||
- [Supabase](https://docs.mem0.ai/components/vectordbs/dbs/supabase) [OSS]: Use when Supabase with pgvector is the backing store.
|
||||
- [Neon](https://docs.mem0.ai/components/vectordbs/dbs/neon) [OSS]: Use when Neon Postgres with pgvector is the backing store.
|
||||
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash-vector) [OSS]: Use for serverless Upstash Vector.
|
||||
- [Vectorize](https://docs.mem0.ai/components/vectordbs/dbs/vectorize) [OSS]: Use when the store is Cloudflare Vectorize.
|
||||
- [Vertex AI Vector Search](https://docs.mem0.ai/components/vectordbs/dbs/vertex_ai) [OSS]: Use when the store is Google Cloud Vertex Vector Search.
|
||||
|
||||
@@ -14,9 +14,9 @@ Move your Mem0 implementation to managed infrastructure with enterprise features
|
||||
| --------------------- | -------------- | ---------------------------- |
|
||||
| Infrastructure & Code | Low (~30 mins) | None (Parallel run possible) |
|
||||
|
||||
<Info>
|
||||
<Note>
|
||||
Using Mem0 Open Source with **hosted Qdrant**? You can migrate your existing memories to Mem0 Platform with a one-line script below.
|
||||
</Info>
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
**Why migrate to Platform?**
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "Server: Upgrading the pgvector Docker Image"
|
||||
description: "Migrate your self-hosted Mem0 server from the archived ankane/pgvector image to the official pgvector/pgvector image."
|
||||
icon: "arrow-right"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The self-hosted Mem0 server has upgraded its PostgreSQL Docker image:
|
||||
|
||||
| | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Docker image | `ankane/pgvector:v0.5.1` | `pgvector/pgvector:pg17` |
|
||||
| PostgreSQL | 15 | 17 |
|
||||
| pgvector | 0.5.1 | 0.8.0 |
|
||||
| Credentials | Hardcoded `postgres` / `postgres` | Set via `POSTGRES_USER` / `POSTGRES_PASSWORD` env vars |
|
||||
|
||||
<Warning>
|
||||
The `ankane/pgvector` image is **archived and no longer maintained**. The new `pgvector/pgvector` image is the official distribution maintained by the pgvector project.
|
||||
</Warning>
|
||||
|
||||
<Info>
|
||||
**Should you migrate?**
|
||||
- You are running the Mem0 server via `docker-compose.yaml` in the `server/` directory.
|
||||
- You want to stay on a maintained, actively-patched PostgreSQL + pgvector image.
|
||||
- You want pgvector 0.8.0 features (improved HNSW performance, parallel index builds).
|
||||
</Info>
|
||||
|
||||
## Fresh Installs
|
||||
|
||||
No migration is needed. Copy the example env file, set your password, and start the stack:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# Edit .env — set POSTGRES_PASSWORD (required) and OPENAI_API_KEY at minimum
|
||||
make up
|
||||
```
|
||||
|
||||
## Migrating an Existing Install
|
||||
|
||||
PostgreSQL 17 cannot read data files created by PostgreSQL 15 directly. You need to export your data from the old container and import it into the new one.
|
||||
|
||||
### 1. Back Up Your Data
|
||||
|
||||
With the **old** stack still running:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker compose exec -T postgres pg_dumpall -U postgres > mem0_backup.sql
|
||||
```
|
||||
|
||||
Verify the dump is non-empty:
|
||||
|
||||
```bash
|
||||
ls -lh mem0_backup.sql
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Do not skip this step. The next step permanently deletes your Postgres data volume.
|
||||
</Warning>
|
||||
|
||||
### 2. Stop the Old Stack and Remove the Volume
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### 3. Update Your `.env`
|
||||
|
||||
Postgres credentials are no longer hardcoded in `docker-compose.yaml`. Add them to your `.env`:
|
||||
|
||||
```bash
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=<your-password> # required — compose will refuse to start without it
|
||||
POSTGRES_COLLECTION_NAME=memories
|
||||
```
|
||||
|
||||
<Info>
|
||||
`POSTGRES_PASSWORD` is **required** — `docker compose up` will refuse to start without it. If you previously relied on the hardcoded default, set `POSTGRES_PASSWORD=postgres`.
|
||||
</Info>
|
||||
|
||||
### 4. Start Only Postgres
|
||||
|
||||
Start **only** the Postgres container first — do **not** start the mem0 API yet.
|
||||
The API runs `alembic upgrade head` on startup, which creates empty tables that
|
||||
would conflict with the restore.
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Wait for Postgres to become healthy:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_isready -q && echo "ready" || echo "not ready"
|
||||
```
|
||||
|
||||
### 5. Restore Your Data
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres psql -U postgres < mem0_backup.sql
|
||||
```
|
||||
|
||||
You may see notices like `role "postgres" already exists` — these are safe to ignore.
|
||||
|
||||
<Warning>
|
||||
You must restore **before** starting the mem0 API container. The API runs
|
||||
database migrations on startup which create empty tables — restoring after
|
||||
that would fail with duplicate-key errors and lose your API keys and settings.
|
||||
</Warning>
|
||||
|
||||
### 6. Start the API
|
||||
|
||||
Now start the mem0 API container. Alembic will detect the existing tables and
|
||||
only apply any new migrations:
|
||||
|
||||
```bash
|
||||
docker compose up -d mem0
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
```bash
|
||||
# Check service health
|
||||
cd server && make health
|
||||
|
||||
# Confirm memories are accessible
|
||||
curl -s http://localhost:8888/memories?user_id=<your-user-id> \
|
||||
-H "X-API-Key: <your-api-key>"
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If something goes wrong, revert the image tag in `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
image: ankane/pgvector:v0.5.1
|
||||
```
|
||||
|
||||
Then destroy the new volume, start the old image, and restore from your backup:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
docker compose up -d --build
|
||||
docker compose exec -T postgres psql -U postgres < mem0_backup.sql
|
||||
```
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Join our [Discord community](https://mem0.ai/discord) for real-time support
|
||||
- Open an issue on [GitHub](https://github.com/mem0ai/mem0/issues)
|
||||
@@ -4,22 +4,7 @@ description: Fine-grained metadata queries for precise OSS memory retrieval.
|
||||
icon: "filter"
|
||||
---
|
||||
|
||||
Enhanced metadata filtering in Mem0 1.0.0 lets you run complex queries across memory metadata. Combine comparisons, logical operators, and wildcard matches to zero in on the exact memories your agent needs.
|
||||
|
||||
<Info>
|
||||
**You’ll use this when…**
|
||||
- Retrieval must respect multiple metadata conditions before returning context.
|
||||
- You need to mix numeric, boolean, and string filters in a single query.
|
||||
- Agents rely on deterministic filtering instead of broad semantic search alone.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
Enhanced filtering requires Mem0 1.0.0 or later and a vector store that supports the operators you enable. Unsupported operators fall back to simple equality filters.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
The TypeScript SDK accepts the same filter shape shown here—transpose the dictionaries to objects and reuse the keys unchanged.
|
||||
</Note>
|
||||
Enhanced metadata filtering in Mem0 lets you run complex queries across memory metadata. Combine comparisons, logical operators, and wildcard matches to zero in on the exact memories your agent needs.
|
||||
|
||||
---
|
||||
|
||||
@@ -148,8 +133,8 @@ Combine filters with `AND`, `OR`, and `NOT` to express complex decision trees. N
|
||||
results = m.search(
|
||||
"complex query",
|
||||
filters={
|
||||
"user_id": "alice",
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"category": "work"},
|
||||
{"priority": {"gte": 7}},
|
||||
{"status": {"ne": "completed"}}
|
||||
@@ -161,15 +146,11 @@ results = m.search(
|
||||
results = m.search(
|
||||
"flexible query",
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{
|
||||
"OR": [
|
||||
{"category": "urgent"},
|
||||
{"priority": {"gte": 9}},
|
||||
{"deadline": {"contains": "today"}}
|
||||
]
|
||||
}
|
||||
"user_id": "alice",
|
||||
"OR": [
|
||||
{"category": "urgent"},
|
||||
{"priority": {"gte": 9}},
|
||||
{"deadline": {"contains": "today"}}
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -178,14 +159,10 @@ results = m.search(
|
||||
results = m.search(
|
||||
"exclusion query",
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{
|
||||
"NOT": [
|
||||
{"category": "archived"},
|
||||
{"status": "deleted"}
|
||||
]
|
||||
}
|
||||
"user_id": "alice",
|
||||
"NOT": [
|
||||
{"category": "archived"},
|
||||
{"status": "deleted"}
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -194,8 +171,8 @@ results = m.search(
|
||||
results = m.search(
|
||||
"advanced query",
|
||||
filters={
|
||||
"user_id": "alice",
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{
|
||||
"OR": [
|
||||
{"category": "work"},
|
||||
@@ -248,8 +225,8 @@ config = {
|
||||
```python
|
||||
# More efficient: Filter on indexed fields first
|
||||
good_filters = {
|
||||
"user_id": "alice",
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"category": "work"},
|
||||
{"content": {"contains": "meeting"}}
|
||||
]
|
||||
@@ -257,9 +234,9 @@ good_filters = {
|
||||
|
||||
# Less efficient: Complex operations first
|
||||
avoid_filters = {
|
||||
"user_id": "alice",
|
||||
"AND": [
|
||||
{"description": {"icontains": "complex text search"}},
|
||||
{"user_id": "alice"}
|
||||
{"description": {"icontains": "complex text search"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -302,8 +279,8 @@ results = m.search(
|
||||
results = m.search(
|
||||
"query",
|
||||
filters={
|
||||
"user_id": "alice",
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"category": "work"},
|
||||
{"status": {"ne": "archived"}},
|
||||
{"priority": {"gte": 5}}
|
||||
@@ -327,8 +304,8 @@ results = m.search(
|
||||
results = m.search(
|
||||
"What tasks need attention?",
|
||||
filters={
|
||||
"user_id": "project_manager",
|
||||
"AND": [
|
||||
{"user_id": "project_manager"},
|
||||
{"project": {"in": ["alpha", ""]}},
|
||||
{"priority": {"gte": 8}},
|
||||
{"status": {"ne": "completed"}},
|
||||
@@ -354,8 +331,8 @@ results = m.search(
|
||||
results = m.search(
|
||||
"pending support issues",
|
||||
filters={
|
||||
"agent_id": "support_bot",
|
||||
"AND": [
|
||||
{"agent_id": "support_bot"},
|
||||
{"ticket_status": {"ne": "resolved"}},
|
||||
{"priority": {"in": ["high", "critical"]}},
|
||||
{"created_date": {"gte": "2024-01-01"}},
|
||||
@@ -380,8 +357,8 @@ results = m.search(
|
||||
results = m.search(
|
||||
"recommend content",
|
||||
filters={
|
||||
"user_id": "reader123",
|
||||
"AND": [
|
||||
{"user_id": "reader123"},
|
||||
{
|
||||
"OR": [
|
||||
{"genre": {"in": ["sci-fi", "fantasy"]}},
|
||||
|
||||
@@ -226,6 +226,20 @@ curl -X POST http://localhost:8888/search \
|
||||
}'
|
||||
```
|
||||
|
||||
Set `explain` to inspect the scoring signals used by OSS hybrid search:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8888/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "vegetable pizza",
|
||||
"user_id": "alice",
|
||||
"explain": true
|
||||
}'
|
||||
```
|
||||
|
||||
Each returned memory includes `score_details` only when explanation mode is enabled.
|
||||
|
||||
### Explore with OpenAPI docs
|
||||
|
||||
1. Navigate to `http://localhost:8888/docs` (Compose) or `http://localhost:8000/docs` (raw Docker / uvicorn).
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"ai": "^4.1.46",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"js-cookie": "^3.0.6",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "15.5.18",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.9",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the `@mem0/opencode-plugin` will be documented in this file.
|
||||
|
||||
## 0.1.3 — File-context injection, session summaries & activity timeline
|
||||
|
||||
### Added
|
||||
|
||||
- **File-context injection (`tool.execute.before` / Read):** Before the agent reads a file, the plugin searches mem0 for memories referencing that file path and injects prior work as system context. Gates on file size (>= 1,500 bytes). Gives the agent "I've worked on this file before" awareness automatically.
|
||||
- **Stop hook session summary (`experimental.session.compacting`):** Enhanced session compaction to store a structured `session_summary` memory with `infer=True`, letting the mem0 backend AI extract key facts (request, decisions, learnings, next steps). Previously only stored a raw stats string.
|
||||
- **SessionStart activity timeline:** The initial memory loading now formats recent memories with type icons (⚖️ decision, 🔴 bug_fix, 🔵 task_learning, etc.) and relative age indicators (2h ago, 1d ago) instead of bare text. Provides a visual "Recent Activity" timeline on first message.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`experimental.session.compacting` handler:** Now stores `metadata.type=session_summary` with `metadata.source=opencode-stop` instead of `metadata.type=session_state` with `metadata.source=pre-compaction`. Includes a structured prompt that instructs mem0's AI to extract request, decisions, learnings, and next steps.
|
||||
- **Initial context formatting:** Memories shown on first message now include type icons and age labels for quick scanning.
|
||||
|
||||
## 0.1.2 — Automatic coding categories & global search
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto-configured coding categories:** The plugin now automatically sets up 17 coding categories (e.g. `architecture_decisions`, `api_design`, `security`, `debugging_notes`) on the Mem0 project at startup. Runs in the background on every session start via `autoSetupCategories()`, is fully idempotent, and never blocks initialization. Uses SHA-256 fingerprints of the category list and API key — stored in `~/.mem0/categories_setup.json` — to skip redundant API calls on subsequent sessions.
|
||||
- **Global search mode (`global_search` setting):** New `global_search` toggle in `~/.mem0/settings.json` (default: `false`). When enabled, all `search_memories` and `get_memories` calls use `{"OR": [{"user_id": "*"}]}` instead of the per-user per-project `AND` filter — returning all memories across all users and all `app_id` scopes. Writes (`add_memory`) still tag with the current `user_id` and `app_id`. Applies to all plugin search paths: initial load, per-message recall, resume detection, error-pattern lookup, and compaction context.
|
||||
- **`/mem0:switch-project --global` / `--no-global`:** Enables or disables global search via the switch-project skill. Persists to `~/.mem0/settings.json`. No manual config editing needed.
|
||||
- **`MEM0_GLOBAL_SEARCH` environment variable:** Exported to child shells via the `shell.env` hook (`"true"` or `"false"`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Search filters are now dynamic:** All search paths throughout the plugin construct filters based on the `global_search` setting instead of always using `AND [user_id, app_id]`.
|
||||
- **Resume-context searches broadened:** Resume and error-pattern searches no longer include `metadata.type` sub-filters (`session_state`, `decision`, `anti_pattern`, `bug_fix`), broadening recall.
|
||||
- **System context message updated:** Informs the model when global search is active (`"Global search is ON — searches return all memories across all users and projects. Writes still use user_id=..., app_id=..."`).
|
||||
- **`/mem0:onboard` Step 5 is no longer interactive:** Removed the manual category installation prompt. Categories now configure automatically in the background; the onboarding step only verifies status and stores a fallback `project_profile` memory if the background run hasn't finished yet.
|
||||
- **`/mem0:switch-project` skill expanded:** Description and execution updated to document the `--global` and `--no-global` flags alongside the existing project-name argument.
|
||||
|
||||
## 0.1.1
|
||||
|
||||
- CI/CD publish flow test (`#5288`).
|
||||
- Fixed tsconfig, added `publishConfig` and bun lockfile (`#5273`).
|
||||
- Renamed package to `@mem0/opencode-plugin` (`#5272`).
|
||||
- Added plugin array to bundled `opencode.json` (`#5271`).
|
||||
|
||||
## 0.1.0 — Initial release
|
||||
|
||||
- **OpenCode plugin** (`@mem0/opencode-plugin` on npm): Pure TypeScript plugin using the `mem0ai` TS SDK — no Python, no shell scripts. Hooks into all 6 OpenCode events (`chat.message`, `tool.execute.before`, `tool.execute.after`, `experimental.chat.system.transform`, `experimental.session.compacting`, `shell.env`). Features: session start memory loading, per-prompt semantic search, error pattern detection with memory lookup, resume/remember intent detection, auto-capture every 3rd message, periodic save nudges, full metadata defaults injection (confidence, source, type, session_id, files, branch), identity injection for search/get/delete filters, type-filtered error pre-fetch (anti_pattern + bug_fix), pre-compaction memory capture, MEMORY.md write blocking, and secret redaction.
|
||||
- **16 OpenCode-native skills** bundled in `opencode-skills/`: `context-loader`, `dream`, `export`, `forget`, `health`, `import`, `list-projects`, `mem0` (SDK reference), `memory-reviewer`, `onboard`, `peek`, `pin`, `remember`, `stats`, `switch-project`, `tour`. All skills are pure MCP-tool-based — no Python scripts, no shell scripts, no Claude Code dependencies.
|
||||
- **Auto-install skills and commands (`installSkills()`):** On plugin load, copies all 16 skills to `.opencode/skills/` and creates command wrapper files in `.opencode/commands/` so they appear in the OpenCode `/` palette.
|
||||
- **CLI installer (`cli.ts`):** `bunx @mem0/opencode-plugin install` auto-configures plugin and MCP server in `~/.config/opencode/opencode.json`.
|
||||
@@ -6,6 +6,9 @@ import { userInfo } from "os";
|
||||
import { basename, resolve, dirname } from "path";
|
||||
import { randomBytes } from "crypto";
|
||||
import { existsSync, readdirSync, cpSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
async function getUserId(): Promise<string> {
|
||||
if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID;
|
||||
@@ -63,6 +66,100 @@ function redact(text: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAge(createdAt: string): string {
|
||||
try {
|
||||
const dt = new Date(createdAt);
|
||||
const now = Date.now();
|
||||
const seconds = Math.floor((now - dt.getTime()) / 1000);
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
if (days === 1) return "1d ago";
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
decision: "⚖️",
|
||||
anti_pattern: "🔴",
|
||||
bug_fix: "🔴",
|
||||
convention: "🔄",
|
||||
task_learning: "🔵",
|
||||
user_preference: "🟣",
|
||||
session_summary: "📋",
|
||||
session_state: "📋",
|
||||
project_profile: "📖",
|
||||
compact_summary: "📋",
|
||||
auto_capture: "✅",
|
||||
};
|
||||
|
||||
const FILE_READ_GATE_MIN_BYTES = 1500;
|
||||
|
||||
function loadGlobalSearch(): boolean {
|
||||
try {
|
||||
const settingsPath = join(homedir(), ".mem0", "settings.json");
|
||||
if (!existsSync(settingsPath)) return false;
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
return settings.global_search === true;
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
const CODING_CATEGORIES = [
|
||||
"architecture_decisions", "api_design", "data_models", "algorithms",
|
||||
"dependencies", "environment_setup", "testing_strategy", "debugging_notes",
|
||||
"performance", "security", "deployment", "code_conventions",
|
||||
"error_handling", "refactoring_history", "integrations", "onboarding",
|
||||
"project_meta",
|
||||
];
|
||||
|
||||
function categoriesFingerprint(): string {
|
||||
const sorted = [...CODING_CATEGORIES].sort();
|
||||
return createHash("sha256").update(sorted.join("\n")).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function apiKeyFingerprint(apiKey: string): string {
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
async function autoSetupCategories(mem0: MemoryClient, apiKey: string): Promise<void> {
|
||||
const stateDir = join(homedir(), ".mem0");
|
||||
const stateFile = join(stateDir, "categories_setup.json");
|
||||
const keyFp = apiKeyFingerprint(apiKey);
|
||||
const catFp = categoriesFingerprint();
|
||||
|
||||
let state: Record<string, string> = {};
|
||||
try {
|
||||
if (existsSync(stateFile)) {
|
||||
state = JSON.parse(readFileSync(stateFile, "utf8"));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (state[keyFp] === catFp) return;
|
||||
|
||||
try {
|
||||
const project = await mem0.getProject({ fields: ["customCategories"] });
|
||||
const existing: string[] = (project as any)?.custom_categories ?? (project as any)?.customCategories ?? [];
|
||||
const sortedExisting = [...existing].sort();
|
||||
const sortedTarget = [...CODING_CATEGORIES].sort();
|
||||
if (JSON.stringify(sortedExisting) === JSON.stringify(sortedTarget)) {
|
||||
state[keyFp] = catFp;
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
await mem0.updateProject({ customCategories: CODING_CATEGORIES as any });
|
||||
|
||||
state[keyFp] = catFp;
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const NUDGE_RE =
|
||||
/\b(remember\s+(this|that)|memorize|save\s+this|note\s+(this|that)|don'?t\s+forget|always\s+remember|never\s+forget|keep\s+(this|that)\s+in\s+(mind|memory)|store\s+(this|that))\b/i;
|
||||
|
||||
@@ -173,6 +270,7 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
const branch = await getBranch($);
|
||||
const stats = { adds: 0, searches: 0, messages: 0 };
|
||||
const sessionId = generateSessionId();
|
||||
const globalSearch = loadGlobalSearch();
|
||||
|
||||
let initialized = false;
|
||||
let memoryCount = 0;
|
||||
@@ -180,6 +278,9 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
|
||||
const systemContext: string[] = [];
|
||||
|
||||
// Auto-configure coding categories in background (idempotent, never blocks)
|
||||
Promise.resolve().then(() => autoSetupCategories(mem0, apiKey)).catch(() => {});
|
||||
|
||||
return {
|
||||
"chat.message": async (input: any, output: any) => {
|
||||
const userText = extractUserText(input, output);
|
||||
@@ -192,11 +293,13 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
|
||||
const searchFilters = globalSearch
|
||||
? { OR: [{ user_id: "*" }] }
|
||||
: { AND: [{ user_id: userId }, { app_id: appId }] };
|
||||
|
||||
try {
|
||||
const all = await mem0.getAll({
|
||||
filters: {
|
||||
AND: [{ user_id: userId }, { app_id: appId }],
|
||||
},
|
||||
filters: searchFilters,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
@@ -205,9 +308,15 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
(all as any)?.results?.length ??
|
||||
0;
|
||||
|
||||
systemContext.push(
|
||||
`Always include user_id="${userId}" and app_id="${appId}" in every search_memories filter and add_memory call.`,
|
||||
);
|
||||
if (globalSearch) {
|
||||
systemContext.push(
|
||||
`Global search is ON — searches return all memories across all users and projects. Writes still use user_id="${userId}", app_id="${appId}".`,
|
||||
);
|
||||
} else {
|
||||
systemContext.push(
|
||||
`Always include user_id="${userId}" and app_id="${appId}" in every search_memories filter and add_memory call.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (memoryCount === 0) {
|
||||
systemContext.push(
|
||||
@@ -223,9 +332,7 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
const res = await mem0.search(
|
||||
"recent session state decisions and learnings",
|
||||
{
|
||||
filters: {
|
||||
AND: [{ user_id: userId }, { app_id: appId }],
|
||||
},
|
||||
filters: searchFilters,
|
||||
topK: 5,
|
||||
},
|
||||
);
|
||||
@@ -233,9 +340,16 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
const memories = extractMemories(res);
|
||||
if (memories.length > 0) {
|
||||
const memLines = memories
|
||||
.map((m) => `- ${m.memory}`)
|
||||
.map((m) => {
|
||||
const meta = (m as any).metadata ?? {};
|
||||
const cat = meta.type ?? "unknown";
|
||||
const icon = TYPE_ICONS[cat] ?? "❓";
|
||||
const age = (m as any).created_at ? formatAge((m as any).created_at) : "";
|
||||
const ageStr = age ? ` (${age})` : "";
|
||||
return `- ${icon} [${cat}]${ageStr} ${m.memory.slice(0, 120)}`;
|
||||
})
|
||||
.join("\n");
|
||||
systemContext.push(`Prior context from mem0:\n${memLines}`);
|
||||
systemContext.push(`### Recent Activity\n\n${memLines}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -265,25 +379,21 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
const hasResume = RESUME_RE.test(safeText);
|
||||
if (hasResume) {
|
||||
try {
|
||||
const [stateRes, decisionsRes] = await Promise.all([
|
||||
mem0.search("session state current task", {
|
||||
filters: {
|
||||
const resumeFilters = globalSearch
|
||||
? { OR: [{ user_id: "*" }] }
|
||||
: {
|
||||
AND: [
|
||||
{ user_id: userId },
|
||||
{ app_id: appId },
|
||||
{ metadata: { type: "session_state" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const [stateRes, decisionsRes] = await Promise.all([
|
||||
mem0.search("session state current task", {
|
||||
filters: resumeFilters,
|
||||
topK: 3,
|
||||
}),
|
||||
mem0.search("recent decisions and learnings", {
|
||||
filters: {
|
||||
AND: [
|
||||
{ user_id: userId },
|
||||
{ app_id: appId },
|
||||
{ metadata: { type: "decision" } },
|
||||
],
|
||||
},
|
||||
filters: resumeFilters,
|
||||
topK: 3,
|
||||
}),
|
||||
]);
|
||||
@@ -309,8 +419,11 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
|
||||
if (!hasResume && memoryCount > 0) {
|
||||
try {
|
||||
const msgFilters = globalSearch
|
||||
? { OR: [{ user_id: "*" }] }
|
||||
: { AND: [{ user_id: userId }, { app_id: appId }] };
|
||||
const res = await mem0.search(safeText, {
|
||||
filters: { AND: [{ user_id: userId }, { app_id: appId }] },
|
||||
filters: msgFilters,
|
||||
topK: 5,
|
||||
});
|
||||
stats.searches++;
|
||||
@@ -371,6 +484,41 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
"tool.execute.before": async (input: any, output: any) => {
|
||||
const toolName: string = input?.tool ?? "";
|
||||
|
||||
// File-context injection: before reading a file, search mem0 for prior work on it
|
||||
if (toolName === "read" || toolName === "Read") {
|
||||
const filePath = String(output?.args?.file_path ?? output?.args?.filePath ?? "");
|
||||
if (filePath && filePath.length > 0) {
|
||||
try {
|
||||
const absPath = filePath.startsWith("/") ? filePath : resolve(process.cwd(), filePath);
|
||||
const { statSync } = await import("fs");
|
||||
const stat = statSync(absPath);
|
||||
if (stat.isFile() && stat.size >= FILE_READ_GATE_MIN_BYTES) {
|
||||
const searchFilters = globalSearch
|
||||
? { OR: [{ user_id: "*" }] }
|
||||
: { AND: [{ user_id: userId }, { app_id: appId }] };
|
||||
const relPath = filePath.startsWith("/")
|
||||
? filePath.replace(process.cwd() + "/", "")
|
||||
: filePath;
|
||||
const res = await mem0.search(relPath, {
|
||||
filters: searchFilters,
|
||||
topK: 5,
|
||||
});
|
||||
stats.searches++;
|
||||
const memories = extractMemories(res);
|
||||
if (memories.length > 0) {
|
||||
const lines = memories.map((m) => {
|
||||
const text = m.memory.slice(0, 150).replace(/\n/g, " ");
|
||||
return `- ${text} [mem0:${m.id.slice(0, 8)}]`;
|
||||
});
|
||||
systemContext.push(
|
||||
`Prior work on \`${relPath}\`:\n${lines.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (WRITE_TOOLS.has(toolName)) {
|
||||
const fp = String(
|
||||
output?.args?.file_path ?? output?.args?.filePath ?? "",
|
||||
@@ -401,32 +549,36 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
}
|
||||
|
||||
if (isMem0SearchOrGet(toolName)) {
|
||||
const existingFilters = output.args.filters;
|
||||
if (existingFilters === undefined || existingFilters === null) {
|
||||
output.args.filters = {
|
||||
AND: [{ user_id: userId }, { app_id: appId }],
|
||||
};
|
||||
} else if (typeof existingFilters === "object") {
|
||||
const andClauses: any[] = existingFilters.AND;
|
||||
if (Array.isArray(andClauses)) {
|
||||
const hasUid = andClauses.some(
|
||||
(c: any) => c && typeof c === "object" && "user_id" in c,
|
||||
);
|
||||
const hasAid = andClauses.some(
|
||||
(c: any) => c && typeof c === "object" && "app_id" in c,
|
||||
);
|
||||
if (!hasUid) andClauses.push({ user_id: userId });
|
||||
if (!hasAid) andClauses.push({ app_id: appId });
|
||||
} else if (andClauses === undefined) {
|
||||
const hasUid = "user_id" in existingFilters;
|
||||
const hasAid = "app_id" in existingFilters;
|
||||
if (!hasUid || !hasAid) {
|
||||
const existing = Object.entries(existingFilters).map(
|
||||
([k, v]) => ({ [k]: v }),
|
||||
if (globalSearch) {
|
||||
output.args.filters = { OR: [{ user_id: "*" }] };
|
||||
} else {
|
||||
const existingFilters = output.args.filters;
|
||||
if (existingFilters === undefined || existingFilters === null) {
|
||||
output.args.filters = {
|
||||
AND: [{ user_id: userId }, { app_id: appId }],
|
||||
};
|
||||
} else if (typeof existingFilters === "object") {
|
||||
const andClauses: any[] = existingFilters.AND;
|
||||
if (Array.isArray(andClauses)) {
|
||||
const hasUid = andClauses.some(
|
||||
(c: any) => c && typeof c === "object" && "user_id" in c,
|
||||
);
|
||||
if (!hasUid) existing.push({ user_id: userId });
|
||||
if (!hasAid) existing.push({ app_id: appId });
|
||||
output.args.filters = { AND: existing };
|
||||
const hasAid = andClauses.some(
|
||||
(c: any) => c && typeof c === "object" && "app_id" in c,
|
||||
);
|
||||
if (!hasUid) andClauses.push({ user_id: userId });
|
||||
if (!hasAid) andClauses.push({ app_id: appId });
|
||||
} else if (andClauses === undefined) {
|
||||
const hasUid = "user_id" in existingFilters;
|
||||
const hasAid = "app_id" in existingFilters;
|
||||
if (!hasUid || !hasAid) {
|
||||
const existing = Object.entries(existingFilters).map(
|
||||
([k, v]) => ({ [k]: v }),
|
||||
);
|
||||
if (!hasUid) existing.push({ user_id: userId });
|
||||
if (!hasAid) existing.push({ app_id: appId });
|
||||
output.args.filters = { AND: existing };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,25 +633,21 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
const errorQuery = errorLine.slice(0, 80);
|
||||
if (errorQuery.length < 10) return;
|
||||
|
||||
const [antiPatternRes, bugFixRes] = await Promise.all([
|
||||
mem0.search(`error: ${errorQuery}`, {
|
||||
filters: {
|
||||
const errorFilters = globalSearch
|
||||
? { OR: [{ user_id: "*" }] }
|
||||
: {
|
||||
AND: [
|
||||
{ user_id: userId },
|
||||
{ app_id: appId },
|
||||
{ metadata: { type: "anti_pattern" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const [antiPatternRes, bugFixRes] = await Promise.all([
|
||||
mem0.search(`error: ${errorQuery}`, {
|
||||
filters: errorFilters,
|
||||
topK: 3,
|
||||
}),
|
||||
mem0.search(`error: ${errorQuery}`, {
|
||||
filters: {
|
||||
AND: [
|
||||
{ user_id: userId },
|
||||
{ app_id: appId },
|
||||
{ metadata: { type: "bug_fix" } },
|
||||
],
|
||||
},
|
||||
filters: errorFilters,
|
||||
topK: 3,
|
||||
}),
|
||||
]);
|
||||
@@ -537,15 +685,22 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
) => {
|
||||
try {
|
||||
const compactSessionId = input?.sessionID ?? sessionId;
|
||||
const summaryContent = `Session compacting. Project: ${appId}. Branch: ${branch}. Session: ${compactSessionId}. Stats: ${stats.adds} memories stored, ${stats.searches} searches, ${stats.messages} messages.`;
|
||||
|
||||
// Session summary capture: store a structured summary of the session
|
||||
const summaryPrompt = [
|
||||
`Session summary for project ${appId} (branch: ${branch}).`,
|
||||
`Session: ${compactSessionId}.`,
|
||||
`Stats: ${stats.adds} memories stored, ${stats.searches} searches, ${stats.messages} messages.`,
|
||||
`Extract and remember: what was requested, what was investigated, key decisions made, what was completed, and what needs to happen next.`,
|
||||
].join(" ");
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
await mem0.add([{ role: "user", content: summaryContent }], {
|
||||
await mem0.add([{ role: "user", content: summaryPrompt }], {
|
||||
user_id: userId,
|
||||
app_id: appId,
|
||||
metadata: {
|
||||
type: "session_state",
|
||||
source: "pre-compaction",
|
||||
type: "session_summary",
|
||||
source: "opencode-stop",
|
||||
session_id: compactSessionId,
|
||||
branch,
|
||||
},
|
||||
@@ -554,8 +709,11 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const compactFilters = globalSearch
|
||||
? { OR: [{ user_id: "*" }] }
|
||||
: { AND: [{ user_id: userId }, { app_id: appId }] };
|
||||
const res = await mem0.search("session state decisions learnings", {
|
||||
filters: { AND: [{ user_id: userId }, { app_id: appId }] },
|
||||
filters: compactFilters,
|
||||
topK: 10,
|
||||
});
|
||||
const memories = extractMemories(res);
|
||||
@@ -577,6 +735,7 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
output.env.MEM0_APP_ID = appId;
|
||||
output.env.MEM0_SESSION_ID = sessionId;
|
||||
output.env.MEM0_BRANCH = branch;
|
||||
output.env.MEM0_GLOBAL_SEARCH = globalSearch ? "true" : "false";
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -128,58 +128,38 @@ If `add_memory` calls fail, print:
|
||||
- Project file import failed. Check API key and MCP connection, then retry with: /mem0:onboard
|
||||
```
|
||||
|
||||
## Step 5: Set up coding categories
|
||||
## Step 5: Verify coding categories
|
||||
|
||||
Ask: "Install coding categories optimized for development workflows? [Y/n]"
|
||||
Coding categories are now configured automatically in the background when the plugin starts. This step only verifies they are set up.
|
||||
|
||||
If the user says no or skips, print `- Coding categories skipped.` and proceed to Step 6.
|
||||
Check if the categories are already configured by searching for a project_profile memory:
|
||||
|
||||
If yes, store a project profile memory that records the project and its active coding categories. Call `add_memory` once with:
|
||||
Call `search_memories` with `query="coding categories project profile"`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`, `top_k=1`.
|
||||
|
||||
- `data`: a plain-text description of the project profile and the list of active coding categories (see below)
|
||||
If a project_profile memory is found, print:
|
||||
```
|
||||
- Coding categories already configured (17 categories, auto-installed).
|
||||
```
|
||||
|
||||
If no project_profile memory is found, store one as a fallback. Call `add_memory` once with:
|
||||
|
||||
- `data`: a plain-text description of the project profile and the list of active coding categories:
|
||||
```
|
||||
Project profile for <project_id>.
|
||||
Active coding categories: architecture_decisions, api_design, data_models,
|
||||
algorithms, dependencies, environment_setup, testing_strategy, debugging_notes,
|
||||
performance, security, deployment, code_conventions, error_handling,
|
||||
refactoring_history, integrations, onboarding, project_meta.
|
||||
```
|
||||
- `user_id`: the active user id
|
||||
- `app_id`: the active project id
|
||||
- `metadata`: `{"type": "project_profile", "source": "onboard"}`
|
||||
|
||||
The standard set of 17 coding categories to include in the `data` field:
|
||||
|
||||
```
|
||||
architecture_decisions - High-level design choices and system structure
|
||||
api_design - Interface contracts, REST/GraphQL/RPC conventions
|
||||
data_models - Schemas, entity definitions, relationships
|
||||
algorithms - Non-trivial logic, performance-sensitive routines
|
||||
dependencies - Libraries, versions, upgrade notes
|
||||
environment_setup - Dev environment, tooling, build system
|
||||
testing_strategy - Test patterns, coverage targets, mocking approach
|
||||
debugging_notes - Known issues, workarounds, gotchas
|
||||
performance - Bottlenecks, profiling results, optimizations
|
||||
security - Auth patterns, secret handling, threat notes
|
||||
deployment - CI/CD pipelines, infra config, release process
|
||||
code_conventions - Naming, formatting, style rules beyond the linter
|
||||
error_handling - Error taxonomy, recovery patterns, logging approach
|
||||
refactoring_history - Past rewrites, why changes were made
|
||||
integrations - Third-party services, webhooks, external APIs
|
||||
onboarding - New-contributor notes, repo orientation
|
||||
project_meta - Goals, non-goals, stakeholder context
|
||||
```
|
||||
|
||||
Example `data` value:
|
||||
|
||||
```
|
||||
Project profile for <project_id>.
|
||||
Active coding categories: architecture_decisions, api_design, data_models,
|
||||
algorithms, dependencies, environment_setup, testing_strategy, debugging_notes,
|
||||
performance, security, deployment, code_conventions, error_handling,
|
||||
refactoring_history, integrations, onboarding, project_meta.
|
||||
```
|
||||
|
||||
After the `add_memory` call succeeds, print:
|
||||
```
|
||||
- Coding categories installed (17 categories).
|
||||
```
|
||||
|
||||
If `add_memory` fails, print the error and suggest re-running `/mem0:onboard`.
|
||||
|
||||
## Step 6: Summary
|
||||
|
||||
Print a summary:
|
||||
|
||||
@@ -1,18 +1,75 @@
|
||||
---
|
||||
name: switch-project
|
||||
description: Overrides the auto-detected project scope to read and write memories under a different project ID. Use when working across multiple projects, accessing memories from another repo, or when auto-detection resolves to the wrong project.
|
||||
description: Overrides the auto-detected project scope to read and write memories under a different project ID, or enables global search to access all memories across all users and projects. Use when working across multiple projects, accessing memories from another repo, enabling team-wide memory access, or when auto-detection resolves to the wrong project.
|
||||
---
|
||||
|
||||
# Mem0 Switch Project
|
||||
|
||||
Override the automatic project_id detection for the current directory.
|
||||
Override the automatic project_id detection for the current directory, or enable global search mode.
|
||||
|
||||
## Usage
|
||||
|
||||
The user provides a project name as an argument: `/mem0:switch-project <project-name>`
|
||||
- `/mem0:switch-project <project-name>` — switch to a specific project scope
|
||||
- `/mem0:switch-project --global` — enable global search (all memories, all users, all projects)
|
||||
- `/mem0:switch-project --no-global` — disable global search and return to per-project scoping
|
||||
|
||||
## Execution
|
||||
|
||||
### If `--global` flag is provided:
|
||||
|
||||
1. Set `global_search: true` in `~/.mem0/settings.json` using the Bash tool:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, os
|
||||
settings_file = os.path.expanduser('~/.mem0/settings.json')
|
||||
settings = {}
|
||||
if os.path.isfile(settings_file):
|
||||
with open(settings_file) as f:
|
||||
settings = json.load(f)
|
||||
settings['global_search'] = True
|
||||
with open(settings_file, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
print('Global search enabled')
|
||||
"
|
||||
```
|
||||
|
||||
2. Print:
|
||||
```
|
||||
Global search enabled.
|
||||
Searches now return all memories across all users and projects.
|
||||
Writes still use the current user_id and app_id.
|
||||
Restart the session for the change to take effect.
|
||||
```
|
||||
|
||||
### If `--no-global` flag is provided:
|
||||
|
||||
1. Set `global_search: false` in `~/.mem0/settings.json` using the Bash tool:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, os
|
||||
settings_file = os.path.expanduser('~/.mem0/settings.json')
|
||||
settings = {}
|
||||
if os.path.isfile(settings_file):
|
||||
with open(settings_file) as f:
|
||||
settings = json.load(f)
|
||||
settings['global_search'] = False
|
||||
with open(settings_file, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
print('Global search disabled')
|
||||
"
|
||||
```
|
||||
|
||||
2. Print:
|
||||
```
|
||||
Global search disabled.
|
||||
Searches now return only memories scoped to the current project.
|
||||
Restart the session for the change to take effect.
|
||||
```
|
||||
|
||||
### If a project name is provided (no flags):
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/opencode-plugin",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -2,17 +2,46 @@
|
||||
|
||||
All notable changes to the Mem0 plugin will be documented in this file.
|
||||
|
||||
## 0.1.0 — OpenCode & Antigravity
|
||||
## 0.2.9 — File-context injection, session summaries & activity timeline
|
||||
|
||||
### Added
|
||||
|
||||
- **File-context injection (`PreToolUse/Read` hook):** Before Claude reads a file, the new `on_file_read.sh` hook searches mem0 for memories that reference that file path and injects a compact timeline of prior work as `additionalContext`. Gives Claude "I've seen this file before and here's what I remember" context automatically. Gates on file size (>= 1,500 bytes), 5-second hard timeout, silent skip on any failure. Applies to Claude Code, Codex, and Cursor (`on_file_read_cursor.sh`).
|
||||
- **Stop hook session summary (`on_stop.sh`):** On session end, parses the transcript JSONL, extracts the last assistant message and files touched, builds a structured prompt, and stores it via the mem0 API with `infer=True` — letting the platform's backend AI extract structured facts (request, decisions, learnings, next steps). Memories are stored as `metadata.type=session_summary` with 90-day expiry. Guards: skips subagent sessions (`agent_id` present), dedup via marker file, always exits 0. Applies to Claude Code, Codex, and Cursor (`on_stop_cursor.sh`).
|
||||
- **SessionStart activity timeline (`session_timeline.py`):** On startup (when project has existing memories), fetches the 10 most recent memories and renders a compact timeline with type icons, age indicators, and short text below the existing banner. Shows recent decisions, bug fixes, and session summaries at a glance. 5-second timeout — if the API is slow, the timeline silently skips while the banner still displays.
|
||||
- **`scripts/file_context.py`:** Core Python module for file-context injection. Searches mem0 cloud API with both relative and absolute file paths, deduplicates results, formats as compact timeline with type icons and memory age.
|
||||
- **`scripts/capture_session_summary.py`:** Core Python module for Stop hook. Reads transcript JSONL (tail 3,000 lines), extracts last assistant message, extracts file paths from tool_input fields, strips system tags and `<private>` blocks, stores via mem0 API.
|
||||
- **`scripts/session_timeline.py`:** Core Python module for SessionStart timeline. Fetches recent memories from mem0 API, formats with type icons, relative age, and short text.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`on_session_start.sh`:** On startup (when memories > 0), calls `session_timeline.py` to inject a compact recent activity timeline below the existing banner and rubric instructions.
|
||||
- **`hooks/hooks.json`:** Added `PreToolUse` matcher for `Read` (5s timeout) and `Stop` hook (30s timeout).
|
||||
- **`hooks/codex-hooks.json`:** Added `PreToolUse` matcher for `Read` (5s timeout) and `Stop` hook (30s timeout).
|
||||
- **`hooks/cursor-hooks.json`:** Added `preToolUse` matcher for `Read` (5s timeout) and `stop` hook (30s timeout).
|
||||
|
||||
## 0.2.8 — Automatic coding categories & global search
|
||||
|
||||
### Added
|
||||
|
||||
- **Global search mode (`global_search` setting):** New `global_search` toggle in `~/.mem0/settings.json` (default: `false`). When enabled, `search_memories` and `get_memories` calls use `{"OR": [{"user_id": "*"}]}` instead of the per-user per-project `AND` filter — returning all memories across all users and all `app_id` scopes in the platform project. Writes (`add_memory`) still tag with the current `user_id` and `app_id`. Solves the team-shared-memory use case where multiple team members need access to all memories regardless of which repo or user created them. Works on Claude Code, Cursor, and Codex.
|
||||
- **`/mem0:switch-project --global` / `--no-global`:** Enables or disables global search via the switch-project skill. Persists to `~/.mem0/settings.json`. No manual config editing needed.
|
||||
- **Session banner scope indicator:** Banner shows `scope=global` when global search is active instead of `project=<app_id>`.
|
||||
- **Global-aware memory count:** Session start memory count query uses the global filter when `global_search` is enabled.
|
||||
- **Background coding-category setup (`scripts/auto_setup_categories.py`):** The coding-focused category taxonomy (17 categories tuned for development work) is now installed automatically in the background on session start — the same way `auto_import.py` imports `CLAUDE.md`/`AGENTS.md`. Users are no longer asked to configure it during onboarding. Mirrors the auto-import design: resolves the API key, holds a lock file (`~/.mem0/categories_setup.lock`), reuses the proven `setup_coding_categories.py` taxonomy + `project.update` path via the plugin venv, logs to stderr only, and always exits 0 so it can never block a session.
|
||||
- **Per-account state gating (`~/.mem0/categories_setup.json`):** Keyed by a hash of the API key → a hash of the taxonomy. Categories are scoped to the mem0 project tied to the API key (not the local repo), so setup runs once per account and skips all network calls thereafter — re-applying only if the taxonomy itself changes.
|
||||
- **`tests/test_auto_setup_categories.py`:** Covers fingerprint determinism/sensitivity, state-file load/save/gating, and idempotent apply via an injected fake client (no SDK, no network).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`on_session_start.sh`:** Spawns `auto_setup_categories.py` in the background on `startup` (alongside `auto_import.py`), preferring the venv python so the SDK is available. Covers Claude Code, Cursor, and Codex, which all route SessionStart through this script.
|
||||
- **`/mem0:onboard` Step 5 is no longer interactive:** Removed the `Install coding categories? [Y/n]` prompt. Categories now configure automatically in the background; the onboarding step only verifies status and applies them if the background run hasn't finished yet — mirroring how Step 4 (project-file import) already works.
|
||||
- **Session-start "new project" hint** now notes that coding categories install automatically in the background.
|
||||
|
||||
## 0.1.0 — Antigravity
|
||||
|
||||
### Added
|
||||
|
||||
- **OpenCode plugin** (`@mem0/opencode-plugin` on npm): Pure TypeScript plugin using the `mem0ai` TS SDK — no Python, no shell scripts. Hooks into all 6 OpenCode events (`chat.message`, `tool.execute.before`, `tool.execute.after`, `experimental.chat.system.transform`, `experimental.session.compacting`, `shell.env`). Features: session start memory loading, per-prompt semantic search, error pattern detection with memory lookup, resume/remember intent detection, auto-capture every 3rd message, periodic save nudges, full metadata defaults injection (confidence, source, type, session_id, files, branch), identity injection for search/get/delete filters, type-filtered error pre-fetch (anti_pattern + bug_fix), pre-compaction memory capture, MEMORY.md write blocking, and secret redaction.
|
||||
- **16 OpenCode-native skills** bundled in `opencode-skills/`: `context-loader`, `dream`, `export`, `forget`, `health`, `import`, `list-projects`, `mem0` (SDK reference), `memory-reviewer`, `onboard`, `peek`, `pin`, `remember`, `stats`, `switch-project`, `tour`. All skills are pure MCP-tool-based — no Python scripts, no shell scripts, no Claude Code dependencies.
|
||||
- **Auto-install skills and commands (`installSkills()`):** On plugin load, copies all 16 skills to `.opencode/skills/` and creates command wrapper files in `.opencode/commands/` so they appear in the OpenCode `/` palette. No manual setup needed.
|
||||
- **`extractUserText()` handler:** Robust text extraction from OpenCode response shapes — handles `parts[]` array, `content[]` array, `message.content`, and plain string responses.
|
||||
- **Identity resolution:** `getUserId()` uses `os.userInfo().username` (matching Claude Code's `${USER}` convention) with `MEM0_USER_ID` env override. `getProjectId()` uses git remote with `MEM0_APP_ID` env override.
|
||||
- **Context injection via `experimental.chat.system.transform`:** All memory context (session start memories, per-prompt search results, error-related memories, compaction context) injected as system context.
|
||||
- **CLI installer (`cli.ts`):** `bunx @mem0/opencode-plugin install` auto-configures plugin and MCP server in `~/.config/opencode/opencode.json`.
|
||||
- **Antigravity plugin** (`.antigravity/`): Restructured to follow the same shared-infrastructure pattern as Claude Code, Cursor, and Codex. Self-contained plugin directory with `plugin.json`, `mcp_config.json`, `hooks/hooks.json` (own file), `scripts/` (symlink → `../scripts/`), and `skills/` (symlink → `../skills/`). Installable via `agy plugin install .antigravity` or `npx degit mem0ai/mem0/mem0-plugin/.antigravity ~/.gemini/config/plugins/mem0`. Uses `contextFileName: "AGENTS.md"` per Antigravity convention.
|
||||
- **Codex hooks parity:** Added missing `PreToolUse` Write/Edit/MultiEdit block and `PreCompact` hook to Codex hooks config, bringing it to full parity with Claude Code.
|
||||
|
||||
|
||||
@@ -268,19 +268,21 @@ Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read
|
||||
|
||||
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
|
||||
|
||||
## Optional: tune categories for coding workflows
|
||||
## Coding-tuned categories (automatic)
|
||||
|
||||
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy:
|
||||
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. **The plugin installs a coding-focused taxonomy automatically in the background on session start** — no prompt, no manual step. New memories then auto-tag against 17 development-oriented categories: `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`.
|
||||
|
||||
The background setup is idempotent and runs once per account (cached in `~/.mem0/categories_setup.json`); it re-applies only if the taxonomy itself changes. To preview the taxonomy or force a refresh manually:
|
||||
|
||||
```bash
|
||||
# Dry-run first -- prints current vs proposed, no changes:
|
||||
# Dry-run -- prints current vs proposed, no changes:
|
||||
python mem0-plugin/scripts/setup_coding_categories.py
|
||||
|
||||
# Actually write:
|
||||
# Write explicitly:
|
||||
python mem0-plugin/scripts/setup_coding_categories.py --apply
|
||||
```
|
||||
|
||||
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
|
||||
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. `project.update(custom_categories=[...])` always replaces the full list.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
|
||||
@@ -51,6 +51,29 @@
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "mem0-file-context",
|
||||
"type": "command",
|
||||
"command": "ANTIGRAVITY_PLUGIN_ROOT=${extensionPath} CLAUDE_PLUGIN_ROOT=${extensionPath} bash ${extensionPath}/scripts/on_file_read.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "mem0-session-summary",
|
||||
"type": "command",
|
||||
"command": "ANTIGRAVITY_PLUGIN_ROOT=${extensionPath} CLAUDE_PLUGIN_ROOT=${extensionPath} bash ${extensionPath}/scripts/on_stop.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${PLUGIN_ROOT}/scripts/on_file_read.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
@@ -68,6 +78,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${PLUGIN_ROOT}/scripts/on_stop.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
|
||||
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories",
|
||||
"timeout": 3
|
||||
},
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_file_read_cursor.sh",
|
||||
"matcher": "Read",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"postToolUse": [
|
||||
@@ -30,6 +35,12 @@
|
||||
"timeout": 12
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh"
|
||||
|
||||
@@ -54,6 +54,16 @@
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_file_read.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -78,6 +88,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_stop.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"name": "mem0",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.",
|
||||
"author": { "name": "Mem0", "email": "support@mem0.ai" },
|
||||
"publisher": "mem0ai",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Shared formatting helpers for mem0 plugin hooks.
|
||||
|
||||
Constants and utilities used by file_context.py, session_timeline.py,
|
||||
and any future hook that displays memories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TYPE_ICONS = {
|
||||
"decision": "⚖️",
|
||||
"anti_pattern": "\U0001f534",
|
||||
"bug_fix": "\U0001f534",
|
||||
"convention": "\U0001f504",
|
||||
"task_learning": "\U0001f535",
|
||||
"user_preference": "\U0001f7e3",
|
||||
"session_summary": "\U0001f4cb",
|
||||
"session_state": "\U0001f4cb",
|
||||
"project_profile": "\U0001f4d6",
|
||||
"compact_summary": "\U0001f4cb",
|
||||
"auto_capture": "✅",
|
||||
"environmental": "🌐",
|
||||
"health_check": "🩺",
|
||||
}
|
||||
|
||||
|
||||
def format_age(memory: dict) -> str:
|
||||
"""Format how long ago a memory was created, e.g. '2h ago', '3d ago'."""
|
||||
created = memory.get("created_at", "")
|
||||
if not created:
|
||||
return ""
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if created.endswith("Z"):
|
||||
created = created[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(created)
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = now - dt
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m ago"
|
||||
if seconds < 86400:
|
||||
return f"{seconds // 3600}h ago"
|
||||
days = seconds // 86400
|
||||
if days == 1:
|
||||
return "1d ago"
|
||||
if days < 30:
|
||||
return f"{days}d ago"
|
||||
return f"{days // 30}mo ago"
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -70,6 +70,7 @@ if command -v python3 >/dev/null 2>&1; then
|
||||
MEM0_SEARCH_LIMIT=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('search_limit',10))" 2>/dev/null || echo "10")
|
||||
MEM0_RETENTION_SESSION_DAYS=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retention_session_days',90))" 2>/dev/null || echo "90")
|
||||
MEM0_CONFIDENCE_THRESHOLD=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('confidence_threshold',0.3))" 2>/dev/null || echo "0.3")
|
||||
MEM0_GLOBAL_SEARCH=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('global_search',False)).lower())" 2>/dev/null || echo "false")
|
||||
MEM0_DEBUG=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('debug',False)).lower())" 2>/dev/null || echo "false")
|
||||
else
|
||||
MEM0_AUTO_SAVE="true"
|
||||
@@ -77,9 +78,10 @@ else
|
||||
MEM0_SEARCH_LIMIT="10"
|
||||
MEM0_RETENTION_SESSION_DAYS="90"
|
||||
MEM0_CONFIDENCE_THRESHOLD="0.3"
|
||||
MEM0_GLOBAL_SEARCH="false"
|
||||
MEM0_DEBUG="false"
|
||||
fi
|
||||
export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_DEBUG
|
||||
export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_GLOBAL_SEARCH MEM0_DEBUG
|
||||
|
||||
# Also resolve project context
|
||||
. "$_SCRIPT_DIR/_project.sh"
|
||||
|
||||
@@ -37,23 +37,28 @@ def search_memories(
|
||||
min_score: float = 0.0,
|
||||
rerank: bool = False,
|
||||
threshold: float = 0.3,
|
||||
global_search: bool = False,
|
||||
) -> list[dict]:
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}]
|
||||
if metadata_type:
|
||||
base_clauses.append({"metadata": {"type": metadata_type}})
|
||||
if metadata_filters:
|
||||
for key, value in metadata_filters.items():
|
||||
base_clauses.append({"metadata": {key: value}})
|
||||
if global_search:
|
||||
filters: dict = {"OR": [{"user_id": "*"}]}
|
||||
else:
|
||||
base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}]
|
||||
if metadata_type:
|
||||
base_clauses.append({"metadata": {"type": metadata_type}})
|
||||
if metadata_filters:
|
||||
for key, value in metadata_filters.items():
|
||||
base_clauses.append({"metadata": {key: value}})
|
||||
filters = {"AND": base_clauses}
|
||||
|
||||
base_payload: dict = {"query": query, "top_k": top_k, "threshold": threshold}
|
||||
if rerank:
|
||||
base_payload["rerank"] = True
|
||||
|
||||
try:
|
||||
payload = {**base_payload, "filters": {"AND": list(base_clauses)}}
|
||||
payload = {**base_payload, "filters": filters}
|
||||
results = _do_search(api_key, payload)[:top_k]
|
||||
|
||||
if min_score > 0:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-configure mem0's coding-category taxonomy in the background.
|
||||
|
||||
Runs from the SessionStart hook (startup only), exactly like auto_import.py.
|
||||
mem0 auto-tags every memory with `categories`; by default that list is
|
||||
consumer-oriented (food, hobbies, ...), which is useless for code. This script
|
||||
replaces it with the coding-focused taxonomy defined in
|
||||
``setup_coding_categories.CODING_CATEGORIES`` so search and retrieval are tuned
|
||||
for development work — without the user ever being asked during onboarding.
|
||||
|
||||
Design (mirrors auto_import.py):
|
||||
- Resolve the API key; do nothing if it is absent.
|
||||
- Gate on a state file (``~/.mem0/categories_setup.json``) keyed by a hash of
|
||||
the API key -> a hash of the taxonomy. Categories are scoped to the mem0
|
||||
*project* tied to the API key (NOT to the local repo), so this only needs to
|
||||
run once per account, and re-runs only if the taxonomy itself changes.
|
||||
- Hold a lock file so concurrent sessions don't race.
|
||||
- Reuse the proven SDK path (``client.project.update``) via the plugin venv.
|
||||
- Always exit 0; log to stderr only. Must never block a session.
|
||||
|
||||
Run with no arguments (background) or in the foreground for onboarding to print
|
||||
a parseable status line.
|
||||
|
||||
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_API_KEY) and the mem0ai SDK,
|
||||
which ensure_deps.sh installs into ${CLAUDE_PLUGIN_DATA}/venv on session start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Importing setup_coding_categories also injects the plugin venv's site-packages
|
||||
# onto sys.path (its module-level bootstrap), so ``from mem0 import MemoryClient``
|
||||
# works even when this script is run with the system python3.
|
||||
from _identity import resolve_api_key # noqa: E402
|
||||
from setup_coding_categories import CODING_CATEGORIES, _categories_match # noqa: E402
|
||||
|
||||
log = logging.getLogger("mem0-auto-categories")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(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-categories] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
STATE_FILE = os.path.expanduser("~/.mem0/categories_setup.json")
|
||||
LOCK_FILE = os.path.expanduser("~/.mem0/categories_setup.lock")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fingerprints #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def categories_fingerprint(categories: list = CODING_CATEGORIES) -> str:
|
||||
"""Stable, order-independent 16-hex digest of the category taxonomy.
|
||||
|
||||
Reordering the categories yields the same fingerprint; adding, removing, or
|
||||
editing a category changes it (so the taxonomy re-applies on upgrade).
|
||||
"""
|
||||
pairs = sorted(
|
||||
(str(key), str(value))
|
||||
for entry in categories
|
||||
if isinstance(entry, dict)
|
||||
for key, value in entry.items()
|
||||
)
|
||||
payload = json.dumps(pairs, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def apikey_fingerprint(api_key: str) -> str:
|
||||
"""Opaque 16-hex digest of the API key. Never stores the key itself."""
|
||||
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# State file #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_state(path: str = STATE_FILE) -> dict:
|
||||
"""Load the apikey-fingerprint -> categories-fingerprint map; {} on any error."""
|
||||
if not os.path.isfile(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state: dict, path: str = STATE_FILE) -> None:
|
||||
"""Persist the state map, creating the parent directory if needed."""
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
try:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
except OSError as e:
|
||||
log.warning("Could not create state dir: %s", e)
|
||||
return
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except OSError as e:
|
||||
log.warning("Could not save categories state: %s", e)
|
||||
|
||||
|
||||
def is_applied(state: dict, key_fp: str, cat_fp: str) -> bool:
|
||||
"""True only when this API key has already had this exact taxonomy applied."""
|
||||
return state.get(key_fp) == cat_fp
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SDK interaction (client injected for testability) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def make_client():
|
||||
"""Construct a MemoryClient. Imported lazily so this module loads without the SDK."""
|
||||
from mem0 import MemoryClient
|
||||
|
||||
return MemoryClient()
|
||||
|
||||
|
||||
def fetch_current_categories(client) -> list | None:
|
||||
"""Return the project's current custom_categories, or None if unavailable."""
|
||||
current = client.project.get(fields=["custom_categories"])
|
||||
if isinstance(current, dict):
|
||||
return current.get("custom_categories")
|
||||
return None
|
||||
|
||||
|
||||
def apply_categories(client, proposed: list = CODING_CATEGORIES) -> str:
|
||||
"""Install the coding taxonomy if it isn't already in place.
|
||||
|
||||
Returns "already-configured" when the project already matches (no write), or
|
||||
"applied" after a successful ``project.update``. Raises on API failure.
|
||||
"""
|
||||
current = fetch_current_categories(client)
|
||||
if _categories_match(current, proposed):
|
||||
return "already-configured"
|
||||
client.project.update(custom_categories=proposed)
|
||||
return "applied"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lock (mirrors auto_import.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _acquire_lock() -> bool:
|
||||
"""Try to acquire a file lock. Returns False if another instance is running."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
||||
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
try:
|
||||
if time.time() - os.path.getmtime(LOCK_FILE) > 120:
|
||||
os.unlink(LOCK_FILE)
|
||||
return _acquire_lock()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _release_lock() -> None:
|
||||
try:
|
||||
os.unlink(LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Entry point #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main() -> None:
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping coding-categories setup")
|
||||
return
|
||||
|
||||
key_fp = apikey_fingerprint(api_key)
|
||||
cat_fp = categories_fingerprint()
|
||||
|
||||
state = load_state()
|
||||
if is_applied(state, key_fp, cat_fp):
|
||||
log.debug("Coding categories already configured for this account (cached); skipping")
|
||||
return
|
||||
|
||||
os.environ["MEM0_API_KEY"] = api_key
|
||||
|
||||
try:
|
||||
client = make_client()
|
||||
except ImportError:
|
||||
log.debug("mem0ai SDK not ready yet (venv installing?); will retry next session")
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning("Could not initialise MemoryClient: %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
result = apply_categories(client)
|
||||
except Exception as e:
|
||||
log.warning("Could not configure coding categories: %s", e)
|
||||
return
|
||||
|
||||
state[key_fp] = cat_fp
|
||||
save_state(state)
|
||||
|
||||
if result == "applied":
|
||||
log.info("Applied %d coding categories", len(CODING_CATEGORIES))
|
||||
else:
|
||||
log.info("Coding categories already configured")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not _acquire_lock():
|
||||
log.debug("Another auto_setup_categories instance is running — skipping")
|
||||
sys.exit(0)
|
||||
try:
|
||||
main()
|
||||
except Exception as e: # never block a session
|
||||
log.error("Unexpected error: %s", e)
|
||||
finally:
|
||||
_release_lock()
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture a structured session summary on Stop hook.
|
||||
|
||||
Runs on every Stop (end of each assistant turn). Each invocation reads
|
||||
the transcript JSONL, extracts the latest assistant message and all
|
||||
files touched so far, then stores via mem0 API with infer=True. Uses
|
||||
run_id=session_id to scope infer dedup to the session, so the final
|
||||
stored summary reflects the most recent turn — not just the first.
|
||||
|
||||
Input: JSON on stdin with transcript_path, session_id, cwd, agent_id
|
||||
Output: stderr logs only (exit 0 always — must not block)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
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
|
||||
|
||||
log = logging.getLogger("mem0-session-summary")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-session-summary] %(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)
|
||||
_fh = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_fh.setFormatter(logging.Formatter("[mem0-session-summary] %(asctime)s %(message)s"))
|
||||
log.addHandler(_fh)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_TAIL_LINES = 3000
|
||||
MAX_SUMMARY_CHARS = 50000
|
||||
SUMMARY_EXPIRY_DAYS = 90
|
||||
|
||||
SYSTEM_TAG_RE = re.compile(
|
||||
r"<(?:system-reminder|private|claude-mem-context|persisted-output|system_instruction)>"
|
||||
r".*?"
|
||||
r"</(?:system-reminder|private|claude-mem-context|persisted-output|system_instruction)>",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
if file_size == 0:
|
||||
return []
|
||||
chunk_size = min(file_size, n * 4096)
|
||||
f.seek(max(0, file_size - chunk_size))
|
||||
data = f.read().decode("utf-8", errors="replace")
|
||||
return data.splitlines()[-n:]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def extract_last_assistant_message(lines: list[str]) -> str:
|
||||
"""Walk transcript backwards, return text content of the last assistant message."""
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if '"type":"assistant"' not in line and '"type": "assistant"' not in line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if entry.get("type") != "assistant":
|
||||
continue
|
||||
message = entry.get("message", {})
|
||||
content = message.get("content", [])
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
return "\n".join(parts).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_files_touched(lines: list[str]) -> list[str]:
|
||||
"""Extract unique file paths from tool_use content blocks in transcript."""
|
||||
files = set()
|
||||
file_ext_re = re.compile(
|
||||
r"[a-zA-Z0-9_./-]+\.(?:py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html)"
|
||||
)
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if '"tool_use"' not in line and '"file_path"' not in line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
content = entry.get("message", {}).get("content", [])
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
||||
continue
|
||||
inp = block.get("input", {})
|
||||
if not isinstance(inp, dict):
|
||||
continue
|
||||
fp = inp.get("file_path", "")
|
||||
if fp:
|
||||
files.add(fp)
|
||||
command = inp.get("command", "")
|
||||
if command:
|
||||
for match in file_ext_re.findall(command):
|
||||
files.add(match)
|
||||
return sorted(files)[:20]
|
||||
|
||||
|
||||
def strip_tags(text: str) -> str:
|
||||
return SYSTEM_TAG_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def build_summary_prompt(assistant_msg: str, files: list[str]) -> str:
|
||||
"""Build a structured prompt that helps mem0's AI extract a good summary."""
|
||||
files_section = ""
|
||||
if files:
|
||||
file_list = ", ".join(files[:10])
|
||||
files_section = f"\n\nFiles touched during this session: {file_list}"
|
||||
|
||||
return (
|
||||
f"Session summary — store the following as a structured session summary.\n\n"
|
||||
f"What the assistant accomplished in this session:\n"
|
||||
f"{assistant_msg[:MAX_SUMMARY_CHARS]}"
|
||||
f"{files_section}\n\n"
|
||||
f"Extract and remember: what was requested, what was investigated, "
|
||||
f"key decisions made, what was completed, and what needs to happen next."
|
||||
)
|
||||
|
||||
|
||||
def store_summary(
|
||||
api_key: str,
|
||||
summary_prompt: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
project_id: str,
|
||||
branch: str,
|
||||
files: list[str],
|
||||
) -> bool:
|
||||
expires = (date.today() + timedelta(days=SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
metadata = {
|
||||
"type": "session_summary",
|
||||
"source": "stop-hook",
|
||||
"session_id": session_id,
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
if files:
|
||||
metadata["files_touched"] = json.dumps(files[:20])
|
||||
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary_prompt}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"run_id": session_id,
|
||||
"metadata": metadata,
|
||||
"infer": True,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
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("Session summary stored")
|
||||
return True
|
||||
log.warning("API returned status %d", resp.status)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
hook_input = json.loads(sys.stdin.read())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
log.debug("No valid JSON on stdin")
|
||||
return
|
||||
|
||||
# Guard: skip subagent sessions (only root sessions get summaries)
|
||||
agent_id = hook_input.get("agent_id", "")
|
||||
if agent_id:
|
||||
log.debug("Subagent session (agent_id=%s), skipping", agent_id)
|
||||
return
|
||||
|
||||
transcript_path = hook_input.get("transcript_path", "")
|
||||
if not transcript_path:
|
||||
log.debug("No transcript_path provided")
|
||||
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:
|
||||
log.debug("Transcript empty or unreadable: %s", transcript_path)
|
||||
return
|
||||
|
||||
assistant_msg = extract_last_assistant_message(lines)
|
||||
if not assistant_msg or len(assistant_msg.strip()) < 100:
|
||||
log.debug("Assistant message too short (%d chars) — skipping", len(assistant_msg.strip()))
|
||||
return
|
||||
|
||||
assistant_msg = strip_tags(assistant_msg)
|
||||
files = extract_files_touched(lines)
|
||||
|
||||
summary_prompt = build_summary_prompt(assistant_msg, files)
|
||||
|
||||
log.info("Capturing session summary (%d chars, %d files)", len(assistant_msg), len(files))
|
||||
store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
@@ -45,6 +45,7 @@ trap 'rm -f "$_PATCH_OUT"' EXIT
|
||||
_MEM0_TOOL_INPUT="$TOOL_INPUT" \
|
||||
_MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \
|
||||
_MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \
|
||||
_MEM0_GLOBAL_SEARCH="${MEM0_GLOBAL_SEARCH:-false}" \
|
||||
_MEM0_HANDLER="$HANDLER" \
|
||||
python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true
|
||||
import json, os, sys
|
||||
@@ -58,6 +59,7 @@ except Exception:
|
||||
handler = os.environ.get("_MEM0_HANDLER", "")
|
||||
resolved_uid = os.environ.get("_MEM0_USER_ID", "")
|
||||
resolved_aid = os.environ.get("_MEM0_APP_ID", "")
|
||||
global_search = os.environ.get("_MEM0_GLOBAL_SEARCH", "false") == "true"
|
||||
changed = False
|
||||
|
||||
|
||||
@@ -177,7 +179,11 @@ if handler == "add_memory":
|
||||
inp["metadata"] = meta
|
||||
|
||||
elif handler in ("search_memories", "get_memories"):
|
||||
changed = inject_filter_identity(inp, resolved_uid, resolved_aid)
|
||||
if global_search:
|
||||
inp["filters"] = {"OR": [{"user_id": "*"}]}
|
||||
changed = True
|
||||
else:
|
||||
changed = inject_filter_identity(inp, resolved_uid, resolved_aid)
|
||||
|
||||
elif handler == "delete_all":
|
||||
changed = inject_top_level_identity(inp, resolved_uid, resolved_aid)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""File-context injection for PreToolUse/Read hook.
|
||||
|
||||
When Claude is about to read a file, this script searches mem0 for
|
||||
memories that reference that file path and returns a compact timeline
|
||||
of prior work. This gives Claude context like "last time you fixed a
|
||||
null pointer here" before it reads the file.
|
||||
|
||||
Modeled after claude-mem's file-context handler but adapted for mem0's
|
||||
cloud API architecture.
|
||||
|
||||
Input: file_path (positional arg), env vars for identity
|
||||
Output: JSON to stdout with hookSpecificOutput.additionalContext
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _formatting import TYPE_ICONS, format_age
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_project_id
|
||||
from _search import search_memories
|
||||
|
||||
FILE_READ_GATE_MIN_BYTES = 1500
|
||||
MAX_RESULTS = 5
|
||||
SEARCH_TIMEOUT = 5
|
||||
|
||||
|
||||
def gate_file(file_path: str, cwd: str) -> str | None:
|
||||
"""Return the resolved absolute path if the file passes gating, else None."""
|
||||
if not file_path:
|
||||
return None
|
||||
p = Path(file_path)
|
||||
if not p.is_absolute():
|
||||
p = Path(cwd) / p
|
||||
try:
|
||||
p = p.resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.stat().st_size < FILE_READ_GATE_MIN_BYTES:
|
||||
return None
|
||||
return str(p)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def relative_path(abs_path: str, cwd: str) -> str:
|
||||
try:
|
||||
return os.path.relpath(abs_path, cwd)
|
||||
except ValueError:
|
||||
return abs_path
|
||||
|
||||
|
||||
def format_timeline(memories: list[dict], file_path: str) -> str:
|
||||
"""Format memories into a compact timeline for context injection."""
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
rel = file_path
|
||||
lines = [
|
||||
f"Prior work on `{rel}` — {len(memories)} memories found.",
|
||||
"Need details? Use `search_memories` with the memory ID.",
|
||||
"",
|
||||
]
|
||||
|
||||
for m in memories:
|
||||
mid = m.get("id", "?")[:8]
|
||||
text = (m.get("memory", "") or "")[:150].replace("\n", " ").strip()
|
||||
meta = m.get("metadata") or {}
|
||||
cat = meta.get("type", "unknown")
|
||||
icon = TYPE_ICONS.get(cat, "❓")
|
||||
age = format_age(m)
|
||||
age_str = f" ({age})" if age else ""
|
||||
lines.append(f"- {icon} [{cat}]{age_str} {text} [mem0:{mid}]")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def search_file_context(
|
||||
api_key: str, user_id: str, project_id: str, file_path: str, cwd: str
|
||||
) -> str:
|
||||
"""Search mem0 for memories related to a file path."""
|
||||
global_search = os.environ.get("MEM0_GLOBAL_SEARCH", "false") == "true"
|
||||
rel = relative_path(file_path, cwd)
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
query = f"{rel} {basename}" if rel != basename else rel
|
||||
results = search_memories(
|
||||
api_key, user_id, project_id, query,
|
||||
top_k=MAX_RESULTS, threshold=0.3,
|
||||
global_search=global_search,
|
||||
)
|
||||
|
||||
results = results[:MAX_RESULTS]
|
||||
|
||||
return format_timeline(results, rel)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(0)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
cwd = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
|
||||
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
sys.exit(0)
|
||||
|
||||
resolved = gate_file(file_path, cwd)
|
||||
if not resolved:
|
||||
sys.exit(0)
|
||||
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id(cwd)
|
||||
|
||||
timeline = search_file_context(api_key, user_id, project_id, resolved, cwd)
|
||||
if not timeline:
|
||||
sys.exit(0)
|
||||
|
||||
print(timeline, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -16,6 +16,7 @@ DEFAULTS = {
|
||||
"search_limit": 10,
|
||||
"retention_session_days": 90,
|
||||
"confidence_threshold": 0.3,
|
||||
"global_search": False,
|
||||
"debug": False,
|
||||
}
|
||||
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Read)
|
||||
#
|
||||
# Injects prior work context before Claude reads a file. Searches mem0
|
||||
# for memories referencing the file path and returns a compact timeline.
|
||||
#
|
||||
# Modeled after claude-mem's file-context handler, adapted for mem0 cloud API.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input (file_path), cwd
|
||||
# Output: JSON with hookSpecificOutput.additionalContext + permissionDecision
|
||||
#
|
||||
# Must never block the Read — silent exit on any failure.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract file path from tool_input
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Resolve API key (covers Desktop app users who set it in shell profile)
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
fi
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
|
||||
|
||||
# Call the Python worker — it handles gating (file size, existence)
|
||||
TIMELINE=$(python3 "$SCRIPT_DIR/file_context.py" "$FILE_PATH" "$CWD" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TIMELINE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Return context injection with permissionDecision: allow
|
||||
jq -cn --arg ctx "$TIMELINE" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx,
|
||||
permissionDecision: "allow"
|
||||
}
|
||||
}' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: preToolUse (matcher: Read) — Cursor variant
|
||||
#
|
||||
# Same as on_file_read.sh but uses CURSOR_PLUGIN_ROOT for path resolution
|
||||
# and sources Cursor-specific identity.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
fi
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
|
||||
|
||||
TIMELINE=$(python3 "$SCRIPT_DIR/file_context.py" "$FILE_PATH" "$CWD" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TIMELINE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg ctx "$TIMELINE" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx,
|
||||
permissionDecision: "allow"
|
||||
}
|
||||
}' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
@@ -76,6 +76,7 @@ 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', '')
|
||||
global_search = os.environ.get('MEM0_GLOBAL_SEARCH', 'false') == 'true'
|
||||
|
||||
def get_count(filters):
|
||||
body = json.dumps({'filters': filters}).encode()
|
||||
@@ -93,8 +94,11 @@ def get_count(filters):
|
||||
return 0
|
||||
|
||||
try:
|
||||
base = [{'user_id': user_id}, {'app_id': app_id}]
|
||||
total = get_count({'AND': base})
|
||||
if global_search:
|
||||
filters = {'OR': [{'user_id': '*'}]}
|
||||
else:
|
||||
filters = {'AND': [{'user_id': user_id}, {'app_id': app_id}]}
|
||||
total = get_count(filters)
|
||||
print(total)
|
||||
except Exception:
|
||||
print('?')
|
||||
@@ -105,21 +109,30 @@ _UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
|
||||
_ANN="${_MEM0_IDENTITY_ANNOTATION:-}"
|
||||
_PID="${MEM0_PROJECT_ID:-unknown}"
|
||||
_BR="${MEM0_BRANCH:-unknown}"
|
||||
_GS="${MEM0_GLOBAL_SEARCH:-false}"
|
||||
|
||||
if [ "$_GS" = "true" ]; then
|
||||
_SCOPE_LABEL="scope=global"
|
||||
_SCOPE_INSTR="Global search is ON — searches return all memories across all users and projects. Writes still use user_id: \`${_UID}\`, app_id: \`${_PID}\`."
|
||||
else
|
||||
_SCOPE_LABEL="project=${_PID}"
|
||||
_SCOPE_INSTR="Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:
|
||||
- user_id: \`${_UID}\`
|
||||
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
|
||||
fi
|
||||
|
||||
cat <<BANNER
|
||||
## Mem0 Active
|
||||
|
||||
\`user=${_UID}${_ANN} | project=${_PID} | branch=${_BR} | memories=${MEM0_COUNT}\`
|
||||
\`user=${_UID}${_ANN} | ${_SCOPE_LABEL} | branch=${_BR} | memories=${MEM0_COUNT}\`
|
||||
|
||||
IMPORTANT: In your FIRST response, display this exact status line as your opening line:
|
||||
|
||||
\`\`\`
|
||||
Mem0 Active | user=${_UID}${_ANN} | project=${_PID} | branch=${_BR} | memories=${MEM0_COUNT}
|
||||
Mem0 Active | user=${_UID}${_ANN} | ${_SCOPE_LABEL} | branch=${_BR} | memories=${MEM0_COUNT}
|
||||
\`\`\`
|
||||
|
||||
Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:
|
||||
- user_id: \`${_UID}\`
|
||||
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)
|
||||
${_SCOPE_INSTR}
|
||||
|
||||
After completing any task, decision, or meaningful exchange, proactively store learnings via \`add_memory\`. Do NOT wait until the session ends — store memories incrementally as work progresses. Focus on: decisions made, bugs fixed, patterns discovered, user preferences, or task outcomes. Aim for 1–3 memories per substantial interaction.
|
||||
|
||||
@@ -140,9 +153,17 @@ fi
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
if [ "$MEM0_COUNT" = "0" ]; then
|
||||
echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files and install coding categories."
|
||||
echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files. Coding categories install automatically in the background."
|
||||
else
|
||||
echo "Search mem0 for recent decisions and task learnings before responding. Run 2 parallel searches: one for decision type, one for task_learning type."
|
||||
|
||||
# Inject compact recent activity timeline (non-blocking, 5s timeout)
|
||||
# Use perl alarm as portable timeout (macOS lacks GNU timeout)
|
||||
_TIMELINE=$(MEM0_CWD="$MEM0_CWD_RESOLVED" perl -e 'alarm 5; exec @ARGV' python3 "$SCRIPT_DIR/session_timeline.py" 2>/dev/null || echo "")
|
||||
if [ -n "$_TIMELINE" ]; then
|
||||
echo ""
|
||||
echo "$_TIMELINE"
|
||||
fi
|
||||
fi
|
||||
|
||||
_PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-')
|
||||
@@ -154,12 +175,23 @@ if [ "$SOURCE" = "startup" ]; then
|
||||
MEM0_CWD="$MEM0_CWD_RESOLVED" \
|
||||
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
||||
|
||||
# Configure the coding-category taxonomy in the background (idempotent, never blocks).
|
||||
# Prefer the venv python since this path needs the mem0ai SDK.
|
||||
_VENV_PY="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/venv/bin/python3"
|
||||
if [ -x "$_VENV_PY" ]; then
|
||||
MEM0_CWD="$MEM0_CWD_RESOLVED" "$_VENV_PY" "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null &
|
||||
else
|
||||
MEM0_CWD="$MEM0_CWD_RESOLVED" python3 "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null &
|
||||
fi
|
||||
|
||||
elif [ "$SOURCE" = "resume" ]; then
|
||||
echo "Session resumed. Search mem0 for session_state and decision memories to pick up where you left off. Run 2 parallel searches."
|
||||
|
||||
elif [ "$SOURCE" = "compact" ]; then
|
||||
echo "Context compacted. Search mem0 for session_state and decision memories to recover context. Run 2 parallel searches."
|
||||
printf '%s' "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
|
||||
if [ "${MEM0_AUTO_SAVE:-true}" != "false" ]; then
|
||||
printf '%s' "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
|
||||
fi
|
||||
fi
|
||||
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
|
||||
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop
|
||||
#
|
||||
# Captures a structured session summary when a Claude Code session ends.
|
||||
# Parses the transcript, extracts the last assistant message and files
|
||||
# touched, then stores via mem0 API with infer=True for AI extraction.
|
||||
#
|
||||
# Guards:
|
||||
# - Skips subagent sessions (agent_id present)
|
||||
# - Skips if no API key
|
||||
# - Skips if no transcript_path
|
||||
# - Dedup via marker file
|
||||
#
|
||||
# Input: JSON on stdin with transcript_path, session_id, agent_id, cwd
|
||||
# Output: Nothing to stdout (background capture). Always exits 0.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# Guard: skip subagent sessions
|
||||
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // ""' 2>/dev/null || echo "")
|
||||
if [ -n "$AGENT_ID" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve identity if needed
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
# Honor auto_save=false in ~/.mem0/settings.json
|
||||
if [ "${MEM0_AUTO_SAVE:-true}" = "false" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$TRANSCRIPT_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run capture in the background — fires every turn now, so avoid blocking
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_session_summary.py" 2>/dev/null &
|
||||
|
||||
# Telemetry
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_stop 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: stop — Cursor variant
|
||||
#
|
||||
# Same as on_stop.sh but uses CURSOR_PLUGIN_ROOT and Cursor-specific
|
||||
# identity resolution.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // ""' 2>/dev/null || echo "")
|
||||
if [ -n "$AGENT_ID" ]; 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
|
||||
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$TRANSCRIPT_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_session_summary.py" 2>/dev/null || true
|
||||
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_stop 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -174,7 +174,7 @@ fi
|
||||
# overlapping window ensures the next batch (MSG_COUNT=6) picks up the
|
||||
# exchange that was incomplete in the previous batch.
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
|
||||
if [ $((MSG_COUNT % 3)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ] && [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
if [ "${MEM0_AUTO_SAVE:-true}" != "false" ] && [ $((MSG_COUNT % 3)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ] && [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
python3 "$SCRIPT_DIR/auto_capture.py" "$TRANSCRIPT_PATH" 2>/dev/null &
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch recent memories and format a compact timeline for SessionStart.
|
||||
|
||||
Searches mem0 cloud API for the most recent memories in the project
|
||||
and formats them as a compact activity timeline injected below the
|
||||
existing SessionStart banner.
|
||||
|
||||
Input: env vars for identity (MEM0_API_KEY, MEM0_RESOLVED_USER_ID, etc.)
|
||||
Output: Compact timeline text to stdout (empty if nothing found)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _formatting import TYPE_ICONS, format_age
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_project_id
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_RECENT = 10
|
||||
MAX_SUMMARIES = 3
|
||||
FETCH_TIMEOUT = 5
|
||||
|
||||
|
||||
def fetch_recent_memories(api_key: str, user_id: str, project_id: str) -> list[dict]:
|
||||
"""Fetch the most recent memories for this project via GET list endpoint."""
|
||||
global_search = os.environ.get("MEM0_GLOBAL_SEARCH", "false") == "true"
|
||||
|
||||
if global_search:
|
||||
filters = {"OR": [{"user_id": "*"}]}
|
||||
else:
|
||||
filters = {"AND": [{"user_id": user_id}, {"app_id": project_id}]}
|
||||
|
||||
body = json.dumps({"filters": filters}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/?page=1&page_size={MAX_RECENT}",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as r:
|
||||
result = json.loads(r.read())
|
||||
if isinstance(result, dict) and "results" in result:
|
||||
return result["results"][:MAX_RECENT]
|
||||
if isinstance(result, list):
|
||||
return result[:MAX_RECENT]
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def format_timeline(memories: list[dict]) -> str:
|
||||
"""Format memories into a compact recent activity timeline."""
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["### Recent Activity", ""]
|
||||
|
||||
for m in memories:
|
||||
mid = m.get("id", "?")[:8]
|
||||
text = (m.get("memory", "") or "")[:120].replace("\n", " ").strip()
|
||||
meta = m.get("metadata") or {}
|
||||
cat = meta.get("type", "unknown")
|
||||
icon = TYPE_ICONS.get(cat, "❓")
|
||||
age = format_age(m)
|
||||
age_str = f" ({age})" if age else ""
|
||||
lines.append(f"- {icon} [{cat}]{age_str} {text} [mem0:{mid}]")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Search mem0 for details on any of these, or for past decisions and task learnings relevant to the current task.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
return
|
||||
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id(os.environ.get("MEM0_CWD"))
|
||||
|
||||
memories = fetch_recent_memories(api_key, user_id, project_id)
|
||||
if not memories:
|
||||
return
|
||||
|
||||
timeline = format_timeline(memories)
|
||||
if timeline:
|
||||
print(timeline, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -141,29 +141,27 @@ Parse the auto_import output and print a user-friendly summary:
|
||||
- Project file import failed. Check API key and retry with: /mem0:onboard
|
||||
```
|
||||
|
||||
## Step 5: Install coding categories
|
||||
## Step 5: Coding categories (automatic)
|
||||
|
||||
The setup script is idempotent — it compares existing categories against the proposed set and skips the API call if they already match (tolerates order differences and extra API fields). Safe to re-run.
|
||||
Coding categories optimized for development workflows are installed **automatically in the background on session start** — the same way project files are imported (Step 4). The user is no longer asked. This step only verifies they are in place and applies them if the background run hasn't finished yet.
|
||||
|
||||
Ask: "Install coding categories optimized for development workflows? [Y/n]"
|
||||
The installer is idempotent and self-caching: it compares existing categories against the proposed set, skips the API call when they already match, and skips all network calls entirely once applied for this account (re-applying only if the taxonomy changes). Safe to run anytime.
|
||||
|
||||
If yes, run the setup script using the plugin's venv python:
|
||||
Run it in the foreground to verify, 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
|
||||
MEM0_DEBUG=1 "${VENV_PY}" "${CLAUDE_PLUGIN_ROOT}/scripts/auto_setup_categories.py"
|
||||
else
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
|
||||
MEM0_DEBUG=1 python3 "${CLAUDE_PLUGIN_ROOT}/scripts/auto_setup_categories.py"
|
||||
fi
|
||||
```
|
||||
|
||||
Parse the output:
|
||||
- `"Categories already match -- skipping update."` → Print: `- Coding categories already installed. Skipped.`
|
||||
- `"Done."` → Print: `- Coding categories installed (<N> categories).`
|
||||
- Error → Print the error and suggest re-running `/mem0:onboard`.
|
||||
|
||||
If the script fails with "mem0ai SDK not found", run the dependency installer first:
|
||||
- contains `Applied <N> coding categories` → Print: `- Coding categories installed (<N> categories).`
|
||||
- contains `already configured` → Print: `- Coding categories already configured.`
|
||||
- error or `SDK not ready` → run the dependency installer first, then retry:
|
||||
```bash
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
|
||||
```
|
||||
@@ -177,7 +175,7 @@ Print a summary:
|
||||
user_id: <user_id>
|
||||
project_id: <project_id> (app_id)
|
||||
files: <N> found, <M> imported
|
||||
categories: <N installed | already installed | skipped by user>
|
||||
categories: <installed | already configured>
|
||||
|
||||
Memory is now active for this project. Start working — mem0 will
|
||||
automatically search relevant context and capture learnings.
|
||||
|
||||
@@ -1,18 +1,75 @@
|
||||
---
|
||||
name: switch-project
|
||||
description: Overrides the auto-detected project scope to read and write memories under a different project ID. Use when working across multiple projects, accessing memories from another repo, or when auto-detection resolves to the wrong project.
|
||||
description: Overrides the auto-detected project scope to read and write memories under a different project ID, or enables global search to access all memories across all users and projects. Use when working across multiple projects, accessing memories from another repo, enabling team-wide memory access, or when auto-detection resolves to the wrong project.
|
||||
---
|
||||
|
||||
# Mem0 Switch Project
|
||||
|
||||
Override the automatic project_id detection for the current directory.
|
||||
Override the automatic project_id detection for the current directory, or enable global search mode.
|
||||
|
||||
## Usage
|
||||
|
||||
The user provides a project name as an argument: `/mem0:switch-project <project-name>`
|
||||
- `/mem0:switch-project <project-name>` — switch to a specific project scope
|
||||
- `/mem0:switch-project --global` — enable global search (all memories, all users, all projects)
|
||||
- `/mem0:switch-project --no-global` — disable global search and return to per-project scoping
|
||||
|
||||
## Execution
|
||||
|
||||
### If `--global` flag is provided:
|
||||
|
||||
1. Set `global_search: true` in `~/.mem0/settings.json` using the Bash tool:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, os
|
||||
settings_file = os.path.expanduser('~/.mem0/settings.json')
|
||||
settings = {}
|
||||
if os.path.isfile(settings_file):
|
||||
with open(settings_file) as f:
|
||||
settings = json.load(f)
|
||||
settings['global_search'] = True
|
||||
with open(settings_file, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
print('Global search enabled')
|
||||
"
|
||||
```
|
||||
|
||||
2. Print:
|
||||
```
|
||||
Global search enabled.
|
||||
Searches now return all memories across all users and projects.
|
||||
Writes still use the current user_id and app_id.
|
||||
Restart the session for the change to take effect.
|
||||
```
|
||||
|
||||
### If `--no-global` flag is provided:
|
||||
|
||||
1. Set `global_search: false` in `~/.mem0/settings.json` using the Bash tool:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, os
|
||||
settings_file = os.path.expanduser('~/.mem0/settings.json')
|
||||
settings = {}
|
||||
if os.path.isfile(settings_file):
|
||||
with open(settings_file) as f:
|
||||
settings = json.load(f)
|
||||
settings['global_search'] = False
|
||||
with open(settings_file, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
print('Global search disabled')
|
||||
"
|
||||
```
|
||||
|
||||
2. Print:
|
||||
```
|
||||
Global search disabled.
|
||||
Searches now return only memories scoped to the current project.
|
||||
Restart the session for the change to take effect.
|
||||
```
|
||||
|
||||
### If a project name is provided (no flags):
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for auto_setup_categories.py -- the background coding-categories installer.
|
||||
|
||||
Covers the pure, network-free logic:
|
||||
- fingerprints (api key + category taxonomy)
|
||||
- state-file gating (load / save / is_applied)
|
||||
- idempotent apply via an injected fake client (no SDK, no network)
|
||||
- single source of truth for the category list (shared with setup_coding_categories)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
if SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS_DIR)
|
||||
|
||||
import auto_setup_categories as asc # noqa: E402
|
||||
from setup_coding_categories import CODING_CATEGORIES # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fake client (dependency injection — apply_categories takes a client) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _FakeProject:
|
||||
def __init__(self, current):
|
||||
self._current = current
|
||||
self.update_calls: list = []
|
||||
|
||||
def get(self, fields=None):
|
||||
return {"custom_categories": self._current}
|
||||
|
||||
def update(self, custom_categories=None, **kwargs):
|
||||
self.update_calls.append(custom_categories)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, current):
|
||||
self.project = _FakeProject(current)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Source-of-truth: the taxonomy is shared, not duplicated #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_uses_shared_category_list():
|
||||
"""auto_setup_categories must reuse setup_coding_categories' list, not fork it."""
|
||||
assert asc.CODING_CATEGORIES is CODING_CATEGORIES
|
||||
assert len(asc.CODING_CATEGORIES) == 17
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fingerprints #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_categories_fingerprint_is_stable_hex():
|
||||
fp = asc.categories_fingerprint(CODING_CATEGORIES)
|
||||
assert isinstance(fp, str)
|
||||
assert len(fp) == 16
|
||||
assert all(c in "0123456789abcdef" for c in fp)
|
||||
# deterministic across calls
|
||||
assert fp == asc.categories_fingerprint(CODING_CATEGORIES)
|
||||
|
||||
|
||||
def test_categories_fingerprint_changes_when_taxonomy_changes():
|
||||
base = asc.categories_fingerprint(CODING_CATEGORIES)
|
||||
changed = asc.categories_fingerprint(CODING_CATEGORIES + [{"new_one": "desc"}])
|
||||
assert base != changed
|
||||
|
||||
|
||||
def test_categories_fingerprint_order_independent():
|
||||
"""Reordering the same categories must yield the same fingerprint."""
|
||||
reordered = list(reversed(CODING_CATEGORIES))
|
||||
assert asc.categories_fingerprint(CODING_CATEGORIES) == asc.categories_fingerprint(reordered)
|
||||
|
||||
|
||||
def test_apikey_fingerprint_is_stable_and_opaque():
|
||||
key = "m0-supersecret-abc123"
|
||||
fp = asc.apikey_fingerprint(key)
|
||||
assert isinstance(fp, str)
|
||||
assert len(fp) == 16
|
||||
assert fp == asc.apikey_fingerprint(key)
|
||||
# never leak the raw key
|
||||
assert "supersecret" not in fp
|
||||
assert key not in fp
|
||||
|
||||
|
||||
def test_apikey_fingerprint_differs_per_key():
|
||||
assert asc.apikey_fingerprint("m0-aaa") != asc.apikey_fingerprint("m0-bbb")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# State file: load / save / gating #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_load_state_missing_file_returns_empty(tmp_path):
|
||||
assert asc.load_state(str(tmp_path / "does_not_exist.json")) == {}
|
||||
|
||||
|
||||
def test_load_state_corrupt_file_returns_empty(tmp_path):
|
||||
p = tmp_path / "categories_setup.json"
|
||||
p.write_text("{not valid json")
|
||||
assert asc.load_state(str(p)) == {}
|
||||
|
||||
|
||||
def test_save_then_load_roundtrip_creates_parent_dir(tmp_path):
|
||||
p = tmp_path / "nested" / "categories_setup.json"
|
||||
asc.save_state({"abc123": "deadbeef00000000"}, str(p))
|
||||
assert p.is_file()
|
||||
assert asc.load_state(str(p)) == {"abc123": "deadbeef00000000"}
|
||||
|
||||
|
||||
def test_is_applied_true_only_on_exact_match():
|
||||
state = {"keyfp": "catfp"}
|
||||
assert asc.is_applied(state, "keyfp", "catfp") is True
|
||||
# stale taxonomy fingerprint
|
||||
assert asc.is_applied(state, "keyfp", "OTHER") is False
|
||||
# unknown api key
|
||||
assert asc.is_applied(state, "other-key", "catfp") is False
|
||||
# empty state
|
||||
assert asc.is_applied({}, "keyfp", "catfp") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# apply_categories: idempotent, network-free via fake client #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_apply_skips_update_when_already_matching():
|
||||
client = _FakeClient(CODING_CATEGORIES)
|
||||
result = asc.apply_categories(client, CODING_CATEGORIES)
|
||||
assert result == "already-configured"
|
||||
assert client.project.update_calls == [] # must NOT hit the write endpoint
|
||||
|
||||
|
||||
def test_apply_updates_when_no_categories_set():
|
||||
client = _FakeClient(None)
|
||||
result = asc.apply_categories(client, CODING_CATEGORIES)
|
||||
assert result == "applied"
|
||||
assert client.project.update_calls == [CODING_CATEGORIES]
|
||||
|
||||
|
||||
def test_apply_updates_when_categories_differ():
|
||||
client = _FakeClient([{"food": "consumer default"}])
|
||||
result = asc.apply_categories(client, CODING_CATEGORIES)
|
||||
assert result == "applied"
|
||||
assert client.project.update_calls == [CODING_CATEGORIES]
|
||||
|
||||
|
||||
def test_fetch_current_categories_handles_non_dict():
|
||||
"""A non-dict project response must degrade to None, not raise."""
|
||||
|
||||
class Weird:
|
||||
project = type("P", (), {"get": staticmethod(lambda fields=None: "unexpected")})()
|
||||
|
||||
assert asc.fetch_current_categories(Weird()) is None
|
||||
+18
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.7",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -100,7 +100,7 @@
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.16.0",
|
||||
"openai": "^4.93.0",
|
||||
"uuid": "9.0.1",
|
||||
"zod": "^3.24.1"
|
||||
@@ -110,10 +110,10 @@
|
||||
"@azure/identity": "^4.0.0",
|
||||
"@azure/search-documents": "^12.0.0",
|
||||
"@cloudflare/workers-types": "^4.20250504.0",
|
||||
"@google/genai": "^1.2.0",
|
||||
"@google/genai": "^1.40.0",
|
||||
"@langchain/core": "^1.1.47",
|
||||
"@mistralai/mistralai": "^1.5.2",
|
||||
"@qdrant/js-client-rest": "1.13.0",
|
||||
"@qdrant/js-client-rest": "^1.18.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/pg": "8.11.0",
|
||||
@@ -137,6 +137,19 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"better-sqlite3"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"picomatch@<2.3.2": "^2.3.2",
|
||||
"jws@4.0.0": "4.0.1",
|
||||
"langsmith@<0.6.0": "^0.6.0",
|
||||
"minimatch@<3.1.3": "^3.1.3",
|
||||
"minimatch@>=5.0.0 <5.1.8": "^5.1.8",
|
||||
"minimatch@>=9.0.0 <9.0.7": "^9.0.7",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": "^8.4.0",
|
||||
"rollup@>=4.0.0 <4.59.0": "^4.59.0",
|
||||
"tar-fs@>=2.0.0 <2.1.4": "^2.1.4",
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+366
-741
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
packages:
|
||||
- "."
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- better-sqlite3
|
||||
|
||||
overrides:
|
||||
"picomatch@<2.3.2": "^2.3.2"
|
||||
"jws@4.0.0": "4.0.1"
|
||||
"langsmith@<0.6.0": "^0.6.0"
|
||||
"minimatch@<3.1.3": "^3.1.3"
|
||||
"minimatch@>=5.0.0 <5.1.8": "^5.1.8"
|
||||
"minimatch@>=9.0.0 <9.0.7": "^9.0.7"
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": "^8.4.0"
|
||||
"rollup@>=4.0.0 <4.59.0": "^4.59.0"
|
||||
"tar-fs@>=2.0.0 <2.1.4": "^2.1.4"
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0"
|
||||
"@modelcontextprotocol/sdk": "^1.25.4"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/community",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Community features for Mem0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -77,7 +77,7 @@
|
||||
"dependencies": {
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/core": "^0.3.42",
|
||||
"axios": "1.7.7",
|
||||
"axios": "^1.16.0",
|
||||
"mem0ai": "^2.1.8",
|
||||
"uuid": "9.0.1",
|
||||
"zod": "3.22.4"
|
||||
|
||||
@@ -44,7 +44,9 @@ export class LMStudioEmbedder implements Embedder {
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data.map((item) => item.embedding);
|
||||
return response.data
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((item) => item.embedding);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
|
||||
@@ -20,6 +20,7 @@ export class OpenAIEmbedder implements Embedder {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: text,
|
||||
encoding_format: "float",
|
||||
...(this.embeddingDims !== undefined && {
|
||||
dimensions: this.embeddingDims,
|
||||
}),
|
||||
@@ -35,6 +36,7 @@ export class OpenAIEmbedder implements Embedder {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: chunk,
|
||||
encoding_format: "float",
|
||||
...(this.embeddingDims !== undefined && {
|
||||
dimensions: this.embeddingDims,
|
||||
}),
|
||||
|
||||
@@ -1064,7 +1064,7 @@ export class Memory {
|
||||
: {};
|
||||
|
||||
await this._ensureInitialized();
|
||||
const { topK = 20, threshold = 0.1 } = config;
|
||||
const { topK = 20, threshold = 0.1, explain = false } = config;
|
||||
|
||||
await this._captureEvent("search", {
|
||||
query_length: query.length,
|
||||
@@ -1169,17 +1169,35 @@ export class Memory {
|
||||
|
||||
if (deduped.length > 0) {
|
||||
const entityStore = await this.getEntityStore();
|
||||
const entitySearchFilters: Record<string, any> = {};
|
||||
for (const k of ["user_id", "agent_id", "run_id"] as const) {
|
||||
if (effectiveFilters[k])
|
||||
entitySearchFilters[k] = effectiveFilters[k];
|
||||
}
|
||||
const entityTexts = deduped.map((e) => e.text);
|
||||
const embeddings = await this.embedder.embedBatch(entityTexts);
|
||||
|
||||
for (const entity of deduped) {
|
||||
try {
|
||||
const entityEmbedding = await this.embedder.embed(entity.text);
|
||||
const matches = await entityStore.search(
|
||||
entityEmbedding,
|
||||
500,
|
||||
effectiveFilters,
|
||||
);
|
||||
if (embeddings.length !== entityTexts.length) {
|
||||
console.warn(
|
||||
`embedBatch returned ${embeddings.length} vectors for ${entityTexts.length} texts — skipping entity boost`,
|
||||
);
|
||||
} else {
|
||||
const searchResults = await Promise.allSettled(
|
||||
deduped.map((_, i) =>
|
||||
entityStore.search(embeddings[i], 500, entitySearchFilters),
|
||||
),
|
||||
);
|
||||
|
||||
for (const match of matches) {
|
||||
for (const result of searchResults) {
|
||||
if (result.status === "rejected") {
|
||||
console.warn(
|
||||
"Entity boost search failed for one entity:",
|
||||
result.reason,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const match of result.value) {
|
||||
const similarity = match.score ?? 0;
|
||||
if (similarity < 0.5) continue;
|
||||
|
||||
@@ -1187,7 +1205,6 @@ export class Memory {
|
||||
const linkedMemoryIds = payload.linkedMemoryIds ?? [];
|
||||
if (!Array.isArray(linkedMemoryIds)) continue;
|
||||
|
||||
// Spread-attenuated boost
|
||||
const numLinked = Math.max(linkedMemoryIds.length, 1);
|
||||
const memoryCountWeight =
|
||||
1.0 / (1.0 + 0.001 * (numLinked - 1) ** 2);
|
||||
@@ -1204,8 +1221,6 @@ export class Memory {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Individual entity boost failed — continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1228,6 +1243,7 @@ export class Memory {
|
||||
entityBoosts,
|
||||
threshold ?? 0.1,
|
||||
topK,
|
||||
explain,
|
||||
);
|
||||
|
||||
// Step 9: Format results
|
||||
@@ -1260,6 +1276,7 @@ export class Memory {
|
||||
...(payload.user_id && { user_id: payload.user_id }),
|
||||
...(payload.agent_id && { agent_id: payload.agent_id }),
|
||||
...(payload.run_id && { run_id: payload.run_id }),
|
||||
...(scored.scoreDetails && { score_details: scored.scoreDetails }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SearchMemoryOptions {
|
||||
topK?: number;
|
||||
filters?: SearchFilters;
|
||||
threshold?: number;
|
||||
explain?: boolean;
|
||||
}
|
||||
|
||||
export interface GetAllMemoryOptions {
|
||||
|
||||
@@ -56,10 +56,21 @@ export function normalizeBm25(
|
||||
return 1.0 / (1.0 + Math.exp(-steepness * (rawScore - midpoint)));
|
||||
}
|
||||
|
||||
export interface ScoreDetails {
|
||||
semanticScore: number;
|
||||
bm25Score: number;
|
||||
entityBoost: number;
|
||||
rawScore: number;
|
||||
maxPossibleScore: number;
|
||||
finalScore: number;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface ScoredResult {
|
||||
id: string;
|
||||
score: number;
|
||||
payload: Record<string, any>;
|
||||
scoreDetails?: ScoreDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +93,7 @@ export interface ScoredResult {
|
||||
* @param entityBoosts - Map of memory ID to entity boost score.
|
||||
* @param threshold - Minimum semantic score to include a candidate.
|
||||
* @param topK - Maximum number of results to return.
|
||||
* @param explain - Include scoreDetails in each result when true.
|
||||
* @returns Sorted list of scored results, highest score first.
|
||||
*/
|
||||
export function scoreAndRank(
|
||||
@@ -94,6 +106,7 @@ export function scoreAndRank(
|
||||
entityBoosts: Record<string, number>,
|
||||
threshold: number,
|
||||
topK: number,
|
||||
explain: boolean = false,
|
||||
): ScoredResult[] {
|
||||
const hasBm25 = Object.keys(bm25Scores).length > 0;
|
||||
const hasEntity = Object.keys(entityBoosts).length > 0;
|
||||
@@ -126,11 +139,23 @@ export function scoreAndRank(
|
||||
const rawCombined = semanticScore + bm25Score + entityBoost;
|
||||
const combined = Math.min(rawCombined / maxPossible, 1.0);
|
||||
|
||||
scored.push({
|
||||
const entry: ScoredResult = {
|
||||
id: memIdStr,
|
||||
score: combined,
|
||||
payload: result.payload,
|
||||
});
|
||||
};
|
||||
if (explain) {
|
||||
entry.scoreDetails = {
|
||||
semanticScore,
|
||||
bm25Score,
|
||||
entityBoost,
|
||||
rawScore: rawCombined,
|
||||
maxPossibleScore: maxPossible,
|
||||
finalScore: combined,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
scored.push(entry);
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
@@ -444,7 +444,7 @@ export class RedisDB implements VectorStore {
|
||||
return {
|
||||
id: doc.value.memory_id,
|
||||
payload: toCamelCase(resultPayload),
|
||||
score: Number(doc.value.__vector_score) ?? 0,
|
||||
score: Math.max(0, 1 - (Number(doc.value.__vector_score) ?? 0)),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Entity boost parallelism tests (#5214).
|
||||
*
|
||||
* Verifies that entity boost searches run concurrently via Promise.allSettled,
|
||||
* scoring is preserved, and individual entity failures don't abort others.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import { ENTITY_BOOST_WEIGHT } from "../src/utils/scoring";
|
||||
import type { VectorStoreResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
memory: [{ id: "0", text: "fact", attributed_to: "user" }],
|
||||
}),
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockEmbedding = new Array(1536).fill(0.1);
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
||||
embedBatch: jest
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => mockEmbedding)),
|
||||
),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function makeMatch(
|
||||
id: string,
|
||||
score: number,
|
||||
linkedMemoryIds: string[],
|
||||
): VectorStoreResult {
|
||||
return { id, score, payload: { linkedMemoryIds } };
|
||||
}
|
||||
|
||||
function createMemory(): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-entity-${Date.now()}-${Math.random()}`,
|
||||
dimension: 1536,
|
||||
dbPath: ":memory:",
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-5-mini" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Entity boost parallelism (#5214)", () => {
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
it("should use Promise.allSettled for concurrent entity searches", async () => {
|
||||
// Spy on Promise.allSettled to confirm it's being used
|
||||
const allSettledSpy = jest.spyOn(Promise, "allSettled");
|
||||
|
||||
// Access internals to inject a mock entity store
|
||||
const m = memory as any;
|
||||
await m._ensureInitialized();
|
||||
|
||||
const mockEntityStore = {
|
||||
search: jest.fn().mockResolvedValue([makeMatch("e1", 0.9, ["mem-1"])]),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
m._entityStore = mockEntityStore;
|
||||
|
||||
m.embedder = {
|
||||
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
||||
embedBatch: jest
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => mockEmbedding)),
|
||||
),
|
||||
};
|
||||
|
||||
// Mock the vector store to return a semantic result
|
||||
m.vectorStore.search = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: "mem-1", score: 0.8, payload: { data: "test" } },
|
||||
]);
|
||||
m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await m.search("alice and bob", { filters: { user_id: "u1" } });
|
||||
|
||||
expect(allSettledSpy).toHaveBeenCalled();
|
||||
allSettledSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should preserve scoring math with parallel execution", async () => {
|
||||
const m = memory as any;
|
||||
await m._ensureInitialized();
|
||||
|
||||
// Two entities: "alice" links to mem-1, "bob" links to mem-1 and mem-2
|
||||
// mem-1 should get max(alice_boost, bob_boost)
|
||||
const mockEntityStore = {
|
||||
search: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(_embedding: number[], _topK: number, _filters: any) => {
|
||||
// We need to differentiate by embedding content — but since all
|
||||
// embeddings are identical mocks, we'll use call order
|
||||
const callCount = mockEntityStore.search.mock.calls.length;
|
||||
if (callCount <= 1) {
|
||||
// First entity: "alice"
|
||||
return Promise.resolve([makeMatch("e-alice", 0.9, ["mem-1"])]);
|
||||
}
|
||||
// Second entity: "bob"
|
||||
return Promise.resolve([
|
||||
makeMatch("e-bob", 0.6, ["mem-1", "mem-2"]),
|
||||
]);
|
||||
},
|
||||
),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
m._entityStore = mockEntityStore;
|
||||
m.embedder = {
|
||||
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
||||
embedBatch: jest
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => mockEmbedding)),
|
||||
),
|
||||
};
|
||||
|
||||
// Semantic results include mem-1 and mem-2
|
||||
m.vectorStore.search = jest.fn().mockResolvedValue([
|
||||
{ id: "mem-1", score: 0.85, payload: { data: "alice memory" } },
|
||||
{ id: "mem-2", score: 0.75, payload: { data: "bob memory" } },
|
||||
]);
|
||||
m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const result = await m.search("alice and bob", {
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
// Entity store was called (parallelized via Promise.allSettled)
|
||||
expect(mockEntityStore.search).toHaveBeenCalled();
|
||||
|
||||
// Results should exist and have scores
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
for (const item of result.results) {
|
||||
expect(typeof item.score).toBe("number");
|
||||
expect(item.score).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should survive one entity search failure without losing other boosts", async () => {
|
||||
const m = memory as any;
|
||||
await m._ensureInitialized();
|
||||
|
||||
let callIndex = 0;
|
||||
const mockEntityStore = {
|
||||
search: jest.fn().mockImplementation(() => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
return Promise.reject(new Error("provider timeout"));
|
||||
}
|
||||
return Promise.resolve([makeMatch("e-ok", 0.8, ["mem-9"])]);
|
||||
}),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
m._entityStore = mockEntityStore;
|
||||
m.embedder = {
|
||||
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
||||
embedBatch: jest
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => mockEmbedding)),
|
||||
),
|
||||
};
|
||||
m.vectorStore.search = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: "mem-9", score: 0.85, payload: { data: "surviving memory" } },
|
||||
]);
|
||||
m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// "John Smith met Jane Doe" extracts two proper entities
|
||||
const result = await m.search("John Smith met Jane Doe", {
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe("mem-9");
|
||||
// Should log the failure like Python does
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"Entity boost search failed for one entity:",
|
||||
expect.any(Error),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should call entity searches concurrently, not sequentially", async () => {
|
||||
const m = memory as any;
|
||||
await m._ensureInitialized();
|
||||
|
||||
const concurrency = { current: 0, peak: 0 };
|
||||
|
||||
const mockEntityStore = {
|
||||
search: jest.fn().mockImplementation(() => {
|
||||
concurrency.current++;
|
||||
concurrency.peak = Math.max(concurrency.peak, concurrency.current);
|
||||
return new Promise<VectorStoreResult[]>((resolve) => {
|
||||
setTimeout(() => {
|
||||
concurrency.current--;
|
||||
resolve([makeMatch("e1", 0.7, ["mem-1"])]);
|
||||
}, 100);
|
||||
});
|
||||
}),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
m._entityStore = mockEntityStore;
|
||||
m.embedder = {
|
||||
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
||||
embedBatch: jest
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => mockEmbedding)),
|
||||
),
|
||||
};
|
||||
m.vectorStore.search = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: "mem-1", score: 0.8, payload: { data: "test" } },
|
||||
]);
|
||||
m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const start = performance.now();
|
||||
await m.search("entity1 and entity2 and entity3 and entity4", {
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
// With 4 entities at 100ms each, sequential would be ~400ms+.
|
||||
// Parallel should be well under that. Use generous bound for CI.
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
// At least 2 searches should have overlapped
|
||||
expect(concurrency.peak).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
/**
|
||||
* OpenAI Embedder — unit tests (mocked OpenAI client).
|
||||
* Verifies that the `dimensions` parameter is only passed to the API
|
||||
* when the user explicitly configures `embeddingDims`.
|
||||
* when the user explicitly configures `embeddingDims`, and that
|
||||
* embeddings are requested as floats for OpenAI-compatible proxies.
|
||||
*/
|
||||
|
||||
const mockEmbeddingsCreate = jest.fn();
|
||||
@@ -42,6 +43,7 @@ describe("OpenAIEmbedder (unit)", () => {
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello",
|
||||
encoding_format: "float",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +60,7 @@ describe("OpenAIEmbedder (unit)", () => {
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello",
|
||||
encoding_format: "float",
|
||||
dimensions: 1024,
|
||||
});
|
||||
});
|
||||
@@ -87,6 +90,7 @@ describe("OpenAIEmbedder (unit)", () => {
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("dimensions");
|
||||
expect(callArgs).toHaveProperty("encoding_format", "float");
|
||||
});
|
||||
|
||||
it("passes dimensions in embedBatch when embeddingDims is explicitly set", async () => {
|
||||
@@ -105,6 +109,7 @@ describe("OpenAIEmbedder (unit)", () => {
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: ["hello", "world"],
|
||||
encoding_format: "float",
|
||||
dimensions: 512,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference types="jest" />
|
||||
|
||||
import { scoreAndRank } from "../src/utils/scoring";
|
||||
|
||||
describe("scoreAndRank", () => {
|
||||
const results = [
|
||||
{ id: "a", score: 0.8, payload: { data: "mem a" } },
|
||||
{ id: "b", score: 0.5, payload: { data: "mem b" } },
|
||||
];
|
||||
|
||||
it("omits scoreDetails by default", () => {
|
||||
const scored = scoreAndRank(results, {}, {}, 0.1, 10);
|
||||
expect(scored[0].scoreDetails).toBeUndefined();
|
||||
expect(scored[1].scoreDetails).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits scoreDetails when explain is false", () => {
|
||||
const scored = scoreAndRank(results, {}, {}, 0.1, 10, false);
|
||||
expect(scored[0].scoreDetails).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes scoreDetails when explain is true", () => {
|
||||
const bm25 = { a: 0.6 };
|
||||
const entity = { a: 0.3 };
|
||||
const scored = scoreAndRank(results, bm25, entity, 0.1, 10, true);
|
||||
|
||||
const details = scored[0].scoreDetails!;
|
||||
expect(details).toBeDefined();
|
||||
expect(details.semanticScore).toBe(0.8);
|
||||
expect(details.bm25Score).toBe(0.6);
|
||||
expect(details.entityBoost).toBe(0.3);
|
||||
expect(details.rawScore).toBeCloseTo(1.7);
|
||||
expect(details.maxPossibleScore).toBe(2.5);
|
||||
expect(details.finalScore).toBeCloseTo(0.68);
|
||||
expect(details.threshold).toBe(0.1);
|
||||
});
|
||||
|
||||
it("includes scoreDetails for results without bm25/entity signals", () => {
|
||||
const scored = scoreAndRank(results, {}, {}, 0.1, 10, true);
|
||||
|
||||
const details = scored[0].scoreDetails!;
|
||||
expect(details.semanticScore).toBe(0.8);
|
||||
expect(details.bm25Score).toBe(0);
|
||||
expect(details.entityBoost).toBe(0);
|
||||
expect(details.rawScore).toBe(0.8);
|
||||
expect(details.maxPossibleScore).toBe(1.0);
|
||||
expect(details.finalScore).toBe(0.8);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,15 @@ setup_config()
|
||||
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
|
||||
|
||||
|
||||
def _validate_and_trim_search_query(query: str) -> str:
|
||||
if not isinstance(query, str):
|
||||
raise ValueError("Invalid query: must be a non-empty string.")
|
||||
trimmed = query.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("Invalid query: cannot be empty or whitespace-only.")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _maybe_alias_anon_to_email(user_email):
|
||||
"""Fire $identify per prior anon ID so PostHog merges them into email.
|
||||
|
||||
@@ -306,6 +315,7 @@ class MemoryClient:
|
||||
|
||||
kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs}
|
||||
params = self._prepare_params(kwargs)
|
||||
query = _validate_and_trim_search_query(query)
|
||||
payload = {"query": query, **params}
|
||||
|
||||
response = self.client.post("/v3/memories/search/", json=payload)
|
||||
@@ -1221,6 +1231,7 @@ class AsyncMemoryClient:
|
||||
|
||||
kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs}
|
||||
params = self._prepare_params(kwargs)
|
||||
query = _validate_and_trim_search_query(query)
|
||||
payload = {"query": query, **params}
|
||||
|
||||
response = await self.async_client.post("/v3/memories/search/", json=payload)
|
||||
|
||||
@@ -25,7 +25,7 @@ class BaseEmbedderConfig(ABC):
|
||||
model_kwargs: Optional[dict] = None,
|
||||
huggingface_base_url: Optional[str] = None,
|
||||
# AzureOpenAI specific
|
||||
azure_kwargs: Optional[AzureConfig] = {},
|
||||
azure_kwargs: Optional[AzureConfig] = None,
|
||||
http_client_proxies: Optional[Union[Dict, str]] = None,
|
||||
# VertexAI specific
|
||||
vertex_credentials_json: Optional[str] = None,
|
||||
@@ -89,7 +89,7 @@ class BaseEmbedderConfig(ABC):
|
||||
self.model_kwargs = model_kwargs or {}
|
||||
self.huggingface_base_url = huggingface_base_url
|
||||
# AzureOpenAI specific
|
||||
self.azure_kwargs = AzureConfig(**azure_kwargs) or {}
|
||||
self.azure_kwargs = AzureConfig(**(azure_kwargs or {})) or {}
|
||||
|
||||
# VertexAI specific
|
||||
self.vertex_credentials_json = vertex_credentials_json
|
||||
|
||||
@@ -23,6 +23,7 @@ class AzureOpenAIConfig(BaseLlmConfig):
|
||||
vision_details: Optional[str] = "auto",
|
||||
reasoning_effort: Optional[str] = None,
|
||||
http_client_proxies: Optional[dict] = None,
|
||||
is_reasoning_model: Optional[bool] = None,
|
||||
# Azure OpenAI-specific parameters
|
||||
azure_kwargs: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
@@ -40,6 +41,10 @@ class AzureOpenAIConfig(BaseLlmConfig):
|
||||
vision_details: Vision detail level, defaults to "auto"
|
||||
reasoning_effort: Effort level for reasoning models ("low", "medium", "high"), defaults to None
|
||||
http_client_proxies: HTTP client proxy settings, defaults to None
|
||||
is_reasoning_model: Explicit override for reasoning-model detection.
|
||||
None (default) uses the name-based heuristic. Set True to drop
|
||||
max_tokens/temperature (e.g. for versioned Azure deployments like
|
||||
"gpt-5.4-nano-2026-03-17"), or False to force standard params.
|
||||
azure_kwargs: Azure-specific configuration, defaults to None
|
||||
"""
|
||||
# Initialize base parameters
|
||||
@@ -54,6 +59,7 @@ class AzureOpenAIConfig(BaseLlmConfig):
|
||||
vision_details=vision_details,
|
||||
reasoning_effort=reasoning_effort,
|
||||
http_client_proxies=http_client_proxies,
|
||||
is_reasoning_model=is_reasoning_model,
|
||||
)
|
||||
|
||||
# Azure OpenAI-specific parameters
|
||||
|
||||
@@ -25,6 +25,7 @@ class BaseLlmConfig(ABC):
|
||||
vision_details: Optional[str] = "auto",
|
||||
reasoning_effort: Optional[str] = None,
|
||||
http_client_proxies: Optional[Union[Dict, str]] = None,
|
||||
is_reasoning_model: Optional[bool] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a base configuration class instance for the LLM.
|
||||
@@ -54,6 +55,14 @@ class BaseLlmConfig(ABC):
|
||||
Defaults to None (uses the model's default reasoning effort)
|
||||
http_client_proxies: Proxy settings for HTTP client.
|
||||
Can be a dict or string. Defaults to None
|
||||
is_reasoning_model: Explicit override for reasoning-model detection.
|
||||
When None (default), the model is classified automatically from its
|
||||
name (preserving existing behavior). Set to True to force the
|
||||
reasoning-model parameter set (drop max_tokens and temperature),
|
||||
or False to force the standard parameter set. Useful for
|
||||
deployments with custom/versioned model names (e.g. Azure
|
||||
"gpt-5.4-nano-2026-03-17") that the name-based heuristic cannot
|
||||
recognize. Defaults to None
|
||||
"""
|
||||
self.model = model
|
||||
self.temperature = temperature
|
||||
@@ -64,4 +73,5 @@ class BaseLlmConfig(ABC):
|
||||
self.enable_vision = enable_vision
|
||||
self.vision_details = vision_details
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.is_reasoning_model = is_reasoning_model
|
||||
self.http_client = httpx.Client(proxies=http_client_proxies) if http_client_proxies else None
|
||||
|
||||
@@ -22,6 +22,7 @@ class OpenAIConfig(BaseLlmConfig):
|
||||
vision_details: Optional[str] = "auto",
|
||||
reasoning_effort: Optional[str] = None,
|
||||
http_client_proxies: Optional[dict] = None,
|
||||
is_reasoning_model: Optional[bool] = None,
|
||||
# OpenAI-specific parameters
|
||||
openai_base_url: Optional[str] = None,
|
||||
models: Optional[List[str]] = None,
|
||||
@@ -47,6 +48,9 @@ class OpenAIConfig(BaseLlmConfig):
|
||||
vision_details: Vision detail level, defaults to "auto"
|
||||
reasoning_effort: Effort level for reasoning models ("low", "medium", "high"), defaults to None
|
||||
http_client_proxies: HTTP client proxy settings, defaults to None
|
||||
is_reasoning_model: Explicit override for reasoning-model detection.
|
||||
None (default) uses the name-based heuristic. Set True to drop
|
||||
max_tokens/temperature, or False to force standard params.
|
||||
openai_base_url: OpenAI API base URL, defaults to None
|
||||
models: List of models for OpenRouter, defaults to None
|
||||
route: OpenRouter route strategy, defaults to "fallback"
|
||||
@@ -72,6 +76,7 @@ class OpenAIConfig(BaseLlmConfig):
|
||||
vision_details=vision_details,
|
||||
reasoning_effort=reasoning_effort,
|
||||
http_client_proxies=http_client_proxies,
|
||||
is_reasoning_model=is_reasoning_model,
|
||||
)
|
||||
|
||||
# OpenAI-specific parameters
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from typing import Optional
|
||||
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
|
||||
|
||||
class XAIConfig(BaseLlmConfig):
|
||||
"""
|
||||
Configuration class for X.AI (Grok) provider parameters.
|
||||
Inherits from BaseLlmConfig and adds X.AI-specific settings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Base parameters
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.1,
|
||||
api_key: Optional[str] = None,
|
||||
max_tokens: int = 2000,
|
||||
top_p: float = 0.1,
|
||||
top_k: int = 1,
|
||||
enable_vision: bool = False,
|
||||
vision_details: Optional[str] = "auto",
|
||||
http_client_proxies: Optional[dict] = None,
|
||||
# X.AI-specific parameters
|
||||
xai_base_url: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize X.AI configuration.
|
||||
|
||||
Args:
|
||||
model: X.AI / Grok model to use, defaults to None
|
||||
temperature: Controls randomness, defaults to 0.1
|
||||
api_key: X.AI API key, defaults to None
|
||||
max_tokens: Maximum tokens to generate, defaults to 2000
|
||||
top_p: Nucleus sampling parameter, defaults to 0.1
|
||||
top_k: Top-k sampling parameter, defaults to 1
|
||||
enable_vision: Enable vision capabilities, defaults to False
|
||||
vision_details: Vision detail level, defaults to "auto"
|
||||
http_client_proxies: HTTP client proxy settings, defaults to None
|
||||
xai_base_url: X.AI API base URL, defaults to None
|
||||
"""
|
||||
super().__init__(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
enable_vision=enable_vision,
|
||||
vision_details=vision_details,
|
||||
http_client_proxies=http_client_proxies,
|
||||
)
|
||||
|
||||
# X.AI-specific parameters
|
||||
self.xai_base_url = xai_base_url
|
||||
@@ -33,6 +33,7 @@ class AzureOpenAILLM(LLMBase):
|
||||
vision_details=config.vision_details,
|
||||
reasoning_effort=getattr(config, 'reasoning_effort', None),
|
||||
http_client_proxies=config.http_client,
|
||||
is_reasoning_model=getattr(config, 'is_reasoning_model', None),
|
||||
)
|
||||
|
||||
super().__init__(config)
|
||||
|
||||
+12
-2
@@ -43,13 +43,23 @@ class LLMBase(ABC):
|
||||
def _is_reasoning_model(self, model: str) -> bool:
|
||||
"""
|
||||
Check if the model is a reasoning model or GPT-5 series that doesn't support certain parameters.
|
||||
|
||||
|
||||
An explicit ``is_reasoning_model`` on the config takes precedence over the
|
||||
name-based heuristic. This lets deployments with custom/versioned model
|
||||
names (e.g. Azure ``gpt-5.4-nano-2026-03-17``) opt in or out without
|
||||
relying on string matching. When the config value is ``None`` (default),
|
||||
classification falls back to the name-based heuristic below.
|
||||
|
||||
Args:
|
||||
model: The model name to check
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the model is a reasoning model or GPT-5 series
|
||||
"""
|
||||
explicit = getattr(self.config, "is_reasoning_model", None)
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
|
||||
reasoning_models = {
|
||||
"o1", "o1-preview", "o3-mini", "o3",
|
||||
"gpt-5", "gpt-5o", "gpt-5o-mini", "gpt-5o-micro",
|
||||
|
||||
@@ -31,6 +31,7 @@ class OpenAILLM(LLMBase):
|
||||
vision_details=config.vision_details,
|
||||
reasoning_effort=getattr(config, 'reasoning_effort', None),
|
||||
http_client_proxies=config.http_client,
|
||||
is_reasoning_model=getattr(config, 'is_reasoning_model', None),
|
||||
)
|
||||
|
||||
super().__init__(config)
|
||||
|
||||
+71
-13
@@ -1,14 +1,36 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.xai import XAIConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.memory.utils import extract_json
|
||||
|
||||
|
||||
class XAILLM(LLMBase):
|
||||
def __init__(self, config: Optional[BaseLlmConfig] = None):
|
||||
def __init__(self, config: Optional[Union[BaseLlmConfig, XAIConfig, Dict]] = None):
|
||||
# Convert to XAIConfig if needed
|
||||
if config is None:
|
||||
config = XAIConfig()
|
||||
elif isinstance(config, dict):
|
||||
config = XAIConfig(**config)
|
||||
elif isinstance(config, BaseLlmConfig) and not isinstance(config, XAIConfig):
|
||||
# Convert BaseLlmConfig to XAIConfig so xai_base_url is available
|
||||
config = XAIConfig(
|
||||
model=config.model,
|
||||
temperature=config.temperature,
|
||||
api_key=config.api_key,
|
||||
max_tokens=config.max_tokens,
|
||||
top_p=config.top_p,
|
||||
top_k=config.top_k,
|
||||
enable_vision=config.enable_vision,
|
||||
vision_details=config.vision_details,
|
||||
http_client_proxies=config.http_client,
|
||||
)
|
||||
|
||||
super().__init__(config)
|
||||
|
||||
if not self.config.model:
|
||||
@@ -18,35 +40,71 @@ class XAILLM(LLMBase):
|
||||
base_url = self.config.xai_base_url or os.getenv("XAI_API_BASE") or "https://api.x.ai/v1"
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
def _parse_response(self, response, tools):
|
||||
"""
|
||||
Process the response based on whether tools are used or not.
|
||||
|
||||
Args:
|
||||
response: The raw response from API.
|
||||
tools: The list of tools provided in the request.
|
||||
|
||||
Returns:
|
||||
str or dict: The processed response.
|
||||
"""
|
||||
if tools:
|
||||
processed_response = {
|
||||
"content": response.choices[0].message.content,
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
if response.choices[0].message.tool_calls:
|
||||
for tool_call in response.choices[0].message.tool_calls:
|
||||
processed_response["tool_calls"].append(
|
||||
{
|
||||
"name": tool_call.function.name,
|
||||
"arguments": json.loads(extract_json(tool_call.function.arguments)),
|
||||
}
|
||||
)
|
||||
|
||||
return processed_response
|
||||
else:
|
||||
return response.choices[0].message.content
|
||||
|
||||
def generate_response(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
response_format=None,
|
||||
tools: Optional[List[Dict]] = None,
|
||||
tool_choice: str = "auto",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Generate a response based on the given messages using XAI.
|
||||
Generate a response based on the given messages using X.AI (Grok).
|
||||
|
||||
Args:
|
||||
messages (list): List of message dicts containing 'role' and 'content'.
|
||||
response_format (str or object, optional): Format of the response. Defaults to "text".
|
||||
response_format (str or object, optional): Format of the response. Defaults to None.
|
||||
tools (list, optional): List of tools that the model can call. Defaults to None.
|
||||
tool_choice (str, optional): Tool choice method. Defaults to "auto".
|
||||
**kwargs: Additional X.AI-specific parameters.
|
||||
|
||||
Returns:
|
||||
str: The generated response.
|
||||
str or dict: The generated response. A string when tools are not requested;
|
||||
a dict ``{"content": ..., "tool_calls": [...]}`` when tools are requested.
|
||||
"""
|
||||
params = {
|
||||
"model": self.config.model,
|
||||
"messages": messages,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
"top_p": self.config.top_p,
|
||||
}
|
||||
params = self._get_supported_params(messages=messages, **kwargs)
|
||||
params.update(
|
||||
{
|
||||
"model": self.config.model,
|
||||
"messages": messages,
|
||||
}
|
||||
)
|
||||
|
||||
if response_format:
|
||||
params["response_format"] = response_format
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
params["tool_choice"] = tool_choice
|
||||
|
||||
response = self.client.chat.completions.create(**params)
|
||||
return response.choices[0].message.content
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
+127
-34
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
@@ -46,6 +47,7 @@ from mem0.utils.scoring import (
|
||||
normalize_bm25,
|
||||
score_and_rank,
|
||||
)
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
# Suppress SWIG deprecation warnings globally
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*SwigPy.*")
|
||||
@@ -168,6 +170,21 @@ def _validate_search_params(threshold: Optional[float] = None, top_k: Optional[i
|
||||
)
|
||||
|
||||
|
||||
def _validate_and_trim_search_query(query: str) -> str:
|
||||
"""
|
||||
Validates and normalizes a search query before embedding/vector search.
|
||||
|
||||
Raises:
|
||||
ValueError: If query is not a string or is empty/whitespace-only.
|
||||
"""
|
||||
if not isinstance(query, str):
|
||||
raise ValueError("Invalid query: must be a non-empty string.")
|
||||
trimmed = query.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("Invalid query: cannot be empty or whitespace-only.")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _is_sensitive_field(field_name: str) -> bool:
|
||||
"""Check if a field should be redacted for telemetry safety.
|
||||
|
||||
@@ -384,6 +401,15 @@ class Memory(MemoryBase):
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
if getattr(type(self.vector_store), "keyword_search", None) is VectorStoreBase.keyword_search:
|
||||
logger.warning(
|
||||
"The '%s' vector store does not support keyword search. "
|
||||
"Hybrid (BM25) scoring will be disabled and search will use "
|
||||
"semantic similarity only. To enable hybrid search, switch to a "
|
||||
"store with keyword_search support (e.g. qdrant, elasticsearch, pgvector).",
|
||||
self.config.vector_store.provider,
|
||||
)
|
||||
|
||||
capture_event("mem0.init", self, {"sync_type": "sync"})
|
||||
|
||||
@property
|
||||
@@ -1131,6 +1157,7 @@ class Memory(MemoryBase):
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
threshold: float = 0.1,
|
||||
rerank: bool = False,
|
||||
explain: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -1161,6 +1188,7 @@ class Memory(MemoryBase):
|
||||
- {"NOT": [filter1]} - logical NOT
|
||||
threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
|
||||
rerank (bool, optional): Whether to rerank results. Defaults to False.
|
||||
explain (bool, optional): Whether to include score_details for each result. Defaults to False.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the search results under a "results" key.
|
||||
@@ -1175,6 +1203,7 @@ class Memory(MemoryBase):
|
||||
|
||||
# Validate search parameters (before applying defaults)
|
||||
_validate_search_params(threshold=threshold, top_k=top_k)
|
||||
query = _validate_and_trim_search_query(query)
|
||||
|
||||
# Validate and trim entity IDs in filters
|
||||
effective_filters = filters.copy() if filters else {}
|
||||
@@ -1220,11 +1249,12 @@ class Memory(MemoryBase):
|
||||
"encoded_ids": encoded_ids,
|
||||
"sync_type": "sync",
|
||||
"threshold": threshold,
|
||||
"explain": explain,
|
||||
"advanced_filters": bool(filters and self._has_advanced_operators(filters)),
|
||||
},
|
||||
)
|
||||
|
||||
original_memories = self._search_vector_store(query, effective_filters, limit, threshold)
|
||||
original_memories = self._search_vector_store(query, effective_filters, limit, threshold, explain=explain)
|
||||
|
||||
# Apply reranking if enabled and reranker is available
|
||||
if rerank and self.reranker and original_memories:
|
||||
@@ -1340,7 +1370,7 @@ class Memory(MemoryBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _search_vector_store(self, query, filters, limit, threshold=0.1):
|
||||
def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=False):
|
||||
# Guard against None threshold (backward compat)
|
||||
if threshold is None:
|
||||
threshold = 0.1
|
||||
@@ -1395,6 +1425,7 @@ class Memory(MemoryBase):
|
||||
entity_boosts=entity_boosts,
|
||||
threshold=threshold,
|
||||
top_k=limit,
|
||||
explain=explain,
|
||||
)
|
||||
|
||||
# Step 9: Format results
|
||||
@@ -1432,6 +1463,8 @@ class Memory(MemoryBase):
|
||||
if not memory_item_dict.get("metadata"):
|
||||
memory_item_dict["metadata"] = {}
|
||||
memory_item_dict["metadata"].update(additional_metadata)
|
||||
if explain and "score_details" in scored:
|
||||
memory_item_dict["score_details"] = scored["score_details"]
|
||||
|
||||
original_memories.append(memory_item_dict)
|
||||
|
||||
@@ -1464,34 +1497,55 @@ class Memory(MemoryBase):
|
||||
memory_boosts = {}
|
||||
|
||||
try:
|
||||
for _, entity_text in deduped:
|
||||
entity_embedding = self.embedding_model.embed(entity_text, "search")
|
||||
matches = self.entity_store.search(
|
||||
query=entity_text,
|
||||
vectors=entity_embedding,
|
||||
top_k=500,
|
||||
filters=search_filters,
|
||||
entity_texts = [text for _, text in deduped]
|
||||
embeddings = self.embedding_model.embed_batch(entity_texts, "search")
|
||||
|
||||
if len(embeddings) != len(entity_texts):
|
||||
logger.warning(
|
||||
"embed_batch returned %d vectors for %d texts — skipping entity boost",
|
||||
len(embeddings),
|
||||
len(entity_texts),
|
||||
)
|
||||
return memory_boosts
|
||||
|
||||
entity_store = self.entity_store
|
||||
|
||||
def _search_entity(entity_text, embedding):
|
||||
return entity_store.search(
|
||||
query=entity_text, vectors=embedding, top_k=500, filters=search_filters
|
||||
)
|
||||
|
||||
for match in matches:
|
||||
similarity = match.score if hasattr(match, 'score') else 0.0
|
||||
if similarity < 0.5:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures = {
|
||||
pool.submit(_search_entity, text, emb): text
|
||||
for text, emb in zip(entity_texts, embeddings)
|
||||
}
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
matches = future.result()
|
||||
except Exception as e:
|
||||
logger.warning("Entity boost search failed for one entity: %s", e)
|
||||
continue
|
||||
|
||||
payload = match.payload if hasattr(match, 'payload') else {}
|
||||
linked_memory_ids = payload.get("linked_memory_ids", [])
|
||||
if not isinstance(linked_memory_ids, list):
|
||||
continue
|
||||
for match in matches:
|
||||
similarity = match.score if hasattr(match, 'score') else 0.0
|
||||
if similarity < 0.5:
|
||||
continue
|
||||
|
||||
# Spread-attenuated boost: entities linking to many memories get attenuated
|
||||
num_linked = max(len(linked_memory_ids), 1)
|
||||
memory_count_weight = 1.0 / (1.0 + 0.001 * ((num_linked - 1) ** 2))
|
||||
boost = similarity * ENTITY_BOOST_WEIGHT * memory_count_weight
|
||||
payload = match.payload if hasattr(match, 'payload') else {}
|
||||
linked_memory_ids = payload.get("linked_memory_ids", [])
|
||||
if not isinstance(linked_memory_ids, list):
|
||||
continue
|
||||
|
||||
for memory_id in linked_memory_ids:
|
||||
if memory_id:
|
||||
memory_key = str(memory_id)
|
||||
memory_boosts[memory_key] = max(memory_boosts.get(memory_key, 0.0), boost)
|
||||
num_linked = max(len(linked_memory_ids), 1)
|
||||
memory_count_weight = 1.0 / (1.0 + 0.001 * ((num_linked - 1) ** 2))
|
||||
boost = similarity * ENTITY_BOOST_WEIGHT * memory_count_weight
|
||||
|
||||
for memory_id in linked_memory_ids:
|
||||
if memory_id:
|
||||
memory_key = str(memory_id)
|
||||
memory_boosts[memory_key] = max(memory_boosts.get(memory_key, 0.0), boost)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Entity boost computation failed: {e}")
|
||||
@@ -1828,6 +1882,15 @@ class AsyncMemory(MemoryBase):
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
|
||||
if getattr(type(self.vector_store), "keyword_search", None) is VectorStoreBase.keyword_search:
|
||||
logger.warning(
|
||||
"The '%s' vector store does not support keyword search. "
|
||||
"Hybrid (BM25) scoring will be disabled and search will use "
|
||||
"semantic similarity only. To enable hybrid search, switch to a "
|
||||
"store with keyword_search support (e.g. qdrant, elasticsearch, pgvector).",
|
||||
self.config.vector_store.provider,
|
||||
)
|
||||
|
||||
capture_event("mem0.init", self, {"sync_type": "async"})
|
||||
|
||||
@property
|
||||
@@ -2546,6 +2609,7 @@ class AsyncMemory(MemoryBase):
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
threshold: float = 0.1,
|
||||
rerank: bool = False,
|
||||
explain: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -2576,6 +2640,7 @@ class AsyncMemory(MemoryBase):
|
||||
- {"NOT": [filter1]} - logical NOT
|
||||
threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
|
||||
rerank (bool, optional): Whether to rerank results. Defaults to False.
|
||||
explain (bool, optional): Whether to include score_details for each result. Defaults to False.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the search results under a "results" key.
|
||||
@@ -2590,6 +2655,7 @@ class AsyncMemory(MemoryBase):
|
||||
|
||||
# Validate search parameters (before applying defaults)
|
||||
_validate_search_params(threshold=threshold, top_k=top_k)
|
||||
query = _validate_and_trim_search_query(query)
|
||||
|
||||
# Validate and trim entity IDs in filters
|
||||
effective_filters = filters.copy() if filters else {}
|
||||
@@ -2637,11 +2703,12 @@ class AsyncMemory(MemoryBase):
|
||||
"encoded_ids": encoded_ids,
|
||||
"sync_type": "async",
|
||||
"threshold": threshold,
|
||||
"explain": explain,
|
||||
"advanced_filters": bool(filters and self._has_advanced_operators(filters)),
|
||||
},
|
||||
)
|
||||
|
||||
original_memories = await self._search_vector_store(query, effective_filters, limit, threshold)
|
||||
original_memories = await self._search_vector_store(query, effective_filters, limit, threshold, explain=explain)
|
||||
|
||||
# Apply reranking if enabled and reranker is available
|
||||
if rerank and self.reranker and original_memories:
|
||||
@@ -2760,7 +2827,7 @@ class AsyncMemory(MemoryBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _search_vector_store(self, query, filters, limit, threshold=0.1):
|
||||
async def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=False):
|
||||
if threshold is None:
|
||||
threshold = 0.1
|
||||
|
||||
@@ -2814,6 +2881,7 @@ class AsyncMemory(MemoryBase):
|
||||
entity_boosts=entity_boosts,
|
||||
threshold=threshold,
|
||||
top_k=limit,
|
||||
explain=explain,
|
||||
)
|
||||
|
||||
# Step 9: Format results
|
||||
@@ -2850,6 +2918,8 @@ class AsyncMemory(MemoryBase):
|
||||
if not memory_item_dict.get("metadata"):
|
||||
memory_item_dict["metadata"] = {}
|
||||
memory_item_dict["metadata"].update(additional_metadata)
|
||||
if explain and "score_details" in scored:
|
||||
memory_item_dict["score_details"] = scored["score_details"]
|
||||
|
||||
original_memories.append(memory_item_dict)
|
||||
|
||||
@@ -2872,15 +2942,38 @@ class AsyncMemory(MemoryBase):
|
||||
memory_boosts = {}
|
||||
|
||||
try:
|
||||
for _, entity_text in deduped:
|
||||
entity_embedding = await asyncio.to_thread(self.embedding_model.embed, entity_text, "search")
|
||||
matches = await asyncio.to_thread(
|
||||
self.entity_store.search,
|
||||
query=entity_text,
|
||||
vectors=entity_embedding,
|
||||
top_k=500,
|
||||
filters=search_filters,
|
||||
entity_texts = [text for _, text in deduped]
|
||||
embeddings = await asyncio.to_thread(self.embedding_model.embed_batch, entity_texts, "search")
|
||||
|
||||
if len(embeddings) != len(entity_texts):
|
||||
logger.warning(
|
||||
"embed_batch returned %d vectors for %d texts — skipping entity boost",
|
||||
len(embeddings),
|
||||
len(entity_texts),
|
||||
)
|
||||
return memory_boosts
|
||||
|
||||
sem = asyncio.Semaphore(4)
|
||||
|
||||
async def _search_entity(entity_text, embedding):
|
||||
async with sem:
|
||||
return await asyncio.to_thread(
|
||||
self.entity_store.search,
|
||||
query=entity_text,
|
||||
vectors=embedding,
|
||||
top_k=500,
|
||||
filters=search_filters,
|
||||
)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(_search_entity(text, emb) for text, emb in zip(entity_texts, embeddings)),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for matches in results:
|
||||
if isinstance(matches, BaseException):
|
||||
logger.warning("Entity boost search failed for one entity: %s", matches)
|
||||
continue
|
||||
|
||||
for match in matches:
|
||||
similarity = match.score if hasattr(match, 'score') else 0.0
|
||||
|
||||
+3
-1
@@ -52,7 +52,7 @@ class Completions:
|
||||
def create(
|
||||
self,
|
||||
model: str,
|
||||
messages: List = [],
|
||||
messages: Optional[List] = None,
|
||||
# Mem0 arguments
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
@@ -92,6 +92,8 @@ class Completions:
|
||||
api_key: Optional[str] = None,
|
||||
model_list: Optional[list] = None, # pass in a list of api_base,keys, etc.
|
||||
):
|
||||
if messages is None:
|
||||
messages = []
|
||||
if not any([user_id, agent_id, run_id]):
|
||||
raise ValueError("One of user_id, agent_id, run_id must be provided")
|
||||
|
||||
|
||||
@@ -7,17 +7,18 @@ from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
|
||||
from mem0.configs.llms.azure import AzureOpenAIConfig
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.deepseek import DeepSeekConfig
|
||||
from mem0.configs.llms.minimax import MinimaxConfig
|
||||
from mem0.configs.llms.lmstudio import LMStudioConfig
|
||||
from mem0.configs.llms.minimax import MinimaxConfig
|
||||
from mem0.configs.llms.ollama import OllamaConfig
|
||||
from mem0.configs.llms.openai import OpenAIConfig
|
||||
from mem0.configs.llms.vllm import VllmConfig
|
||||
from mem0.configs.llms.xai import XAIConfig
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
||||
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||
from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig
|
||||
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
||||
from mem0.embeddings.mock import MockEmbeddings
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ class LlmFactory:
|
||||
"gemini": ("mem0.llms.gemini.GeminiLLM", BaseLlmConfig),
|
||||
"deepseek": ("mem0.llms.deepseek.DeepSeekLLM", DeepSeekConfig),
|
||||
"minimax": ("mem0.llms.minimax.MiniMaxLLM", MinimaxConfig),
|
||||
"xai": ("mem0.llms.xai.XAILLM", BaseLlmConfig),
|
||||
"xai": ("mem0.llms.xai.XAILLM", XAIConfig),
|
||||
"sarvam": ("mem0.llms.sarvam.SarvamLLM", BaseLlmConfig),
|
||||
"lmstudio": ("mem0.llms.lmstudio.LMStudioLLM", LMStudioConfig),
|
||||
"vllm": ("mem0.llms.vllm.VllmLLM", VllmConfig),
|
||||
@@ -209,7 +210,6 @@ class VectorStoreFactory:
|
||||
return instance
|
||||
|
||||
|
||||
|
||||
class RerankerFactory:
|
||||
"""
|
||||
Factory for creating reranker instances with appropriate configurations.
|
||||
@@ -219,7 +219,10 @@ class RerankerFactory:
|
||||
# Provider mappings with their config classes
|
||||
provider_to_class = {
|
||||
"cohere": ("mem0.reranker.cohere_reranker.CohereReranker", CohereRerankerConfig),
|
||||
"sentence_transformer": ("mem0.reranker.sentence_transformer_reranker.SentenceTransformerReranker", SentenceTransformerRerankerConfig),
|
||||
"sentence_transformer": (
|
||||
"mem0.reranker.sentence_transformer_reranker.SentenceTransformerReranker",
|
||||
SentenceTransformerRerankerConfig,
|
||||
),
|
||||
"zero_entropy": ("mem0.reranker.zero_entropy_reranker.ZeroEntropyReranker", ZeroEntropyRerankerConfig),
|
||||
"llm_reranker": ("mem0.reranker.llm_reranker.LLMReranker", LLMRerankerConfig),
|
||||
"huggingface": ("mem0.reranker.huggingface_reranker.HuggingFaceReranker", HuggingFaceRerankerConfig),
|
||||
|
||||
+24
-6
@@ -63,6 +63,7 @@ def score_and_rank(
|
||||
entity_boosts: Dict[str, float],
|
||||
threshold: float,
|
||||
top_k: int,
|
||||
explain: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Score candidates additively and return top-k results.
|
||||
|
||||
@@ -79,6 +80,14 @@ def score_and_rank(
|
||||
- Semantic + BM25 + entity: max_possible = 2.5
|
||||
- Semantic + entity (no BM25): max_possible = 1.5
|
||||
|
||||
Args:
|
||||
semantic_results: Candidate memories from vector search.
|
||||
bm25_scores: Normalized keyword scores keyed by memory ID.
|
||||
entity_boosts: Entity-link boosts keyed by memory ID.
|
||||
threshold: Minimum semantic score required before hybrid scoring.
|
||||
top_k: Maximum number of results to return.
|
||||
explain: Include score_details in each result when true.
|
||||
|
||||
Returns:
|
||||
List of scored result dicts sorted by combined score descending.
|
||||
"""
|
||||
@@ -109,13 +118,22 @@ def score_and_rank(
|
||||
raw_combined = semantic_score + bm25_score + entity_boost
|
||||
combined = min(raw_combined / max_possible, 1.0)
|
||||
|
||||
scored.append(
|
||||
{
|
||||
"id": mem_id_str,
|
||||
"score": combined,
|
||||
"payload": result.get("payload"),
|
||||
scored_result = {
|
||||
"id": mem_id_str,
|
||||
"score": combined,
|
||||
"payload": result.get("payload"),
|
||||
}
|
||||
if explain:
|
||||
scored_result["score_details"] = {
|
||||
"semantic_score": semantic_score,
|
||||
"bm25_score": bm25_score,
|
||||
"entity_boost": entity_boost,
|
||||
"raw_score": raw_combined,
|
||||
"max_possible_score": max_possible,
|
||||
"final_score": combined,
|
||||
"threshold": threshold,
|
||||
}
|
||||
)
|
||||
scored.append(scored_result)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
@@ -313,13 +313,10 @@ class AzureMySQL(VectorStoreBase):
|
||||
|
||||
for row in results:
|
||||
vec = np.array(json.loads(row['vector']))
|
||||
# Cosine similarity
|
||||
similarity = np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec))
|
||||
distance = 1 - similarity
|
||||
scored_results.append((row['id'], distance, row['payload']))
|
||||
similarity = float(np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec)))
|
||||
scored_results.append((row['id'], similarity, row['payload']))
|
||||
|
||||
# Sort by distance and apply limit
|
||||
scored_results.sort(key=lambda x: x[1])
|
||||
scored_results.sort(key=lambda x: x[1], reverse=True)
|
||||
scored_results = scored_results[:top_k]
|
||||
|
||||
return [
|
||||
|
||||
@@ -14,7 +14,15 @@ class VectorStoreBase(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def search(self, query, vectors, top_k=5, filters=None):
|
||||
"""Search for similar vectors."""
|
||||
"""Search for similar vectors.
|
||||
|
||||
All implementations must return similarity scores where higher values
|
||||
indicate greater similarity (range [0, 1] preferred). Implementations
|
||||
using distance metrics must convert to similarity before returning:
|
||||
- Cosine distance: score = max(0.0, 1.0 - distance)
|
||||
- L2 distance: score = 1.0 / (1.0 + distance)
|
||||
- Inner product: score = value (already higher = better)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -258,10 +258,8 @@ class CassandraDB(VectorStoreBase):
|
||||
continue
|
||||
|
||||
vec = np.array(row.vector)
|
||||
|
||||
# Cosine similarity
|
||||
similarity = np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec))
|
||||
distance = 1 - similarity
|
||||
|
||||
similarity = float(np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec)))
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
@@ -273,10 +271,9 @@ class CassandraDB(VectorStoreBase):
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
scored_results.append((row.id, distance, row.payload))
|
||||
scored_results.append((row.id, similarity, row.payload))
|
||||
|
||||
# Sort by distance and apply limit
|
||||
scored_results.sort(key=lambda x: x[1])
|
||||
scored_results.sort(key=lambda x: x[1], reverse=True)
|
||||
scored_results = scored_results[:top_k]
|
||||
|
||||
return [
|
||||
|
||||
@@ -97,9 +97,11 @@ class ChromaDB(VectorStoreBase):
|
||||
|
||||
result = []
|
||||
for i in range(max_length):
|
||||
raw_distance = distances[i] if isinstance(distances, list) and distances and i < len(distances) else None
|
||||
score = 1.0 / (1.0 + raw_distance) if raw_distance is not None else None
|
||||
entry = OutputData(
|
||||
id=ids[i] if isinstance(ids, list) and ids and i < len(ids) else None,
|
||||
score=(distances[i] if isinstance(distances, list) and distances and i < len(distances) else None),
|
||||
score=score,
|
||||
payload=(metadatas[i] if isinstance(metadatas, list) and metadatas and i < len(metadatas) else None),
|
||||
)
|
||||
result.append(entry)
|
||||
|
||||
@@ -279,7 +279,11 @@ class FAISS(VectorStoreBase):
|
||||
|
||||
payload_copy = payload.copy()
|
||||
|
||||
score = float(scores[i])
|
||||
raw_score = float(scores[i])
|
||||
if self.distance_strategy.lower() == "euclidean":
|
||||
score = 1.0 / (1.0 + raw_score)
|
||||
else:
|
||||
score = raw_score
|
||||
entry = OutputData(
|
||||
id=vector_id,
|
||||
score=score,
|
||||
|
||||
@@ -11,7 +11,14 @@ try:
|
||||
except ImportError:
|
||||
raise ImportError("The 'pymilvus' library is required. Please install it using 'pip install pymilvus'.")
|
||||
|
||||
from pymilvus import CollectionSchema, DataType, FieldSchema, Function, FunctionType, MilvusClient
|
||||
from pymilvus import (
|
||||
CollectionSchema,
|
||||
DataType,
|
||||
FieldSchema,
|
||||
Function,
|
||||
FunctionType,
|
||||
MilvusClient,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -167,11 +174,14 @@ class MilvusDB(VectorStoreBase):
|
||||
memory = []
|
||||
|
||||
for value in data:
|
||||
uid, score, metadata = (
|
||||
value.get("id"),
|
||||
value.get("distance"),
|
||||
value.get("entity", {}).get("metadata"),
|
||||
)
|
||||
uid = value.get("id")
|
||||
raw_distance = value.get("distance")
|
||||
metadata = value.get("entity", {}).get("metadata")
|
||||
|
||||
if raw_distance is not None and self.metric_type in (MetricType.L2, "L2"):
|
||||
score = 1.0 / (1.0 + raw_distance)
|
||||
else:
|
||||
score = raw_distance
|
||||
|
||||
memory_obj = OutputData(id=uid, score=score, payload=metadata)
|
||||
memory.append(memory_obj)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -113,6 +115,24 @@ def _build_filter_conditions(filters):
|
||||
return conditions, params
|
||||
|
||||
|
||||
def _with_sslmode(connection_string: str, sslmode: str) -> str:
|
||||
"""Add or replace sslmode in URI and keyword conninfo strings.
|
||||
|
||||
Keyword conninfo values are assumed not to contain nested ``sslmode=``
|
||||
substrings, such as inside an ``options`` value.
|
||||
"""
|
||||
if "://" in connection_string:
|
||||
parsed = urlsplit(connection_string)
|
||||
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "sslmode"]
|
||||
query.append(("sslmode", sslmode))
|
||||
return parsed._replace(query=urlencode(query)).geturl()
|
||||
|
||||
if re.search(r"(^|\s)sslmode=", connection_string):
|
||||
return re.sub(r"(^|\s)sslmode=\S+", lambda match: f"{match.group(1)}sslmode={sslmode}", connection_string)
|
||||
|
||||
return f"{connection_string} sslmode={sslmode}"
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
score: Optional[float]
|
||||
@@ -161,6 +181,7 @@ class PGVector(VectorStoreBase):
|
||||
self.use_hnsw = hnsw
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.connection_pool = None
|
||||
self._collection_ensured = False
|
||||
|
||||
# Connection setup with priority: connection_pool > connection_string > individual parameters
|
||||
if connection_pool is not None:
|
||||
@@ -168,30 +189,33 @@ class PGVector(VectorStoreBase):
|
||||
self.connection_pool = connection_pool
|
||||
elif connection_string:
|
||||
if sslmode:
|
||||
# Append sslmode to connection string if provided
|
||||
if 'sslmode=' in connection_string:
|
||||
# Replace existing sslmode
|
||||
import re
|
||||
connection_string = re.sub(r'sslmode=[^ ]*', f'sslmode={sslmode}', connection_string)
|
||||
else:
|
||||
# Add sslmode to connection string
|
||||
connection_string = f"{connection_string} sslmode={sslmode}"
|
||||
connection_string = _with_sslmode(connection_string, sslmode)
|
||||
else:
|
||||
connection_string = f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
|
||||
if sslmode:
|
||||
connection_string = f"{connection_string} sslmode={sslmode}"
|
||||
connection_string = _with_sslmode(connection_string, sslmode)
|
||||
|
||||
if self.connection_pool is None:
|
||||
if PSYCOPG_VERSION == 3:
|
||||
# psycopg3 ConnectionPool
|
||||
self.connection_pool = ConnectionPool(conninfo=connection_string, min_size=minconn, max_size=maxconn, open=True)
|
||||
# open=False avoids blocking when DB DNS is not yet resolvable (e.g. Docker startup)
|
||||
self.connection_pool = ConnectionPool(
|
||||
conninfo=connection_string,
|
||||
min_size=minconn,
|
||||
max_size=maxconn,
|
||||
open=False,
|
||||
)
|
||||
self.connection_pool.open(wait=False)
|
||||
else:
|
||||
# psycopg2 ThreadedConnectionPool
|
||||
self.connection_pool = ConnectionPool(minconn=minconn, maxconn=maxconn, dsn=connection_string)
|
||||
|
||||
def _ensure_collection(self):
|
||||
if self._collection_ensured:
|
||||
return
|
||||
collections = self.list_cols()
|
||||
if collection_name not in collections:
|
||||
if self.collection_name not in collections:
|
||||
self.create_col()
|
||||
self._collection_ensured = True
|
||||
|
||||
@contextmanager
|
||||
def _get_cursor(self, commit: bool = False):
|
||||
@@ -281,6 +305,7 @@ class PGVector(VectorStoreBase):
|
||||
)
|
||||
|
||||
def insert(self, vectors: list[list[float]], payloads=None, ids=None) -> None:
|
||||
self._ensure_collection()
|
||||
logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}")
|
||||
json_payloads = [json.dumps(payload) for payload in payloads]
|
||||
|
||||
@@ -318,6 +343,7 @@ class PGVector(VectorStoreBase):
|
||||
Returns:
|
||||
list: Search results.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
filter_conditions, filter_params = _build_filter_conditions(filters)
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
@@ -334,7 +360,7 @@ class PGVector(VectorStoreBase):
|
||||
)
|
||||
|
||||
results = cur.fetchall()
|
||||
return [OutputData(id=str(r[0]), score=float(r[1]), payload=r[2]) for r in results]
|
||||
return [OutputData(id=str(r[0]), score=max(0.0, 1.0 - float(r[1])), payload=r[2]) for r in results]
|
||||
|
||||
def keyword_search(self, query, top_k=5, filters=None):
|
||||
"""
|
||||
@@ -348,6 +374,7 @@ class PGVector(VectorStoreBase):
|
||||
Returns:
|
||||
List[OutputData]: Search results ranked by text relevance.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
filter_conditions, filter_params = _build_filter_conditions(filters)
|
||||
filter_clause = sql.SQL("AND " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
@@ -378,6 +405,7 @@ class PGVector(VectorStoreBase):
|
||||
Args:
|
||||
vector_id (str): ID of the vector to delete.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(sql.SQL("DELETE FROM {} WHERE id = %s").format(self._col()), (vector_id,))
|
||||
|
||||
@@ -395,6 +423,7 @@ class PGVector(VectorStoreBase):
|
||||
vector (List[float], optional): Updated vector.
|
||||
payload (Dict, optional): Updated payload.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
if vector:
|
||||
cur.execute(
|
||||
@@ -427,6 +456,7 @@ class PGVector(VectorStoreBase):
|
||||
Returns:
|
||||
OutputData: Retrieved vector.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()),
|
||||
@@ -460,6 +490,7 @@ class PGVector(VectorStoreBase):
|
||||
Returns:
|
||||
Dict[str, Any]: Collection information.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
@@ -490,6 +521,7 @@ class PGVector(VectorStoreBase):
|
||||
Returns:
|
||||
List[OutputData]: List of vectors.
|
||||
"""
|
||||
self._ensure_collection()
|
||||
filter_conditions, filter_params = _build_filter_conditions(filters)
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
@@ -521,6 +553,7 @@ class PGVector(VectorStoreBase):
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the index by deleting and recreating it."""
|
||||
self._ensure_collection()
|
||||
logger.warning(f"Resetting index {self.collection_name}...")
|
||||
self.delete_col()
|
||||
self.create_col()
|
||||
|
||||
@@ -158,7 +158,7 @@ class RedisDB(VectorStoreBase):
|
||||
return [
|
||||
MemoryResult(
|
||||
id=result["memory_id"],
|
||||
score=float(result["vector_distance"]),
|
||||
score=max(0.0, 1.0 - float(result["vector_distance"])),
|
||||
payload={
|
||||
"hash": result["hash"],
|
||||
"data": result["memory"],
|
||||
|
||||
@@ -80,7 +80,9 @@ class S3Vectors(VectorStoreBase):
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse metadata for key {v.get('key')}")
|
||||
payload = {}
|
||||
results.append(OutputData(id=v.get("key"), score=v.get("distance"), payload=payload))
|
||||
raw_distance = v.get("distance")
|
||||
score = max(0.0, 1.0 - raw_distance) if raw_distance is not None else None
|
||||
results.append(OutputData(id=v.get("key"), score=score, payload=payload))
|
||||
return results
|
||||
|
||||
def insert(self, vectors, payloads=None, ids=None):
|
||||
@@ -180,10 +182,6 @@ class S3Vectors(VectorStoreBase):
|
||||
return response.get("index", {})
|
||||
|
||||
def list(self, filters=None, top_k=None):
|
||||
# Note: list_vectors does not support metadata filtering.
|
||||
if filters:
|
||||
logger.warning("S3 Vectors `list` does not support metadata filtering. Ignoring filters.")
|
||||
|
||||
params = {
|
||||
"vectorBucketName": self.vector_bucket_name,
|
||||
"indexName": self.collection_name,
|
||||
@@ -198,7 +196,16 @@ class S3Vectors(VectorStoreBase):
|
||||
all_vectors = []
|
||||
for page in pages:
|
||||
all_vectors.extend(page.get("vectors", []))
|
||||
return [self._parse_output(all_vectors)]
|
||||
results = self._parse_output(all_vectors)
|
||||
if filters:
|
||||
results = [
|
||||
result
|
||||
for result in results
|
||||
if result.payload and all(result.payload.get(k) == v for k, v in filters.items())
|
||||
]
|
||||
if top_k:
|
||||
results = results[:top_k]
|
||||
return [results]
|
||||
|
||||
def reset(self):
|
||||
logger.warning(f"Resetting index {self.collection_name}...")
|
||||
|
||||
@@ -135,7 +135,7 @@ class Supabase(VectorStoreBase):
|
||||
data=vectors, limit=top_k, filters=filters, include_metadata=True, include_value=True
|
||||
)
|
||||
|
||||
return [OutputData(id=str(result[0]), score=float(result[1]), payload=result[2]) for result in results]
|
||||
return [OutputData(id=str(result[0]), score=max(0.0, 1.0 - float(result[1])), payload=result[2]) for result in results]
|
||||
|
||||
def delete(self, vector_id: str):
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user