diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 51668aff8..156d52054 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./integrations/mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.12" + "version": "0.2.14" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 40527fb93..5f726ccc9 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./integrations/mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", - "version": "0.2.12" + "version": "0.2.14" } ] } diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..1eef9e6e9 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,102 @@ +# CI/CD and repository automation (`.github/`) + +> **Do not modify any workflow without explicit approval from a maintainer.** Publishing +> credentials are bound to workflow filenames, and the gate workflows decide whether +> contributions are accepted. Read this file before proposing any change here. + +## CI: one gate, many pipelines + +`ci-gate.yml` (**CI Gate**) is the single entry point. It runs on every PR, detects which packages changed, and calls only the relevant package workflows as reusable workflows (`workflow_call`). Its final `CI Gate` job aggregates the results: skipped pipelines pass, failed or cancelled ones fail. It is the **only CI status check that needs to be required** in branch protection. + +Package workflows keep their own push-to-main and manual triggers. Their `pull_request` triggers live in the gate's path filters instead. + +| Workflow | File | Standalone triggers | Runs | +|----------|------|---------------------|------| +| CI Gate | `ci-gate.yml` | All PRs | Routes to and aggregates everything below | +| Python SDK | `ci.yml` | Push to main | Ruff + pytest on Python 3.10, 3.11, 3.12 | +| TypeScript SDK | `ts-sdk-ci.yml` | Push to main (`mem0-ts/`) | Prettier + build + jest on Node 20, 22 | +| Python CLI | `cli-python-ci.yml` | Push to main (`cli/python/`), manual | Ruff + pytest + hatch build on Python 3.10, 3.11, 3.12 | +| Node CLI | `cli-node-ci.yml` | Push to main (`cli/node/`), manual | Biome + tsc + vitest + tsup on Node 20, 22 | +| OpenClaw | `openclaw-checks.yml` | Push to main (`integrations/openclaw/`), manual | tsc + vitest (Codecov) + tsup on Node 20, 22 | +| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (`integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 | +| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (`.opencode-plugin/`), manual | Bun: tsc + build + dist artifact check | +| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (`integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 | +| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (`integrations/n8n-nodes-mem0/`), manual | ESLint + tsc build on Node 20 | +| Zapier App | `zapier-mem0-checks.yml` | Push to main (`integrations/zapier-mem0/`), manual | tsc + `zapier validate` + offline unit tests on Node 22 | +| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage | + +Adding a package CI workflow: give it `workflow_call` plus `push` / `workflow_dispatch` as needed but **no `pull_request` trigger**, then register it in `ci-gate.yml` with a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list. + +## Branch protection on `main` + +A repository ruleset named `Main Branch Rule`, id `11813754`. It enforces squash-only merges, linear history, no deletion, no force-push, and one approving review. Two status checks belong in its `required_status_checks` rule: + +| Context | Posted by | Why | +|---------|-----------|-----| +| `CI Gate` | `ci-gate.yml` | Aggregates every package pipeline | +| `license/cla` | CLA Assistant | Proves the CLA is signed, not merely requested | + +Editing the ruleset requires repo **admin**. `maintain` is not enough, and the API returns 404 rather than 403 in that case. Until `license/cla` is required, the claim in `CONTRIBUTING.md` that unsigned PRs are blocked from merging holds by convention only. + +Requiring `CI Gate` also means fork PRs from first-time contributors cannot merge until a maintainer approves the workflow run. Those sit at `action_required`, which is intended behavior. + +## CD: one router, many publishers + +`release.yml` (**Release Router**) is the only workflow listening to `release: published`. It matches the tag prefix and dispatches the matching package workflow through `workflow_dispatch`, so one release produces exactly one routed run. + +| Workflow | File | Tag prefix | Target | +|----------|------|------------|--------| +| Release Router | `release.yml` | all releases | dispatches the rows 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`) | +| Node CLI | `cli-node-cd.yml` | `cli-node-v*` | npm (`@mem0/cli`) | +| 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`) | +| n8n Node | `n8n-nodes-mem0-cd.yml` | `n8n-nodes-mem0-v*` | npm (`@mem0/n8n-nodes-mem0`) | + +- Package CD workflows are `workflow_dispatch`-only, with `tag` and `prerelease` inputs. They check out and build the given tag. +- All publishing uses **OIDC trusted publishing**. No tokens, no secrets. +- Registry trusted-publisher settings are pinned to each package's own workflow **filename**. Renaming a CD workflow breaks publishing for that package. +- First publish of a new npm package must be done manually. OIDC works from the second version onward. +- To re-publish a release, do **not** delete and recreate the GitHub release. Dispatch the workflow directly: `gh workflow run -cd.yml --ref refs/tags/ -f tag=`. +- The Zapier app deploys to Zapier's platform, not npm, so it is not in the router. Deploy with `gh workflow run zapier-mem0-cd.yml --ref main`. +- Adding a package: add its CD workflow, then register its tag prefix in the `case` block in `release.yml`, keeping the bare `v*` arm last. + +## Contribution gates + +| Workflow | File | Purpose | +|----------|------|---------| +| PR Gate | `pr-gate.yml` | Closes PRs that do not link an issue labeled `accepted`, with a reopen path. Exempts members, bots, drafts, and docs-only changes. Never checks out PR code. | +| Vouch (check PR) | `vouch-check-pr.yml` | Comments on PRs from authors absent from `VOUCHED.td`. Comment-only mode (`auto-close: false`). | +| Vouch (manage list) | `vouch-manage-by-issue.yml` | Maintainers edit the trust list by commenting `!vouch @user`, `!denounce @user`, or `!unvouch @user` on any issue. Commits back to `VOUCHED.td` through a GitHub App token. | +| Issue Labeler | `issue-labeler.yml` | Labels issues from the `component` field in the issue forms | +| PR Labeler | `pr-labeler.yml` | Path-based labels, plus propagating labels from linked issues | +| Stale Bot | `stale.yml` | Marks stale issues and PRs | +| llms.txt Check | `docs-llms-txt-check.yml` | Blocks PRs touching `docs/**/*.mdx` when `docs/llms.txt` is out of sync | + +`pr-gate.yml` and `vouch-check-pr.yml` use `pull_request_target`, which is required to label and close fork PRs. Neither checks out PR code and neither has a `run:` step, so there is no pwn-request or script-injection surface. Keep it that way: any future `run:` step in these files must never interpolate `github.event.*` text. + +`GATE_EFFECTIVE_FROM` in `pr-gate.yml` is a `created_at` cutoff. `edited`, `reopened`, and `ready_for_review` fire on PRs opened long before the gate existed, so without the cutoff the whole open backlog would be closed by a rule that did not exist when those PRs were filed. Set it to the actual merge date in UTC. + +## Issue forms and templates + +`ISSUE_TEMPLATE/*.yml` are GitHub issue **forms**, not markdown templates. Only forms support `required: true` and machine-parseable field ids. Blank issues are disabled in `config.yml`. + +`issue-labeler.yml` reads only the `component` field id through `stefanbuck/github-issue-parser` and `redhat-plumbers-in-action/advanced-issue-labeler`, so adding new field ids is safe. Renaming `component` is not. + +Current field ids: + +| Form | Ids | +|------|-----| +| `bug_report.yml` | `component`, `description`, `verification`, `ai_assistance` | +| `feature_request.yml` | `component`, `description`, `ai_assistance` | +| `documentation_issue.yml` | `description`, `ai_assistance` | + +## Trust list + +`VOUCHED.td` is one GitHub username per line, `#` for comments. Seeded from every author with at least one merged PR in this repository, then filtered: accounts at or below a 16% merge rate across five or more attempts were dropped, since landing one change out of many is the signature of automated submission rather than contribution. + +Vouch's only built-in exemptions are accounts ending in `[bot]` and repo collaborators with `write` or `admin`. **Organization membership alone is not one of them.** So `vouch-check-pr.yml` carries a job-level `if:` that skips the check for `OWNER`, `MEMBER`, and `COLLABORATOR` authors, the same exemption `pr-gate.yml` already applies. `author_association` is `MEMBER` for every org member regardless of repository permission, so no member can be flagged even if their `VOUCHED.td` entry is missing, misspelled, or miscased. Org members are still listed in the file as a fallback, but the workflow guard is what actually holds. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/.github/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a851488d9..7b3e1827b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,12 +9,10 @@ body: label: Component description: Which part of mem0 is affected? options: - - Core / Python SDK + - Python SDK - TypeScript SDK - - Vector Store (Qdrant, PGVector, Redis, Chroma, etc.) - - Graph Memory (Neo4j, Memgraph, etc.) - - Ollama / Local Models - - OpenClaw + - Vector Store + - Plugin - REST API - Other validations: @@ -53,3 +51,43 @@ body: - OS: validations: required: true + + - type: textarea + id: verification + attributes: + label: How You Verified This + description: We only take on bugs someone has actually reproduced. Show your work. + value: | + ### What I Ran + + The exact command or script, and where it ran. + + ### What I Saw + + The real output, log line, or traceback. Paste it, do not describe it. + + ### Why This Is a Bug + + What should have happened instead, and what says so: a docs link, a + docstring, a test, or the code itself. + + ### What I Ruled Out + + Anything you checked that turned out not to be the cause. + validations: + required: true + + - type: dropdown + id: ai_assistance + attributes: + label: AI Assistance + description: >- + This asks how the bug was found and confirmed, not how the text was + written. Drafting the write-up with AI is fine. We ask because it tells + us how much to trust the reproduction, not because it counts against you. + options: + - No AI involved + - AI helped me find it, and I reproduced it myself afterwards + - AI found and wrote this, and I have not reproduced it myself + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0e2f83054..2e97aeff9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,11 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - - name: Discord Community + - name: Question or general help url: https://discord.gg/6PzXDgEjG5 - about: Ask questions and discuss with the community + about: Ask on Discord. The issue tracker is for bugs and accepted work only. - name: Documentation url: https://docs.mem0.ai about: Read the official mem0 documentation + - name: Report a security vulnerability + url: https://github.com/mem0ai/mem0/security/advisories/new + about: Report privately through a security advisory. Never open a public issue. diff --git a/.github/ISSUE_TEMPLATE/documentation_issue.yml b/.github/ISSUE_TEMPLATE/documentation_issue.yml index 0ac173a52..faf0f3258 100644 --- a/.github/ISSUE_TEMPLATE/documentation_issue.yml +++ b/.github/ISSUE_TEMPLATE/documentation_issue.yml @@ -21,3 +21,17 @@ body: How should the docs be improved? validations: required: true + + - type: dropdown + id: ai_assistance + attributes: + label: AI Assistance + description: >- + This asks how the problem was found, not how the text was written. + Drafting the write-up with AI is fine. + options: + - No AI involved, I hit this reading the docs + - AI-assisted, but I checked the page myself + - AI found this, and I have not opened the page + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 61c9927e0..32cc05df7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -9,14 +9,11 @@ body: label: Component description: Which part of mem0 does this relate to? options: - - Core / Python SDK + - Python SDK - TypeScript SDK - - Vector Store (Qdrant, PGVector, Redis, Chroma, etc.) - - Graph Memory (Neo4j, Memgraph, etc.) - - Ollama / Local Models - - OpenClaw + - Vector Store + - Plugin - REST API - - Benchmarks / Evals - Other validations: required: true @@ -39,3 +36,18 @@ body: Any workarounds you've tried or other approaches considered. validations: required: true + + - type: dropdown + id: ai_assistance + attributes: + label: AI Assistance + description: >- + This asks where the idea came from, not how the text was written. + Drafting the write-up with AI is fine. A request you hit yourself while + building something carries more weight than one a model suggested. + options: + - No AI involved, this is a need I hit myself + - AI-assisted, but the need is mine + - AI suggested this feature + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 14f7bdaac..3baf4457d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,18 @@ Closes # - [ ] Refactor (no functional changes) - [ ] Documentation update +## AI Assistance + + + +- [ ] No AI assistance +- [ ] AI-assisted (autocomplete, or I asked a model questions while writing this) +- [ ] AI-generated (an agent wrote most or all of this diff) + + + +- [ ] **I can explain every line of this diff and how it interacts with the rest of the codebase, without asking an AI tool.** + ## Breaking Changes diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 000000000..b559eff63 --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,413 @@ +# The list of vouched (or denounced) users for this repository. +# +# Only vouched users can open pull requests here. A denounced user (prefixed +# with a minus) is blocked outright. +# +# Vouch automatically allows two kinds of account without consulting this file: +# accounts ending in [bot], and repo collaborators with write or admin +# permission. Org membership on its own is NOT one of them, so +# vouch-check-pr.yml skips the check entirely for OWNER, MEMBER, and +# COLLABORATOR authors. mem0ai org members are listed below as well, but that +# workflow guard is what actually protects them: a missing, misspelled, or +# miscased entry here can never cause a member to be flagged. +# +# Syntax: +# - One handle per line (without @), sorted alphabetically. +# - Optionally specify platform: `platform:username` (e.g. `github:mitchellh`). +# - To denounce a user, prefix with minus: `-username`. +# - Optionally add a note after a space following the handle. +# +# Maintainers vouch by commenting "!vouch @username" on any issue, and denounce +# with "!denounce @username". The bot commits the change back to this file. +# +# Seeded on 2026-08-12 from every author with at least one merged pull request, +# then filtered: accounts with a merge rate at or below 16% across six or more +# attempts were dropped, since landing one change out of many is the signature +# of automated submission rather than contribution. Removal is not a ban. Any +# maintainer can !vouch these accounts back in. +1MikeMakuch +aaishikdutta +Aarkin7 +abdullahirfann +AbdurNawaz +abhay-codes07 +Abhineshhh +ac12644 +acarbonetto +adh-wonolo +Aditya-Tripuraneni +aesher9o1 +agumpandey +ahnedeee +ajmalmohad +AkisAya +akshat1423 +akshseh +alessandropanzieri +alohays +aloktripathi1 +amahuli03 +amanagarwal042 +Ameysr +amjadraza +anantoj +anchit-nishant +andrewghlee +andy-k-improving +anifort +anishesg +AnkushMalaker +AnnaSuSu +anujshandillya +ArchishmanSengupta +Arsh-mem0 +ArthurHoward1 +aryankhanna475 +Ashu463 +atahanyild +AtharvaJaiswal005 +atkinsh +avp1598 +axelray-dev +ayaangazali +aymenkrifa +barry166 +being-abhi +berwinjoule +BillionClaw +bioshazard +bisla +bkidd1 +blino +bmsvinci1729 +boss-mao +Br1an67 +brucewkz +cachho +caifeizhi +candidosales +cclauss +chaithanyak42 +chinnuabey +ChiragArora31 +ChrisFloofyKitsune +chrisqu777 +clementantonyk +codexvn +Colsrch +CrepuscularIRIS +ctxlong +danielsiwiec +darkhaniop +davidatorres +deshraj +Dev-Khant +Devan019 +deven298 +devYRPauli +DhanushNehru +DhilipBinny +Dhravya +dimigerontaki +Diveyam-Mishra +divyansh-1009 +Divyanshu9822 +dog-last +DrJsPBs +dtee1 +DumoeDss +e-biswas +Echo3ToEcho7 +eldar702 +eltociear +EnzoFanAccount +Esparon1 +Fahmid-Arman +Failfail2603 +FarukhS52 +farzad528 +felipeavilis +femto +fengjikui +fenilfaldu +fileames +Flyfoxs +fmercurio +FoliageOwO +fran3cc +frank-zsy +frederikb96 +Freshield +freya0926 +G26karthik +gabe-l-hart +gabrielstein-mem0 +gajazlikovac +gasolin +gaurav0107 +gauravagerwala +Genarojrsanchez +ghdcksgml1 +GingerMoon +gmdorf +golemus +GongRzhe +GopalGB +Gyubin +haarishmk26 +hackice20 +halanm +hardik1408 +Harin329 +harshgupta-mem0 +harshpandit007 +hayescode +hcsum +he-yufeng +heng-ah +Hexecu +Himanshu-Sangshetti +hjlarry +HowieG +HrushiYadav +HScarb +huveewomg +Hybirdss +ianupamsingh +IgnazioDS +immuhammadfurqan +into-the-night +invincible04 +Itz-Antaripa +ixchio +Jaco-Ren +Jai0401 +JainamShah-22 +Jainish-S +jarediaz +jeanibarz +Jerry-Terrasse +jessai2026 +jesse-c +jfeng18 +jferrettiboke +jjjojoj +joaomdmoura +JoeSL +johnwlockwood +jonasiwnl +josephchancey +juananpe +juaneloDev +junmo1215 +Jupiter363 +KapilM26 +karthik-indla +KarthikeyaKollu +kartik-mem0 +katarinasupe +ketangangal +kimnamu +kindertheo +kirex0 +kirklin +kk2211 +kmitul +koi646 +kratos0718 +krescent +Krishnachaitanyakc +kriszlazar +KushagraB424 +l1anch1 +lamost423 +lan17 +LeonieFreisinger +lh0x00 +limboinf +liviaellen +longway-code +lsvishaal +LuciAkirami +lucifertrj +lvpx +ly-wang19 +maamalama +maccuryj +mae5357 +mahone3297 +Malhis +maljazaery +manganeseheptoxide +manthanguptaa +mark-watson +Mark-Zeng +markmbain +matanco1 +mauricioalarcon +maxvonhippel +me-tusharchandra +mezotv +MgeeeeK +mggger +mgoulart +microbluey +mikejgray +Mingxiangyu +Mini256 +misrasaurabh1 +mjzcng +mogith-pn +morgoth9808 +moyueheng +mrbusche +Mrinank-Bhowmick +muhammed-mamun +MUZAMMILPERVAIZ +mvanhorn +naman09 +NavyaAlapati13 +neilbhutada +NightClover-code +nikhilsharma26500 +NILAY1556 +niv-hertz +NoahStapp +norrishuang +OfficialAbhinavSingh +officialasishkumar +OjusWiZard +okaditya84 +omahs +OsamaNabih +oskarrough +p-tirth +Padarn +paipeline +ParseDark +parshvadaftari +Parteeksachdeva +parthshr370 +parzival418 +Paulie-Aditya +paurushmittal +pc9 +Pecunia201 +peterj +pragnyanramtha +PranavPuranik +PrashantDixit0 +prateekchhikara +prathameshagrawal +pratikgajjar +PratikRai0101 +Prikshit7766 +Prithvi1994 +QunBB +rafid001 +raghavtyagii +rahulsharmavishwakarma +rajib76 +rakheesingh +ranjithkumar8352 +Rayhanpatel +reachAnushaKondam +Real5K +Rhythm-08 +richawo +Rishiraj2594 +RitwijParmar +RobinALG87 +rocke2020 +rodboev +rohitgr7 +ron-42 +roshan-shaik-ml +rst0070 +rudra717 +rudrajmehta-mem0 +rupamoraczen +rupeshbansal +ryanrozich +SaharshPatel24 +sahilyadav902 +sahithreddy05 +SakshiSrivastava2024 +SamuelDevdas +sarkarsaurabh27 +sdht0 +seetharam-rajagopal +sergio-toro +SerSamgy +shafdev +shashank42 +ShauryaaSharma +Sheharyar570 +shenxiangzhuang +ShivamMenda +shlokkhemani +shraderdm +shrivastavanolo +shubhampal123 +shuoli84 +sidmohanty11 +siroa +slobodaapl +soapun +soumil-rathi +spike-spiegel-21 +srishti-git1110 +SSDWGG +sssserrano +subhadip001 +subhajit20 +SudoAnirudh +sukkritsharmaofficial +sw8fbar +swarnaprakash +sxu75374 +SZemse +taranjeet +techcontributor +tgabi333 +theagenticguy +thomasgtaylor +tomasonjo +TommyZihao +TruptiAgrawal +turtletongue +Tushar-kalsi +Ukong0324 +umran666 +utkarsh240799 +UzairNaeem3 +V-Silpin +vatsalrathod16 +vedant381 +veeceey +vgvoleg +VictorECDSA +VikramIyer125 +Vir-8 +vsatyamuralikrishna +vuonghuuhung +WayneCao +whysosaket +wobushixiaoj +xiangpingjiang +XiaojuCH +xu-xiang +xyb +yashikabadaya +yashs33244 +ygorth +youneshima +ytkimirti +YuriyTW +YusukeJustinNakajima +zaiddkhan +zegerhoogeboom +zinyando +Zlo7 +Zncl2222 +zzaym diff --git a/.github/advanced-issue-labeler.yml b/.github/advanced-issue-labeler.yml index 502a662a1..51b773cb0 100644 --- a/.github/advanced-issue-labeler.yml +++ b/.github/advanced-issue-labeler.yml @@ -1,18 +1,15 @@ -# Maps dropdown selections to GitHub labels -# Used by the advanced-issue-labeler GitHub Action - -component: - - label: "sdk-python" - matcher: "Core / Python SDK" - - label: "sdk-typescript" - matcher: "TypeScript SDK" - - label: "vector-store" - matcher: "Vector Store" - - label: "graph-memory" - matcher: "Graph Memory" - - label: "ollama" - matcher: "Ollama" - - label: "openclaw" - matcher: "OpenClaw" - - label: "rest-api" - matcher: "REST API" +policy: + - section: + - id: ['component'] + block-list: ['Other'] + label: + - name: 'sdk-python' + keys: ['Python SDK'] + - name: 'sdk-typescript' + keys: ['TypeScript SDK'] + - name: 'vector-store' + keys: ['Vector Store'] + - name: 'plugin' + keys: ['Plugin'] + - name: 'rest-api' + keys: ['REST API'] diff --git a/.github/component-keywords.json b/.github/component-keywords.json new file mode 100644 index 000000000..badf548d2 --- /dev/null +++ b/.github/component-keywords.json @@ -0,0 +1,40 @@ +{ + "language": { + "sdk-python": [ + "python", "pip install", "pypi", "pyproject", "requirements.txt", + "from mem0", "import mem0", "traceback", "pydantic", "asyncmemory", + "poetry", "virtualenv", "venv", "conda", "pytest", "async def" + ], + "sdk-typescript": [ + "typescript", "javascript", "pnpm", "yarn", "node.js", "nodejs", + "mem0-ts", "mem0ai/oss", "tsconfig", "await import", + "=> {", "undefined is not" + ] + }, + "area": { + "plugin": [ + "openclaw", "openclaw-mem0", "openclaw.json", "openclaw plugin", + "claude code", "opencode", "pi agent", "mem0-plugin", + "cursor plugin", "codex plugin", "editor plugin" + ], + "cli": ["mem0-cli", "@mem0/cli", "npx mem0", "command line"], + "vector-store": [ + "pgvector", "pinecone", "chroma", "chromadb", "weaviate", + "milvus", "faiss", "vector store", "vectorstore", + "elasticsearch", "supabase", "azure ai search", + "s3 vectors", "mongodb" + ], + "integrations": [ + "vercel ai", "vercel-ai-sdk", "@mem0/vercel-ai-provider", + "llamaindex", "crewai", "autogen", "langgraph" + ], + "rest-api": [ + "rest api", "fastapi", "docker-compose", "/v1/memories", + "localhost:8000", "localhost:8888", "curl -x", "http endpoint" + ], + "documentation": [ + "docs.mem0.ai", "documentation", "typo", "readme", "docstring", "broken link", + "issue on docs", "docs:", "link to the docs page", "issue with current documentation" + ] + } +} diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..c35f2da49 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,59 @@ +sdk-python: + - changed-files: + - any-glob-to-any-file: + - 'mem0/**' + - 'tests/**' + - 'cli/python/**' + - 'pyproject.toml' + - 'poetry.lock' + +sdk-typescript: + - changed-files: + - any-glob-to-any-file: + - 'mem0-ts/**' + - 'cli/node/**' + +vector-store: + - changed-files: + - any-glob-to-any-file: + - 'mem0/vector_stores/**' + - 'mem0-ts/src/oss/src/vector_stores/**' + +rest-api: + - changed-files: + - any-glob-to-any-file: 'server/**' + +integrations: + - changed-files: + - any-glob-to-any-file: 'integrations/**' + +plugin: + - changed-files: + - all-globs-to-any-file: + - 'integrations/**' + - '!integrations/vercel-ai-sdk/**' + - any-glob-to-any-file: + - 'skills/**' + - '.agents/**' + - '.claude-plugin/**' + - '.codex-plugin/**' + - '.cursor-plugin/**' + - 'marketplace.json' + +cli: + - changed-files: + - any-glob-to-any-file: 'cli/**' + +documentation: + - changed-files: + - any-glob-to-any-file: + - 'docs/**' + - 'examples/**' + - '*.md' + +ci: + - changed-files: + - any-glob-to-any-file: + - '.github/**' + - 'scripts/**' + - '.pre-commit-config.yaml' diff --git a/.github/scripts/infer-component-labels.js b/.github/scripts/infer-component-labels.js new file mode 100644 index 000000000..5b2a7ebd2 --- /dev/null +++ b/.github/scripts/infer-component-labels.js @@ -0,0 +1,44 @@ +const fs = require('fs'); + +function componentLabels(keywords) { + return Object.values(keywords).flatMap(Object.keys); +} + +function toMatcher(term) { + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const prefix = /^[a-z0-9]/i.test(term) ? '\\b' : ''; + return new RegExp(prefix + escaped, 'i'); +} + +function scoreGroup(text, group) { + let winner = null; + let best = 0; + for (const [label, terms] of Object.entries(group)) { + const score = terms.reduce((n, term) => n + (toMatcher(term).test(text) ? 1 : 0), 0); + if (score > best) { + winner = label; + best = score; + } + } + return winner; +} + +const UMBRELLA = { plugin: 'integrations' }; + +function inferComponentLabels(text, keywords) { + if (!text) return []; + const labels = [scoreGroup(text, keywords.language), scoreGroup(text, keywords.area)].filter( + Boolean, + ); + for (const label of labels.slice()) { + const parent = UMBRELLA[label]; + if (parent && !labels.includes(parent)) labels.push(parent); + } + return labels; +} + +function loadKeywords(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +module.exports = { componentLabels, inferComponentLabels, loadKeywords }; diff --git a/.github/scripts/infer-component-labels.test.js b/.github/scripts/infer-component-labels.test.js new file mode 100644 index 000000000..8b32bdc9e --- /dev/null +++ b/.github/scripts/infer-component-labels.test.js @@ -0,0 +1,102 @@ +const assert = require('assert'); +const path = require('path'); +const { inferComponentLabels, loadKeywords } = require('./infer-component-labels.js'); + +const keywords = loadKeywords(path.join(__dirname, '..', 'component-keywords.json')); + +const cases = [ + { + number: 6210, + title: "but(anthropic): sampling parameters returns 400 error for new model", + body: "### Component\n\nCore / Python SDK\n\n### Description\n\n### Summary\n\nWhen using Anthropic latest models such as `claude-opus-4-7`, `claude-opus-4-8`, or `claude-sonnet-5`, Mem0 still sends sampling parameters like `temperature` / `top_p`. These models do not support those parameters, causing Anthropic API requests to fail.\n\nSee https://platform.claude.com/docs/en/about-claude/models/migration-guide\n\n### Steps to Reproduce\n\n```python\n from mem0 import Memory\n\n m = Memory.from_config({\n \"llm\": {\n \"provider\": \"anthropic\",\n \"config\": {\n \"model\": \"claude-opus-4-8\",\n \"api_key\": \"your-anthropic-api-key\"\n },\n },\n ...\n })\n```\n\n### Expected Behavior\n\nMem0 should detect Anthropic models that do not support sampling parameters and omit temperature and top_p from the request.\n\nFor models that still support sampling parameters, such as claude-opus-4-6, claude-sonnet-4-6, and claude-haiku-4-5, Mem0 should continue sending supported sampling parameters till they're deprecated.\n\n### Actual Behavior\n\nMem0 includes temperature by default for Anthropic requests. With newer Anthropic models that do not support sampling parameters, the API request fails because unsupported parameters are sent.\n\n### Environment\n\n - mem0 version: 2.0.11\n - Python/Node version: Python 3.11\n - OS: macOS\n", + expected: ["sdk-python"], + }, + { + number: 5770, + title: "feat(ts-sdk): add FastEmbed embedding provider", + body: "## Summary\n\nThe Python SDK supports **FastEmbed** as an embedding provider, but the TypeScript OSS SDK (`mem0ai/oss`) does not. Add it to bring the TS SDK to parity.\n\n| | |\n|---|---|\n| Python reference | `mem0/embeddings/fastembed.py` |\n| Registered in (Python) | `mem0/utils/factory.py` (EmbedderFactory) |\n| Target file (TypeScript) | `mem0-ts/src/oss/src/embeddings/fastembed.ts` |\n| Suggested implementation | Use the `fastembed` npm package (ONNX local embeddings). |\n\n## Requirements\n\n- [ ] Implement `FastEmbedEmbedder` in `mem0-ts/src/oss/src/embeddings/fastembed.ts`, extending `Embedder` (`mem0-ts/src/oss/src/embeddings/base.ts`) and mirroring the Python provider's behavior (embed / embedBatch).\n- [ ] Register the `\"fastembed\"` provider in `mem0-ts/src/oss/src/utils/factory.ts` (EmbedderFactory).\n- [ ] Add config typing in `mem0-ts/src/oss/src/types/`.\n- [ ] Add a unit test under `mem0-ts/src/oss/src/tests/`.\n- [ ] Add `fastembed` to `mem0-ts/package.json` (optional/peer dependency, lazy-imported like other providers).\n- [ ] Update docs under `docs/` if this provider is user-facing.\n\n## Reference pattern\n\nMirror an existing TS provider: `embeddings/openai.ts`.\n\n## Notes\n\n`fastembed` (v2.x) is the JS port of Qdrant's FastEmbed β€” local/offline embeddings. Mirror the default model in `mem0/embeddings/fastembed.py`.\n\n---\n_Part of the TypeScript ↔ Python SDK provider-parity effort. One provider per issue (atomic)._\n", + expected: ["sdk-typescript"], + }, + { + number: 3940, + title: "Milvus database will return distance not similarity score", + body: "### πŸ› Describe the bug\n\nMilvus database will return distance not similarity score\n\n## in milvus.py\n\ndef _parse_output(self, data: list):\n \"\"\"\n Parse the output data.\n\n Args:\n data (Dict): Output data.\n\n Returns:\n List[OutputData]: Parsed output data.\n \"\"\"\n memory = []\n\n for value in data:\n uid, score, metadata = (\n value.get(\"id\"),\n value.get(\"distance\"), # here\n value.get(\"entity\", {}).get(\"metadata\"),\n )\n\n memory_obj = OutputData(id=uid, score=score, payload=metadata)\n memory.append(memory_obj)\n\n return memory\n", + expected: ["vector-store"], + }, + { + number: 5290, + title: "Recall search failed: Bad Request Using OpenAI Embedding Model", + body: "### Component\n\nOpenClaw\n\n### Description\n\n### Summary\nuse openclaw.json config:\n\n```json\n...\n\"embedder\": {\n \"provider\": \"openai\",\n \"config\": {\n \"model\": \"bge-base-zh-v1.5\",\n \"embedding_dims\": 1024,\n \"embeddingDims\": 1024,\n \"url\": \"https://xxxxxxxxx/v1\",\n \"apiKey\": \"xxxxxxxxxxxx\"\n }\n },\n\"vectorStore\": {\n \"provider\": \"qdrant\",\n \"config\": {\n \"url\": \"http://qdrant:6333\",\n \"apiKey\": \"${QDRANT_API_KEY}\",\n \"collectionName\": \"mem0\",\n \"embeddingModelDims\": 1024\n }\n }\n```\n```\n\nopenclaw log info is:\n\n```\n23:14:20 Api key is used with unsecure connection.\n23:14:21 [mem0] Recall search failed: Bad Request\n23:14:21 [plugins] openclaw-mem0: skills-mode recall (strategy=smart) injecting 0 memories (~20 tokens)\n23:14:22 [ws] ⇄ res βœ“ sessions.list 256ms conn=d1eb9bc4…17da id=201b8113…c9dc\n23:14:22 [ws] ⇄ res βœ“ sessions.list 264ms conn=d1eb9bc4…17da id=4939f962…2f16\n23:14:34 [ws] ⇄ res βœ“ sessions.list 250ms conn=d1eb9bc4…17da id=f7ad503f…baa6\n23:15:12 [mem0] **Recall search failed: Bad Request**\n23:15:12 [plugins] openclaw-mem0: skills-mode recall (strategy=smart) injecting 0 memories (~20 tokens)\n23:15:12 [ws] ⇄ res βœ“ sessions.list 288ms conn=d1eb9bc4…17da id=9e20bb86…371e\n23:15:13 [ws] ⇄ res βœ“ sessions.list 268ms conn=d1eb9bc4…17da id=3b49a2ad…7ada\n23:15:20 [ws] ⇄ res βœ“ sessions.list 235ms conn=d1eb9bc4…17da id=a192da30…069f\n```\n\n### Actual Behavior\n\nembedding model response ok,response message has 1024 vectors,but the vectors are submitted to vector-db:qdrant with all zero vectors,and vectors has only 256 size.\n\n```http\nPOST /collections/mem0/points/search HTTP/1.1\nhost: qdrant:6333\nconnection: keep-alive\nuser-agent: qdrant-js/1.13.0\napi-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxx\nContent-Type: application/json\nAccept: application/json\naccept-language: *\nsec-fetch-mode: cors\naccept-encoding: gzip, deflate\ncontent-length: 651\n\n{\"vector\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"limit\":120,\"offset\":0,\"filter\":{\"must\":[{\"key\":\"user_id\",\"match\":{\"value\":\"agent\"}}]},\"with_payload\":true,\"with_vector\":false}\n\n**HTTP/1.1 400 Bad Request**\ntransfer-encoding: chunked\ncontent-type: application/json\nvary: accept-encoding, Origin, Access-Control-Request-Method, Access-Control-Request-Headers\ncontent-encoding: gzip\n\n```\n\n### Expected Behavior\n\nembedding model response ok by tcpdump, response message has 1024 vectors,and this vectors are submitted to vector-db:qdrant with the same vectors,and vectors has also 1024 size.\n\n\n### Environment\n\n- openclaw-mem0 version: 1.0.11\n- qdrant: 1.13.6\n", + expected: ["plugin", "integrations"], + }, + { + number: 3696, + title: "Cannot set expiration_date for memory in REST API server (Docker Compose)", + body: "### πŸ› Describe the bug\n\nI'm using docker compose to deploy a REST API server. When adding memory, I'm unable to set the expiration_date. Is this feature not supported?", + expected: ["rest-api"], + }, + { + number: 6252, + title: "cursor: on_file_read_cursor.sh ignores auto_search / MEM0_AUTO_SEARCH", + body: "### Component\n\nCursor / mem0-plugin\n\n### Description\n\n`on_file_read_cursor.sh` never checks `MEM0_AUTO_SEARCH`. In Claude Code, #6065/#6071 added a guard on `on_file_read.sh`, but the Cursor PreToolUse variant still always calls `file_context.py` (and thus Platform search) once `MEM0_API_KEY` is set.\n\n### Expected\n\nWhen `auto_search: false` / `MEM0_AUTO_SEARCH=false`, `on_file_read_cursor.sh` should exit 0 without searching.\n\n### Actual\n\nTimeline search still runs.\n\n### Related\n\n#6065, #6071, #6250\n", + expected: ["plugin", "integrations"], + }, + { + number: 6032, + title: "docs: fix typos and punctuation errors across docs", + body: "### Description\n\n### Page\nMultiple pages β€” see list below.\n\n### What's Wrong or Missing\n1. https://docs.mem0.ai/components/llms/overview β€” \"a llm\" should be \"an LLM\"\n2. https://docs.mem0.ai/components/vectordbs/dbs/azure β€” 2 comma splices + \"setup\" used as a verb (should be \"set up\")\n3. https://docs.mem0.ai/components/embedders/models/azure_openai β€” \"from the Azure.\" is an incomplete sentence\n4. https://docs.mem0.ai/components/llms/models/azure_openai β€” same incomplete \"from the Azure\" phrasing\n5. https://docs.mem0.ai/cookbooks/companions/voice-companion-openai β€” \"an important information\" (uncountable noun)\n6. https://docs.mem0.ai/cookbooks/essentials/exporting-memories β€” comma splice\n7. https://docs.mem0.ai/cookbooks/integrations/tavily-search β€” \"usecase\" should be \"use case\"\n8. https://docs.mem0.ai/cookbooks/overview β€” broken parallelism in bullet list\n9. README.md β€” \"Github App\" should be \"GitHub App\"\n10. https://docs.mem0.ai/platform/overview β€” table cell not capitalized like other rows\n\n### Suggested Fix\nApply the corrections listed above for each page. I will submit a PR soon addressing all of the issues mentioned.", + expected: ["documentation"], + }, +]; + +const cliRegressionCase = { + number: 3144, + title: "Bug Report: Memory Score Does Not Match Expected Relevance in Local Search", + body: "### πŸ› Describe the bug\n\n#### Description\n\nWhen using the locally deployed `mem0` server, the returned memory `score` from the `search` interface does not align with the expected semantic relevance. In particular, irrelevant or less relevant memories sometimes receive higher scores than directly related ones.\n\n#### Reproduction Steps\n\n```python\nmem0 = mem0_client(mode=\"local\")\nprint(\"Mem0 client initialized successfully.\")\n\nprint(\"Adding memories...\")\nresult = mem0.add(messages=[\n {\"role\": \"user\", \"content\": \"I like drinking coffee in the morning\"},\n {\"role\": \"user\", \"content\": \"I enjoy reading books at night\"}\n], user_id=\"alice\")\nprint(\"Memory added:\", result)\n\nprint(\"Searching memories...\")\nsearch_result = mem0.search(query=\"coffee\", user_id=\"alice\", top_k=2)\nprint(\"Search results:\", search_result)\n```\n\n#### Actual Output\n\n```json\n{\n \"results\": [\n {\n \"id\": \"5099b5be-c673-4f09-99de-a196f43b6476\",\n \"memory\": \"Likes drinking coffee in the morning\",\n \"score\": 0.5115111920687857\n },\n {\n \"id\": \"08df5c51-c52b-4c45-a5b6-b3f864ea149a\",\n \"memory\": \"Enjoys reading books at night\",\n \"score\": 0.7755568273863331\n }\n ],\n \"relations\": [\n {\"source\": \"coffee\", \"relationship\": \"consumed_in\", \"destination\": \"morning\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"likes\", \"destination\": \"coffee\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"likes_drinking\", \"destination\": \"coffee\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"in_time\", \"destination\": \"morning\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"drinks_in\", \"destination\": \"morning\"}\n ]\n}\n```\n\n#### Expected Behavior\n\nThe memory `\"Likes drinking coffee in the morning\"` should have a **higher score** than `\"Enjoys reading books at night\"` when querying for `\"coffee\"`, since it is directly semantically related.", +}; + +let failures = 0; + +function run(name, fn) { + try { + fn(); + console.log(`PASS ${name}`); + } catch (err) { + failures++; + console.error(`FAIL ${name}: ${err.message}`); + } +} + +for (const { number, title, body, expected } of cases) { + const text = `${title} + +${body}`; + run(`#${number}`, () => { + assert.deepStrictEqual(inferComponentLabels(text, keywords), expected); + }); +} + +run('#3144 cliKeywordPrefixSubstringRegression', () => { + const text = `${cliRegressionCase.title} + +${cliRegressionCase.body}`; + const inferred = inferComponentLabels(text, keywords); + assert.ok(!inferred.includes('cli'), `expected 'cli' absent (body contains 'Mem0 client', a substring of the removed 'mem0 cli' term), got ${JSON.stringify(inferred)}`); +}); + +run('noKeywordMatchReturnsEmptyArray', () => { + const text = 'The weather today is sunny and I went for a walk in the park with my dog.'; + assert.deepStrictEqual(inferComponentLabels(text, keywords), []); +}); + +run('emptyStringReturnsEmptyArray', () => { + assert.deepStrictEqual(inferComponentLabels('', keywords), []); +}); + +if (failures > 0) { + console.error(` +${failures} test(s) failed.`); + process.exit(1); +} +console.log(` +All ${cases.length + 3} tests passed.`); diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index 01309a731..eaf00275c 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -38,8 +38,11 @@ jobs: cli_python: ${{ steps.filter.outputs.cli_python }} cli_node: ${{ steps.filter.outputs.cli_node }} openclaw: ${{ steps.filter.outputs.openclaw }} + mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }} opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }} pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} + n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }} + zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }} docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }} steps: - uses: dorny/paths-filter@v3 @@ -71,6 +74,11 @@ jobs: - 'integrations/openclaw/**' - '.github/workflows/openclaw-checks.yml' - '.github/workflows/ci-gate.yml' + mem0_plugin: + - 'integrations/mem0-plugin/**' + - '!integrations/mem0-plugin/.opencode-plugin/**' + - '.github/workflows/mem0-plugin-checks.yml' + - '.github/workflows/ci-gate.yml' opencode_plugin: - 'integrations/mem0-plugin/.opencode-plugin/**' - '.github/workflows/opencode-plugin-checks.yml' @@ -79,6 +87,13 @@ jobs: - 'integrations/pi-agent-plugin/**' - '.github/workflows/pi-agent-plugin-checks.yml' - '.github/workflows/ci-gate.yml' + n8n_nodes_mem0: + - 'integrations/n8n-nodes-mem0/**' + - '.github/workflows/n8n-nodes-mem0-checks.yml' + zapier_mem0: + - 'integrations/zapier-mem0/**' + - '.github/workflows/zapier-mem0-checks.yml' + - '.github/workflows/ci-gate.yml' docs_llms_txt: - 'docs/**/*.mdx' - 'docs/llms.txt' @@ -122,6 +137,13 @@ jobs: uses: ./.github/workflows/openclaw-checks.yml secrets: inherit + mem0-plugin: + name: Mem0 Plugin + needs: changes + if: needs.changes.outputs.mem0_plugin == 'true' + uses: ./.github/workflows/mem0-plugin-checks.yml + secrets: inherit + opencode-plugin: name: OpenCode Plugin needs: changes @@ -136,6 +158,18 @@ jobs: uses: ./.github/workflows/pi-agent-plugin-checks.yml secrets: inherit + n8n-nodes-mem0: + name: n8n Node + needs: changes + if: needs.changes.outputs.n8n_nodes_mem0 == 'true' + uses: ./.github/workflows/n8n-nodes-mem0-checks.yml + zapier-mem0: + name: Zapier App + needs: changes + if: needs.changes.outputs.zapier_mem0 == 'true' + uses: ./.github/workflows/zapier-mem0-checks.yml + secrets: inherit + docs-llms-txt: name: docs llms.txt needs: changes @@ -152,8 +186,11 @@ jobs: - cli-python - cli-node - openclaw + - mem0-plugin - opencode-plugin - pi-agent-plugin + - n8n-nodes-mem0 + - zapier-mem0 - docs-llms-txt if: always() runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e28983cff..d17550230 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: } base_version=$(git show "$BASE_SHA:pyproject.toml" 2>/dev/null | extract_version || echo "") - head_version=$(extract_version < pyproject.toml) + head_version=$(git show "$HEAD_SHA:pyproject.toml" | extract_version) echo "Base version: ${base_version:-}" echo "Head version: $head_version" @@ -106,7 +106,7 @@ jobs: run: | pip install --upgrade pip pip install -e ".[test,graph,vector_stores,llms,extras]" - pip install ruff + pip install ruff==0.16.0 - name: Run Linting if: needs.check_changes.outputs.mem0_changed == 'true' run: make lint diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 0486a57b1..f747e4784 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -12,28 +12,56 @@ jobs: label: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - uses: stefanbuck/github-issue-parser@v3 id: issue-parser + continue-on-error: true with: template-path: .github/ISSUE_TEMPLATE/bug_report.yml - uses: redhat-plumbers-in-action/advanced-issue-labeler@v3 + continue-on-error: true with: issue-form: ${{ steps.issue-parser.outputs.jsonString }} - section: component token: ${{ secrets.GITHUB_TOKEN }} config-path: .github/advanced-issue-labeler.yml - - uses: stefanbuck/github-issue-parser@v3 - id: feature-parser - if: contains(github.event.issue.labels.*.name, 'enhancement') + - name: Infer component from text when the form was not used + uses: actions/github-script@v7 with: - template-path: .github/ISSUE_TEMPLATE/feature_request.yml + script: | + const { + componentLabels, + inferComponentLabels, + loadKeywords, + } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/infer-component-labels.js`); - - uses: redhat-plumbers-in-action/advanced-issue-labeler@v3 - if: contains(github.event.issue.labels.*.name, 'enhancement') - with: - issue-form: ${{ steps.feature-parser.outputs.jsonString }} - section: component - token: ${{ secrets.GITHUB_TOKEN }} - config-path: .github/advanced-issue-labeler.yml + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const keywords = loadKeywords(`${process.env.GITHUB_WORKSPACE}/.github/component-keywords.json`); + const known = componentLabels(keywords); + const existing = issue.labels.map((label) => label.name || label); + if (existing.some((name) => known.includes(name))) { + core.info(`Component label already present: ${existing.join(', ')}`); + return; + } + + const labels = inferComponentLabels(`${issue.title}\n\n${issue.body || ''}`, keywords); + + if (labels.length === 0) { + core.info('No component could be inferred from the issue text'); + return; + } + + core.info(`Inferred: ${labels.join(', ')}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels, + }); diff --git a/.github/workflows/mem0-plugin-checks.yml b/.github/workflows/mem0-plugin-checks.yml new file mode 100644 index 000000000..87eda7ada --- /dev/null +++ b/.github/workflows/mem0-plugin-checks.yml @@ -0,0 +1,58 @@ +name: Mem0 Plugin Checks + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +# +# Covers the Python plugin (scripts/ + tests/). The nested .opencode-plugin/ +# is a separate package with its own workflow (opencode-plugin-checks.yml). +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/mem0-plugin/**' + - '!integrations/mem0-plugin/.opencode-plugin/**' + - '.github/workflows/mem0-plugin-checks.yml' + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: integrations/mem0-plugin + run: | + pip install -r requirements.txt + pip install pytest + + - name: Verify hook entry points are executable + working-directory: integrations/mem0-plugin + run: | + missing=$(find scripts -name '*.sh' ! -name '_*' ! -perm -u+x -print) + if [ -n "$missing" ]; then + echo "Hook entry points must be executable:" + echo "$missing" + exit 1 + fi + + - name: Check hook manifests are valid JSON + working-directory: integrations/mem0-plugin + run: | + for f in plugin.json mcp_config.json hooks.json hooks/*.json; do + jq empty "$f" || (echo "Invalid JSON: $f" && exit 1) + done + + - name: Run tests + working-directory: integrations/mem0-plugin + run: pytest -q diff --git a/.github/workflows/n8n-nodes-mem0-cd.yml b/.github/workflows/n8n-nodes-mem0-cd.yml new file mode 100644 index 000000000..dec25b4f3 --- /dev/null +++ b/.github/workflows/n8n-nodes-mem0-cd.yml @@ -0,0 +1,60 @@ +name: Publish n8n-nodes-mem0 πŸ“¦ to npm + +# Dispatched by release.yml (Release Router) when a release tagged +# n8n-nodes-mem0-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. n8n-nodes-mem0-v0.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 n8n-nodes-mem0 πŸ“¦ to npm + if: startsWith(inputs.tag, 'n8n-nodes-mem0-v') + runs-on: ubuntu-latest + permissions: + id-token: write + defaults: + run: + working-directory: integrations/n8n-nodes-mem0 + 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: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + cache-dependency-path: integrations/n8n-nodes-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Build + run: pnpm run 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 diff --git a/.github/workflows/n8n-nodes-mem0-checks.yml b/.github/workflows/n8n-nodes-mem0-checks.yml new file mode 100644 index 000000000..f211af515 --- /dev/null +++ b/.github/workflows/n8n-nodes-mem0-checks.yml @@ -0,0 +1,88 @@ +name: n8n-nodes-mem0 checks + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/n8n-nodes-mem0/**' + - '.github/workflows/n8n-nodes-mem0-checks.yml' + workflow_call: + +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: integrations/n8n-nodes-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts + + - name: Lint + run: cd integrations/n8n-nodes-mem0 && pnpm run lint + + test: + 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: integrations/n8n-nodes-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts + + - name: Run tests + run: cd integrations/n8n-nodes-mem0 && pnpm test + + 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: integrations/n8n-nodes-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts + + - name: Build + run: cd integrations/n8n-nodes-mem0 && pnpm run build + + - name: Verify dist output exists + run: | + test -f integrations/n8n-nodes-mem0/dist/nodes/Mem0/Mem0.node.js || (echo "Build output missing: dist/nodes/Mem0/Mem0.node.js" && exit 1) + test -f integrations/n8n-nodes-mem0/dist/credentials/Mem0Api.credentials.js || (echo "Build output missing: dist/credentials/Mem0Api.credentials.js" && exit 1) + test -f integrations/n8n-nodes-mem0/dist/nodes/Mem0/mem0.svg || (echo "Build output missing: dist/nodes/Mem0/mem0.svg" && exit 1) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..de75c72a9 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,103 @@ +name: PR Gate + +on: + pull_request_target: + types: [opened, reopened, edited, ready_for_review] + +concurrency: + group: pr-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + GATE_EFFECTIVE_FROM: '2026-08-12T00:00:00Z' + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + gate: + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + + const effectiveFrom = process.env.GATE_EFFECTIVE_FROM; + if (effectiveFrom && Date.parse(pr.created_at) < Date.parse(effectiveFrom)) { + core.info(`Opened ${pr.created_at}, before the gate took effect ${effectiveFrom}. Skipped.`); + return; + } + + const { data: current } = await github.rest.pulls.get({ + owner, repo, pull_number: pr.number, + }); + if (current.state !== 'open') { + core.info(`#${pr.number} is already ${current.state}. Skipped.`); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + if (files.length > 0 && files.every((file) => file.filename.startsWith('docs/'))) { + core.info('Docs-only PR, gate skipped'); + return; + } + + const { repository } = await github.graphql( + `query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { number labels(first: 50) { nodes { name } } } + } + } + } + }`, + { owner, repo, number: pr.number }, + ); + + const accepted = repository.pullRequest.closingIssuesReferences.nodes + .filter((issue) => issue.labels.nodes.some((label) => label.name === 'accepted')) + .map((issue) => issue.number); + + if (accepted.length > 0) { + core.info(`Accepted issue linked: #${accepted.join(', #')}`); + return; + } + + const body = [ + 'Thanks for taking the time to open this.', + '', + 'We only review pull requests that fix an issue we have already agreed to take on, so this one is closed for now.', + '**Closed does not mean rejected.** It means it is not in the queue yet, and reopening takes about a minute.', + '', + 'To get it reviewed:', + '', + '1. Make sure an issue describes the problem, with the version you are on, a runnable reproduction, and the real output or traceback you saw.', + '2. Link it from this pull request description with `Closes #`.', + '3. Ask a maintainer to label that issue `accepted`.', + '4. Reopen this pull request. The check runs again and it stays open.', + '', + 'Already linked an accepted issue? Edit the description to include `Closes #` and reopen. The check reruns automatically.', + '', + 'Documentation-only changes skip this gate entirely.', + '', + 'See [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/CONTRIBUTING.md) for the full policy.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, body, + }); + await github.rest.pulls.update({ + owner, repo, pull_number: pr.number, state: 'closed', + }); + core.info(`Closed #${pr.number}: no accepted issue linked`); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..94d66b41a --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,64 @@ +name: PR Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + +concurrency: + group: pr-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Propagate labels from linked issues + uses: actions/github-script@v7 + with: + script: | + const allowed = new Set([ + 'sdk-python', 'sdk-typescript', 'vector-store', 'plugin', + 'rest-api', 'documentation', 'ci', 'cli', 'integrations', + ]); + const umbrella = { plugin: 'integrations' }; + const { repository } = await github.graphql( + `query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { labels(first: 50) { nodes { name } } } + } + } + } + }`, + { owner: context.repo.owner, repo: context.repo.repo, number: context.issue.number }, + ); + + const labels = new Set(); + for (const issue of repository.pullRequest.closingIssuesReferences.nodes) { + for (const label of issue.labels.nodes) { + if (allowed.has(label.name)) labels.add(label.name); + } + } + + for (const label of [...labels]) { + if (umbrella[label]) labels.add(umbrella[label]); + } + + if (labels.size > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [...labels], + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be797b7b..a2d474f56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: openclaw-v*) workflow="openclaw-cd.yml" ;; opencode-v*) workflow="opencode-plugin-cd.yml" ;; pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;; + n8n-nodes-mem0-v*) workflow="n8n-nodes-mem0-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." diff --git a/.github/workflows/ts-sdk-ci.yml b/.github/workflows/ts-sdk-ci.yml index 6dd38b257..b7fa21413 100644 --- a/.github/workflows/ts-sdk-ci.yml +++ b/.github/workflows/ts-sdk-ci.yml @@ -41,7 +41,7 @@ jobs: set -euo pipefail base_version=$(git show "$BASE_SHA:mem0-ts/package.json" 2>/dev/null | jq -r .version || echo "") - head_version=$(jq -r .version mem0-ts/package.json) + head_version=$(git show "$HEAD_SHA:mem0-ts/package.json" | jq -r .version) echo "Base version: ${base_version:-}" echo "Head version: $head_version" diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml new file mode 100644 index 000000000..ed609d6e8 --- /dev/null +++ b/.github/workflows/vouch-check-pr.yml @@ -0,0 +1,27 @@ +name: Vouch - Check PR + +on: + pull_request_target: + types: [opened, reopened] + +concurrency: + group: vouch-check-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + check: + if: >- + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + steps: + - uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml new file mode 100644 index 000000000..1cbd7aee0 --- /dev/null +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -0,0 +1,42 @@ +name: Vouch - Manage by Issue + +on: + issue_comment: + types: [created] + +concurrency: + group: vouch-manage + cancel-in-progress: false + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + manage: + if: contains(github.event.comment.body, '!vouch') || contains(github.event.comment.body, '!denounce') || contains(github.event.comment.body, '!unvouch') + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: mitchellh/vouch/action/manage-by-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 + with: + repo: ${{ github.repository }} + issue-id: ${{ github.event.issue.number }} + comment-id: ${{ github.event.comment.id }} + vouch-keyword: "!vouch" + denounce-keyword: "!denounce" + unvouch-keyword: "!unvouch" + pull-request: "true" + merge-immediately: "true" + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/zapier-mem0-cd.yml b/.github/workflows/zapier-mem0-cd.yml new file mode 100644 index 000000000..54642fd7c --- /dev/null +++ b/.github/workflows/zapier-mem0-cd.yml @@ -0,0 +1,42 @@ +name: Deploy zapier-mem0 to Zapier + +# Zapier apps deploy to Zapier's own platform (not npm), so this is NOT wired +# into the npm release router (release.yml). It is manual workflow_dispatch +# only and requires the ZAPIER_DEPLOY_KEY repo secret. +# +# gh workflow run zapier-mem0-cd.yml --ref main +on: + workflow_dispatch: + +jobs: + push: + name: Push zapier-mem0 to Zapier + runs-on: ubuntu-latest + defaults: + run: + working-directory: integrations/zapier-mem0 + steps: + - uses: actions/checkout@v4 + + - 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 + cache: 'pnpm' + cache-dependency-path: integrations/zapier-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build TypeScript + run: pnpm build + + - name: Push to Zapier + env: + ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }} + run: npx zapier-platform-cli@19 push diff --git a/.github/workflows/zapier-mem0-checks.yml b/.github/workflows/zapier-mem0-checks.yml new file mode 100644 index 000000000..a5203f285 --- /dev/null +++ b/.github/workflows/zapier-mem0-checks.yml @@ -0,0 +1,47 @@ +name: zapier-mem0 checks + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +# +# CI compiles the TypeScript app, runs `zapier validate` (offline schema + style +# checks) against the build, plus the offline jest unit suite (test/unit.test.ts β€” +# mocked z.request, no network). The end-to-end jest suite is skipped here because +# it hits the live Mem0 API β€” it runs locally with MEM0_API_KEY set (see README). +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/zapier-mem0/**' + - '.github/workflows/zapier-mem0-checks.yml' + workflow_call: + +jobs: + validate: + 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: 22 + cache: 'pnpm' + cache-dependency-path: integrations/zapier-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: cd integrations/zapier-mem0 && pnpm install --frozen-lockfile + + - name: Build TypeScript + run: cd integrations/zapier-mem0 && pnpm build + + - name: Validate Zapier app definition + run: cd integrations/zapier-mem0 && npx zapier-platform-cli@19 validate + + - name: Run offline unit tests + run: cd integrations/zapier-mem0 && pnpm test:unit diff --git a/.kimi-plugin/marketplace.json b/.kimi-plugin/marketplace.json new file mode 100644 index 000000000..faaed4998 --- /dev/null +++ b/.kimi-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "mem0-plugins", + "version": "1", + "plugins": [ + { + "id": "mem0", + "displayName": "Mem0", + "version": "0.1.0", + "description": "Persistent memory for Kimi Code. Remembers decisions, patterns, and preferences across sessions.", + "homepage": "https://mem0.ai", + "keywords": ["memory", "personalization", "mcp", "semantic-search"], + "source": "https://github.com/mem0ai/mem0/tree/main/integrations/mem0-plugin" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index b10ac7b90..7dfecf934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,611 +1,155 @@ # AGENTS.md -This file provides context for AI coding assistants (Claude Code, Cursor, GitHub Copilot, Codex, etc.) working with the Mem0 repository. +Context for AI coding assistants (Claude Code, Cursor, Copilot, Codex) working in the Mem0 repository. -## Project Overview +**Mem0** ("mem-zero") is a memory layer for AI agents: persistent, personalized memory through a hosted platform API and self-hosted open-source SDKs. Apache-2.0. +[Repository](https://github.com/mem0ai/mem0) Β· [Documentation](https://docs.mem0.ai) -**Mem0** ("mem-zero") is an intelligent memory layer for AI agents and assistants. It provides persistent, personalized memory via both a hosted platform API and self-hosted open-source SDKs. +This is a polyglot monorepo and **every package sets its own rules**. Read the `AGENTS.md` nearest the files you are editing before running any command. The linters, formatters, test runners, and line lengths genuinely differ per package, and using the wrong one fails CI or produces a diff full of noise. -- **Repository**: https://github.com/mem0ai/mem0 -- **Documentation**: https://docs.mem0.ai -- **License**: Apache-2.0 +## Do NOT -## Repository Structure +- Open a pull request without a signed CLA. It will not be reviewed. See [The CLA is not optional](#the-cla-is-not-optional). +- Modify anything in `.github/workflows/` without explicit maintainer approval. Publishing credentials are pinned to workflow filenames. +- Commit `.env` files, API keys, or credentials. +- Skip pre-commit hooks. +- Use npm or yarn in TypeScript packages. This repo is pnpm-only (Bun in `.opencode-plugin/`). +- Use `require()` in TypeScript. ES module `import` syntax only. +- Mix up linter configs. Root Python is ruff at line length **120**, `cli/python/` is ruff at **100**, `cli/node/` is Biome, `mem0-ts/` is Prettier, `integrations/vercel-ai-sdk/` is ESLint. +- Add Python dependencies to the core `dependencies` list in `pyproject.toml`. Use an optional group. +- Change a public API without updating `docs/` in the same pull request. +- Introduce a new framework or abstraction without discussion. Follow the patterns already in the file you are editing. -This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs, servers, plugins, and documentation. +## Where to look -### Key Directories +| Editing | Read | Toolchain | +|---------|------|-----------| +| `mem0/` | [`mem0/AGENTS.md`](mem0/AGENTS.md) | hatch, ruff 120, pytest | +| `tests/` | [`tests/AGENTS.md`](tests/AGENTS.md) | pytest | +| `mem0-ts/` | [`mem0-ts/AGENTS.md`](mem0-ts/AGENTS.md) | pnpm, tsup, Prettier, jest | +| `cli/python/` | [`cli/python/AGENTS.md`](cli/python/AGENTS.md) | ruff **100**, pytest | +| `cli/node/` | [`cli/node/AGENTS.md`](cli/node/AGENTS.md) | pnpm, tsup, Biome, vitest | +| `integrations/` | [`integrations/AGENTS.md`](integrations/AGENTS.md) | varies per integration | +| `server/` | [`server/AGENTS.md`](server/AGENTS.md) | Docker Compose, FastAPI | +| `docs/` | [`docs/AGENTS.md`](docs/AGENTS.md) | Mintlify | +| `skills/` | [`skills/AGENTS.md`](skills/AGENTS.md) | markdown, size-budgeted | +| `.github/` | [`.github/AGENTS.md`](.github/AGENTS.md) | GitHub Actions | -| Directory | Description | -|-----------|-------------| -| `mem0/` | Core Python SDK (`mem0ai` on PyPI) β€” memory, LLMs, embeddings, vector stores, graphs, rerankers | -| `mem0-ts/` | TypeScript SDK (`mem0ai` on npm) β€” client + OSS memory | -| `cli/python/` | Python CLI (`mem0-cli` on PyPI) β€” Typer-based, entry point `mem0` | -| `cli/node/` | Node CLI (`@mem0/cli` on npm) β€” Commander-based, entry point `mem0` | -| `integrations/` | **Agent & editor integrations**, one directory per integration (see "Adding a New Integration") | -| `integrations/mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) β€” MCP server connection, lifecycle hooks, skills. Contains nested `.opencode-plugin/` (`@mem0/opencode-plugin`) | -| `integrations/openclaw/` | `@mem0/openclaw-mem0` β€” OpenClaw plugin for Claude Code / AI editors | -| `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` β€” Pi Agent plugin | -| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` β€” Vercel AI SDK memory provider | -| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) | -| `openmemory/` | Self-hosted memory platform β€” `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) | -| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` | +## Repository map + +| Directory | What it is | +|-----------|------------| +| `mem0/` | Core Python SDK (`mem0ai` on PyPI): memory, LLMs, embeddings, vector stores, graphs, rerankers | +| `mem0-ts/` | TypeScript SDK (`mem0ai` on npm): hosted client + OSS memory | +| `cli/python/` | Python CLI (`mem0-cli` on PyPI), Typer-based, entry point `mem0` | +| `cli/node/` | Node CLI (`@mem0/cli` on npm), Commander-based, entry point `mem0` | +| `integrations/` | Agent and editor integrations, one self-contained directory each | +| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + pgvector + Neo4j) | +| `skills/` | Claude Code skill definitions, published by raw URL | | `docs/` | Documentation site (Mintlify) | | `tests/` | Python SDK tests (pytest) | -| `evaluation/` | Submodule β†’ [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) β€” benchmarking (LOCOMO, LongMemEval, BEAM) lives in that repo | -| `examples/` | Sample projects & runnable demos β€” apps, Chrome extension, multi-agent patterns, and Jupyter notebooks (`notebooks/`) | +| `examples/` | Sample apps, Chrome extension, multi-agent patterns, notebooks | +| `scripts/` | Repo-wide utilities, e.g. `check-llms-txt-coverage.py` | +| `evaluation/` | Submodule pinned to [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) | | `pr-reviews/` | Pull request review materials | -| `scripts/` | Repo-wide utility scripts (e.g., `check-llms-txt-coverage.py` for docs/llms.txt sync) | - -### Core Package Dependencies ``` mem0 (Python SDK) mem0-ts (TypeScript SDK) -β”œβ”€β”€ mem0/memory/ β”œβ”€β”€ src/client/ (MemoryClient β€” hosted) -β”œβ”€β”€ mem0/llms/ └── src/oss/ (Memory β€” self-hosted) +β”œβ”€β”€ mem0/memory/ β”œβ”€β”€ src/client/ MemoryClient (hosted) +β”œβ”€β”€ mem0/llms/ └── src/oss/ Memory (self-hosted) β”œβ”€β”€ mem0/embeddings/ β”œβ”€β”€ src/llms/ β”œβ”€β”€ mem0/vector_stores/ β”œβ”€β”€ src/embeddings/ β”œβ”€β”€ mem0/graphs/ β”œβ”€β”€ src/vector_stores/ └── mem0/reranker/ └── src/graphs/ -cli/python/ ──▢ mem0ai (optional, for OSS mode) -cli/node/ ──▢ mem0ai (npm, for API calls) -integrations/vercel-ai-sdk/ ──▢ ai, @ai-sdk/* providers -integrations/openclaw/ ──▢ mem0ai (npm) +cli/python/ ──▢ mem0ai (optional, OSS mode) +cli/node/ ──▢ mem0ai (npm) +integrations/vercel-ai-sdk/ ──▢ ai, @ai-sdk/* +integrations/openclaw/ ──▢ mem0ai (npm) ``` -## Development Setup - -### Requirements - -- **Python**: 3.9+ (3.10+ for CLI) -- **Node.js**: v18+ (v20 or v22 recommended) -- **pnpm**: v10+ (`npm install -g pnpm@10`) β€” used for all TypeScript packages -- **Hatch**: Python build/environment tool (`pip install hatch`) -- **Docker**: Required for `server/` and `openmemory/` development - -### Initial Setup +## Setup ```bash -# Python SDK -hatch shell dev_py_3_11 # creates environment with all deps -pre-commit install # install git hooks +hatch shell dev_py_3_11 # Python: creates the env with all deps +pre-commit install # ruff + isort on commit -# TypeScript packages -cd mem0-ts && pnpm install # TS SDK -cd cli/node && pnpm install # Node CLI -cd integrations/vercel-ai-sdk && pnpm install # Vercel AI provider -cd integrations/openclaw && pnpm install # OpenClaw plugin +cd && pnpm install ``` -## Build, Lint, and Test Commands +Requirements: Python 3.9+ (3.10+ for the CLI), Node 18+ (20 or 22 preferred), pnpm 10+, hatch, Docker for `server/`. -### Python SDK (`mem0/`) +## Conventions everywhere + +- **Naming:** `snake_case.py`, `test_.py`, `snake_case.ts`, `.test.ts`, `kebab-case` for config and manifest files. +- **Python:** Pydantic v2 for models and config. Providers inherit a `base.py` abstract class; config lives in `configs.py`. +- **TypeScript:** strict mode, tsup builds, ES module imports. +- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`). +- **Versions:** bump in `pyproject.toml` or `package.json`. Releases are cut by tag prefix; see [`.github/AGENTS.md`](.github/AGENTS.md). + +## Benchmarking + +Benchmarks (LOCOMO, LongMemEval, BEAM) live in [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks). The in-repo `evaluation/` path is a submodule pinned to that repo's `main`: ```bash -# Environment setup (uses Hatch) -hatch shell dev_py_3_11 # or dev_py_3_9, dev_py_3_10, dev_py_3_12 - -# Linting and formatting -make lint # ruff check -make format # ruff format -make sort # isort mem0/ - -# Tests -make test # pytest tests/ -make test-py-3.9 # test specific Python version (3.9–3.12) - -# Build and publish -make build # hatch build -make publish # hatch publish +git submodule update --init evaluation ``` -- **Python:** 3.9, 3.10, 3.11, 3.12 -- **Linter/formatter:** Ruff (line length **120**) -- **Import sorting:** isort (`profile = "black"`) -- **Test framework:** pytest (with pytest-mock, pytest-asyncio) -- **Pre-commit hooks:** ruff + isort β€” run `pre-commit install` before committing +## What to ship with a change -### TypeScript SDK (`mem0-ts/`) +Guidelines, not rules. Trivial fixes need less; anything user-facing needs more. -```bash -cd mem0-ts -pnpm install -pnpm run build # tsup -pnpm run test # jest (all tests) -pnpm run test:unit # jest --coverage (unit tests only) -pnpm run test:integration # jest (integration tests, needs MEM0_API_KEY) -pnpm run test:ci # jest --coverage --ci (CI mode) -pnpm run test:watch # jest watch mode -``` +| Change | Expect | +|--------|--------| +| **Bug fix** | A regression test that fails without the fix, written first. The fix. The relevant suite passing. The package's linter run. | +| **New feature** | Implementation following existing patterns, test coverage, `docs/` updates for public APIs, an example if the behavior is user-facing, and an `llms.txt` entry for any new `.mdx` page. | +| **New provider** | See [Adding a provider](mem0/AGENTS.md#adding-a-provider). | +| **New integration** | See [Adding an integration](integrations/AGENTS.md#adding-an-integration). | +| **Refactor** | Tests for changed behavior, existing tests still green. No docs needed for internal-only changes. | -- **Node:** 20, 22 (CI-tested) -- **Build:** tsup (CJS + ESM) -- **Test:** jest -- **Formatter:** prettier +Fix bugs at the root, not at the symptom. If a guard belongs in a shared function, put it there rather than in each caller. -### Python CLI (`cli/python/`) +## Contributing -```bash -cd cli/python -pip install -e ".[dev]" # dev install with ruff + pytest -ruff check . # lint -ruff format . # format -pytest # test -hatch build # build -``` +Full guide: [`CONTRIBUTING.md`](CONTRIBUTING.md). Conduct: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). -- **Python:** 3.10+ (not 3.9) -- **Linter/formatter:** Ruff (line length **100** β€” different from root SDK) -- **Ruff rules:** E, F, I, W, UP, B, SIM, RUF (ignores E501, B008 for Typer patterns, SIM108) -- **Framework:** Typer + Rich + httpx -- **Entry point:** `mem0 = "mem0_cli.app:main"` -- **Source layout:** `src/mem0_cli/` -- **Optional dependency:** `mem0ai` (for OSS mode, via `[oss]` extra) +1. Open an issue **first** and wait for a maintainer to apply the `accepted` label. Every PR must link it with `Closes #`. PRs without an accepted linked issue are closed automatically by the [PR Gate](.github/workflows/pr-gate.yml), with a reopen path. Documentation-only changes are exempt. +2. Fork, then branch from `main` (`feature/...`, `fix/...`). +3. Make the change: code, tests, docs, examples. +4. Run lint and tests for **every** package you touched. +5. Commit with Conventional Commits. +6. Open the PR against `main` and fill in [the template](.github/PULL_REQUEST_TEMPLATE.md). Do not paraphrase it; GitHub prefills it. +7. **Sign the CLA.** -### Node CLI (`cli/node/`) +### The CLA is not optional -```bash -cd cli/node -pnpm install -pnpm run build # tsup -pnpm run lint # biome check src/ -pnpm run lint:fix # biome check --write src/ -pnpm run typecheck # tsc --noEmit -pnpm run test # vitest run -pnpm run test:watch # vitest (watch mode) -pnpm run dev # tsx src/index.ts (development) -``` +**A pull request from a contributor who has not signed the Contributor License Agreement is not accepted, not reviewed, and not merged.** This is not a formality applied at merge time. An unsigned pull request does not enter the review queue at all: maintainers do not read the diff, do not leave feedback, and do not discuss the approach. It sits until the CLA is signed, and it is closed if it goes stale. -- **Node:** 18+ required -- **Build:** tsup (ESM) -- **Linter:** Biome (not ESLint, not Ruff) -- **Test:** vitest (not jest) -- **Framework:** Commander + Chalk + ora + cli-table3 +The `CLAassistant` bot comments on your first pull request with a link. Signing takes under a minute, is done once per GitHub account, and covers every contribution you make afterwards. Until it is signed the `license/cla` check stays red. -### Vercel AI SDK Provider (`integrations/vercel-ai-sdk/`) +If you are an agent opening a pull request on someone's behalf, tell them they must sign it themselves. Nobody else can sign for them, and the pull request goes nowhere until they do. -```bash -cd integrations/vercel-ai-sdk -pnpm install -pnpm run build # tsup -pnpm run lint # eslint -pnpm run type-check # tsc --noEmit -pnpm run prettier-check # prettier --check -pnpm run test # jest -pnpm run test:edge # vitest (edge runtime) -pnpm run test:node # vitest (node runtime) -``` +### What gets a pull request closed -- **Build:** tsup (CJS + ESM) -- **Lint:** ESLint + Prettier -- **Test:** jest + vitest (edge/node configs) +Beyond the CLA and the accepted-issue gate, the [Contribution Conduct](CODE_OF_CONDUCT.md#contribution-conduct) section of the code of conduct is the enforceable form of this repo's anti-slop policy: -### OpenClaw Plugin (`integrations/openclaw/`) +- **Disclose AI use.** The PR template asks how the *code* was written; drafting the description with a model is fine. The disclosure is never held against you, it tells a reviewer where to look. Silence followed by a review comment you cannot answer is what costs everyone the afternoon. +- **Do not submit work you have not run.** A bug report means you reproduced it. A PR means you ran the tests. +- **Do not fabricate evidence.** Invented tracebacks, unmeasured benchmarks, tests that assert the implementation back at itself, descriptions that describe a different change than the diff makes. +- **Match your volume to your engagement.** Open changes at the rate you can discuss them. +- **Do not press for merges.** One polite follow-up after a reasonable wait is fine. +- **You must be able to explain every line of your diff** and how it interacts with the rest of the codebase, without asking an AI tool. This is the one rule that does not bend. -```bash -cd integrations/openclaw -pnpm install -pnpm run build # tsup -pnpm run test # vitest run -``` +### Reference -- **Build:** tsup (ESM) -- **Test:** vitest (with Codecov in CI) -- **Plugin manifest:** `openclaw.plugin.json` - -### Server (`server/`) - -```bash -# Docker production build -cd server -make build # docker build -t mem0-api-server . -make run_local # docker run -p 8000:8000 with .env - -# Docker Compose development (FastAPI + PostgreSQL/pgvector + Neo4j) -cd server -docker-compose up # starts all 3 services -# mem0 API: localhost:8888 -# PostgreSQL: localhost:8432 -# Neo4j HTTP: localhost:8474, Bolt: localhost:8687 -``` - -- **Framework:** FastAPI with uvicorn (auto-reload in dev) -- **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin -- **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes - -### OpenMemory (`openmemory/`) - -```bash -# Full stack via Docker Compose -cd openmemory -docker-compose up -# Qdrant: localhost:6333 -# API (MCP): localhost:8765 -# UI: localhost:3000 - -# Individual development -cd openmemory/api && uvicorn main:app --reload # FastAPI backend -cd openmemory/ui && npm run dev # Next.js frontend - -# Tests -cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py) -``` - -- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol) -- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts -- **Vector store:** Qdrant - -### Documentation (`docs/`) - -```bash -make docs # or: cd docs && mintlify dev -``` - -- **Framework:** Mintlify -- **API spec:** `docs/openapi.json` -- **Structure:** `api-reference/`, `open-source/`, `platform/`, `integrations/`, `cookbooks/`, `core-concepts/` - -### Evaluation / Benchmarking - -Benchmarking lives in the external [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) repo (LOCOMO + LongMemEval + BEAM). The in-repo `evaluation/` path is a **git submodule** pinned to that repo's `main` β€” populate it with `git submodule update --init evaluation` (or clone mem0 with `--recurse-submodules`), or clone the benchmarks repo standalone: - -```bash -git clone https://github.com/mem0ai/memory-benchmarks.git -cd memory-benchmarks -pip install -r requirements.txt - -# Run a benchmark (Mem0 Cloud; use docker compose for OSS) -python -m benchmarks.locomo.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY -python -m benchmarks.longmemeval.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY --all-questions -python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY --chat-sizes 100K --conversations 0-9 -``` - -## Core APIs - -### Python - -| Function / Class | Purpose | Import | -|-----------------|---------|--------| -| `Memory` | Self-hosted memory (sync) | `from mem0 import Memory` | -| `AsyncMemory` | Self-hosted memory (async) | `from mem0 import AsyncMemory` | -| `MemoryClient` | Hosted platform client (sync) | `from mem0 import MemoryClient` | -| `AsyncMemoryClient` | Hosted platform client (async) | `from mem0 import AsyncMemoryClient` | - -**Key `Memory` / `MemoryClient` methods:** - -| Method | Purpose | -|--------|---------| -| `add(messages, *, user_id, agent_id, run_id, metadata)` | Store a new memory | -| `search(query, *, user_id, agent_id, run_id, limit, filters)` | Search memories | -| `get(memory_id)` | Retrieve a single memory by ID | -| `get_all(*, user_id, agent_id, run_id, limit)` | List all memories | -| `update(memory_id, data)` | Update a memory | -| `delete(memory_id)` | Delete a memory | -| `delete_all(*, user_id, agent_id, run_id)` | Delete all memories | -| `history(memory_id)` | Get change history for a memory | - -### TypeScript - -| Export | Purpose | Import | -|--------|---------|--------| -| `MemoryClient` | Hosted platform client | `import { MemoryClient } from 'mem0ai'` | -| `Memory` | Self-hosted OSS memory | `import { Memory } from 'mem0ai/oss'` | - -## Import Patterns - -### Python - -| What | Import | -|------|--------| -| Core memory classes | `from mem0 import Memory, AsyncMemory` | -| Platform client | `from mem0 import MemoryClient, AsyncMemoryClient` | -| Configuration | `from mem0.configs.base import MemoryConfig` | -| LLM providers | `from mem0.llms. import ` | -| Embedding providers | `from mem0.embeddings. import ` | -| Vector store providers | `from mem0.vector_stores. import ` | - -### TypeScript - -| What | Import | -|------|--------| -| Hosted client | `import { MemoryClient } from 'mem0ai'` | -| OSS memory | `import { Memory } from 'mem0ai/oss'` | -| Specific providers (OSS) | `import { OpenAIEmbedding } from 'mem0ai/oss'` | - -## Coding Standards - -### File Naming Conventions - -- **Python source files:** `snake_case.py` (e.g., `azure_openai.py`, `cohere_reranker.py`) -- **Python test files:** `test_.py` (e.g., `test_memory.py`, `test_main.py`) -- **TypeScript source files:** `snake_case.ts` (e.g., `azure_ai_search.ts`) -- **TypeScript test files:** `.test.ts` (e.g., `memory.test.ts`) -- **Config/manifest files:** `kebab-case` (e.g., `openclaw.plugin.json`, `jest.config.js`) - -### Python Conventions - -- **Provider pattern:** All providers (LLMs, embeddings, vector stores, graphs, rerankers) inherit from a `base.py` abstract class in their directory. Config classes live in `configs.py`. -- **Pydantic v2** for all data models and configuration. -- **Ruff** is the single linting and formatting tool β€” no black, no flake8. - - Root SDK: line length **120** - - Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF) -- **isort** with `profile = "black"` for import sorting. -- Ruff excludes `openmemory/` from root config. - -### TypeScript Conventions - -- **Build:** tsup across all packages. -- **Package manager:** pnpm everywhere (no npm, no yarn). -- **TypeScript strict mode** across all packages. -- **Linting varies by package:** - -| Package | Linter | Formatter | Test Framework | -|---------|--------|-----------|---------------| -| `mem0-ts/` | β€” | Prettier | jest | -| `cli/node/` | Biome | Biome | vitest | -| `integrations/vercel-ai-sdk/` | ESLint | Prettier | jest + vitest | -| `integrations/openclaw/` | β€” | β€” | vitest | - -### Type Checking - -Always run type checking after modifying TypeScript code: - -```bash -cd && pnpm run typecheck # or: tsc --noEmit -``` - -## Architecture - -### Provider Pattern - -The SDK uses a consistent plugin architecture across 5 categories. Each category has a `base.py` abstract class and concrete provider implementations: - -| Category | Count | Examples | -|----------|-------|---------| -| **LLMs** | 24 | OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, Gemini, Groq, Ollama, Together, DeepSeek, vLLM, LiteLLM, LM Studio, xAI | -| **Vector Stores** | 30 | Qdrant, Pinecone, Chroma, Weaviate, Milvus, MongoDB, Redis, Elasticsearch, pgvector, Supabase, Faiss, S3 Vectors | -| **Embeddings** | 15 | OpenAI, Azure OpenAI, Gemini, HuggingFace, FastEmbed, Together, AWS Bedrock, Ollama, Vertex AI | -| **Graph Stores** | 4 | Neo4j, Memgraph, Kuzu, Apache AGE | -| **Rerankers** | 5 | Cohere, HuggingFace, LLM-based, Sentence Transformer, Zero Entropy | - -### Two Usage Modes - -Self-hosted `Memory` / `AsyncMemory` classes and hosted-platform `MemoryClient` β€” both in Python and TypeScript. - -### Graph Memory - -Optional layer on top of vector memory for relationship-aware retrieval. Configured via the `graph` section of `MemoryConfig`. - -### MCP Integration - -Model Context Protocol support in multiple places: - -- **Remote:** MCP server at `mcp.mem0.ai` -- **Local:** MCP server in `openmemory/api/` (FastAPI-based) -- **Plugin:** MCP tools in `integrations/mem0-plugin/` β€” 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities` - -### Plugin & Skills System - -- `integrations/mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture. -- `skills/` contains structured skill definitions for AI agents, split into two categories: - - **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider). - - **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch (the two are loosely coupled via `.mem0-integration/` artifacts); `mem0-oss-to-platform` migrates an existing project from Mem0 OSS to the hosted Platform SDK (plan, then execute on approval). - -### Adding a New Provider - -To add a new LLM, embedding, vector store, or reranker provider: - -1. Create `mem0//.py` -2. Inherit from the abstract base class in `mem0//base.py` -3. Add configuration to `mem0//configs.py` (if the category uses one) -4. Register the provider in `mem0//__init__.py` -5. Add tests in `tests///` -6. Add any new dependencies to the appropriate optional group in `pyproject.toml` (never to core `dependencies`) -7. Follow the exact pattern of existing providers in the same category β€” match method signatures, error handling, and config structure - -### Adding a New Integration - -Agent/editor integrations live under `integrations/`. Each is a self-contained directory (its own `package.json`/lockfile, build, and tests). To add one: - -1. Create `integrations//` and build the integration there. -2. If it publishes to a registry, set `repository.directory: "integrations/"` in its `package.json` so npm provenance links to the correct subdirectory. -3. Add CI/CD under `.github/workflows/` (`-checks.yml`, `-cd.yml`). Use `integrations/` in `paths:` triggers, `working-directory`, and `cache-dependency-path`. Register the release tag prefix in the `case` block in `release.yml` (keep the bare `v*` arm last). Keep workflow **filenames** stable β€” npm OIDC trusted publishing is pinned to repo + workflow filename. -4. If it is a Claude Code / editor marketplace plugin, register its path in the five `marketplace.json` files (root + `.claude-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`). -5. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`. -6. Add rows to the "Key Directories" table and the CI/CD tables in this file. - -## CI/CD - -### CI Workflows (automated testing) - -PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)** runs on every PR, detects which packages changed, and invokes only the relevant package workflows below as reusable workflows (`workflow_call`). Its final **`CI Gate`** job aggregates the results (skipped pipelines pass; failed or cancelled ones fail) and is the **only status check that needs to be required** in branch protection. Package workflows keep their own push-to-main and manual triggers; their `pull_request` triggers moved into the gate's path filters. - -| Workflow | File | Standalone Triggers | Tests | -|----------|------|---------------------|-------| -| CI Gate | `ci-gate.yml` | All PRs | Routes to and aggregates the workflows below | -| Python SDK | `ci.yml` | Push to main | Ruff lint + pytest on Python 3.10, 3.11, 3.12 | -| TypeScript SDK | `ts-sdk-ci.yml` | Push to main (on `mem0-ts/`) | Prettier + build + jest on Node 20, 22 | -| Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 | -| Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | -| OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | -| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check | -| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 | -| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage check | - -When adding a new package CI workflow: give it `workflow_call` (plus `push`/`workflow_dispatch` as needed, but no `pull_request` trigger), then register it in `ci-gate.yml` β€” a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list. - -### 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`) | -| Node CLI | `cli-node-cd.yml` | `cli-node-v*` | npm (`@mem0/cli`) | -| 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 -cd.yml --ref refs/tags/ -f 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 - -| Workflow | File | Purpose | -|----------|------|---------| -| Issue Labeler | `issue-labeler.yml` | Automatic issue labeling | -| Stale Bot | `stale.yml` | Marks stale issues and PRs | -| llms.txt Check | `docs-llms-txt-check.yml` | Blocks PRs touching `docs/**/*.mdx` when `docs/llms.txt` is out of sync. Fix locally with `python scripts/check-llms-txt-coverage.py --write`. | - -## Task Completion Guidelines - -These guidelines outline typical artifacts for different task types. Use judgment to adapt based on scope and context. - -### Bug Fixes - -1. **Unit tests**: Add tests that would fail without the fix (regression tests) -2. **Implementation**: Fix the bug -3. **Manual verification**: Run the relevant test suite to confirm the fix -4. **Lint**: Run the appropriate linter for the package you modified - -### New Features - -1. **Implementation**: Build the feature following existing patterns -2. **Unit tests**: Comprehensive test coverage for new functionality -3. **Documentation**: Update relevant docs in `docs/` for public APIs -4. **Examples**: Add usage examples if the feature introduces new user-facing behavior -5. **llms.txt**: Any new `.mdx` page under `docs/` must be linked in `docs/llms.txt` with a scope tag (`[Platform]` / `[OSS]` / `[Both]`) and a `Use when ...` description. The `docs-llms-txt-check.yml` workflow runs on every PR that touches docs and **fails the check** if the index is out of sync. To fix: run `python scripts/check-llms-txt-coverage.py --write` locally to scaffold placeholders under `## Unclassified - needs triage`, then replace the `[TODO: ...]` tags, rewrite descriptions as `Use when ...`, move entries into the right section, and delete the triage heading when empty. - -### New Provider (LLM / Embedding / Vector Store / Reranker) - -1. **Implementation**: Follow the "Adding a New Provider" steps above -2. **Tests**: Add unit tests matching the pattern of existing providers -3. **Configuration**: Add to the appropriate `configs.py` and `__init__.py` -4. **Dependencies**: Add to the correct optional group in `pyproject.toml` -5. **Documentation**: Add an integration guide in `docs/integrations/` - -### Refactoring / Internal Changes - -- Unit tests for any changed behavior -- No documentation needed for internal-only changes -- Ensure all existing tests still pass - -### When to Deviate - -These are guidelines, not rigid rules. Adjust based on: - -- **Scope**: Trivial fixes (typos, comments) may not need tests -- **Visibility**: Internal changes may not need documentation -- **Context**: Some changes span multiple categories β€” use judgment - -When uncertain about expected artifacts, ask for clarification. - -## Contributing Guidelines - -### Workflow - -1. Fork and clone the repository. -2. Create a feature branch from `main` (e.g., `feature/my-new-feature`). -3. Make your changes β€” add tests, docs, and examples as appropriate. -4. Run linting and tests for every package you modified (see commands above). -5. Run `pre-commit install` on first setup β€” hooks run ruff + isort automatically. -6. Commit with a clear message following [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `docs:`, `refactor:`). -7. Push and open a Pull Request against `main`. - -### Pull Request Requirements - -Every PR must follow the repo's PR template (`.github/PULL_REQUEST_TEMPLATE.md`): - -1. **Linked Issue** β€” Reference the issue with `Closes #`. If no issue exists, create one first or explain why in the description. -2. **Description** β€” Explain what the PR does and why it's needed. -3. **Type of Change** β€” Check the appropriate box: - - Bug fix / New feature / Breaking change / Refactor / Documentation update -4. **Breaking Changes** β€” If applicable, describe what breaks and the migration path. -5. **Test Coverage** β€” Check what applies: - - Added/updated unit tests - - Added/updated integration tests - - Tested manually (describe how) - - No tests needed (explain why) -6. **Checklist** β€” All must be checked before merge: - - [ ] Code follows the project's style guidelines - - [ ] Self-review performed - - [ ] Tests added that prove the fix/feature works - - [ ] New and existing tests pass locally - - [ ] Documentation updated if needed - -### PR Description Template - -```markdown -## Linked Issue - -Closes # - -## Description - - - -## Type of Change - -- [ ] Bug fix (non-breaking change that fixes an issue) -- [ ] New feature (non-breaking change that adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) -- [ ] Refactor (no functional changes) -- [ ] Documentation update - -## Breaking Changes - -N/A - -## Test Coverage - -- [ ] I added/updated unit tests -- [ ] I added/updated integration tests -- [ ] I tested manually (describe below) -- [ ] No tests needed (explain why) - -## Checklist - -- [ ] My code follows the project's style guidelines -- [ ] I have performed a self-review of my code -- [ ] I have added tests that prove my fix/feature works -- [ ] New and existing tests pass locally -- [ ] I have updated documentation if needed -``` - -### General Rules - -- Follow existing code patterns β€” don't introduce new frameworks or abstractions without discussion. -- Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript). -- For `server/` and `openmemory/` work, use Docker Compose for local development. -- Do NOT use `pip` or `conda` for dependency management β€” use `hatch` (see `docs/contributing/development.mdx`). - -### Contributing Guides - -| Task | Guide | -|------|-------| -| Code contributions | `docs/contributing/development.mdx` | +| Topic | File | +|-------|------| +| Contributor guide | [`CONTRIBUTING.md`](CONTRIBUTING.md) | +| Code of conduct | [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) | +| Security reports | [`SECURITY.md`](SECURITY.md) | +| Development setup | `docs/contributing/development.mdx` | | Documentation contributions | `docs/contributing/documentation.mdx` | | PR template | `.github/PULL_REQUEST_TEMPLATE.md` | -| Bug reports | `.github/ISSUE_TEMPLATE/bug_report.yml` | -| Feature requests | `.github/ISSUE_TEMPLATE/feature_request.yml` | -| Documentation issues | `.github/ISSUE_TEMPLATE/documentation_issue.yml` | - -## Do NOT - -- Modify CI/CD workflows without explicit approval. -- Add new Python dependencies to the core `dependencies` list in `pyproject.toml` without discussion β€” use optional dependency groups instead. -- Commit `.env` files, API keys, or credentials. -- Skip pre-commit hooks. -- Use npm or yarn in TypeScript packages β€” this repo uses pnpm exclusively. -- Use `require()` for imports in TypeScript β€” use ES module `import` syntax. -- Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff). -- Modify `openmemory/` database migrations without understanding the Alembic migration chain. -- Change public APIs without updating documentation in `docs/`. +| Issue forms | `.github/ISSUE_TEMPLATE/` | +| Trust list (vouch) | `.github/VOUCHED.td` | +| CI/CD, gates, rulesets | [`.github/AGENTS.md`](.github/AGENTS.md) | diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..a0bd43bb6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,181 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Contribution Conduct + +Mem0 receives more contributions than any maintainer can read line by line. The +rules below exist so that the time we do have goes to people who are actually +trying to improve the project. They apply to issues, pull requests, discussions, +and reviews. + +**Be honest about how the work was produced.** Using an AI tool to find a bug, +write a patch, or draft a description is fine and welcome. Not saying so is not. +Every issue form and the pull request template ask about AI, and the answer is +never held against you. It tells a reviewer where to look. An unanswered review +comment on code the author cannot explain is what costs us the afternoon. + +**Do not submit work you have not verified.** A reported bug means you ran it and +saw it. A pull request means you ran the tests. Pasting a model's output, a +scanner result, or a plausible-looking patch and letting maintainers find out +whether it is real moves your work onto someone else's desk. Reports and patches +that turn out to be unverified are closed without a detailed response. + +**Do not fabricate evidence.** Invented tracebacks, benchmark numbers you did not +measure, reproductions that were never run, tests that assert the implementation +back at itself, and descriptions that describe a different change than the diff +makes are all treated the same way, regardless of whether a person or a tool +produced them. + +**Match your volume to your engagement.** Open changes at the rate you can +discuss them. A queue of open pull requests from one author, none of them +answered when questioned, is treated as automated submission and handled under +enforcement below, whatever the individual diffs look like. + +**Do not press for merges.** Bumping a thread, tagging maintainers repeatedly, +asking in Discord or by direct message for a review, and reopening a closed pull +request without addressing why it was closed all take attention away from the +queue rather than moving your change through it. One polite follow-up after a +reasonable wait is fine. + +**Do not contribute for a badge.** Changes made to raise a contribution count, +qualify for an event, or pad a profile, whitespace edits, README churn, and +mechanical reformatting bundled with nothing else, are closed on sight. + +**Disagreement is fine, and closing is not a verdict.** Our pull request gate +closes changes that do not yet link an accepted issue. That is a queue decision, +not a judgment of you or your code, and reopening takes about a minute. Argue for +your change on its merits; that is a normal and welcome part of contributing. + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, including this +repository, our Discord, and our documentation, and also applies when an +individual is officially representing the community in public spaces. Examples of +representing our community include using an official email address, posting via +an official social media account, or acting as an appointed representative at an +online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers at **support@mem0.ai**. All complaints will be +reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community, or a first contribution that +breaches the Contribution Conduct rules above. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions, including a repeated pattern of unverified or automated submissions +after a first warning. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. At this stage the account may be denounced in `.github/VOUCHED.td`, which +means new pull requests are flagged automatically. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. +The Contribution Conduct section is specific to this repository. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla]: https://github.com/mozilla/inclusion +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddba58989..b62ad1717 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,12 @@ new features, documentation, examples, and integrations. Mem0 is a polyglot monorepo, and this guide covers contributing to both the **Python SDK** and the **TypeScript SDK** (and the rest of the repository). +By participating you agree to our [Code of Conduct](./CODE_OF_CONDUCT.md). Its +**Contribution Conduct** section is the enforceable form of the rules on this +page: disclose AI use, don't submit work you haven't run, don't fabricate +reproductions or benchmarks, keep your volume matched to your engagement, and +don't press for merges. + ## Before You Start ### 1. Open an Issue First @@ -23,9 +29,53 @@ in code. - For anything beyond a trivial fix, wait for a maintainer to confirm the approach before starting significant work. -Every pull request must link to an issue using `Closes #`. +A bug report needs a reproduction we can run, the version you are on, and the +real output or traceback you saw. Reports without those cannot be acted on and +get closed. A feature request needs the problem you hit and the workaround you +are living with, not just the API you would like. -### 2. Sign the Contributor License Agreement (CLA) +Every pull request must link to an issue using `Closes #`, and that +issue must carry the `accepted` label. A maintainer applies `accepted` once we +agree the change is one we want. + +Pull requests that don't link an accepted issue are closed automatically by the +[PR Gate](./.github/workflows/pr-gate.yml). **Closed does not mean rejected.** It +means the change isn't in the queue yet. Once a maintainer labels the issue, +reopen the pull request and it stays open. Documentation-only changes skip the +gate entirely. + +Security fixes are the one exception, and they don't go through public pull +requests at all. Follow the [Security Policy](./SECURITY.md) instead, which uses +a private advisory and a private fork so the vulnerability isn't disclosed before +the fix ships. + +### 2. Understand Your Code + +**You must be able to explain what your changes do and how they interact with +the rest of the codebase without the help of an AI tool.** This is the one rule +we will not bend on. + +Using AI to write code is fine. Most of us do. You can build real understanding +by interrogating an agent about this codebase until you grasp the edge cases and +the blast radius of your change. What is not fine is opening a pull request for +a diff you cannot defend in review. + +Disclose it in the pull request template and say what you checked yourself. +We ask about the code, not the write-up: using AI to draft the pull request +description is fine. We ask because it tells reviewers where to look, not +because it counts against you. An honest "an agent wrote this, here is what I +verified" is welcome. Silence, followed by a review comment you cannot answer, +is what wastes everyone's time. + +Signs your pull request will be closed: + +- Invented APIs, config keys, or providers that don't exist in this repo. +- Tests that assert the implementation back at itself rather than the behaviour. +- A description that describes a different change than the diff makes. +- Sweeping unrelated reformatting bundled with a small fix. +- You cannot answer a direct question about your own diff. + +### 3. Sign the Contributor License Agreement (CLA) **We cannot accept or merge any pull request until you have signed our Contributor License Agreement (CLA).** @@ -45,7 +95,7 @@ The two most common contribution targets are the SDKs: | TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` | Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations -(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site +(`integrations/`), the self-hosted `server/`, and the docs site (`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository. ## Development Workflow diff --git a/Makefile b/Makefile index ccc01b53b..605b08ec9 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ install: hatch env create install_all: - pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \ + pip install ruff==0.16.0 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \ google-generativeai elasticsearch opensearch-py vecs "pinecone<7.0.0" pinecone-text faiss-cpu langchain-community \ upstash-vector azure-search-documents langchain-memgraph langchain-neo4j langchain-aws rank-bm25 pymochow pymongo psycopg kuzu databricks-sdk valkey diff --git a/SECURITY.md b/SECURITY.md index 67bf77ba8..fae1fb4b6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,11 +21,17 @@ privately through one of the following channels: To help us triage and resolve the issue quickly, please include as much of the following as you can: -- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory) +- Affected component or package (e.g. Python SDK, TypeScript SDK, server, CLI) - Affected version, tag, or commit - Clear, step-by-step reproduction instructions - The security impact and a proof of concept, if available - Any suggested fix or mitigation +- Whether an AI tool was involved in finding or writing up the report + +Reports generated by an AI tool are welcome, but only once you have run the +reproduction yourself and confirmed the impact is real. A scan result or model +output pasted in without that step is not a vulnerability report, and we close +those without a detailed response so we can spend the time on real ones. ## Response Process diff --git a/cli/README.md b/cli/README.md index d411daa65..2bce6aad6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,9 +60,8 @@ mem0 delete | `mem0 entity` | List or delete entities (users, agents, apps, runs) | | `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) | | `mem0 status` | Verify API connection and display current project | -| `mem0 version` | Print the CLI version | -Run `mem0 --help` for detailed usage on any command. +Run `mem0 --help` for detailed usage on any command, or `mem0 --version` to print the CLI version. ## Agent mode diff --git a/cli/cli-spec.json b/cli/cli-spec.json index 437bb8e46..c8cecad3a 100644 --- a/cli/cli-spec.json +++ b/cli/cli-spec.json @@ -207,7 +207,11 @@ { "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." }, { "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." }, { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, - { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Categories (JSON array or comma-separated)." }, + { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Not supported on add, use --custom-categories instead." }, + { "name": "custom_instructions", "flags": ["--custom-instructions"], "type": "string", "help": "Custom instructions for fact extraction." }, + { "name": "custom_categories", "flags": ["--custom-categories"], "type": "string", "help": "Custom categories as a JSON array of {name: description} objects." }, + { "name": "structured_data_schema", "flags": ["--structured-data-schema"], "type": "string", "help": "Schema for structured data extraction, as JSON." }, + { "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" } @@ -244,6 +248,9 @@ { "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" }, { "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" }, { "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" }, + { "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Search" }, + { "name": "reference_date", "flags": ["--reference-date"], "type": "string", "help": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", "panel": "Search" }, + { "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Search" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" } @@ -296,6 +303,8 @@ { "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" }, { "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" }, { "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" }, + { "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Filters" }, + { "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Filters" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" } @@ -329,6 +338,8 @@ ], "options": [ { "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." }, + { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, + { "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } ], "apiEndpoint": "update" @@ -356,6 +367,7 @@ { "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." }, { "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." }, { "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." }, + { "name": "delete_linked", "flags": ["--delete-linked"], "type": "boolean", "default": false, "help": "Also delete memories linked to this memory." }, { "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." }, { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." }, { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" }, diff --git a/cli/node/AGENTS.md b/cli/node/AGENTS.md new file mode 100644 index 000000000..06d51f1be --- /dev/null +++ b/cli/node/AGENTS.md @@ -0,0 +1,41 @@ +# Node CLI (`cli/node/`) + +The `@mem0/cli` package on npm. Commander-based, entry point `mem0`. + +## Commands + +```bash +pnpm install +pnpm run build # tsup (ESM) +pnpm run lint # biome check src/ +pnpm run lint:fix # biome check --write src/ +pnpm run typecheck # tsc --noEmit +pnpm run test # vitest run +pnpm run test:watch +pnpm run dev # tsx src/index.ts +``` + +pnpm only. Never npm, never yarn. + +## Conventions + +> **Biome, not ESLint. vitest, not jest.** `mem0-ts/` uses Prettier + jest and +> `integrations/vercel-ai-sdk/` uses ESLint + jest. Running those tools here produces +> spurious diffs. Every toolchain in this repo is per-package. + +- **Node 18+** required. +- **Build:** tsup, ESM output only. +- **Linter and formatter:** Biome, configured in `biome.json`. +- **Tests:** vitest. +- **TypeScript strict mode.** ES module `import` syntax only, never `require()`. + +Run `pnpm run typecheck` after every change. + +## Dependencies + +Commander + Chalk + ora + cli-table3, and `mem0ai` (npm) for API calls. + +## CI and release + +- CI: `cli-node-ci.yml`, Biome + tsc + vitest + tsup build on Node 20 and 22. +- Release: tag prefix `cli-node-v*` dispatches `cli-node-cd.yml`, publishing to npm over OIDC. diff --git a/cli/node/CLAUDE.md b/cli/node/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/cli/node/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/cli/node/README.md b/cli/node/README.md index 6110d1149..dd96b6515 100644 --- a/cli/node/README.md +++ b/cli/node/README.md @@ -232,14 +232,6 @@ Verify your API connection and display the current project. mem0 status ``` -### `mem0 version` - -Print the CLI version. - -```bash -mem0 version -``` - ## Agent mode Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: @@ -298,6 +290,8 @@ These flags are available on all commands: | `--base-url` | Override the configured API base URL for this request | | `-o, --output` | Set the output format | +`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one. + ## Environment variables | Variable | Description | diff --git a/cli/node/development.md b/cli/node/development.md index 5b5d0b060..38317253f 100644 --- a/cli/node/development.md +++ b/cli/node/development.md @@ -51,7 +51,7 @@ pnpm link --global # Now use it like a normal CLI mem0 --help -mem0 version +mem0 --version ``` > **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`. diff --git a/cli/node/package.json b/cli/node/package.json index 4df1dc60c..77b30b0c9 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/cli", - "version": "0.2.10", + "version": "0.2.12", "description": "The official CLI for mem0 β€” the memory layer for AI agents", "type": "module", "bin": { @@ -51,8 +51,8 @@ "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", - "postcss@<8.5.10": ">=8.5.10", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "postcss@<8.5.18": ">=8.5.18 <9.0.0" } } } diff --git a/cli/node/pnpm-lock.yaml b/cli/node/pnpm-lock.yaml index 0edb7ac12..390560cd1 100644 --- a/cli/node/pnpm-lock.yaml +++ b/cli/node/pnpm-lock.yaml @@ -9,8 +9,8 @@ overrides: 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 - postcss@<8.5.10: '>=8.5.10' esbuild: '>=0.28.1' + postcss@<8.5.18: '>=8.5.18 <9.0.0' importers: @@ -40,7 +40,7 @@ importers: version: 20.19.37 tsup: specifier: ^8.0.0 - version: 8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.23)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.7.0 version: 4.21.0 @@ -78,24 +78,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -316,66 +320,79 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -653,8 +670,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -695,7 +712,7 @@ packages: engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.5.10' + postcss: '>=8.5.18 <9.0.0' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -708,8 +725,8 @@ packages: yaml: optional: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} readdirp@4.1.2: @@ -821,7 +838,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: '>=8.5.10' + postcss: '>=8.5.18 <9.0.0' typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -1388,7 +1405,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.12: {} + nanoid@3.3.16: {} object-assign@4.1.1: {} @@ -1424,16 +1441,16 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.15)(tsx@4.21.0): + postcss-load-config@6.0.1(postcss@8.5.23)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.23 tsx: 4.21.0 - postcss@8.5.15: + postcss@8.5.23: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1554,7 +1571,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.23)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -1565,7 +1582,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.15)(tsx@4.21.0) + postcss-load-config: 6.0.1(postcss@8.5.23)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.60.0 source-map: 0.7.6 @@ -1574,7 +1591,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.23 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -1602,7 +1619,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.23 rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: diff --git a/cli/node/pnpm-workspace.yaml b/cli/node/pnpm-workspace.yaml index 53fde1b88..daadb240e 100644 --- a/cli/node/pnpm-workspace.yaml +++ b/cli/node/pnpm-workspace.yaml @@ -10,5 +10,5 @@ overrides: 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 - "postcss@<8.5.10": ">=8.5.10" "esbuild": ">=0.28.1" + "postcss@<8.5.18": ">=8.5.18 <9.0.0" diff --git a/cli/node/src/backend/base.ts b/cli/node/src/backend/base.ts index be1a0ce1f..073c9c5a4 100644 --- a/cli/node/src/backend/base.ts +++ b/cli/node/src/backend/base.ts @@ -14,7 +14,11 @@ export interface AddOptions { immutable?: boolean; infer?: boolean; expires?: string; - categories?: string[]; + customInstructions?: string; + agentCustomInstructions?: string; + customCategories?: Record[]; + structuredDataSchema?: Record; + timestamp?: number; } export interface SearchOptions { @@ -28,6 +32,9 @@ export interface SearchOptions { keyword?: boolean; filters?: Record; fields?: string[]; + showExpired?: boolean; + referenceDate?: string | number; + latestOnly?: boolean; } export interface ListOptions { @@ -40,6 +47,8 @@ export interface ListOptions { category?: string; after?: string; before?: string; + showExpired?: boolean; + latestOnly?: boolean; } export interface DeleteOptions { @@ -48,6 +57,12 @@ export interface DeleteOptions { agentId?: string; appId?: string; runId?: string; + deleteLinked?: boolean; +} + +export interface UpdateOptions { + expirationDate?: string; + timestamp?: number; } export interface EntityIds { @@ -77,6 +92,7 @@ export interface Backend { memoryId: string, content?: string, metadata?: Record, + opts?: UpdateOptions, ): Promise>; delete( diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 0819bf3cb..705a3d470 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -15,6 +15,7 @@ import { type ListOptions, NotFoundError, type SearchOptions, + type UpdateOptions, } from "./base.js"; function encodePathSegment(value: unknown): string { @@ -150,7 +151,15 @@ export class PlatformBackend implements Backend { if (opts.immutable) payload.immutable = true; if (opts.infer === false) payload.infer = false; if (opts.expires) payload.expiration_date = opts.expires; - if (opts.categories) payload.categories = opts.categories; + if (opts.customInstructions) + payload.custom_instructions = opts.customInstructions; + if (opts.agentCustomInstructions) + payload.agent_custom_instructions = opts.agentCustomInstructions; + if (opts.customCategories) + payload.custom_categories = opts.customCategories; + if (opts.structuredDataSchema) + payload.structured_data_schema = opts.structuredDataSchema; + if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp; payload.source = "CLI"; return (await this._request("POST", "/v3/memories/add/", { @@ -211,6 +220,10 @@ export class PlatformBackend implements Backend { if (opts.rerank) payload.rerank = true; if (opts.keyword) payload.keyword_search = true; if (opts.fields) payload.fields = opts.fields; + if (opts.showExpired) payload.show_expired = true; + if (opts.referenceDate !== undefined) + payload.reference_date = opts.referenceDate; + if (opts.latestOnly) payload.latest_only = true; payload.source = "CLI"; const result = (await this._request("POST", "/v3/memories/search/", { @@ -265,6 +278,8 @@ export class PlatformBackend implements Backend { extraFilters: Object.keys(extra).length > 0 ? extra : undefined, }); if (apiFilters) payload.filters = apiFilters; + if (opts.showExpired) payload.show_expired = true; + if (opts.latestOnly) payload.latest_only = true; payload.source = "CLI"; const result = (await this._request("POST", "/v3/memories/", { @@ -280,10 +295,13 @@ export class PlatformBackend implements Backend { memoryId: string, content?: string, metadata?: Record, + opts: UpdateOptions = {}, ): Promise> { const payload: Record = {}; if (content) payload.text = content; if (metadata) payload.metadata = metadata; + if (opts.expirationDate) payload.expiration_date = opts.expirationDate; + if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp; payload.source = "CLI"; return (await this._request( "PUT", @@ -309,12 +327,12 @@ export class PlatformBackend implements Backend { })) as Record; } if (memoryId) { + const params: Record = { source: "CLI" }; + if (opts.deleteLinked) params.delete_linked = "true"; return (await this._request( "DELETE", `/v1/memories/${encodePathSegment(memoryId)}/`, - { - params: { source: "CLI" }, - }, + { params }, )) as Record; } throw new Error("Either memoryId or --all is required"); diff --git a/cli/node/src/commands/memory.ts b/cli/node/src/commands/memory.ts index 3b7b67b4f..5f1bc984b 100644 --- a/cli/node/src/commands/memory.ts +++ b/cli/node/src/commands/memory.ts @@ -21,16 +21,19 @@ import { formatSingleMemory, printResultSummary, } from "../output.js"; -import { isAgentMode, setCurrentCommand } from "../state.js"; +import { isAgentMode, setCurrentCommand, stdinIsPiped } from "../state.js"; -/** True only when stdin is an actual pipe or file redirect β€” never in agent mode. */ -function _stdinIsPiped(): boolean { - if (isAgentMode()) return false; - try { - const stat = fs.fstatSync(0); - return stat.isFIFO() || stat.isFile(); - } catch { - return false; +/** Exit 1 if value is not a future YYYY-MM-DD date. */ +function _validateExpires(value: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + printError( + "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).", + ); + process.exit(1); + } + if (new Date(value) <= new Date()) { + printError("--expires date must be in the future."); + process.exit(1); } } @@ -49,10 +52,23 @@ export async function cmdAdd( infer?: boolean; expires?: string; categories?: string; + customInstructions?: string; + agentCustomInstructions?: string; + customCategories?: string; + structuredDataSchema?: string; + timestamp?: number; output: string; }, ): Promise { setCurrentCommand("add"); + + if (opts.categories) { + printError( + "--categories is not supported on add. Use --custom-categories instead.", + ); + process.exit(1); + } + let msgs: Record[] | undefined; let content = text; @@ -78,7 +94,7 @@ export async function cmdAdd( } } // Read from stdin only if stdin is an actual pipe or file redirect - else if (!content && _stdinIsPiped()) { + else if (!content && stdinIsPiped()) { content = fs.readFileSync(0, "utf-8").trim(); } @@ -93,20 +109,6 @@ export async function cmdAdd( process.exit(1); } - // Validate --expires - if (opts.expires) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) { - printError( - "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).", - ); - process.exit(1); - } - if (new Date(opts.expires) <= new Date()) { - printError("--expires date must be in the future."); - process.exit(1); - } - } - let meta: Record | undefined; if (opts.metadata) { try { @@ -117,15 +119,28 @@ export async function cmdAdd( } } - let cats: string[] | undefined; - if (opts.categories) { + let customCats: Record[] | undefined; + if (opts.customCategories) { try { - cats = JSON.parse(opts.categories); + customCats = JSON.parse(opts.customCategories); } catch { - cats = opts.categories.split(",").map((c) => c.trim()); + printError("Invalid JSON in --custom-categories."); + process.exit(1); } } + let schema: Record | undefined; + if (opts.structuredDataSchema) { + try { + schema = JSON.parse(opts.structuredDataSchema); + } catch { + printError("Invalid JSON in --structured-data-schema."); + process.exit(1); + } + } + + if (opts.expires) _validateExpires(opts.expires); + let result: Record; try { result = await timedStatus("Adding memory...", async () => { @@ -138,7 +153,11 @@ export async function cmdAdd( immutable: opts.immutable, infer: opts.infer !== false, expires: opts.expires, - categories: cats, + customInstructions: opts.customInstructions, + agentCustomInstructions: opts.agentCustomInstructions, + customCategories: customCats, + structuredDataSchema: schema, + timestamp: opts.timestamp, }); }); } catch (e) { @@ -223,6 +242,9 @@ export async function cmdSearch( keyword: boolean; filterJson?: string; fields?: string; + showExpired?: boolean; + referenceDate?: string; + latestOnly?: boolean; output: string; }, ): Promise { @@ -271,6 +293,9 @@ export async function cmdSearch( keyword: opts.keyword, filters, fields: fieldList, + showExpired: opts.showExpired, + referenceDate: opts.referenceDate, + latestOnly: opts.latestOnly, }); }); } catch (e) { @@ -364,6 +389,8 @@ export async function cmdList( category?: string; after?: string; before?: string; + showExpired?: boolean; + latestOnly?: boolean; output: string; }, ): Promise { @@ -391,6 +418,8 @@ export async function cmdList( category: opts.category, after: opts.after, before: opts.before, + showExpired: opts.showExpired, + latestOnly: opts.latestOnly, }); }); } catch (e) { @@ -450,7 +479,12 @@ export async function cmdUpdate( backend: Backend, memoryId: string, text: string | undefined, - opts: { metadata?: string; output: string }, + opts: { + metadata?: string; + expires?: string; + timestamp?: number; + output: string; + }, ): Promise { setCurrentCommand("update"); let meta: Record | undefined; @@ -463,11 +497,16 @@ export async function cmdUpdate( } } + if (opts.expires) _validateExpires(opts.expires); + const start = performance.now(); let result: Record; try { result = await timedStatus("Updating memory...", async () => { - return backend.update(memoryId, text, meta); + return backend.update(memoryId, text, meta, { + expirationDate: opts.expires, + timestamp: opts.timestamp, + }); }); } catch (e) { printError(e instanceof Error ? e.message : String(e)); @@ -493,7 +532,12 @@ export async function cmdUpdate( export async function cmdDelete( backend: Backend, memoryId: string, - opts: { output: string; dryRun?: boolean; force?: boolean }, + opts: { + output: string; + dryRun?: boolean; + force?: boolean; + deleteLinked?: boolean; + }, ): Promise { setCurrentCommand("delete"); if (opts.dryRun) { @@ -514,7 +558,7 @@ export async function cmdDelete( let result: Record; try { result = await timedStatus("Deleting...", async () => { - return backend.delete(memoryId); + return backend.delete(memoryId, { deleteLinked: opts.deleteLinked }); }); } catch (e) { printError(e instanceof Error ? e.message : String(e)); diff --git a/cli/node/src/commands/utils.ts b/cli/node/src/commands/utils.ts index 1e12d231a..354200d71 100644 --- a/cli/node/src/commands/utils.ts +++ b/cli/node/src/commands/utils.ts @@ -1,5 +1,5 @@ /** - * Utility commands: status, version, import. + * Utility commands: status, import. */ import fs from "node:fs"; @@ -8,7 +8,6 @@ import type { Backend } from "../backend/base.js"; import { colors, printError, printSuccess, timedStatus } from "../branding.js"; import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js"; import { setCurrentCommand } from "../state.js"; -import { CLI_VERSION } from "../version.js"; const { brand, dim, success, error: errorColor } = colors; @@ -82,10 +81,6 @@ export async function cmdStatus( console.log(); } -export function cmdVersion(): void { - console.log(` ${brand("β—† Mem0")} CLI v${CLI_VERSION}`); -} - export async function cmdImport( backend: Backend, filePath: string, diff --git a/cli/node/src/help.ts b/cli/node/src/help.ts index 4b014a50d..d89ca59f2 100644 --- a/cli/node/src/help.ts +++ b/cli/node/src/help.ts @@ -36,7 +36,16 @@ const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [ }, { panel: "Management", - commands: ["init", "status", "import", "help", "entity", "event", "config"], + commands: [ + "init", + "status", + "version", + "import", + "help", + "entity", + "event", + "config", + ], }, ]; diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index b038fb171..5639cd6b4 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -17,6 +17,7 @@ import { isAgentMode, setAgentMode, setCurrentCommand, + stdinIsPiped, takeNotice, } from "./state.js"; import { captureEvent } from "./telemetry.js"; @@ -94,6 +95,10 @@ async function getBackendOnly( return (await getBackendAndConfig(apiKey, baseUrl)).backend; } +function printVersion(): void { + console.log(` ${colors.brand("β—† Mem0")} CLI v${CLI_VERSION}`); +} + function checkAgentMode(): boolean { const rootOpts = program.opts(); const isAgent = !!(rootOpts.json || rootOpts.agent); @@ -153,7 +158,7 @@ program .enablePositionalOptions() .option("--version", "Show version and exit.") .on("option:version", () => { - console.log(` ${colors.brand("β—† Mem0")} CLI v${CLI_VERSION}`); + printVersion(); process.exit(0); }) .option("--json", "Output as JSON for agent/programmatic use.") @@ -319,7 +324,29 @@ program .option("--immutable", "Prevent future updates.", false) .option("--no-infer", "Skip inference, store raw.") .option("--expires ", "Expiration date (YYYY-MM-DD).") - .option("--categories ", "Categories (JSON array or comma-separated).") + .option( + "--categories ", + "Not supported on add, use --custom-categories instead.", + ) + .option( + "--custom-instructions ", + "Custom instructions for fact extraction.", + ) + .option( + "--agent-custom-instructions ", + "Extraction instructions for agent-scoped memories, overriding the project setting.", + ) + .option( + "--custom-categories ", + "Custom categories as a JSON array of {name: description} objects.", + ) + .option( + "--structured-data-schema ", + "Schema for structured data extraction, as JSON.", + ) + .option("--timestamp ", "Unix timestamp for the memory.", (v) => + Number.parseInt(v), + ) .option("-o, --output ", "Output format: text, json, quiet.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -364,18 +391,31 @@ program ) .option("--rerank", "Enable reranking (Platform only).", false) .option("--keyword", "Use keyword search.", false) - .option("--filter ", "Advanced filter expression (JSON).") + .option( + "--filter ", + 'Advanced filter as JSON: {"AND": [...]} or {"OR": [...]}, e.g. {"AND": [{"categories": {"in": ["work"]}}]}.', + ) .option("--fields ", "Specific fields to return (comma-separated).") + .option("--show-expired", "Include expired memories.", false) + .option( + "--reference-date ", + "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + ) + .option( + "--latest-only", + "Only return the latest version of each memory.", + false, + ) .option("-o, --output ", "Output: text, json, table.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") .addHelpText( "after", - '\nExamples:\n $ mem0 search "preferences" --user-id alice\n $ mem0 search "tools" -u alice -o json -k 5\n $ echo "preferences" | mem0 search -u alice', + '\nExamples:\n $ mem0 search "preferences" --user-id alice\n $ mem0 search "tools" -u alice -o json -k 5\n $ echo "preferences" | mem0 search -u alice\n $ mem0 search "invoices" -u alice --filter \'{"AND": [{"categories": {"in": ["work"]}}]}\'', ) .action(async (query, opts) => { let resolvedQuery = query; - if (!resolvedQuery && !process.stdin.isTTY) { + if (!resolvedQuery && stdinIsPiped()) { resolvedQuery = fs.readFileSync(0, "utf-8").trim(); } if (!resolvedQuery) { @@ -398,6 +438,9 @@ program keyword: opts.keyword, filterJson: opts.filter, fields: opts.fields, + showExpired: opts.showExpired, + referenceDate: opts.referenceDate, + latestOnly: opts.latestOnly, output, }); }); @@ -441,6 +484,12 @@ program .option("--category ", "Filter by category.") .option("--after ", "Created after (YYYY-MM-DD).") .option("--before ", "Created before (YYYY-MM-DD).") + .option("--show-expired", "Include expired memories.", false) + .option( + "--latest-only", + "Only return the latest version of each memory.", + false, + ) .option("-o, --output ", "Output: text, json, table.", "table") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -464,6 +513,8 @@ program category: opts.category, after: opts.after, before: opts.before, + showExpired: opts.showExpired, + latestOnly: opts.latestOnly, output, }); }); @@ -474,6 +525,10 @@ program .command("update [text]") .description("Update a memory's text or metadata.") .option("-m, --metadata ", "Update metadata (JSON).") + .option("--expires ", "Expiration date (YYYY-MM-DD).") + .option("--timestamp ", "Unix timestamp for the memory.", (v) => + Number.parseInt(v), + ) .option("-o, --output ", "Output: text, json, quiet.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -483,7 +538,7 @@ program ) .action(async (memoryId, text, opts) => { let resolvedText = text; - if (!resolvedText && !opts.metadata && !process.stdin.isTTY) { + if (!resolvedText && stdinIsPiped()) { resolvedText = fs.readFileSync(0, "utf-8").trim(); } const { cmdUpdate } = await import("./commands/memory.js"); @@ -492,6 +547,8 @@ program const output = isAgent ? "agent" : opts.output; await cmdUpdate(backend, memoryId, resolvedText, { metadata: opts.metadata, + expires: opts.expires, + timestamp: opts.timestamp, output, }); }); @@ -510,6 +567,11 @@ program .option("--project", "With --all: delete ALL memories project-wide.", false) .option("--dry-run", "Show what would be deleted without deleting.", false) .option("--force", "Skip confirmation.", false) + .option( + "--delete-linked", + "Also delete memories linked to this memory.", + false, + ) .option("-u, --user-id ", "Scope to user.") .option("--agent-id ", "Scope to agent.") .option("--app-id ", "Scope to app.") @@ -563,6 +625,7 @@ program output, dryRun: opts.dryRun, force: opts.force, + deleteLinked: opts.deleteLinked, }); return; } @@ -767,6 +830,13 @@ program }); }); +program + .command("version") + .description("Show version and exit.") + .action(() => { + printVersion(); + }); + program .command("import ") .description("Import memories from a JSON file.") @@ -806,8 +876,8 @@ program .addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json") .action((opts) => { // opts.json is set when `mem0 help --json` is used (subcommand flag). - // program.opts().json is set when the root --json global flag was used first. - if (opts.json || program.opts().json) { + // program.opts().json/.agent is set when a root global flag was used first. + if (opts.json || program.opts().json || program.opts().agent) { // Load spec from parent directory const __dirname = path.dirname(fileURLToPath(import.meta.url)); const specPath = path.join(__dirname, "..", "..", "cli-spec.json"); diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts index fa14849e7..84dffb1fe 100644 --- a/cli/node/src/output.ts +++ b/cli/node/src/output.ts @@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown { } case "search": return (data as Record[]).map((r) => - pick(r, ["id", "memory", "score", "created_at", "categories"]), + pick(r, [ + "id", + "memory", + "score", + "created_at", + "categories", + "expiration_date", + ]), ); case "list": return (data as Record[]).map((r) => - pick(r, ["id", "memory", "created_at", "categories"]), + pick(r, [ + "id", + "memory", + "created_at", + "categories", + "expiration_date", + ]), ); case "get": { const r = data as Record; @@ -297,11 +310,12 @@ export function sanitizeAgentData(command: string, data: unknown): unknown { "updated_at", "categories", "metadata", + "expiration_date", ]); } case "update": { const r = data as Record; - return pick(r, ["id", "memory"]); + return pick(r, ["id", "memory", "expiration_date"]); } case "delete": case "delete-all": diff --git a/cli/node/src/state.ts b/cli/node/src/state.ts index 724068925..03ace9a4b 100644 --- a/cli/node/src/state.ts +++ b/cli/node/src/state.ts @@ -3,6 +3,8 @@ * read by commands and branding functions. */ +import fs from "node:fs"; + let _agentMode = false; let _currentCommand = ""; let _pendingNotice = ""; @@ -38,3 +40,14 @@ export function takeNotice(): string { _pendingNotice = ""; return msg; } + +/** True only when stdin is an actual pipe or file redirect (never in agent mode). */ +export function stdinIsPiped(): boolean { + if (isAgentMode()) return false; + try { + const stat = fs.fstatSync(0); + return stat.isFIFO() || stat.isFile(); + } catch { + return false; + } +} diff --git a/cli/node/tests/cli-integration.test.ts b/cli/node/tests/cli-integration.test.ts index 630cfec4b..96d5178a4 100644 --- a/cli/node/tests/cli-integration.test.ts +++ b/cli/node/tests/cli-integration.test.ts @@ -44,15 +44,31 @@ describe("CLI Integration β€” help and version", () => { expect(result.stdout).toContain("search"); }); - it("help --json produces valid JSON", () => { - const result = run(["help", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - // spec may have cli.name or top-level name - const name = parsed.name ?? parsed.cli?.name; - expect(name).toBe("mem0"); + it("prints the version with --version", () => { + const flag = run(["--version"]); + expect(flag.exitCode).toBe(0); + expect(flag.stdout).toContain("Mem0"); }); + it("version subcommand output matches --version output byte-for-byte", () => { + const flag = run(["--version"]); + const cmd = run(["version"]); + expect(cmd.exitCode).toBe(0); + expect(cmd.stdout).toBe(flag.stdout); + }); + + it.each([["help", "--json"], ["--json", "help"], ["--agent", "help"]])( + "%s %s produces valid JSON", + (...args) => { + const result = run(args); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + // spec may have cli.name or top-level name + const name = parsed.name ?? parsed.cli?.name; + expect(name).toBe("mem0"); + }, + ); + it("shows add help", () => { const result = run(["add", "--help"]); expect(result.exitCode).toBe(0); @@ -119,6 +135,13 @@ describe("CLI Integration β€” help and version", () => { expect(result.stdout).toContain("--rerank"); }); + it("search help documents the --filter JSON shape with an example", () => { + const result = run(["search", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("AND"); + expect(result.stdout).toContain("categories"); + }); + it("list help has --category flag", () => { const result = run(["list", "--help"]); expect(result.exitCode).toBe(0); diff --git a/cli/node/tests/commands.test.ts b/cli/node/tests/commands.test.ts index 4cd43d1be..02789f056 100644 --- a/cli/node/tests/commands.test.ts +++ b/cli/node/tests/commands.test.ts @@ -2,11 +2,11 @@ * Tests for CLI commands using mock backend. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; -import { createMockBackend } from "./setup.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Backend } from "../src/backend/base.js"; import { setAgentMode } from "../src/state.js"; +import { createMockBackend } from "./setup.js"; let mockBackend: Backend; @@ -17,450 +17,657 @@ const originalLog = console.log; const originalError = console.error; beforeEach(() => { - mockBackend = createMockBackend(); - output = ""; - errOutput = ""; - console.log = (...args: unknown[]) => { - output += args.map(String).join(" ") + "\n"; - }; - console.error = (...args: unknown[]) => { - errOutput += args.map(String).join(" ") + "\n"; - }; + mockBackend = createMockBackend(); + output = ""; + errOutput = ""; + console.log = (...args: unknown[]) => { + output += args.map(String).join(" ") + "\n"; + }; + console.error = (...args: unknown[]) => { + errOutput += args.map(String).join(" ") + "\n"; + }; }); // Restore after each test import { afterEach } from "vitest"; afterEach(() => { - console.log = originalLog; - console.error = originalError; - setAgentMode(false); + console.log = originalLog; + console.error = originalError; + setAgentMode(false); }); describe("cmdAdd", () => { - it("adds text memory", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "I prefer dark mode", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledOnce(); - }); + it("adds text memory", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "I prefer dark mode", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledOnce(); + }); - it("adds from messages JSON", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, undefined, { - userId: "alice", - messages: JSON.stringify([{ role: "user", content: "I love Python" }]), - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledOnce(); - }); + it("adds from messages JSON", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, undefined, { + userId: "alice", + messages: JSON.stringify([{ role: "user", content: "I love Python" }]), + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledOnce(); + }); - it("outputs json format", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "json", - }); - expect(output).toContain("results"); - }); + it("outputs json format", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "json", + }); + expect(output).toContain("results"); + }); - it("quiet mode produces no memory content", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "quiet", - }); - expect(output).not.toContain("dark mode"); - }); + it("quiet mode produces no memory content", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "quiet", + }); + expect(output).not.toContain("dark mode"); + }); }); describe("cmdAdd forwards --no-infer (regression for #5261)", () => { - it("forwards infer: false when --no-infer is set", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - // `infer: false` is the shape Commander produces for `--no-infer`. - await cmdAdd(mockBackend, "store me verbatim", { - userId: "alice", - immutable: false, - infer: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledWith( - "store me verbatim", - undefined, - expect.objectContaining({ infer: false }), - ); - }); + it("forwards infer: false when --no-infer is set", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "store me verbatim", { + userId: "alice", + immutable: false, + infer: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "store me verbatim", + undefined, + expect.objectContaining({ infer: false }), + ); + }); - it("forwards infer: true by default (flag absent)", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "infer me", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledWith( - "infer me", - undefined, - expect.objectContaining({ infer: true }), - ); - }); + it("forwards infer: true by default (flag absent)", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "infer me", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "infer me", + undefined, + expect.objectContaining({ infer: true }), + ); + }); - it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => { - // Pins the assumption the fix relies on: Commander's `--no-X` option - // populates the positive camelCase key (`infer`), never `noInfer`. - const withFlag = new Command(); - withFlag.option("--no-infer", "Skip inference, store raw.").action(() => {}); - withFlag.parse(["--no-infer"], { from: "user" }); - expect(withFlag.opts().infer).toBe(false); - expect(withFlag.opts().noInfer).toBeUndefined(); + it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => { + const withFlag = new Command(); + withFlag + .option("--no-infer", "Skip inference, store raw.") + .action(() => {}); + withFlag.parse(["--no-infer"], { from: "user" }); + expect(withFlag.opts().infer).toBe(false); + expect(withFlag.opts().noInfer).toBeUndefined(); - const withoutFlag = new Command(); - withoutFlag.option("--no-infer", "Skip inference, store raw.").action(() => {}); - withoutFlag.parse([], { from: "user" }); - expect(withoutFlag.opts().infer).toBe(true); - }); + const withoutFlag = new Command(); + withoutFlag + .option("--no-infer", "Skip inference, store raw.") + .action(() => {}); + withoutFlag.parse([], { from: "user" }); + expect(withoutFlag.opts().infer).toBe(true); + }); +}); + +describe("cmdAdd forwards MEM-5893 option-parity flags", () => { + it("forwards customInstructions, customCategories, structuredDataSchema, timestamp", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + customInstructions: "Extract only preferences.", + customCategories: JSON.stringify([{ prefs: "user preferences" }]), + structuredDataSchema: JSON.stringify({ type: "object" }), + timestamp: 1700000000, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "test", + undefined, + expect.objectContaining({ + customInstructions: "Extract only preferences.", + customCategories: [{ prefs: "user preferences" }], + structuredDataSchema: { type: "object" }, + timestamp: 1700000000, + }), + ); + }); + + it("regression: metadata, expires, and customCategories reach the backend together", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + metadata: JSON.stringify({ source: "test" }), + expires: "2099-01-01", + customCategories: JSON.stringify([{ prefs: "user preferences" }]), + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "test", + undefined, + expect.objectContaining({ + metadata: { source: "test" }, + expires: "2099-01-01", + customCategories: [{ prefs: "user preferences" }], + }), + ); + }); +}); + +describe("cmdAdd rejects invalid input before calling the backend (MEM-5893)", () => { + function mockProcessExit() { + return vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + } + + it("rejects --categories with a message pointing to --custom-categories", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + categories: "prefs", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--custom-categories"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("rejects invalid JSON in --custom-categories", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + customCategories: "not-json", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--custom-categories"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("rejects invalid JSON in --structured-data-schema", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + structuredDataSchema: "not-json", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--structured-data-schema"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); }); describe("cmdAdd deduplicates PENDING", () => { - const DUPLICATE_PENDING = { - results: [ - { status: "PENDING", event_id: "evt-dup" }, - { status: "PENDING", event_id: "evt-dup" }, - ], - }; + const DUPLICATE_PENDING = { + results: [ + { status: "PENDING", event_id: "evt-dup" }, + { status: "PENDING", event_id: "evt-dup" }, + ], + }; - it("text shows one pending block", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(output.match(/Queued/g)?.length).toBe(1); - }); + it("text shows one pending block", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(output.match(/Queued/g)?.length).toBe(1); + }); - it("json shows one pending entry", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "json", - }); - const data = JSON.parse(output); - const pending = data.results.filter((r: Record) => r.status === "PENDING"); - expect(pending).toHaveLength(1); - }); + it("json shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "json", + }); + const data = JSON.parse(output); + const pending = data.results.filter( + (r: Record) => r.status === "PENDING", + ); + expect(pending).toHaveLength(1); + }); - it("agent shows one pending entry", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - setAgentMode(true); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "agent", - }); - const data = JSON.parse(output); - expect(data.count).toBe(1); - expect(data.data).toHaveLength(1); - }); + it("agent shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "agent", + }); + const data = JSON.parse(output); + expect(data.count).toBe(1); + expect(data.data).toHaveLength(1); + }); }); describe("cmdSearch", () => { - it("searches and shows results in text mode", async () => { - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("searches and shows results in text mode", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "text", - }); - expect(output).toContain("Found 2"); - }); + output: "text", + }); + expect(output).toContain("Found 2"); + }); - it("outputs json format", async () => { - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("outputs json format", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "json", - }); - expect(output).toContain("memory"); - }); + output: "json", + }); + expect(output).toContain("memory"); + }); - it("shows no results message", async () => { - (mockBackend.search as ReturnType).mockResolvedValue([]); - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "nonexistent", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("shows no results message", async () => { + (mockBackend.search as ReturnType).mockResolvedValue([]); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "nonexistent", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "text", - }); - expect(errOutput).toContain("No memories found"); - }); + output: "text", + }); + expect(errOutput).toContain("No memories found"); + }); + + it("forwards showExpired, referenceDate, latestOnly (MEM-5893)", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + output: "text", + }); + expect(mockBackend.search).toHaveBeenCalledWith( + "preferences", + expect.objectContaining({ + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + }), + ); + }); }); describe("cmdGet", () => { - it("gets memory in text mode", async () => { - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "text" }); - expect(output).toContain("dark mode"); - }); + it("gets memory in text mode", async () => { + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "text" }); + expect(output).toContain("dark mode"); + }); - it("gets memory in json mode", async () => { - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "json" }); - expect(output).toContain("memory"); - }); + it("gets memory in json mode", async () => { + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "json" }); + expect(output).toContain("memory"); + }); }); describe("cmdList", () => { - it("lists in table mode", async () => { - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("lists in table mode", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "table", - }); - expect(output).toContain("dark mode"); - }); + output: "table", + }); + expect(output).toContain("dark mode"); + }); - it("shows empty message", async () => { - (mockBackend.listMemories as ReturnType).mockResolvedValue([]); - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("shows empty message", async () => { + (mockBackend.listMemories as ReturnType).mockResolvedValue( + [], + ); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "text", - }); - expect(errOutput).toContain("No memories found"); - }); + output: "text", + }); + expect(errOutput).toContain("No memories found"); + }); + + it("forwards showExpired and latestOnly (MEM-5893)", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, + showExpired: true, + latestOnly: true, + output: "text", + }); + expect(mockBackend.listMemories).toHaveBeenCalledWith( + expect.objectContaining({ showExpired: true, latestOnly: true }), + ); + }); }); describe("cmdUpdate", () => { - it("updates memory", async () => { - const { cmdUpdate } = await import("../src/commands/memory.js"); - await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" }); - expect(output.toLowerCase()).toContain("updated"); - }); + it("updates memory", async () => { + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" }); + expect(output.toLowerCase()).toContain("updated"); + }); + + it("forwards expires and timestamp (MEM-5893)", async () => { + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "New text", { + expires: "2099-01-01", + timestamp: 1700000000, + output: "text", + }); + expect(mockBackend.update).toHaveBeenCalledWith( + "abc-123", + "New text", + undefined, + expect.objectContaining({ + expirationDate: "2099-01-01", + timestamp: 1700000000, + }), + ); + }); }); describe("cmdDelete", () => { - it("deletes memory", async () => { - const { cmdDelete } = await import("../src/commands/memory.js"); - await cmdDelete(mockBackend, "abc-123", { output: "text" }); - expect(output.toLowerCase()).toContain("deleted"); - }); + it("deletes memory", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "text" }); + expect(output.toLowerCase()).toContain("deleted"); + }); + + it("forwards deleteLinked (MEM-5893)", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { + deleteLinked: true, + output: "text", + }); + expect(mockBackend.delete).toHaveBeenCalledWith( + "abc-123", + expect.objectContaining({ deleteLinked: true }), + ); + }); }); describe("cmdDeleteAll", () => { - it("deletes all with force", async () => { - const { cmdDeleteAll } = await import("../src/commands/memory.js"); - await cmdDeleteAll(mockBackend, { - force: true, - userId: "alice", - output: "text", - }); - expect(output.toLowerCase()).toContain("deleted"); - }); + it("deletes all with force", async () => { + const { cmdDeleteAll } = await import("../src/commands/memory.js"); + await cmdDeleteAll(mockBackend, { + force: true, + userId: "alice", + output: "text", + }); + expect(output.toLowerCase()).toContain("deleted"); + }); }); - describe("cmdEntitiesList", () => { - it("lists users in table mode", async () => { - const { cmdEntitiesList } = await import("../src/commands/entities.js"); - await cmdEntitiesList(mockBackend, "users", { output: "table" }); - expect(output).toContain("alice"); - }); + it("lists users in table mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "table" }); + expect(output).toContain("alice"); + }); - it("lists in json mode", async () => { - const { cmdEntitiesList } = await import("../src/commands/entities.js"); - await cmdEntitiesList(mockBackend, "users", { output: "json" }); - expect(output).toContain("alice"); - }); + it("lists in json mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "json" }); + expect(output).toContain("alice"); + }); }); describe("cmdEventList", () => { - it("lists events in table mode", async () => { - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "table" }); - expect(output).toContain("evt-abc-"); - expect(output).toContain("ADD"); - expect(output).toContain("SUCCEEDED"); - }); + it("lists events in table mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect(output).toContain("evt-abc-"); + expect(output).toContain("ADD"); + expect(output).toContain("SUCCEEDED"); + }); - it("lists events in json mode", async () => { - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "json" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("evt-def-456-ghi-789"); - }); + it("lists events in json mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "json" }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("evt-def-456-ghi-789"); + }); - it("shows empty message when no events", async () => { - (mockBackend.listEvents as ReturnType).mockResolvedValueOnce([]); - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "table" }); - expect((output + errOutput).toLowerCase()).toContain("no events"); - }); + it("shows empty message when no events", async () => { + (mockBackend.listEvents as ReturnType).mockResolvedValueOnce( + [], + ); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect((output + errOutput).toLowerCase()).toContain("no events"); + }); }); describe("cmdEventStatus", () => { - it("shows event details in text mode", async () => { - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("SUCCEEDED"); - }); + it("shows event details in text mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "text", + }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("SUCCEEDED"); + }); - it("shows event details in json mode", async () => { - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("ADD"); - }); + it("shows event details in json mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "json", + }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("ADD"); + }); }); describe("agent mode", () => { - it("cmdAdd outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test preference", { - userId: "alice", - immutable: false, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("add"); - expect(parsed.data).toBeDefined(); - expect(parsed.scope).toMatchObject({ user_id: "alice" }); - expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort()); - }); + it("cmdAdd outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test preference", { + userId: "alice", + immutable: false, + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("add"); + expect(parsed.data).toBeDefined(); + expect(parsed.scope).toMatchObject({ user_id: "alice" }); + expect(Object.keys(parsed.data[0]).sort()).toEqual( + ["event", "id", "memory"].sort(), + ); + }); - it("cmdSearch outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("cmdSearch outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("search"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - const keys = Object.keys(parsed.data[0]); - expect(keys).toContain("id"); - expect(keys).toContain("memory"); - expect(keys).toContain("score"); - expect(keys).toContain("created_at"); - expect(keys).toContain("categories"); - expect(keys).not.toContain("user_id"); - expect(keys).not.toContain("agent_id"); - }); + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("search"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + const keys = Object.keys(parsed.data[0]); + expect(keys).toContain("id"); + expect(keys).toContain("memory"); + expect(keys).toContain("score"); + expect(keys).toContain("created_at"); + expect(keys).toContain("categories"); + expect(keys).not.toContain("user_id"); + expect(keys).not.toContain("agent_id"); + }); - it("cmdList outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("cmdList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("list"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]); - }); + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual([ + "categories", + "created_at", + "id", + "memory", + ]); + }); - it("cmdGet outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("get"); - expect(parsed.data).toBeDefined(); - expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); - expect(Object.keys(parsed.data)).not.toContain("user_id"); - }); + it("cmdGet outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("get"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); + expect(Object.keys(parsed.data)).not.toContain("user_id"); + }); - it("cmdUpdate outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdUpdate } = await import("../src/commands/memory.js"); - await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("update"); - expect(parsed.data).toBeDefined(); - }); + it("cmdUpdate outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "Updated text", { + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("update"); + expect(parsed.data).toBeDefined(); + }); - it("cmdDelete outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdDelete } = await import("../src/commands/memory.js"); - await cmdDelete(mockBackend, "abc-123", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("delete"); - expect(parsed.data).toBeDefined(); - }); + it("cmdDelete outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("delete"); + expect(parsed.data).toBeDefined(); + }); - it("cmdEventList outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("event list"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - expect(Object.keys(parsed.data[0]).sort()).toEqual( - ["created_at", "event_type", "id", "latency", "status"], - ); - expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); - }); + it("cmdEventList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual([ + "created_at", + "event_type", + "id", + "latency", + "status", + ]); + expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); + }); - it("cmdEventStatus outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("event status"); - expect(parsed.data).toBeDefined(); - expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); - expect(parsed.data.results[0]).toHaveProperty("memory"); - expect(parsed.data.results[0]).not.toHaveProperty("data"); - }); + it("cmdEventStatus outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event status"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); + expect(parsed.data.results[0]).toHaveProperty("memory"); + expect(parsed.data.results[0]).not.toHaveProperty("data"); + }); }); diff --git a/cli/node/tests/option-parity.test.ts b/cli/node/tests/option-parity.test.ts new file mode 100644 index 000000000..be8e653e0 --- /dev/null +++ b/cli/node/tests/option-parity.test.ts @@ -0,0 +1,164 @@ +/** + * Drift test: every documented v3 add/search/list param must be reachable from the Node CLI. + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const OPENAPI_PATH = path.join( + __dirname, + "..", + "..", + "..", + "docs", + "openapi.json", +); + +const KNOWN_UNSURFACED: Record> = { + "/v3/memories/add/": { + includes: "extraction hint, no CLI flag yet", + excludes: "extraction hint, no CLI flag yet", + enable_graph: "graph memory toggle, no CLI flag yet", + output_format: "response envelope is pinned by the CLI", + prompt_profile_id: "no CLI flag yet", + temporal_reasoning: "no CLI flag yet", + timezone: "no CLI flag yet", + observation_datetime: "no CLI flag yet, --timestamp backdates instead", + observation_date: "no CLI flag yet, --timestamp backdates instead", + }, + "/v3/memories/search/": { + categories: "expressible through --filter", + metadata: "expressible through --filter", + }, + "/v3/memories/": { + start_date: "covered by --after via filters.created_at.gte", + end_date: "covered by --before via filters.created_at.lte", + categories: "covered by --category via filters.categories", + fields: "no CLI flag yet", + keywords: "no CLI flag yet", + }, +}; + +const ADD_MAPPING: Record = { + messages: ["--messages", "--file", "text"], + user_id: ["--user-id"], + agent_id: ["--agent-id"], + app_id: ["--app-id"], + run_id: ["--run-id"], + metadata: ["--metadata"], + expiration_date: ["--expires"], + custom_instructions: ["--custom-instructions"], + agent_custom_instructions: ["--agent-custom-instructions"], + custom_categories: ["--custom-categories"], + infer: ["--no-infer"], + immutable: ["--immutable"], + structured_data_schema: ["--structured-data-schema"], + timestamp: ["--timestamp"], +}; + +const SEARCH_MAPPING: Record = { + query: ["query"], + filters: ["--filter", "--user-id", "--agent-id", "--run-id"], + show_expired: ["--show-expired"], + top_k: ["--top-k"], + threshold: ["--threshold"], + rerank: ["--rerank"], + reference_date: ["--reference-date"], + fields: ["--fields"], +}; + +const LIST_MAPPING: Record = { + filters: [ + "--user-id", + "--agent-id", + "--run-id", + "--category", + "--after", + "--before", + ], + show_expired: ["--show-expired"], + page: ["--page"], + page_size: ["--page-size"], +}; + +function documentedFields(endpoint: string): string[] { + const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const schema = + spec.paths[endpoint].post.requestBody.content["application/json"].schema; + return Object.keys(schema.properties); +} + +function helpText(command: string): string { + return execSync(`npx tsx src/index.ts ${command} --help`, { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + timeout: 15000, + }); +} + +function assertAllReachable( + endpoint: string, + mapping: Record, + command: string, +) { + const documented = documentedFields(endpoint); + const help = helpText(command); + for (const field of documented) { + if (KNOWN_UNSURFACED[endpoint]?.[field]) continue; + const candidates = mapping[field]; + expect( + candidates, + `${endpoint}: documented field "${field}" has no mapping entry for command "${command}"`, + ).toBeDefined(); + const reachable = candidates.some((flag) => + flag.startsWith("--") ? help.includes(flag) : true, + ); + expect( + reachable, + `${endpoint}: documented field "${field}" not reachable via any of ${JSON.stringify(candidates)} on command "${command}"`, + ).toBe(true); + } +} + +describe("Option parity: Node CLI reachability of documented v3 params", () => { + it("add covers documented fields", () => { + assertAllReachable("/v3/memories/add/", ADD_MAPPING, "add"); + }); + + it("search covers documented fields", () => { + assertAllReachable("/v3/memories/search/", SEARCH_MAPPING, "search"); + }); + + it("list covers documented fields", () => { + assertAllReachable("/v3/memories/", LIST_MAPPING, "list"); + }); +}); + +describe("stdin fallback uses the shared piped-stdin guard", () => { + const SOURCES = ["src/index.ts", "src/commands/memory.ts"]; + + for (const rel of SOURCES) { + it(`${rel} never checks process.stdin.isTTY directly`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8"); + expect( + src.includes("process.stdin.isTTY"), + `${rel}: use stdinIsPiped() from state.ts. A bare !isTTY check is also true for /dev/null and sockets, so readFileSync(0) crashes with EAGAIN in scripts, CI, and agent mode.`, + ).toBe(false); + }); + + it(`${rel} guards every readFileSync(0) with stdinIsPiped()`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8"); + const lines = src.split("\n"); + for (const [i, line] of lines.entries()) { + if (!line.includes("readFileSync(0")) continue; + const guard = lines.slice(Math.max(0, i - 3), i).join("\n"); + expect( + guard.includes("stdinIsPiped()"), + `${rel}:${i + 1}: readFileSync(0) must be guarded by stdinIsPiped()`, + ).toBe(true); + } + }); + } +}); diff --git a/cli/node/tests/platform-backend.test.ts b/cli/node/tests/platform-backend.test.ts index cb577a638..f186b24bb 100644 --- a/cli/node/tests/platform-backend.test.ts +++ b/cli/node/tests/platform-backend.test.ts @@ -7,94 +7,236 @@ import { PlatformBackend } from "../src/backend/platform.js"; import { createDefaultConfig } from "../src/config.js"; function makeBackend(): PlatformBackend { - // apiKey/baseUrl only build request headers; every test spies on _request, - // so no real network calls are made. - return new PlatformBackend(createDefaultConfig().platform); + return new PlatformBackend(createDefaultConfig().platform); } function mockFetch() { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: vi.fn().mockReturnValue(null) }, - json: vi.fn().mockResolvedValue({ message: "ok" }), - }); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue(null) }, + json: vi.fn().mockResolvedValue({ message: "ok" }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; } beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("deleteEntities", () => { - it("returns all results keyed by entity type for a multi-entity delete", async () => { - const backend = makeBackend(); - const responses: Record = { - "/v2/entities/user/alice/": { message: "user deleted" }, - "/v2/entities/agent/bob/": { message: "agent deleted" }, - }; - const spy = vi - // biome-ignore lint/suspicious/noExplicitAny: spying on a private method - .spyOn(backend as any, "_request") - .mockImplementation(async (_method: string, path: string) => responses[path]); + it("returns all results keyed by entity type for a multi-entity delete", async () => { + const backend = makeBackend(); + const responses: Record = { + "/v2/entities/user/alice/": { message: "user deleted" }, + "/v2/entities/agent/bob/": { message: "agent deleted" }, + }; + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockImplementation( + async (_method: string, path: string) => responses[path], + ); - const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" }); + const result = await backend.deleteEntities({ + userId: "alice", + agentId: "bob", + }); - // Regression: previously only the last entity's response survived. - expect(result).toEqual({ - user: { message: "user deleted" }, - agent: { message: "agent deleted" }, - }); - expect(spy).toHaveBeenCalledTimes(2); - }); + expect(result).toEqual({ + user: { message: "user deleted" }, + agent: { message: "agent deleted" }, + }); + expect(spy).toHaveBeenCalledTimes(2); + }); - it("keys a single-entity delete by its type", async () => { - const backend = makeBackend(); - // biome-ignore lint/suspicious/noExplicitAny: spying on a private method - vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" }); + it("keys a single-entity delete by its type", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + vi.spyOn(backend as any, "_request").mockResolvedValue({ + message: "user deleted", + }); - const result = await backend.deleteEntities({ userId: "alice" }); - expect(result).toEqual({ user: { message: "user deleted" } }); - }); + const result = await backend.deleteEntities({ userId: "alice" }); + expect(result).toEqual({ user: { message: "user deleted" } }); + }); - it("throws when no entity id is provided", async () => { - const backend = makeBackend(); - await expect(backend.deleteEntities({})).rejects.toThrow( - "At least one entity ID is required", - ); - }); + it("throws when no entity id is provided", async () => { + const backend = makeBackend(); + await expect(backend.deleteEntities({})).rejects.toThrow( + "At least one entity ID is required", + ); + }); +}); + +describe("PlatformBackend option-parity payloads (MEM-5893)", () => { + it("add: custom_instructions, custom_categories, structured_data_schema, timestamp reach the payload alongside existing fields", async () => { + const backend = makeBackend(); + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockResolvedValue({ results: [] }); + + await backend.add("hello", undefined, { + userId: "alice", + metadata: { source: "test" }, + expires: "2099-01-01", + customInstructions: "Extract only preferences.", + agentCustomInstructions: "Extract only tool outcomes.", + customCategories: [{ prefs: "user preferences" }], + structuredDataSchema: { type: "object" }, + timestamp: 1700000000, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.custom_instructions).toBe("Extract only preferences."); + expect(payload.agent_custom_instructions).toBe( + "Extract only tool outcomes.", + ); + expect(payload.custom_categories).toEqual([{ prefs: "user preferences" }]); + expect(payload.structured_data_schema).toEqual({ type: "object" }); + expect(payload.timestamp).toBe(1700000000); + expect(payload.metadata).toEqual({ source: "test" }); + expect(payload.expiration_date).toBe("2099-01-01"); + }); + + it("add: omitted optional fields are absent from the payload", async () => { + const backend = makeBackend(); + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockResolvedValue({ results: [] }); + + await backend.add("hello", undefined, { userId: "alice" }); + + const payload = spy.mock.calls[0][2].json; + expect(payload).not.toHaveProperty("custom_instructions"); + expect(payload).not.toHaveProperty("agent_custom_instructions"); + expect(payload).not.toHaveProperty("custom_categories"); + expect(payload).not.toHaveProperty("structured_data_schema"); + expect(payload).not.toHaveProperty("timestamp"); + }); + + it("search: show_expired, reference_date, latest_only reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", { + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.show_expired).toBe(true); + expect(payload.reference_date).toBe("2024-01-01"); + expect(payload.latest_only).toBe(true); + }); + + it("search: keyword_search and fields reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", { + keyword: true, + fields: ["memory", "score"], + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.keyword_search).toBe(true); + expect(payload.fields).toEqual(["memory", "score"]); + }); + + it("search: omitted keyword and fields are absent from the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", {}); + + const payload = spy.mock.calls[0][2].json; + expect(payload).not.toHaveProperty("keyword_search"); + expect(payload).not.toHaveProperty("fields"); + }); + + it("listMemories: show_expired and latest_only are top-level, not nested inside filters", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.listMemories({ + userId: "alice", + showExpired: true, + latestOnly: true, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.show_expired).toBe(true); + expect(payload.latest_only).toBe(true); + expect(payload.filters ?? {}).not.toHaveProperty("show_expired"); + expect(payload.filters ?? {}).not.toHaveProperty("latest_only"); + }); + + it("update: expiration_date and timestamp reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({}); + + await backend.update("mem-123", undefined, undefined, { + expirationDate: "2099-01-01", + timestamp: 1700000000, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.expiration_date).toBe("2099-01-01"); + expect(payload.timestamp).toBe(1700000000); + }); + + it("delete: delete_linked is a query param, not part of the JSON body", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({}); + + await backend.delete("mem-123", { deleteLinked: true }); + + const opts = spy.mock.calls[0][2]; + expect(opts.params.delete_linked).toBe("true"); + expect(opts.json).toBeUndefined(); + }); }); describe("PlatformBackend path encoding", () => { - it("encodes memory IDs before interpolating them into paths", async () => { - const fetchMock = mockFetch(); - const backend = makeBackend(); + it("encodes memory IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); - await backend.get("mem/a?b#c"); - await backend.update("mem/a?b#c", "updated"); - await backend.delete("mem/a?b#c"); + await backend.get("mem/a?b#c"); + await backend.update("mem/a?b#c", "updated"); + await backend.delete("mem/a?b#c"); - const urls = fetchMock.mock.calls.map((call) => call[0]); - expect(urls).toEqual([ - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", - ]); - }); + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + ]); + }); - it("encodes entity and event IDs before interpolating them into paths", async () => { - const fetchMock = mockFetch(); - const backend = makeBackend(); + it("encodes entity and event IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); - await backend.deleteEntities({ userId: "org/team?active#frag" }); - await backend.getEvent("evt/a?b#c"); + await backend.deleteEntities({ userId: "org/team?active#frag" }); + await backend.getEvent("evt/a?b#c"); - const urls = fetchMock.mock.calls.map((call) => call[0]); - expect(urls).toEqual([ - "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", - "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", - ]); - }); + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", + "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", + ]); + }); }); diff --git a/cli/python/AGENTS.md b/cli/python/AGENTS.md new file mode 100644 index 000000000..91d70e2d0 --- /dev/null +++ b/cli/python/AGENTS.md @@ -0,0 +1,47 @@ +# Python CLI (`cli/python/`) + +The `mem0-cli` package on PyPI. Typer-based, entry point `mem0`. + +## Commands + +```bash +pip install -e ".[dev]" # dev install: ruff + pytest +ruff check . # lint +ruff format . # format +pytest # test +hatch build # build +``` + +## Conventions + +> **Line length is 100 here, not 120.** The root Python SDK uses 120. Running the root +> `make format` over this directory reformats every file and fails CI. Use the local +> `ruff` invocations above. + +- **Python 3.10+.** Not 3.9, unlike the root SDK. +- **Ruff** with an extended rule set: `E`, `F`, `I`, `W`, `UP`, `B`, `SIM`, `RUF`. + Ignores `E501` (formatter handles it), `B008` (required by Typer's argument defaults), + and `SIM108`. +- **Ruff format:** double quotes, space indent, `docstring-code-format = true`. +- **isort** first-party is `mem0_cli` only. +- **pytest** for tests. +- Target version pinned to `py310`. + +## Layout + +``` +cli/python/ +β”œβ”€β”€ src/mem0_cli/ package source (src layout) +└── tests/ +``` + +Entry point: `mem0 = "mem0_cli.app:main"`. + +## Dependencies + +Typer + Rich + httpx. `mem0ai` is **optional**, exposed through the `[oss]` extra for OSS mode. Do not promote it to a required dependency. + +## CI and release + +- CI: `cli-python-ci.yml`, ruff + pytest + `hatch build` on Python 3.10, 3.11, 3.12. +- Release: tag prefix `cli-v*` dispatches `cli-python-cd.yml`, publishing to PyPI over OIDC. diff --git a/cli/python/CLAUDE.md b/cli/python/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/cli/python/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/cli/python/README.md b/cli/python/README.md index a6ac0beb1..8cb0aa29d 100644 --- a/cli/python/README.md +++ b/cli/python/README.md @@ -241,14 +241,6 @@ Verify your API connection and display the current project. mem0 status ``` -### `mem0 version` - -Print the CLI version. - -```bash -mem0 version -``` - ## Agent mode Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: @@ -307,6 +299,8 @@ These flags are available on all commands: | `--base-url` | Override the configured API base URL for this request | | `-o, --output` | Set the output format | +`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one. + ## Environment variables | Variable | Description | diff --git a/cli/python/development.md b/cli/python/development.md index e05a2b062..bf77594a5 100644 --- a/cli/python/development.md +++ b/cli/python/development.md @@ -80,7 +80,7 @@ mem0 --help # Using Python directly (with venv activated) source .venv/bin/activate mem0 --help -mem0 version +mem0 --version # Or run without activating .venv/bin/mem0 --help diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml index ecbca070b..dfa60c677 100644 --- a/cli/python/pyproject.toml +++ b/cli/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0-cli" -version = "0.2.9" +version = "0.2.11" description = "The official CLI for mem0 β€” the memory layer for AI agents" readme = "README.md" license = "Apache-2.0" diff --git a/cli/python/src/mem0_cli/__init__.py b/cli/python/src/mem0_cli/__init__.py index 0216470ca..5cc1cc956 100644 --- a/cli/python/src/mem0_cli/__init__.py +++ b/cli/python/src/mem0_cli/__init__.py @@ -1,3 +1,3 @@ """mem0 CLI β€” the command-line interface for the mem0 memory layer.""" -__version__ = "0.2.9" +__version__ = "0.2.11" diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 850bfa0cd..fae19acc8 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -273,7 +273,26 @@ def add( no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."), expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), categories: str | None = typer.Option( - None, "--categories", help="Categories (JSON array or comma-separated)." + None, "--categories", help="Not supported on add, use --custom-categories instead." + ), + custom_instructions: str | None = typer.Option( + None, "--custom-instructions", help="Custom instructions for fact extraction." + ), + agent_custom_instructions: str | None = typer.Option( + None, + "--agent-custom-instructions", + help="Extraction instructions for agent-scoped memories, overriding the project setting.", + ), + custom_categories: str | None = typer.Option( + None, + "--custom-categories", + help="Custom categories as a JSON array of {name: description} objects.", + ), + structured_data_schema: str | None = typer.Option( + None, "--structured-data-schema", help="Schema for structured data extraction, as JSON." + ), + timestamp: int | None = typer.Option( + None, "--timestamp", help="Unix timestamp for the memory." ), output: str = typer.Option( "text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output" @@ -312,6 +331,11 @@ def add( no_infer=no_infer, expires=expires, categories=categories, + custom_instructions=custom_instructions, + agent_custom_instructions=agent_custom_instructions, + custom_categories=custom_categories, + structured_data_schema=structured_data_schema, + timestamp=timestamp, output=output, ) @@ -347,7 +371,11 @@ def search( False, "--keyword", help="Use keyword search.", rich_help_panel="Search" ), filter_json: str | None = typer.Option( - None, "--filter", help="Advanced filter expression (JSON).", rich_help_panel="Search" + None, + "--filter", + help='Advanced filter as JSON: {"AND": [...]} or {"OR": [...]}, ' + 'e.g. {"AND": [{"categories": {"in": ["work"]}}]}.', + rich_help_panel="Search", ), fields: str | None = typer.Option( None, @@ -355,6 +383,21 @@ def search( help="Specific fields to return (comma-separated).", rich_help_panel="Search", ), + show_expired: bool = typer.Option( + False, "--show-expired", help="Include expired memories.", rich_help_panel="Search" + ), + reference_date: str | None = typer.Option( + None, + "--reference-date", + help="Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + rich_help_panel="Search", + ), + latest_only: bool = typer.Option( + False, + "--latest-only", + help="Only return the latest version of each memory.", + rich_help_panel="Search", + ), output: str = typer.Option( "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" ), @@ -375,6 +418,7 @@ def search( mem0 search "preferences" --user-id alice mem0 search "tools" -u alice -o json -k 5 echo "preferences" | mem0 search -u alice + mem0 search "invoices" -u alice --filter '{"AND": [{"categories": {"in": ["work"]}}]}' """ from mem0_cli.commands.memory import cmd_search @@ -398,6 +442,9 @@ def search( keyword=keyword, filter_json=filter_json, fields=fields, + show_expired=show_expired, + reference_date=reference_date, + latest_only=latest_only, output=output, ) @@ -464,6 +511,15 @@ def list_cmd( before: str | None = typer.Option( None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters" ), + show_expired: bool = typer.Option( + False, "--show-expired", help="Include expired memories.", rich_help_panel="Filters" + ), + latest_only: bool = typer.Option( + False, + "--latest-only", + help="Only return the latest version of each memory.", + rich_help_panel="Filters", + ), output: str = typer.Option( "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" ), @@ -497,6 +553,8 @@ def list_cmd( category=category, after=after, before=before, + show_expired=show_expired, + latest_only=latest_only, output=output, ) @@ -509,6 +567,10 @@ def update( memory_id: str = typer.Argument(..., help="Memory ID to update."), text: str | None = typer.Argument(None, help="New memory text."), metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."), + expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), + timestamp: int | None = typer.Option( + None, "--timestamp", help="Unix timestamp for the memory." + ), output: str = typer.Option( "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" ), @@ -537,7 +599,15 @@ def update( text = _read_stdin() backend = _get_backend(api_key, base_url) - cmd_update(backend, memory_id, text, metadata=metadata, output=output) + cmd_update( + backend, + memory_id, + text, + metadata=metadata, + expires=expires, + timestamp=timestamp, + output=output, + ) # ── Memory: delete ──────────────────────────────────────────────────────── @@ -559,6 +629,9 @@ def delete( False, "--dry-run", help="Show what would be deleted without deleting." ), force: bool = typer.Option(False, "--force", help="Skip confirmation."), + delete_linked: bool = typer.Option( + False, "--delete-linked", help="Also delete memories linked to this memory." + ), user_id: str | None = typer.Option( None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" ), @@ -616,7 +689,14 @@ def delete( from mem0_cli.commands.memory import cmd_delete backend = _get_backend(api_key, base_url) - cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output) + cmd_delete( + backend, + memory_id, + dry_run=dry_run, + force=force, + delete_linked=delete_linked, + output=output, + ) elif all_: _fire_telemetry("delete", {"delete_mode": "all"}) @@ -1009,6 +1089,18 @@ def status( ) +@app.command(rich_help_panel="Management") +def version() -> None: + """Show version and exit. + + Example: + mem0 version + """ + from mem0_cli.commands.utils import cmd_version + + cmd_version() + + @app.command("import", rich_help_panel="Management") def import_cmd( file_path: str = typer.Argument(..., help="JSON file to import."), @@ -1068,7 +1160,12 @@ def _build_help_json() -> dict: "--immutable": "Prevent future updates.", "--no-infer": "Skip inference, store raw.", "--expires": "Expiration date (YYYY-MM-DD).", - "--categories": "Categories (JSON array or comma-separated).", + "--categories": "Not supported on add, use --custom-categories instead.", + "--custom-instructions": "Custom instructions for fact extraction.", + "--agent-custom-instructions": "Extraction instructions for agent-scoped memories, overriding the project setting.", + "--custom-categories": "Custom categories as a JSON array of {name: description} objects.", + "--structured-data-schema": "Schema for structured data extraction, as JSON.", + "--timestamp": "Unix timestamp for the memory.", "--graph": "Enable graph memory extraction.", "--no-graph": "Disable graph memory extraction.", "--output, -o": "Output format: text, json, quiet.", @@ -1085,8 +1182,14 @@ def _build_help_json() -> dict: "--threshold": "Minimum similarity score (default: 0.3).", "--rerank": "Enable reranking (Platform only).", "--keyword": "Use keyword search instead of semantic.", - "--filter": "Advanced filter expression (JSON).", + "--filter": ( + 'Advanced filter as JSON: {"AND": [...]} or {"OR": [...]}, ' + 'e.g. {"AND": [{"categories": {"in": ["work"]}}]}.' + ), "--fields": "Specific fields to return (comma-separated).", + "--show-expired": "Include expired memories.", + "--reference-date": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + "--latest-only": "Only return the latest version of each memory.", "--graph": "Enable graph in search.", "--no-graph": "Disable graph in search.", "--output, -o": "Output format: text, json, table.", @@ -1110,6 +1213,8 @@ def _build_help_json() -> dict: "--category": "Filter by category.", "--after": "Created after (YYYY-MM-DD).", "--before": "Created before (YYYY-MM-DD).", + "--show-expired": "Include expired memories.", + "--latest-only": "Only return the latest version of each memory.", "--graph": "Enable graph in listing.", "--no-graph": "Disable graph in listing.", "--output, -o": "Output format: text, json, table.", @@ -1124,6 +1229,8 @@ def _build_help_json() -> dict: }, "options": { "--metadata, -m": "Update metadata (JSON).", + "--expires": "Expiration date (YYYY-MM-DD).", + "--timestamp": "Unix timestamp for the memory.", "--output, -o": "Output format: text, json, quiet.", }, }, @@ -1140,6 +1247,7 @@ def _build_help_json() -> dict: "--all": "Delete all memories matching scope filters.", "--entity": "Delete the entity itself and all its memories (cascade).", "--project": "With --all: delete ALL memories project-wide.", + "--delete-linked": "Also delete memories linked to this memory.", "--dry-run": "Show what would be deleted without deleting.", "--force": "Skip confirmation.", "--user-id, -u": "Scope to user.", @@ -1269,8 +1377,10 @@ def help( mem0 help mem0 help --json """ - if json: - console.print(_json.dumps(_build_help_json(), indent=2)) + from mem0_cli.state import is_agent_mode + + if json or is_agent_mode(): + console.print_json(_json.dumps(_build_help_json())) else: console.print( f"[{BRAND_COLOR}]β—† mem0 CLI[/] v{__version__} β€” The Memory Layer for AI Agents\n" diff --git a/cli/python/src/mem0_cli/backend/base.py b/cli/python/src/mem0_cli/backend/base.py index 183e0bfa1..3db5cf238 100644 --- a/cli/python/src/mem0_cli/backend/base.py +++ b/cli/python/src/mem0_cli/backend/base.py @@ -25,7 +25,11 @@ class Backend(ABC): immutable: bool = False, infer: bool = True, expires: str | None = None, - categories: list[str] | None = None, + custom_instructions: str | None = None, + agent_custom_instructions: str | None = None, + custom_categories: list[dict] | None = None, + structured_data_schema: dict | None = None, + timestamp: int | None = None, ) -> dict: ... @abstractmethod @@ -43,6 +47,9 @@ class Backend(ABC): keyword: bool = False, filters: dict | None = None, fields: list[str] | None = None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, ) -> list[dict]: ... @abstractmethod @@ -61,11 +68,19 @@ class Backend(ABC): category: str | None = None, after: str | None = None, before: str | None = None, + show_expired: bool = False, + latest_only: bool = False, ) -> list[dict]: ... @abstractmethod def update( - self, memory_id: str, content: str | None = None, metadata: dict | None = None + self, + memory_id: str, + content: str | None = None, + metadata: dict | None = None, + *, + expiration_date: str | None = None, + timestamp: int | None = None, ) -> dict: ... @abstractmethod @@ -78,6 +93,7 @@ class Backend(ABC): agent_id: str | None = None, app_id: str | None = None, run_id: str | None = None, + delete_linked: bool = False, ) -> dict: ... @abstractmethod diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 1df3deadd..1d153da9c 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -87,7 +87,11 @@ class PlatformBackend(Backend): immutable: bool = False, infer: bool = True, expires: str | None = None, - categories: list[str] | None = None, + custom_instructions: str | None = None, + agent_custom_instructions: str | None = None, + custom_categories: list[dict] | None = None, + structured_data_schema: dict | None = None, + timestamp: int | None = None, ) -> dict: payload: dict[str, Any] = {} @@ -112,8 +116,16 @@ class PlatformBackend(Backend): payload["infer"] = False if expires: payload["expiration_date"] = expires - if categories: - payload["categories"] = categories + if custom_instructions: + payload["custom_instructions"] = custom_instructions + if agent_custom_instructions: + payload["agent_custom_instructions"] = agent_custom_instructions + if custom_categories: + payload["custom_categories"] = custom_categories + if structured_data_schema: + payload["structured_data_schema"] = structured_data_schema + if timestamp is not None: + payload["timestamp"] = timestamp payload["source"] = "CLI" return self._request("POST", "/v3/memories/add/", json=payload) @@ -173,6 +185,9 @@ class PlatformBackend(Backend): keyword: bool = False, filters: dict | None = None, fields: list[str] | None = None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, ) -> list[dict]: payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold} @@ -191,6 +206,12 @@ class PlatformBackend(Backend): payload["keyword_search"] = True if fields: payload["fields"] = fields + if show_expired: + payload["show_expired"] = True + if reference_date is not None: + payload["reference_date"] = reference_date + if latest_only: + payload["latest_only"] = True payload["source"] = "CLI" result = self._request("POST", "/v3/memories/search/", json=payload) @@ -219,6 +240,8 @@ class PlatformBackend(Backend): category: str | None = None, after: str | None = None, before: str | None = None, + show_expired: bool = False, + latest_only: bool = False, ) -> list[dict]: payload: dict[str, Any] = {} params = {"page": str(page), "page_size": str(page_size)} @@ -241,6 +264,10 @@ class PlatformBackend(Backend): ) if api_filters: payload["filters"] = api_filters + if show_expired: + payload["show_expired"] = True + if latest_only: + payload["latest_only"] = True payload["source"] = "CLI" result = self._request("POST", "/v3/memories/", json=payload, params=params) @@ -251,13 +278,23 @@ class PlatformBackend(Backend): ) def update( - self, memory_id: str, content: str | None = None, metadata: dict | None = None + self, + memory_id: str, + content: str | None = None, + metadata: dict | None = None, + *, + expiration_date: str | None = None, + timestamp: int | None = None, ) -> dict: payload: dict[str, Any] = {} if content: payload["text"] = content if metadata: payload["metadata"] = metadata + if expiration_date: + payload["expiration_date"] = expiration_date + if timestamp is not None: + payload["timestamp"] = timestamp payload["source"] = "CLI" return self._request( "PUT", @@ -274,6 +311,7 @@ class PlatformBackend(Backend): agent_id: str | None = None, app_id: str | None = None, run_id: str | None = None, + delete_linked: bool = False, ) -> dict: if all: params: dict[str, str] = {"source": "CLI"} @@ -287,10 +325,13 @@ class PlatformBackend(Backend): params["run_id"] = run_id return self._request("DELETE", "/v1/memories/", params=params) elif memory_id: + params = {"source": "CLI"} + if delete_linked: + params["delete_linked"] = "true" return self._request( "DELETE", f"/v1/memories/{_encode_path_segment(memory_id)}/", - params={"source": "CLI"}, + params=params, ) else: raise ValueError("Either memory_id or --all is required") diff --git a/cli/python/src/mem0_cli/commands/memory.py b/cli/python/src/mem0_cli/commands/memory.py index 7e9ace6e4..1771d8d20 100644 --- a/cli/python/src/mem0_cli/commands/memory.py +++ b/cli/python/src/mem0_cli/commands/memory.py @@ -4,9 +4,11 @@ from __future__ import annotations import json import os +import re import stat as _stat_mod import sys import time as _time +from datetime import date from pathlib import Path import typer @@ -47,6 +49,18 @@ def _stdin_is_piped() -> bool: return False +def _validate_expires(value: str) -> None: + """Exit 1 if value is not a future YYYY-MM-DD date.""" + if not re.match(r"^\d{4}-\d{2}-\d{2}$", value): + print_error( + err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)." + ) + raise typer.Exit(1) + if date.fromisoformat(value) <= date.today(): + print_error(err_console, "--expires date must be in the future.") + raise typer.Exit(1) + + def cmd_add( backend: Backend, text: str | None, @@ -62,6 +76,11 @@ def cmd_add( no_infer: bool, expires: str | None, categories: str | None, + custom_instructions: str | None = None, + agent_custom_instructions: str | None = None, + custom_categories: str | None = None, + structured_data_schema: str | None = None, + timestamp: int | None = None, output: str = "text", ) -> None: """Add a memory.""" @@ -70,6 +89,13 @@ def cmd_add( set_current_command("add") if is_agent_mode(): output = "agent" + + if categories: + print_error( + err_console, "--categories is not supported on add. Use --custom-categories instead." + ) + raise typer.Exit(1) + msgs = None content = text @@ -108,27 +134,24 @@ def cmd_add( print_error(err_console, "Invalid JSON in --metadata.") raise typer.Exit(1) from None - cats = None - if categories: + custom_cats = None + if custom_categories: try: - cats = json.loads(categories) + custom_cats = json.loads(custom_categories) except json.JSONDecodeError: - cats = [c.strip() for c in categories.split(",")] + print_error(err_console, "Invalid JSON in --custom-categories.") + raise typer.Exit(1) from None + + schema = None + if structured_data_schema: + try: + schema = json.loads(structured_data_schema) + except json.JSONDecodeError: + print_error(err_console, "Invalid JSON in --structured-data-schema.") + raise typer.Exit(1) from None - # Validate --expires if expires: - import re - - if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires): - print_error( - err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)." - ) - raise typer.Exit(1) - from datetime import date - - if date.fromisoformat(expires) <= date.today(): - print_error(err_console, "--expires date must be in the future.") - raise typer.Exit(1) + _validate_expires(expires) with timed_status(err_console, "Adding memory...") as ts: try: @@ -143,7 +166,11 @@ def cmd_add( immutable=immutable, infer=not no_infer, expires=expires, - categories=cats, + custom_instructions=custom_instructions, + agent_custom_instructions=agent_custom_instructions, + custom_categories=custom_cats, + structured_data_schema=schema, + timestamp=timestamp, ) except Exception as e: ts.error_msg = str(e) @@ -224,6 +251,9 @@ def cmd_search( keyword: bool, filter_json: str | None, fields: str | None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, output: str = "text", ) -> None: """Search memories.""" @@ -266,6 +296,9 @@ def cmd_search( keyword=keyword, filters=filters, fields=field_list, + show_expired=show_expired, + reference_date=reference_date, + latest_only=latest_only, ) except Exception as e: print_error(err_console, str(e)) @@ -352,6 +385,8 @@ def cmd_list( category: str | None, after: str | None, before: str | None, + show_expired: bool = False, + latest_only: bool = False, output: str = "table", ) -> None: """List memories.""" @@ -380,6 +415,8 @@ def cmd_list( category=category, after=after, before=before, + show_expired=show_expired, + latest_only=latest_only, ) except Exception as e: print_error(err_console, str(e)) @@ -446,6 +483,8 @@ def cmd_update( text: str | None, *, metadata: str | None, + expires: str | None = None, + timestamp: int | None = None, output: str, ) -> None: """Update a memory.""" @@ -462,10 +501,19 @@ def cmd_update( print_error(err_console, "Invalid JSON in --metadata.") raise typer.Exit(1) from None + if expires: + _validate_expires(expires) + _start = _time.perf_counter() with timed_status(err_console, "Updating memory...") as _ts: try: - result = backend.update(memory_id, content=text, metadata=meta) + result = backend.update( + memory_id, + content=text, + metadata=meta, + expiration_date=expires, + timestamp=timestamp, + ) except Exception as e: print_error(err_console, str(e)) raise typer.Exit(1) from None @@ -490,6 +538,7 @@ def cmd_delete( *, dry_run: bool = False, force: bool = False, + delete_linked: bool = False, output: str, ) -> None: """Delete a single memory by ID.""" @@ -512,7 +561,7 @@ def cmd_delete( _start = _time.perf_counter() with timed_status(err_console, "Deleting...") as _ts: try: - result = backend.delete(memory_id=memory_id) + result = backend.delete(memory_id=memory_id, delete_linked=delete_linked) except Exception as e: print_error(err_console, str(e)) raise typer.Exit(1) from None diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py index 8aad44bbb..dc1524d33 100644 --- a/cli/python/src/mem0_cli/output.py +++ b/cli/python/src/mem0_cli/output.py @@ -262,16 +262,32 @@ def sanitize_agent_data(command: str, data: Any) -> Any: return result if command == "search": - return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data] + return [ + pick(r, ["id", "memory", "score", "created_at", "categories", "expiration_date"]) + for r in data + ] if command == "list": - return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data] + return [ + pick(r, ["id", "memory", "created_at", "categories", "expiration_date"]) for r in data + ] if command == "get": - return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"]) + return pick( + data, + [ + "id", + "memory", + "created_at", + "updated_at", + "categories", + "metadata", + "expiration_date", + ], + ) if command == "update": - return pick(data, ["id", "memory"]) + return pick(data, ["id", "memory", "expiration_date"]) if command in ("delete", "delete-all", "entity delete"): return data diff --git a/cli/python/tests/test_cli_integration.py b/cli/python/tests/test_cli_integration.py index 8abd145d0..e4396306b 100644 --- a/cli/python/tests/test_cli_integration.py +++ b/cli/python/tests/test_cli_integration.py @@ -7,6 +7,7 @@ boundaries). from __future__ import annotations +import json import os import re import subprocess @@ -84,6 +85,36 @@ class TestCLIIntegration: assert "add" in result.stdout assert "search" in result.stdout + def test_version_flag_only(self): + from mem0_cli import __version__ + + flag = _run(["--version"]) + assert flag.returncode == 0 + assert __version__ in flag.stdout + + def test_version_subcommand_matches_flag_byte_for_byte(self): + flag = _run(["--version"]) + cmd = _run(["version"]) + assert cmd.returncode == 0 + assert cmd.stdout == flag.stdout + + @pytest.mark.parametrize( + "args", + [["help", "--json"], ["--json", "help"], ["help", "--agent"], ["--agent", "help"]], + ) + def test_help_json_produces_valid_json(self, args): + result = _run(args) + assert result.returncode == 0 + spec = json.loads(result.stdout) + assert spec["name"] == "mem0" + assert "add" in spec["commands"] + + def test_help_without_json_is_text(self): + result = _run(["help"]) + assert result.returncode == 0 + with pytest.raises(json.JSONDecodeError): + json.loads(result.stdout) + def test_add_help(self): result = _run(["add", "--help"]) assert result.returncode == 0 @@ -101,6 +132,12 @@ class TestCLIIntegration: assert result.returncode == 0 assert "top-k" in result.stdout + def test_search_help_documents_filter_json_shape(self): + result = _run(["search", "--help"]) + assert result.returncode == 0 + assert "AND" in result.stdout + assert "categories" in result.stdout + def test_list_help(self): result = _run(["list", "--help"]) assert result.returncode == 0 diff --git a/cli/python/tests/test_commands.py b/cli/python/tests/test_commands.py index 4fd6a0c39..ad900f605 100644 --- a/cli/python/tests/test_commands.py +++ b/cli/python/tests/test_commands.py @@ -252,12 +252,13 @@ class TestAddCommand: ) mock_backend.add.assert_called_once() - def test_add_categories_csv(self, mock_backend): + def test_add_categories_rejected(self, mock_backend): console, _buf = _make_console() - err_console, _err_buf = _make_err_console() + err_console, err_buf = _make_err_console() with ( patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), ): cmd_add( mock_backend, @@ -275,7 +276,93 @@ class TestAddCommand: categories="health,prefs", output="text", ) - mock_backend.add.assert_called_once() + assert "--custom-categories" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_invalid_custom_categories_json(self, mock_backend): + console, _buf = _make_console() + err_console, err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + custom_categories="not-json", + output="text", + ) + assert "--custom-categories" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_invalid_structured_data_schema_json(self, mock_backend): + console, _buf = _make_console() + err_console, err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + structured_data_schema="not-json", + output="text", + ) + assert "--structured-data-schema" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_regression_metadata_expiration_custom_categories_together(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata='{"source": "test"}', + immutable=False, + no_infer=False, + expires="2099-01-01", + categories=None, + custom_categories='[{"prefs": "user preferences"}]', + output="text", + ) + call_kwargs = mock_backend.add.call_args.kwargs + assert call_kwargs["metadata"] == {"source": "test"} + assert call_kwargs["expires"] == "2099-01-01" + assert call_kwargs["custom_categories"] == [{"prefs": "user preferences"}] class TestAddDeduplicatesPending: @@ -464,6 +551,36 @@ class TestSearchCommand: ) mock_backend.search.assert_called_once() + def test_search_new_flags_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "preferences", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + show_expired=True, + reference_date="2024-01-01", + latest_only=True, + output="text", + ) + call_kwargs = mock_backend.search.call_args.kwargs + assert call_kwargs["show_expired"] is True + assert call_kwargs["reference_date"] == "2024-01-01" + assert call_kwargs["latest_only"] is True + class TestGetCommand: def test_get_text(self, mock_backend): @@ -561,6 +678,32 @@ class TestListCommand: output = buf.getvalue() assert "No memories found" in output + def test_list_new_flags_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + show_expired=True, + latest_only=True, + output="table", + ) + call_kwargs = mock_backend.list_memories.call_args.kwargs + assert call_kwargs["show_expired"] is True + assert call_kwargs["latest_only"] is True + class TestUpdateCommand: def test_update(self, mock_backend): @@ -585,6 +728,26 @@ class TestUpdateCommand: output = buf.getvalue() assert '"memory"' in output + def test_update_new_fields_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_update( + mock_backend, + "abc-123", + "New text", + metadata=None, + expires="2099-01-01", + timestamp=1700000000, + output="text", + ) + call_kwargs = mock_backend.update.call_args.kwargs + assert call_kwargs["expiration_date"] == "2099-01-01" + assert call_kwargs["timestamp"] == 1700000000 + class TestDeleteCommand: def test_delete_single(self, mock_backend): @@ -610,6 +773,17 @@ class TestDeleteCommand: assert "dry run" in output.lower() mock_backend.delete.assert_not_called() + def test_delete_linked_reaches_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete(mock_backend, "abc-123", delete_linked=True, output="text") + call_kwargs = mock_backend.delete.call_args.kwargs + assert call_kwargs["delete_linked"] is True + class TestDeleteAllCommand: def test_delete_all_force(self, mock_backend): diff --git a/cli/python/tests/test_option_parity.py b/cli/python/tests/test_option_parity.py new file mode 100644 index 000000000..3c8821857 --- /dev/null +++ b/cli/python/tests/test_option_parity.py @@ -0,0 +1,103 @@ +"""Drift test: every documented v3 add/search/list param must be reachable from the Python CLI.""" + +import json +from pathlib import Path + +import typer.main + +from mem0_cli.app import app + +REPO_ROOT = Path(__file__).resolve().parents[3] +OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json" + +KNOWN_UNSURFACED: dict[tuple[str, str], str] = { + ("/v3/memories/add/", "includes"): "extraction hint, no CLI flag yet", + ("/v3/memories/add/", "excludes"): "extraction hint, no CLI flag yet", + ("/v3/memories/add/", "enable_graph"): "graph memory toggle, no CLI flag yet", + ("/v3/memories/add/", "output_format"): "response envelope is pinned by the CLI", + ("/v3/memories/add/", "prompt_profile_id"): "no CLI flag yet", + ("/v3/memories/add/", "temporal_reasoning"): "no CLI flag yet", + ("/v3/memories/add/", "timezone"): "no CLI flag yet", + ("/v3/memories/add/", "observation_datetime"): "no CLI flag yet, --timestamp backdates instead", + ("/v3/memories/add/", "observation_date"): "no CLI flag yet, --timestamp backdates instead", + ("/v3/memories/search/", "categories"): "expressible through --filter", + ("/v3/memories/search/", "metadata"): "expressible through --filter", + ("/v3/memories/", "start_date"): "covered by --after via filters.created_at.gte", + ("/v3/memories/", "end_date"): "covered by --before via filters.created_at.lte", + ("/v3/memories/", "categories"): "covered by --category via filters.categories", + ("/v3/memories/", "fields"): "no CLI flag yet", + ("/v3/memories/", "keywords"): "no CLI flag yet", +} + +ADD_MAPPING: dict[str, list[str]] = { + "messages": ["messages", "file", "text"], + "user_id": ["user_id"], + "agent_id": ["agent_id"], + "app_id": ["app_id"], + "run_id": ["run_id"], + "metadata": ["metadata"], + "expiration_date": ["expires"], + "custom_instructions": ["custom_instructions"], + "agent_custom_instructions": ["agent_custom_instructions"], + "custom_categories": ["custom_categories"], + "infer": ["no_infer"], + "immutable": ["immutable"], + "structured_data_schema": ["structured_data_schema"], + "timestamp": ["timestamp"], +} + +SEARCH_MAPPING: dict[str, list[str]] = { + "query": ["query"], + "filters": ["filter_json", "user_id", "agent_id", "run_id"], + "show_expired": ["show_expired"], + "top_k": ["top_k"], + "threshold": ["threshold"], + "rerank": ["rerank"], + "reference_date": ["reference_date"], + "fields": ["fields"], +} + +LIST_MAPPING: dict[str, list[str]] = { + "filters": ["user_id", "agent_id", "run_id", "category", "after", "before"], + "show_expired": ["show_expired"], + "page": ["page"], + "page_size": ["page_size"], +} + + +def _documented_fields(endpoint: str) -> set[str]: + spec = json.loads(OPENAPI_PATH.read_text()) + schema = spec["paths"][endpoint]["post"]["requestBody"]["content"]["application/json"]["schema"] + return set(schema["properties"]) + + +def _cli_param_names(command_name: str) -> set[str]: + click_app = typer.main.get_command(app) + command = click_app.commands[command_name] + return {param.name for param in command.params} + + +def _assert_all_reachable(endpoint: str, mapping: dict[str, list[str]], command_name: str) -> None: + documented = _documented_fields(endpoint) + reachable = _cli_param_names(command_name) + for field in documented: + if (endpoint, field) in KNOWN_UNSURFACED: + continue + candidates = mapping.get(field) + assert candidates, ( + f"{endpoint}: documented field {field!r} has no mapping entry for command {command_name!r}" + ) + assert any(candidate in reachable for candidate in candidates), ( + f"{endpoint}: documented field {field!r} not reachable via any of {candidates} on command {command_name!r}" + ) + + +class TestOptionParity: + def test_add_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/add/", ADD_MAPPING, "add") + + def test_search_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/search/", SEARCH_MAPPING, "search") + + def test_list_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/", LIST_MAPPING, "list") diff --git a/cli/python/tests/test_platform_backend_options.py b/cli/python/tests/test_platform_backend_options.py new file mode 100644 index 000000000..9d6c25fdf --- /dev/null +++ b/cli/python/tests/test_platform_backend_options.py @@ -0,0 +1,112 @@ +"""Tests that the MEM-5893 option-parity flags reach the correct request payload/params.""" + +from __future__ import annotations + +from unittest.mock import patch + +from mem0_cli.backend.platform import PlatformBackend +from mem0_cli.config import PlatformConfig + + +def _make_backend() -> PlatformBackend: + return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai")) + + +class TestAddOptions: + def test_new_fields_and_existing_fields_land_in_payload_together(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={"results": []}) as mock_request: + backend.add( + content="hello", + user_id="alice", + metadata={"source": "test"}, + expires="2099-01-01", + custom_instructions="Extract only preferences.", + agent_custom_instructions="Extract only tool outcomes.", + custom_categories=[{"prefs": "user preferences"}], + structured_data_schema={"type": "object"}, + timestamp=1700000000, + ) + payload = mock_request.call_args.kwargs["json"] + assert payload["custom_instructions"] == "Extract only preferences." + assert payload["agent_custom_instructions"] == "Extract only tool outcomes." + assert payload["custom_categories"] == [{"prefs": "user preferences"}] + assert payload["structured_data_schema"] == {"type": "object"} + assert payload["timestamp"] == 1700000000 + assert payload["metadata"] == {"source": "test"} + assert payload["expiration_date"] == "2099-01-01" + + def test_omitted_fields_are_absent_from_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={"results": []}) as mock_request: + backend.add(content="hello", user_id="alice") + payload = mock_request.call_args.kwargs["json"] + assert "custom_instructions" not in payload + assert "agent_custom_instructions" not in payload + assert "custom_categories" not in payload + assert "structured_data_schema" not in payload + assert "timestamp" not in payload + + +class TestSearchOptions: + def test_show_expired_reference_date_latest_only_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search( + "query", + show_expired=True, + reference_date="2024-01-01", + latest_only=True, + ) + payload = mock_request.call_args.kwargs["json"] + assert payload["show_expired"] is True + assert payload["reference_date"] == "2024-01-01" + assert payload["latest_only"] is True + + def test_keyword_and_fields_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search("query", keyword=True, fields=["memory", "score"]) + payload = mock_request.call_args.kwargs["json"] + assert payload["keyword_search"] is True + assert payload["fields"] == ["memory", "score"] + + def test_keyword_and_fields_omitted_are_absent_from_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search("query") + payload = mock_request.call_args.kwargs["json"] + assert "keyword_search" not in payload + assert "fields" not in payload + + +class TestListOptions: + def test_show_expired_and_latest_only_are_top_level_not_in_filters(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.list_memories(user_id="alice", show_expired=True, latest_only=True) + payload = mock_request.call_args.kwargs["json"] + assert payload["show_expired"] is True + assert payload["latest_only"] is True + assert "show_expired" not in payload.get("filters", {}) + assert "latest_only" not in payload.get("filters", {}) + + +class TestUpdateOptions: + def test_expires_and_timestamp_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={}) as mock_request: + backend.update("mem-123", expiration_date="2099-01-01", timestamp=1700000000) + payload = mock_request.call_args.kwargs["json"] + assert payload["expiration_date"] == "2099-01-01" + assert payload["timestamp"] == 1700000000 + + +class TestDeleteOptions: + def test_delete_linked_is_a_query_param_not_json_body(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={}) as mock_request: + backend.delete(memory_id="mem-123", delete_linked=True) + call = mock_request.call_args + assert call.kwargs["params"]["delete_linked"] == "true" + assert "json" not in call.kwargs diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 000000000..d45a4830d --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,49 @@ +# Documentation (`docs/`) + +Mintlify site published at https://docs.mem0.ai. + +## Commands + +```bash +make docs # from repo root +cd docs && mintlify dev +``` + +## Structure + +| Path | Contents | +|------|----------| +| `api-reference/` | Platform REST endpoints | +| `open-source/` | Self-hosted SDK guides | +| `platform/` | Hosted platform guides | +| `integrations/` | One page per integration | +| `core-concepts/` | Memory model, graph memory, scoping | +| `cookbooks/` | End-to-end recipes | +| `contributing/` | Contributor guides | +| `docs.json` | Navigation tree | +| `openapi.json` | Platform API spec | +| `llms.txt` | Scope-tagged index for agents | + +## Adding a page + +Every new `.mdx` page needs three things, or CI fails: + +1. The page itself under the right section. +2. A navigation entry in `docs.json`. +3. A line in `llms.txt` with a scope tag (`[Platform]`, `[OSS]`, or `[Both]`) and a description that starts with `Use when ...`. + +`docs-llms-txt-check.yml` runs on every PR touching `docs/**/*.mdx` and **blocks the merge** when `llms.txt` is out of sync. To fix: + +```bash +python scripts/check-llms-txt-coverage.py --write +``` + +That scaffolds placeholders under `## Unclassified - needs triage`. Then replace each `[TODO: ...]` tag, rewrite the descriptions as `Use when ...`, move entries into the correct section, and delete the triage heading once it is empty. + +## Conventions + +- Frontmatter needs `title`, `description`, and usually `icon`. +- Mintlify components (``, ``, ``, ``) are available; prefer them over raw HTML. +- Code samples must be runnable. If a sample calls a public SDK method, it has to match the real signature. +- Documentation-only PRs are exempt from the `accepted`-issue requirement in the PR gate, but not from the CLA. +- Any change to a public SDK signature has to update the matching page here in the same PR. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/api-reference/memory/add-memories.mdx b/docs/api-reference/memory/add-memories.mdx index ab5d73b65..a0529c4da 100644 --- a/docs/api-reference/memory/add-memories.mdx +++ b/docs/api-reference/memory/add-memories.mdx @@ -65,9 +65,8 @@ The request is queued for background processing. The response contains an `event ```json 200 response { - "message": "Memory processing has been queued for background execution", - "status": "PENDING", - "event_id": "evt-uuid" + "event_id": "evt-uuid", + "status": "PENDING" } ``` diff --git a/docs/api-reference/organizations-projects.mdx b/docs/api-reference/organizations-projects.mdx index 36a2ee9eb..c1b111fb9 100644 --- a/docs/api-reference/organizations-projects.mdx +++ b/docs/api-reference/organizations-projects.mdx @@ -79,7 +79,7 @@ new_project = client.project.create( ### Update Project Settings -Modify project configuration including custom instructions, categories, language preferences, retrieval criteria, and memory decay: +Modify project configuration including custom instructions, categories, language preferences, and memory decay: ```python # Update project with custom categories @@ -95,17 +95,15 @@ client.project.update( custom_instructions="..." ) +# Separate extraction instructions for agent-scoped memories +# (see /platform/features/custom-instructions) +client.project.update( + agent_custom_instructions="..." +) + # Use the input language for memory storage and retrieval client.project.update(multilingual=True) -# Set retrieval criteria to control which memories are surfaced in search -client.project.update( - retrieval_criteria=[ - {"name": "relevance", "description": "How directly relevant this memory is to the current topic or user query", "weight": 3}, - {"name": "access_frequency", "description": "How often this memory has been accessed or surfaced recently", "weight": 1} - ] -) - # Enable Memory Decay (boosts recently-accessed memories at search time) client.project.update(decay=True) @@ -120,34 +118,6 @@ client.project.update( ) ``` -#### Set Retrieval Criteria - -`retrieval_criteria` is a per-project list of dictionaries (`List[Dict]`) that shapes how memories are ranked and filtered during search. Each dictionary has three fields: `name` (identifier), `description` (interpreted by the LLM to score each memory), and `weight` (relative influence on the final score). Use this to focus retrieval on intent-aligned or signal-specific memories: - -```python -client.project.update( - retrieval_criteria=[ - { - "name": "joy", - "description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the memory. A higher score reflects greater joy.", - "weight": 3 - }, - { - "name": "curiosity", - "description": "Assess the extent to which the memory reflects inquisitiveness or interest in exploring new information. A higher score reflects stronger curiosity.", - "weight": 2 - }, - { - "name": "access_frequency", - "description": "How often this memory has been accessed or surfaced recently.", - "weight": 1 - } - ] -) -``` - -Pass an empty list to clear all criteria and restore default retrieval behaviour. - #### Toggle Memory Decay `decay` is a per-project boolean that turns on [Memory Decay](/platform/features/memory-decay): a search-time ranking bias that reinforces recently-accessed memories and gently dampens stale ones. The flag is `false` by default; set it via the same project-update endpoint: diff --git a/docs/changelog/highlights.mdx b/docs/changelog/highlights.mdx index 880eeb603..7590dfecf 100644 --- a/docs/changelog/highlights.mdx +++ b/docs/changelog/highlights.mdx @@ -4,6 +4,39 @@ description: "Major product launches, headline features, and milestones for Mem0 mode: "wide" --- + + +**Workflow Automation: Mem0 Memory in n8n and Zapier** + +Mem0 now plugs into two no-code automation platforms, so workflows that used to start from zero on every run can store durable facts and recall them later. + +- **n8n community node:** [`@mem0/n8n-nodes-mem0`](https://www.npmjs.com/package/@mem0/n8n-nodes-mem0) adds a **Mem0** node with a Memory resource covering Add, Search, Get, Get Many, Update, and Delete. Install it from **Settings β†’ Community Nodes** on a self-hosted instance, then connect your API key once as a Mem0 API credential. See [n8n](/integrations/n8n). +- **n8n AI Agent tool:** Attach the same node to an [AI Agent](https://docs.n8n.io/advanced-ai/) node and it becomes a tool the agent calls on its own, so it can decide when to remember and when to recall. +- **Zapier app:** Add Memory, Search Memories, Get Memories, and Delete Memory actions let any of Zapier's thousands of apps write and read Mem0 context with no code and no server. See [Zapier](/integrations/zapier). +- **One-time connection:** Both integrations authenticate with a single Mem0 API key and default to `https://api.mem0.ai`, with a configurable base URL for self-hosted deployments. + + + The Zapier app is not yet listed in Zapier's public App Directory. Email [support@mem0.ai](mailto:support@mem0.ai) for an invite link. + + + + + + +**TypeScript OSS SDK: 26 New Providers, Reranking, and Zero-Dependency Imports** + +TypeScript SDK v3.1.0 is the largest provider release for the OSS SDK so far, closing most of the remaining gap with the Python SDK. Python SDK v2.0.12 ships alongside it with fixes and security patches. + +- **17 new vector stores:** Pinecone, Weaviate, Milvus, Chroma, MongoDB, Elasticsearch, OpenSearch, Databricks, AWS Neptune Analytics, S3 Vectors, Azure MySQL, Google Vertex AI Vector Search, Turbopuffer, Upstash Vector, Valkey, Cassandra, and Baidu Mochow. +- **5 new LLM providers:** AWS Bedrock, xAI Grok, Together, vLLM, and Sarvam. +- **4 new embedding providers:** Vertex AI, HuggingFace, FastEmbed, and Together. +- **Reranking in TypeScript:** Four rerankers (Cohere, ZeroEntropy, cross-encoder, and LLM-based) with per-search rerank via a `rerank` option on `search()`. +- **Install only what you use:** Importing `mem0ai/oss` no longer pulls in any provider SDK. Provider packages are resolved lazily on first use, so an app that configures only OpenAI and Qdrant does not need the other provider SDKs installed. + +See [SDK & Tools](/changelog/sdk) for version details and PR links. + + + **SDK Memory Expiration: Expiring Memories Across Python and TypeScript** diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index a3d28e3ea..869028d46 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -7,6 +7,112 @@ mode: "wide" + + +**Bug Fixes:** +- **Core:** Percent-escape `%`, `&`, and `=` in `user_id`, `agent_id`, and `run_id` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892)) +- **Vector Stores:** Raise `ValueError` when a PGVector `in`/`nin` filter value is not a list. A string value was previously iterated character by character into the generated `= ANY(...)` array (so `{"user_id": {"in": "alice"}}` matched `a`, `l`, `i`, `c`, `e`), and a non-iterable value raised a bare `TypeError` from deep inside filter building ([#6879](https://github.com/mem0ai/mem0/pull/6879)) +- **Vector Stores:** Reject `index_accuracy=0` in the Oracle AI Vector Search config. The range check sat behind a truthiness test, so `0` skipped validation entirely and was passed through to `WITH TARGET ACCURACY 0` instead of raising ([#6848](https://github.com/mem0ai/mem0/pull/6848)) +- **Vector Stores:** Close the Oracle connection or pool that Mem0 opened when initialization fails. A client version check, a database version check, or a `create_col()` error previously propagated with the connection still open, leaking it for the life of the process. A caller-supplied `client` is left untouched ([#6839](https://github.com/mem0ai/mem0/pull/6839)) + + + + + +**New Features:** +- **Client:** Add `agent_custom_instructions` to `project.update()`/`update_project()` (sync and async) and to the `ProjectUpdateOptions` and `AddMemoryOptions` typed models. It sets a second extraction instruction set that applies only to agent-scoped memories: an add passing `agent_id` without `user_id` uses it, one passing both splits by attribution, and while it is unset `custom_instructions` continues to apply to every memory ([#6809](https://github.com/mem0ai/mem0/pull/6809)) + + + + + +**New Features:** +- **Client:** Add `reference_date`, `latest_only`, and `keyword_search` to `SearchMemoryOptions`, and `latest_only` to `GetAllMemoryOptions`, keeping the Python client's typed options in sync with the Platform API and the CLIs ([#6696](https://github.com/mem0ai/mem0/pull/6696)) + +**Bug Fixes:** +- **Core:** Stop `add()` metadata from setting a memory's identity scope. `_build_filters_and_metadata()` now strips `user_id`, `agent_id`, `run_id`, and `actor_id` from caller-supplied `metadata` before building the creation template, so metadata can no longer place a memory into a scope that was never passed through the entity params ([#6656](https://github.com/mem0ai/mem0/pull/6656)) +- **Vector Stores:** Validate Upstash filter keys and values in `search()`, `keyword_search()`, and `list()`. Filter keys must match a safe identifier pattern, values must be `str`/`int`/`float`/`bool`, and string values containing a double quote or backslash are now rejected instead of being interpolated unescaped into the generated query string ([#5981](https://github.com/mem0ai/mem0/pull/5981)) +- **Embeddings:** `FastEmbedEmbedding.embed()` now converts its result with `.tolist()` before returning, so callers get a plain `List[float]` instead of a numpy array ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Embeddings:** `HuggingFaceEmbedding` now passes `api_key` to the OpenAI-compatible client when `huggingface_base_url` is set. The configured key was previously dropped, so the client fell back to `OPENAI_API_KEY` from the environment or raised `OpenAIError` at construction when that was unset ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Embeddings:** Replace Ollama's interactive `pip install` prompt on import with a plain `ImportError`. Importing `mem0.embeddings.ollama` without the `ollama` package previously blocked on stdin and then called `sys.exit(1)`, killing the host process instead of raising ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Core:** `remove_code_blocks()` now returns an empty string for `None` input instead of raising `AttributeError` ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Core:** `parse_vision_messages()` now chains the original exception (`raise ... from e`) when an image download fails, so the root cause is preserved in the traceback ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Core:** `process_telemetry_filters(None)` now returns `([], {})`, matching the two-value tuple every caller unpacks, instead of `{}` ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Core:** `LlmFactory.create()` no longer mutates the caller's config dict in place via `.update(kwargs)`; the merge now builds a new dict ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Rerankers:** The Cohere, HuggingFace, SentenceTransformer, and Zero Entropy rerankers' failure-fallback path no longer mutates the caller's document dicts in place when stamping `rerank_score`; it now falls back on copies ([#6770](https://github.com/mem0ai/mem0/pull/6770)) +- **Vector Stores:** Remove `logging.basicConfig()` calls from the MongoDB and Vertex AI Vector Search providers, so selecting either provider no longer reconfigures the host application's root logger as a side effect ([#6770](https://github.com/mem0ai/mem0/pull/6770)) + + + + + +**Bug Fixes:** +- **Core:** `delete_all()` now paginates through the vector store in batches of 1000 instead of listing once, so accounts with more memories than a single page (most vector stores default to ~100) had the remainder silently left behind ([#6636](https://github.com/mem0ai/mem0/pull/6636)) +- **Vector Stores:** Cap Supabase `search()`/`list()` `top_k` at the `vecs` query limit of 1000 instead of erroring, and fix a `col_info()` crash by reading collection attributes directly instead of calling the removed `describe()` method ([#6695](https://github.com/mem0ai/mem0/pull/6695)) +- **Vector Stores:** Set `size` on Elasticsearch KNN search queries, so results respect `top_k` instead of being capped at Elasticsearch's default of 10 hits ([#5910](https://github.com/mem0ai/mem0/pull/5910)) + +**Changes:** +- **Rerankers:** `LLMReranker`'s default model is now `gpt-5-mini` (was `gpt-4o-mini`) ([#6703](https://github.com/mem0ai/mem0/pull/6703)) + + + + + +**New Features:** +- **Vector Stores:** Add an Oracle AI Vector Search provider (`oracledb`) with connection pooling, `HNSW`/`IVF` indexes, JSON metadata filtering, and six selectable distance metrics ([#5358](https://github.com/mem0ai/mem0/pull/5358)) + +**Bug Fixes:** +- **Vector Stores:** Translate a `"*"` filter value in OpenSearch into an `exists` query for every key, not just identity keys. It was previously ignored or matched literally against the string `"*"`, so a wildcard filter returned nothing ([#6522](https://github.com/mem0ai/mem0/pull/6522)) +- **Vector Stores:** Re-raise errors from OpenSearch `search()` instead of returning `[]`, so a transport, auth, or index misconfiguration surfaces instead of looking like zero matches. `keyword_search()` still degrades on failure, since it is a best-effort BM25 signal ([#6519](https://github.com/mem0ai/mem0/pull/6519)) +- **Vector Stores:** Guard the `text` field in Milvus `update()` behind the `_has_bm25_schema` check, matching `insert()`, so updating a memory in a collection without the BM25 `text`/`sparse` schema no longer fails ([#5705](https://github.com/mem0ai/mem0/pull/5705)) + + + + + +**Bug Fixes:** +- **Vector Stores:** Fix `reset()` silently leaving stale vectors behind on local (on-disk) Qdrant when the old collection directory could not be removed, for example an open file handle on Windows or NFS ([#6412](https://github.com/mem0ai/mem0/pull/6412)) +- **Core:** Stop `update()` metadata from overwriting or injecting `user_id`, `agent_id`, `run_id`, or `actor_id`. These identity fields are immutable after creation, so passing them in `metadata` can no longer move a memory into a different tenant's scope ([#6278](https://github.com/mem0ai/mem0/pull/6278)) +- **Vector Stores:** Scope Pinecone `delete_col()`/`reset()` to the configured namespace instead of deleting the whole index, so resetting a namespaced Pinecone store no longer wipes out the other namespaces sharing that index ([#6287](https://github.com/mem0ai/mem0/pull/6287)) +- **Vector Stores:** Convert Baidu Mochow's raw L2 distance into a similarity score in `search()` (`1 / (1 + distance)`), so closer matches rank higher instead of lower, matching the Milvus provider and the rest of the `VectorStoreBase` contract ([#6435](https://github.com/mem0ai/mem0/pull/6435)) +- **LLMs:** Read `OPENAI_BASE_URL` (was `OPENAI_API_BASE`) in `OpenAIStructuredLLM`, matching the official OpenAI SDK's environment variable and the rest of the OpenAI-compatible providers ([#6322](https://github.com/mem0ai/mem0/pull/6322)) + +**Improvements:** +- **LLMs:** Remove a dead, no-op `api_key` attribute check from `LLMBase.__init__` ([#6460](https://github.com/mem0ai/mem0/pull/6460)) + +**Changes:** +- **Client:** Remove the `retrieval_criteria` parameter from `MemoryClient.update_project()`/`AsyncMemoryClient.update_project()` and `Project.update()`/`AsyncProject.update()`. It was accepted and forwarded but never affected retrieval, so removing it is not a behavior change ([#6313](https://github.com/mem0ai/mem0/pull/6313)) + + + + + +**New Features:** +- **Memory (OSS):** Accept `text` in `Memory.update()` and `AsyncMemory.update()`. `data` still works but is now deprecated, so prefer `text` in new code ([#6044](https://github.com/mem0ai/mem0/pull/6044)) + +**Bug Fixes:** +- **Core:** Coerce non-string entity IDs (`user_id`, `agent_id`, `run_id`) instead of crashing on `.strip()`, so passing an integer ID no longer raises `AttributeError` ([#6206](https://github.com/mem0ai/mem0/pull/6206)) +- **Core:** Stop requiring `langchain-core` for the default async procedural memory path. The optional dependency is now only imported when you pass a custom LangChain LLM, matching the sync behavior ([#6209](https://github.com/mem0ai/mem0/pull/6209)) +- **Client:** Encode dynamic URL path segments so IDs containing special characters no longer produce malformed requests ([#5963](https://github.com/mem0ai/mem0/pull/5963)) +- **LLMs:** Skip `temperature` and `top_p` for newer Anthropic models that reject sampling parameters. Detection is automatic per model family and version, and the new `enable_sampling_parameters` config flag overrides it ([#6211](https://github.com/mem0ai/mem0/pull/6211)) +- **Vector Stores:** Stop writing internal `OutputData` model fields as properties on Weaviate `update()` ([#6149](https://github.com/mem0ai/mem0/pull/6149)) +- **Vector Stores:** Improve wildcard search handling in Milvus ([#6187](https://github.com/mem0ai/mem0/pull/6187)) +- **Vector Stores:** Keep env-resolved Upstash Vector credentials after config validation. An env-var-only config previously passed validation and then failed to build ([#5811](https://github.com/mem0ai/mem0/pull/5811)) +- **Vector Stores:** Restore the previous payload when a Neptune Analytics vector upsert fails inside `update()`, so a partial write can no longer leave the payload and embedding out of sync ([#5824](https://github.com/mem0ai/mem0/pull/5824)) + +**Changes:** +- **LLMs:** The Together default model is now `MiniMaxAI/MiniMax-M3` (was `mistralai/Mixtral-8x7B-Instruct-v0.1`) ([#6049](https://github.com/mem0ai/mem0/pull/6049)) +- **LLMs:** The xAI default model is now `grok-4.3` (was `grok-2-latest`) ([#6115](https://github.com/mem0ai/mem0/pull/6115)) +- **Embeddings:** The Together default embedding model is now `intfloat/multilingual-e5-large-instruct` at 1024 dimensions (was `togethercomputer/m2-bert-80M-8k-retrieval` at 768). If you use the Together embedder without pinning `model`, existing vectors were written at the old dimension: either re-embed them, or pin `model` and `embedding_dims` to the old values ([#5989](https://github.com/mem0ai/mem0/pull/5989)) +- **Rerankers:** The Cohere default rerank model is now `rerank-v3.5` (was `rerank-english-v3.0`) ([#6055](https://github.com/mem0ai/mem0/pull/6055)) + +**Security:** +- **Vector Stores:** Fix SQL and Cypher injection vulnerabilities in the PGVector, Azure MySQL, and Neptune providers ([#4878](https://github.com/mem0ai/mem0/pull/4878)) +- **Vector Stores:** Validate Elasticsearch filter keys and values to prevent term query injection ([#5980](https://github.com/mem0ai/mem0/pull/5980)) +- **Dependencies:** Require `transformers>=5.3.0` to remediate GHSA-29pf-2h5f-8g72 (CVE-2026-4372) ([#6110](https://github.com/mem0ai/mem0/pull/6110)) + + + **Bug Fixes:** @@ -1100,6 +1206,117 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to- + + +**New Features:** +- **Vector Stores:** Add an Oracle AI Vector Search vector store (`oracledb`) to the OSS SDK, with pooled connections, `HNSW`/`IVF` indexes, an optional `indexAccuracy` target, JSON payload filtering, and six selectable distance metrics ([#6690](https://github.com/mem0ai/mem0/pull/6690)) + +**Bug Fixes:** +- **Core:** Percent-escape `%`, `&`, and `=` in `userId`, `agentId`, and `runId` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892)) +- **Vector Stores:** Close and clear the Oracle pool that Mem0 created when `initialize()` fails, and reset the cached init promise so the next call retries instead of replaying the rejection forever. A caller-supplied `client` is left untouched ([#6839](https://github.com/mem0ai/mem0/pull/6839)) +- **Vector Stores:** Validate Oracle `insert()` batches before touching the database: `ids` and `payloads` must match `vectors` in length, so a short array can no longer write rows with `undefined` ids or silently drop payloads ([#6839](https://github.com/mem0ai/mem0/pull/6839)) + +**Improvements:** +- **Vector Stores:** Cut Oracle round trips. `insert()` now sends the whole batch through one `executeMany()` with explicit `bindDefs` instead of one `INSERT` per vector, `list()` reads the total from a `COUNT(*) OVER ()` window in the same statement instead of issuing a second count query, and an unfiltered `search()` adds the `VECTOR_INDEX_TRANSFORM` hint so the vector index is used ([#6835](https://github.com/mem0ai/mem0/pull/6835)) + +**Security:** +- **Dependencies:** Patched 8 high and 18 medium severity dependency vulnerabilities across the pnpm workspace via `pnpm.overrides` (`undici`, `brace-expansion`, `ip-address`) ([#6847](https://github.com/mem0ai/mem0/pull/6847)) + + + + + +**New Features:** +- **Client:** Add `agentCustomInstructions` to `PromptUpdatePayload`, `AddMemoryOptions`, and `ProjectResponse`. It sets a second extraction instruction set that applies only to agent-scoped memories: an add passing `agentId` without `userId` uses it, one passing both splits by attribution, and while it is unset `customInstructions` continues to apply to every memory ([#6809](https://github.com/mem0ai/mem0/pull/6809)) + + + + + +**New Features:** +- **Client:** Add `referenceDate` and `keywordSearch` to `SearchMemoryOptions`, keeping the TypeScript client's typed search options in sync with the Platform API ([#6696](https://github.com/mem0ai/mem0/pull/6696)) + +**Bug Fixes:** +- **Client:** Take the `/v1/ping/` identity/telemetry call off the request critical path. Every method previously did `if (this.telemetryId === "") await this.ping();`, blocking the first call on each client instance on a network round trip; the ping now resolves in the background through a shared, credentials-keyed promise cache (FIFO-capped at 50 entries), so concurrent clients on the same host/API key share one ping instead of issuing one each, and a failed ping is not cached so the process can retry ([#6788](https://github.com/mem0ai/mem0/pull/6788)) +- **Client:** `deleteUsers()` now issues its per-entity DELETE requests through the shared `fetch()`-based helper instead of the axios instance, which defaulted to `keepAlive: false`. Deleting every user, agent, app, and run now reuses pooled connections instead of paying a fresh TCP/TLS handshake per entity ([#6788](https://github.com/mem0ai/mem0/pull/6788)) +- **Memory (OSS):** Stop `add()` metadata from setting or overwriting a memory's identity scope. Metadata now runs through the same `stripIdentityKeys()` helper as `update()`, so `user_id`/`agent_id`/`run_id` (snake_case or camelCase) and `actor_id` passed in metadata can no longer place a memory into a scope the caller didn't request through `userId`/`agentId`/`runId`/`filters` ([#6377](https://github.com/mem0ai/mem0/pull/6377)) +- **Vector Stores:** Fix Redis `search()` and `list()` building an invalid, empty RediSearch filter expression when `filters` was an empty object or contained only null/undefined values. The shared `buildRedisFilterExpr()` helper now falls back to `"*"` (match all) instead of joining zero conditions into `""` ([#6014](https://github.com/mem0ai/mem0/pull/6014)) + + + + + +**New Features:** +- **Vector Stores:** Add Qdrant server-side BM25 `keywordSearch()` (requires Qdrant >= 1.15.2) plus payload filter indexes, so keyword search runs without a client-side BM25 dependency ([#5851](https://github.com/mem0ai/mem0/pull/5851)) + +**Bug Fixes:** +- **Core:** `deleteAll()` now paginates through the vector store in batches of 1000 instead of listing once, so accounts with more memories than a single page had the remainder silently left behind ([#4872](https://github.com/mem0ai/mem0/pull/4872)) +- **Vector Stores:** Supabase `list()` now paginates past PostgREST's 1000-row cap instead of stopping at the first page, `search()` warns when results may have been truncated by that same cap, and the initialization probe reads a row instead of writing a test vector, so Row Level Security policies that only grant read access no longer fail table verification ([#6695](https://github.com/mem0ai/mem0/pull/6695)) +- **Embeddings:** Honor `TOGETHER_API_BASE` in the Together embedder, matching the Together LLM provider, so a custom gateway URL is no longer silently ignored for embeddings ([#6572](https://github.com/mem0ai/mem0/pull/6572)) + +**Changes:** +- **Rerankers:** `RerankerFactory`'s default LLM reranker model is now `gpt-5-mini` (was `gpt-4o-mini`) ([#6703](https://github.com/mem0ai/mem0/pull/6703)) + +**Security:** +- **Dependencies:** Patched 32 high and 57 medium severity dependency vulnerabilities across the pnpm workspace via `pnpm.overrides` (`axios`, `brace-expansion`, `js-yaml`, `postcss`, `protobufjs`, `mongoose`, `tar`, `fast-xml-parser`, `thrift`) ([#6639](https://github.com/mem0ai/mem0/pull/6639)) + + + + + +**Bug Fixes:** +- **Vector Stores:** Apply every operator in a Cassandra compound field filter (e.g. `{ age: { gte: 10, lte: 20 } }`) instead of stopping after the first, so the remaining bounds are no longer silently ignored ([#6511](https://github.com/mem0ai/mem0/pull/6511)) +- **Vector Stores:** Stop the Chroma where-clause translator from dropping filter conditions. Same-field ranges (`gte` + `lte`), multi-field conditions inside `$or`, and negated `contains`/`icontains` under `$not` each collapsed to a single clause or vanished, widening the search instead of narrowing it ([#6521](https://github.com/mem0ai/mem0/pull/6521)) +- **Vector Stores:** Skip `"*"` wildcard filter values in Milvus instead of matching them literally, so a filter like `{ user_id: "*" }` no longer returns zero memories ([#6508](https://github.com/mem0ai/mem0/pull/6508)) +- **Vector Stores:** Read `textLemmatized` for BM25 keyword search on Milvus, OpenSearch, and MongoDB, matching the field the memory layer actually writes, so hybrid search on those backends no longer loses the keyword signal ([#6497](https://github.com/mem0ai/mem0/pull/6497)) +- **LLMs:** Forward `responseFormat` to Gemini's `responseMimeType` in `generateResponse()`, so requesting `json_object` returns JSON instead of free-form text ([#6468](https://github.com/mem0ai/mem0/pull/6468)) +- **LLMs:** Find the Anthropic text block by type instead of indexing `content[0]`, so a thinking-enabled model whose `thinking` block comes first no longer throws `Unexpected response type from Anthropic API` ([#6506](https://github.com/mem0ai/mem0/pull/6506)) + + + + + +**New Features:** +- **Embeddings:** Add an AWS Bedrock embedding provider ([#6185](https://github.com/mem0ai/mem0/pull/6185)) + +**Bug Fixes:** +- **Packaging:** Finish the lazy-loading work started in v3.1.0. The remaining LLMs (Anthropic, Google, Groq, LangChain, Mistral, Ollama), embedders (Google, LangChain, Ollama, Vertex AI), vector stores (Azure AI Search, Azure MySQL, Baidu, LangChain, Qdrant, Redis, Supabase, Valkey, Vectorize), and the Supabase history store still imported their SDKs at module load, so importing `mem0ai/oss` required every provider package to be installed ([#6389](https://github.com/mem0ai/mem0/pull/6389)) +- **Vector Stores:** Convert Baidu Mochow's raw L2 distance into a similarity score in `search()` (`1 / (1 + distance)`), so closer matches rank higher instead of lower. A row the backend returns without a score is now left `undefined` instead of being treated as the closest match ([#6485](https://github.com/mem0ai/mem0/pull/6485)) +- **Memory (OSS):** Coerce non-string entity IDs (e.g. a numeric `user_id`) to strings instead of crashing on `.trim()` ([#6263](https://github.com/mem0ai/mem0/pull/6263)) +- **Memory (OSS):** Stop `update()` metadata from overwriting or injecting `user_id`, `agent_id`, `run_id`, or `actor_id` (in either snake_case or camelCase). These identity fields are immutable after creation, so passing them in `metadata` can no longer move a memory into a different tenant's scope ([#6343](https://github.com/mem0ai/mem0/pull/6343)) +- **Vector Stores:** Scope Pinecone `deleteCol()`/`reset()` to the configured namespace instead of deleting the whole index, so resetting a namespaced Pinecone store no longer wipes out the other namespaces sharing that index ([#6287](https://github.com/mem0ai/mem0/pull/6287)) + +**Changes:** +- **Client:** Remove the unused `retrievalCriteria` field from `PromptUpdatePayload`. It was accepted and forwarded but never affected retrieval, so removing it is not a behavior change ([#6313](https://github.com/mem0ai/mem0/pull/6313)) + + + + + +The largest provider release for the TypeScript OSS SDK so far: 17 new vector stores, 5 new LLM providers, 4 new embedders, and reranking support. Importing `mem0ai/oss` no longer pulls in any provider SDK, so you only install what you actually configure. + +**New Features:** +- **Rerankers:** Add reranking to the OSS SDK with four providers (Cohere, ZeroEntropy, cross-encoder, and LLM-based), plus per-search rerank via a `rerank` option on `search()` ([#6055](https://github.com/mem0ai/mem0/pull/6055)) +- **Memory (OSS):** Accept `text` in `Memory.update()`. `data` still works but is now deprecated, so prefer `text` in new code ([#6044](https://github.com/mem0ai/mem0/pull/6044)) +- **Vector Stores:** Add Pinecone ([#5802](https://github.com/mem0ai/mem0/pull/5802)), Weaviate ([#5800](https://github.com/mem0ai/mem0/pull/5800)), Milvus ([#5889](https://github.com/mem0ai/mem0/pull/5889)), Chroma ([#6145](https://github.com/mem0ai/mem0/pull/6145)), MongoDB ([#5793](https://github.com/mem0ai/mem0/pull/5793)), Elasticsearch ([#5866](https://github.com/mem0ai/mem0/pull/5866)), and OpenSearch ([#5810](https://github.com/mem0ai/mem0/pull/5810)) +- **Vector Stores:** Add Databricks ([#5824](https://github.com/mem0ai/mem0/pull/5824)), AWS Neptune Analytics ([#5797](https://github.com/mem0ai/mem0/pull/5797)), S3 Vectors ([#5822](https://github.com/mem0ai/mem0/pull/5822)), Azure MySQL ([#5827](https://github.com/mem0ai/mem0/pull/5827)), and Google Vertex AI Vector Search ([#5791](https://github.com/mem0ai/mem0/pull/5791)) +- **Vector Stores:** Add Turbopuffer ([#5801](https://github.com/mem0ai/mem0/pull/5801)), Upstash Vector ([#5811](https://github.com/mem0ai/mem0/pull/5811)), Valkey ([#5826](https://github.com/mem0ai/mem0/pull/5826)), Cassandra ([#5823](https://github.com/mem0ai/mem0/pull/5823)), and Baidu Mochow ([#5790](https://github.com/mem0ai/mem0/pull/5790)) +- **LLMs:** Add AWS Bedrock ([#5890](https://github.com/mem0ai/mem0/pull/5890)), xAI Grok ([#6115](https://github.com/mem0ai/mem0/pull/6115)), Together ([#6049](https://github.com/mem0ai/mem0/pull/6049)), vLLM ([#5805](https://github.com/mem0ai/mem0/pull/5805)), and Sarvam ([#6130](https://github.com/mem0ai/mem0/pull/6130)) +- **Embeddings:** Add Vertex AI ([#5882](https://github.com/mem0ai/mem0/pull/5882)), HuggingFace ([#6027](https://github.com/mem0ai/mem0/pull/6027)), FastEmbed ([#5862](https://github.com/mem0ai/mem0/pull/5862)), and Together ([#5989](https://github.com/mem0ai/mem0/pull/5989)) + +**Improvements:** +- **Packaging:** Lazy-load optional provider SDKs so importing `mem0ai/oss` never requires them. Provider packages are now resolved on first use, so an app that only configures OpenAI and Qdrant does not need the other provider SDKs installed ([#6280](https://github.com/mem0ai/mem0/pull/6280)) + +**Bug Fixes:** +- **Memory (OSS):** Re-raise LLM extraction transport failures instead of returning `[]`, so a network error during extraction surfaces as an error rather than a silently empty result ([#6102](https://github.com/mem0ai/mem0/pull/6102)) +- **Vector Stores:** Prevent an unhandled promise rejection in the Supabase and Redis constructors ([#6111](https://github.com/mem0ai/mem0/pull/6111)) +- **Client:** Encode dynamic URL path segments so IDs containing special characters no longer produce malformed requests ([#5963](https://github.com/mem0ai/mem0/pull/5963)) + +**Security:** +- **Dependencies:** Patch the `fast-xml-parser` and `tar` transitive CVEs ([#6160](https://github.com/mem0ai/mem0/pull/6160)) + + + **Bug Fixes:** @@ -1606,6 +1823,33 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to + + +**New Features:** +- **`add`:** New `--custom-instructions`, `--custom-categories`, `--structured-data-schema`, and `--timestamp` flags, matching the Platform `/v3/memories/add/` payload fields (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696)) +- **`search`:** New `--show-expired`, `--reference-date`, and `--latest-only` flags, forwarded to `/v3/memories/search/` as `show_expired`, `reference_date`, and `latest_only` (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696)) +- **`list`:** New `--show-expired` and `--latest-only` flags (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696)) +- **`update`:** New `--expires` and `--timestamp` flags (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696)) +- **`delete`:** New `--delete-linked` flag to also delete memories linked to the target memory (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696)) + +**Bug Fixes:** +- **`help --json`:** Emit JSON automatically under agent mode (`--agent`), not only when `--json` is passed explicitly. Python checks `is_agent_mode()` and now renders through `console.print_json()` instead of a plain `console.print(json.dumps(...))` call; Node also checks the root `--agent` flag in addition to the subcommand's `--json` flag (Python and Node [#6773](https://github.com/mem0ai/mem0/pull/6773)) + +**Changes:** +- **`add`:** `--categories` is no longer forwarded on `add`. The `/v3/memories/add/` payload has no `categories` field (only `custom_categories`), so the CLI was sending a key the endpoint does not accept. Passing the flag now exits 1 with a message pointing to `--custom-categories` (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696)) + +**Security:** +- **Dependencies (Node):** Tighten the `postcss` pnpm override from `<8.5.10 β†’ >=8.5.10` to `<8.5.18 β†’ >=8.5.18 <9.0.0`, closing a newer CVE range the previous floor didn't cover, as part of a wider dependency patch sweep across the pnpm workspaces ([#6639](https://github.com/mem0ai/mem0/pull/6639)) + + + + + +**Bug Fixes:** +- **Platform backend:** Encode dynamic URL path segments so memory and entity IDs containing special characters no longer produce malformed requests (Python and Node [#5963](https://github.com/mem0ai/mem0/pull/5963)) + + + **Bug Fixes:** @@ -1764,6 +2008,28 @@ A full-featured command-line interface for Mem0, available in both Python and No + + +**Fixes:** +- **Settings loading no longer crashes on a malformed `settings.json`:** If `~/.mem0/settings.json` parsed as valid JSON but wasn't an object (a list or a bare string, for example), `load_settings()` called `.items()` on it and raised. `resolve_config()` guards only against `ImportError`, so the failure escaped into every hook that resolves identity, not just setup. Settings loading now keeps the defaults instead. Setup also stopped claiming it "Created" the settings file when one already existed; it only prints that when it actually wrote one. Shared across Claude Code, Cursor, Codex, and Antigravity. + +**New Features:** +- **Unknown settings key warning:** Setup now flags keys in `settings.json` that the plugin doesn't recognize, so a typo'd setting no longer fails silently. + +**Removed:** +- Dropped the `mem0_doc_search` "openmemory" section now that OpenMemory has been removed from the docs site, so the skill no longer links to pages that don't exist. + + + + + +**Fixes:** +- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences: "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`. Affects Claude Code, Cursor, Codex, and Antigravity, which share these hooks. + +Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them; the plugin will not recreate them. + + + **New Features:** @@ -2014,6 +2280,13 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Fixes:** +- **Shell-profile API key recovery:** When `MEM0_API_KEY` isn't set in the process environment, the plugin now falls back to reading it from `.zshrc`, `.bashrc`, `.zprofile`, `.bash_profile`, or `.profile`, fixing startup failures on clients (e.g. Desktop) that launch without shell-exported environment variables. + + + **Improvements:** @@ -2087,6 +2360,28 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Fixes:** +- **Settings loading no longer crashes on a malformed `settings.json`:** If `~/.mem0/settings.json` parsed as valid JSON but wasn't an object, every hook that resolves identity previously raised, not just setup. It now keeps the defaults instead, and setup only reports it "Created" the settings file when it actually wrote one. + +**Improvements:** +- **Unknown settings key warning:** Setup now flags keys in `settings.json` it doesn't recognize instead of ignoring them silently. + +**Removed:** +- Dropped the doc-search skill's "openmemory" section now that OpenMemory has been removed from the docs site. + + + + + +**Fixes:** +- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences: "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`. + +Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them; the plugin will not recreate them. + + + **New Features:** @@ -2134,6 +2429,16 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Improvements:** +- **Onboarding suggestions:** The example commands shown by `openclaw mem0 config show` now suggest `gpt-5-mini` instead of `gpt-4o` ([#6704](https://github.com/mem0ai/mem0/pull/6704)) + +**Security:** +- **Dependencies:** Patched high and medium severity dependency vulnerabilities via `pnpm.overrides` (`protobufjs`, `axios`, `postcss`, `mongoose`) ([#6639](https://github.com/mem0ai/mem0/pull/6639)) + + + **Improvements:** @@ -2387,6 +2692,13 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Security:** +- **Dependencies:** Patched high and medium severity dependency vulnerabilities via `pnpm.overrides` (`axios`, `brace-expansion`, `postcss`, `mongoose`, `protobufjs`) ([#6639](https://github.com/mem0ai/mem0/pull/6639)) + + + **New Features:** @@ -2439,6 +2751,13 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Security:** +- **Dependencies:** Patched high and medium severity dependency vulnerabilities via `pnpm.overrides` (`brace-expansion`, `js-yaml`) ([#6639](https://github.com/mem0ai/mem0/pull/6639)) + + + **Major Release**: Migrated to Vercel AI SDK v6 (`LanguageModelV3` / `ProviderV3`) and Mem0 v3 API. @@ -2520,6 +2839,80 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex - Added support for graph memories. + + + + + + +**Changes:** +- **License changed to MIT:** The published `@mem0/n8n-nodes-mem0` package is now MIT (was Apache-2.0). n8n's Creator Portal requires verified community nodes to be MIT, and the failing license check was the blocker for verification. The rest of the mem0 repo stays Apache-2.0 ([#6804](https://github.com/mem0ai/mem0/pull/6804)) +- **Themed icons:** The node and credential icons now declare `{ light, dark }` variants instead of a single icon, clearing the remaining `icon-prefer-themed-variants` warnings from the Creator Portal scan. No functional changes ([#6804](https://github.com/mem0ai/mem0/pull/6804)) + + + + + +**Changes:** +- **Package contact:** `author.email` in the published package is now `integrations@mem0.ai` (was `founders@mem0.ai`), so npm and n8n Creator Portal correspondence reaches the integrations team directly. No functional changes ([#6791](https://github.com/mem0ai/mem0/pull/6791)) + + + + + +**Changes:** +- **Published with npm provenance:** Republished through the `n8n-nodes-mem0-cd.yml` GitHub Actions workflow so the package carries a signed provenance attestation. `0.1.0` was published manually and has none, which blocks submission for n8n Creator Portal verification. No functional changes ([#6685](https://github.com/mem0ai/mem0/pull/6685)) + + + + + +**Initial release** of [`@mem0/n8n-nodes-mem0`](https://www.npmjs.com/package/@mem0/n8n-nodes-mem0), a community node that adds long-term memory to n8n workflows and AI Agents ([#6517](https://github.com/mem0ai/mem0/pull/6517)) + +**New Features:** +- **Memory operations:** A single **Mem0** node covers Add, Search, Get, Get Many, Update, and Delete on the Memory resource. +- **AI Agent tool:** The node sets `usableAsTool`, so it can be attached to an n8n AI Agent node and invoked by the agent itself rather than wired into a fixed workflow path. +- **Scoping:** Add, Search, and Get Many accept User ID, Agent ID, App ID, and Run ID, so memories stay partitioned per user, agent, or session. +- **Add options:** Metadata JSON, custom categories, custom instructions, includes/excludes, an `infer` toggle, and a **Wait for Completion** switch that polls until the write lands instead of returning immediately. +- **Pagination:** Get Many supports Return All, or explicit Page and Page Size. +- **Credential:** A **Mem0 API** credential holds the API key plus a configurable base URL, defaulting to `https://api.mem0.ai` for self-hosted deployments. + + + Community nodes install from npm, which is a self-hosted n8n feature. See [n8n](/integrations/n8n) for setup. + + + + + + + + + + +**Bug Fixes:** +- **Add Memory:** **User ID** is now a required field. Mem0 rejects a write that carries no entity ID, so a Zap left blank failed at the API instead of in the editor. If you scoped memories only by Agent ID or Run ID, set a User ID as well ([#6790](https://github.com/mem0ai/mem0/pull/6790)) +- **Deployment:** Add a root `index.js` that re-exports the compiled app from `dist/`, and point `main` at it. Zapier's Lambda wrapper loads `/index.js` and ignores the `package.json` `main` field, so the deployed app could not resolve its entry point ([#6789](https://github.com/mem0ai/mem0/pull/6789)) + + + + + +**Initial release** of the Mem0 Zapier app, built on the Zapier Platform CLI ([#6518](https://github.com/mem0ai/mem0/pull/6518)) + +**New Features:** +- **Actions:** Add Memory and Delete Memory. +- **Searches:** Search Memories and Get Memories, usable as lookup steps in any Zap. +- **Authentication:** An API key connection validated against Mem0 the moment it is saved, sent as `Authorization: Token `, with a configurable base URL for self-hosted deployments. + +**Bug Fixes:** +- **Add Memory:** Raise the poll budget past the real API latency tail, so a slower write is no longer reported as a failure ([#6680](https://github.com/mem0ai/mem0/pull/6680)) + + + The app deploys to Zapier's platform rather than npm and is not yet listed in the public App Directory. See [Zapier](/integrations/zapier) for invite access. + + + + diff --git a/docs/components/embedders/config.mdx b/docs/components/embedders/config.mdx index 52735b4fb..8bde10ba7 100644 --- a/docs/components/embedders/config.mdx +++ b/docs/components/embedders/config.mdx @@ -97,4 +97,4 @@ Here's a comprehensive list of all parameters that can be used across different ## Supported Embedding Models -For detailed information on configuring specific embedders, please visit the [Embedding Models](./models) section. There you'll find information for each supported embedder with provider-specific usage examples and configuration details. +For detailed information on configuring specific embedders, please visit the [Embedding Models](./overview) section. There you'll find information for each supported embedder with provider-specific usage examples and configuration details. diff --git a/docs/components/embedders/models/aws_bedrock.mdx b/docs/components/embedders/models/aws_bedrock.mdx index d474f9655..23d25f091 100644 --- a/docs/components/embedders/models/aws_bedrock.mdx +++ b/docs/components/embedders/models/aws_bedrock.mdx @@ -3,11 +3,27 @@ title: AWS Bedrock description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication." --- -To use AWS Bedrock embedding models, you need to have the appropriate AWS credentials and permissions. The embeddings implementation relies on the `boto3` library. +To use AWS Bedrock embedding models, you need the appropriate AWS credentials and permissions. Python uses `boto3`, and TypeScript uses `@aws-sdk/client-bedrock-runtime`. + +Both SDKs support the Amazon Titan and Cohere embedding model families. ### Setup -- Ensure you have model access from the [AWS Bedrock Console](https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess) -- Authenticate the boto3 client using a method described in the [AWS documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) + +- Model access is automatic: Bedrock enables serverless foundation models on first invocation in AWS commercial regions, and the [Model access page has been retired](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html). Cohere models are served from AWS Marketplace, so an account's first invocation must come from a principal with the `aws-marketplace:Subscribe` permission; after that, any user in the account can invoke them. Browse the models available to you in the [Bedrock model catalog](https://console.aws.amazon.com/bedrock/). +- Install the AWS client for your language: + + + ```bash Python + pip install boto3 + ``` + + ```bash TypeScript + npm install @aws-sdk/client-bedrock-runtime + ``` + + + In TypeScript this package is an optional peer dependency, so it is only required when you actually use the Bedrock embedder. + - Set up environment variables for authentication: ```bash export AWS_REGION=us-east-1 @@ -15,6 +31,8 @@ To use AWS Bedrock embedding models, you need to have the appropriate AWS creden export AWS_SECRET_ACCESS_KEY=your-secret-key ``` +Both SDKs fall back to the standard AWS credential chain (environment variables, shared config, SSO, or an instance role) when you do not pass credentials in the config, so you rarely need to hardcode keys. See the [boto3 credentials guide](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) for the Python resolution order. + ### Usage @@ -48,8 +66,46 @@ messages = [ ] m.add(messages, user_id="alice") ``` + +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +// Credentials are read from the AWS default chain (AWS_REGION, +// AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, SSO, or an instance role). +const memory = new Memory({ + embedder: { + provider: "aws_bedrock", + config: { + model: "amazon.titan-embed-text-v2:0", + awsRegion: "us-west-2", + }, + }, +}); + +const 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." }, +]; +await memory.add(messages, { userId: "alice" }); +``` +### Choosing a model + +| Model | Notes | +| --- | --- | +| `amazon.titan-embed-text-v1` | Default. Fixed 1536-dimension output. | +| `amazon.titan-embed-text-v2:0` | Supports a configurable output size of 256, 512, or 1024. | +| `cohere.embed-english-v3` | English text. Embeds up to 96 texts per request. | +| `cohere.embed-multilingual-v3` | Multilingual text. Embeds up to 96 texts per request. | +| `cohere.embed-v4:0` | Text. Embeds up to 96 texts per request. Supports a configurable output size of 256, 512, 1024, or 1536. TypeScript only. | + +Custom output sizes are model specific. In Python, only Titan Text Embeddings V2 accepts one. In TypeScript, Titan Text Embeddings V2 and Cohere Embed v4 both do, and `embeddingDims` is ignored on Titan V1 and on Cohere v3, which have no such parameter. When you do set it, make sure your vector store dimension matches, otherwise inserts will fail. + +Bedrock caps a Cohere embedding call at 96 texts. The TypeScript SDK splits larger batches into multiple requests for you, so a 200 text batch becomes 3 calls. + ### Config Here are the parameters available for configuring AWS Bedrock embedder: @@ -64,4 +120,16 @@ Here are the parameters available for configuring AWS Bedrock embedder: | `aws_secret_access_key` | AWS secret access key for authentication | `None` | | `aws_session_token` | AWS session token for temporary credentials | `None` | + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `model` | The name of the embedding model to use | `amazon.titan-embed-text-v1` | +| `awsRegion` | AWS region for the Bedrock client. Falls back to the `AWS_REGION` environment variable | `us-west-2` | +| `embeddingDims` | Output vector size. Titan Text Embeddings V2 (256, 512, or 1024) and Cohere Embed v4 (256, 512, 1024, or 1536) only | `undefined` | +| `awsAccessKeyId` | AWS access key ID for authentication | `undefined` | +| `awsSecretAccessKey` | AWS secret access key for authentication | `undefined` | +| `awsSessionToken` | AWS session token for temporary credentials | `undefined` | + +Omit the three credential fields to use the AWS default credential chain. If you do pass them, `awsAccessKeyId` and `awsSecretAccessKey` are both required. + diff --git a/docs/components/embedders/models/vertexai.mdx b/docs/components/embedders/models/vertexai.mdx index 6c67ae85d..8ed5d7539 100644 --- a/docs/components/embedders/models/vertexai.mdx +++ b/docs/components/embedders/models/vertexai.mdx @@ -4,11 +4,36 @@ description: "Configure Google Cloud Vertex AI as an embedding provider in Mem0 --- ### Vertex AI -To use Google Cloud's Vertex AI for text embedding models, set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to point to the path of your service account's credentials JSON file. These credentials can be created in the [Google Cloud Console](https://console.cloud.google.com/). +Google Cloud's Vertex AI serves text embedding models such as `gemini-embedding-001`. Mem0 uses them through the provider's own SDK, which you install alongside Mem0. + +### Installation + +The Vertex AI client is an optional dependency, so install it yourself. + + +```bash Python +pip install vertexai +``` + +```bash TypeScript +npm install @google-cloud/aiplatform +``` + + +### Authentication + +Both SDKs authenticate with [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). Pick whichever fits your environment: + +- **Local development:** run `gcloud auth application-default login`. +- **Service account:** create a key in the [Google Cloud Console](https://console.cloud.google.com/) and point `GOOGLE_APPLICATION_CREDENTIALS` at the JSON file, or pass its path through the embedder config. +- **Google Cloud runtimes** (Cloud Run, GKE, Compute Engine): the attached service account is picked up automatically. + +The TypeScript SDK reads the project ID from `googleProjectId`, then the `GCP_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, and `GCLOUD_PROJECT` environment variables, and finally from your credentials. Set it explicitly when your credentials cover more than one project. ### Usage -```python + +```python Python import os from mem0 import Memory @@ -32,28 +57,87 @@ 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": "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="john") ``` -The embedding types can be one of the following: + +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +const config = { + embedder: { + provider: "vertexai", + config: { + model: "gemini-embedding-001", + // Optional. Falls back to GCP_PROJECT_ID / GOOGLE_CLOUD_PROJECT / + // GCLOUD_PROJECT, then to the project on your credentials. + googleProjectId: process.env.GCP_PROJECT_ID, + location: "us-central1", + // Optional. Path to a service account key file, or pass the JSON inline + // via googleServiceAccountJson. + vertexCredentialsJson: "/path/to/your/credentials.json", + embeddingDims: 256, + memoryAddEmbeddingType: "RETRIEVAL_DOCUMENT", + memoryUpdateEmbeddingType: "RETRIEVAL_DOCUMENT", + memorySearchEmbeddingType: "RETRIEVAL_QUERY", + }, + }, +}; + +const memory = new Memory(config); +await memory.add("I love sci-fi movies but not thrillers", { userId: "john" }); +``` + + +### Embedding types + +Vertex AI embeds the same text differently depending on the task you declare. The embedding types can be one of the following: - SEMANTIC_SIMILARITY - CLASSIFICATION - CLUSTERING - RETRIEVAL_DOCUMENT, RETRIEVAL_QUERY, QUESTION_ANSWERING, FACT_VERIFICATION -- CODE_RETRIEVAL_QUERY -Check out the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/task-types#supported_task_types) for more information. - +- CODE_RETRIEVAL_QUERY + +Check out the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/task-types#supported_task_types) for more information. + + +These embedding types map to the add, update, and search memory actions in both the Python and TypeScript SDKs. Stored memories use the add or update type, and searches use the search type. + + +### Choosing a model + + +`gemini-embedding-001` accepts **one input text per request**. When Mem0 embeds several texts at once, such as the memories extracted from a single conversation turn, it issues one request per text. The older `text-embedding-005` and `text-multilingual-embedding-002` models accept up to 250 texts per request, so they are faster and cheaper for large batches. See [Get text embeddings](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-text-embeddings). + + ### Config Here are the parameters available for configuring the Vertex AI embedder: -| Parameter | Description | Default Value | -| ------------------------- | ------------------------------------------------ | -------------------- | -| `model` | The name of the Vertex AI embedding model to use | `gemini-embedding-001` | -| `vertex_credentials_json` | Path to the Google Cloud credentials JSON file | `None` | -| `embedding_dims` | Dimensions of the embedding model | `256` | -| `memory_add_embedding_type` | The type of embedding to use for the add memory action | `RETRIEVAL_DOCUMENT` | -| `memory_update_embedding_type` | The type of embedding to use for the update memory action | `RETRIEVAL_DOCUMENT` | -| `memory_search_embedding_type` | The type of embedding to use for the search memory action | `RETRIEVAL_QUERY` | + + +| Parameter | Description | Default Value | +| -------------------------------- | ---------------------------------------------------------- | ---------------------- | +| `model` | The name of the Vertex AI embedding model to use | `gemini-embedding-001` | +| `vertex_credentials_json` | Path to the Google Cloud credentials JSON file | `None` | +| `embedding_dims` | Dimensions of the embedding model | `256` | +| `memory_add_embedding_type` | The embedding type to use for the add memory action | `RETRIEVAL_DOCUMENT` | +| `memory_update_embedding_type` | The embedding type to use for the update memory action | `RETRIEVAL_DOCUMENT` | +| `memory_search_embedding_type` | The embedding type to use for the search memory action | `RETRIEVAL_QUERY` | + + +| Parameter | Description | Default Value | +| ----------------------------- | -------------------------------------------------------------------------- | ---------------------- | +| `model` | The name of the Vertex AI embedding model to use | `gemini-embedding-001` | +| `googleProjectId` | Google Cloud project ID (falls back to `GCP_PROJECT_ID` env var, then to your credentials) | Resolved from credentials | +| `location` | Google Cloud region (falls back to `GCP_LOCATION` env var) | `us-central1` | +| `vertexCredentialsJson` | Path to the Google Cloud credentials JSON file | `None` | +| `googleServiceAccountJson` | Service account credentials as a JSON string or object | `None` | +| `embeddingDims` | Dimensions of the embedding model | `256` | +| `memoryAddEmbeddingType` | The embedding type to use for the add memory action | `RETRIEVAL_DOCUMENT` | +| `memoryUpdateEmbeddingType` | The embedding type to use for the update memory action | `RETRIEVAL_DOCUMENT` | +| `memorySearchEmbeddingType` | The embedding type to use for the search memory action | `RETRIEVAL_QUERY` | + + diff --git a/docs/components/embedders/overview.mdx b/docs/components/embedders/overview.mdx index ab473cde6..0a1b66961 100644 --- a/docs/components/embedders/overview.mdx +++ b/docs/components/embedders/overview.mdx @@ -10,7 +10,7 @@ Mem0 offers support for various embedding models, allowing users to choose the o See the list of supported embedders below. - All embedders listed below are supported in the Python implementation. The TypeScript implementation supports: **OpenAI**, **Azure OpenAI**, **FastEmbed**, **Google AI**, **Langchain**, **LM Studio**, **Ollama**, and **Together**. + All embedders listed below are supported in the Python implementation. The TypeScript implementation supports: **OpenAI**, **Azure OpenAI**, **AWS Bedrock**, **FastEmbed**, **Google AI**, **Hugging Face**, **Langchain**, **LM Studio**, **Ollama**, **Together**, and **Vertex AI**. diff --git a/docs/components/llms/config.mdx b/docs/components/llms/config.mdx index f493d9fc7..1f183a6bd 100644 --- a/docs/components/llms/config.mdx +++ b/docs/components/llms/config.mdx @@ -133,4 +133,4 @@ Here's a comprehensive list of all parameters that can be used across different ## Supported LLMs -For detailed information on configuring specific LLMs, please visit the [LLMs](./models) section. There you'll find information for each supported LLM with provider-specific usage examples and configuration details. +For detailed information on configuring specific LLMs, please visit the [LLMs](./overview) section. There you'll find information for each supported LLM with provider-specific usage examples and configuration details. diff --git a/docs/components/llms/models/aws_bedrock.mdx b/docs/components/llms/models/aws_bedrock.mdx index 3d3de2d29..089ec3c05 100644 --- a/docs/components/llms/models/aws_bedrock.mdx +++ b/docs/components/llms/models/aws_bedrock.mdx @@ -5,16 +5,18 @@ description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authenti ### Setup - Before using the AWS Bedrock LLM, make sure you have the appropriate model access from [Bedrock Console](https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess). -- You will also need to authenticate the `boto3` client by using a method in the [AWS documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials) -- You will have to export `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` to set environment variables. +- Model availability is per-region. `anthropic.claude-sonnet-4-20250514-v1:0` supports on-demand inference in `us-east-1` and `ap-southeast-4`; from any other region, use the cross-region inference profile ID `us.anthropic.claude-sonnet-4-20250514-v1:0` instead. +- Install the AWS SDK for your language: `pip install boto3` (Python) or `npm install @aws-sdk/client-bedrock-runtime` (TypeScript). +- Both SDKs fall back to the standard AWS credential chain (environment variables, `~/.aws/credentials`, or an attached IAM role), so exporting `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` is the quickest way to get started. In TypeScript you can also pass credentials inline with `awsRegion`, `awsAccessKeyId`, `awsSecretAccessKey`, and `awsSessionToken`, as shown below. ### Usage -```python + +```python Python import os from mem0 import Memory -os.environ['AWS_REGION'] = 'us-west-2' +os.environ['AWS_REGION'] = 'us-east-1' os.environ["AWS_ACCESS_KEY_ID"] = "xx" os.environ["AWS_SECRET_ACCESS_KEY"] = "xx" @@ -22,7 +24,7 @@ config = { "llm": { "provider": "aws_bedrock", "config": { - "model": "anthropic.claude-3-5-haiku-20241022-v1:0", + "model": "anthropic.claude-sonnet-4-20250514-v1:0", "temperature": 0.2, "max_tokens": 2000, } @@ -39,6 +41,75 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; + +const config = { + llm: { + provider: 'aws_bedrock', + config: { + model: 'anthropic.claude-sonnet-4-20250514-v1:0', + temperature: 0.2, + maxTokens: 2000, + // Optional. Omit these to use the default AWS credential chain. + awsRegion: process.env.AWS_REGION, + awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID, + awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }, +}; + +const memory = new Memory(config); +const 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."} +]; +await memory.add(messages, { userId: 'alice', metadata: { category: 'movies' } }); +``` + + + + `@aws-sdk/client-bedrock-runtime` is an optional peer dependency of `mem0ai`, so npm will not install it for you. The TypeScript provider loads it lazily and throws a clear error on the first request if the package is missing. + + + + The TypeScript provider calls the Bedrock [Converse API](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html), a single uniform interface across the current Bedrock model families. Streaming and `InvokeModel`-only models are not supported yet. + + +### Application inference profiles + +Bedrock resolves the model family from the model identifier. An application inference profile ARN ends in an opaque ID, so there is nothing to resolve from. Set `provider_override` (Python) / `providerOverride` (TypeScript) when your model is one: + + +```python Python +config = { + "llm": { + "provider": "aws_bedrock", + "config": { + "model": "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz", + "provider_override": "anthropic", + } + } +} +``` + +```typescript TypeScript +const config = { + llm: { + provider: 'aws_bedrock', + config: { + model: 'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz', + providerOverride: 'anthropic', + }, + }, +}; +``` + + +Without it, initialization raises `Unknown provider in model` (Python: `ValueError`; TypeScript: `Error`). Plain model IDs and cross-region inference profiles such as `us.anthropic.claude-sonnet-4-20250514-v1:0` still resolve automatically and need no override. + ### Config -All available parameters for the `aws_bedrock` config are present in [Master List of All Params in Config](../config). \ No newline at end of file +All available parameters for the `aws_bedrock` config are present in [Master List of All Params in Config](../config). diff --git a/docs/components/llms/overview.mdx b/docs/components/llms/overview.mdx index ce9cfa7dd..12b280f47 100644 --- a/docs/components/llms/overview.mdx +++ b/docs/components/llms/overview.mdx @@ -16,7 +16,7 @@ For a comprehensive list of available parameters for llm configuration, please r See the list of supported LLMs below. - All LLMs are supported in Python. The following LLMs are also supported in TypeScript: **OpenAI**, **Anthropic**, **Groq**, **Azure OpenAI**, **DeepSeek**, **Google AI**, **Langchain**, **LM Studio**, **Mistral AI**, and **Ollama**. + All LLMs are supported in Python. The following LLMs are also supported in TypeScript: **OpenAI**, **Anthropic**, **AWS Bedrock**, **Groq**, **Azure OpenAI**, **DeepSeek**, **Google AI**, **Langchain**, **LM Studio**, **Mistral AI**, and **Ollama**. diff --git a/docs/components/rerankers/config.mdx b/docs/components/rerankers/config.mdx index 475e8ca09..25d8691c4 100644 --- a/docs/components/rerankers/config.mdx +++ b/docs/components/rerankers/config.mdx @@ -26,7 +26,7 @@ All rerankers share these common configuration parameters: | Parameter | Description | Type | Default | | -------------------- | -------------------------------------------- | ------ | ----------------------- | -| `model` | Cohere rerank model | `str` | `"rerank-english-v3.0"` | +| `model` | Cohere rerank model | `str` | `"rerank-v3.5"` | | `api_key` | Cohere API key | `str` | `None` | | `return_documents` | Whether to return document texts in response | `bool` | `False` | | `max_chunks_per_doc` | Maximum chunks per document | `int` | `None` | @@ -52,7 +52,7 @@ All rerankers share these common configuration parameters: | Parameter | Description | Type | Default | | ---------------- | ------------------------------------------ | ------- | ---------------------- | -| `model` | LLM model to use for scoring | `str` | `"gpt-4o-mini"` | +| `model` | LLM model to use for scoring | `str` | `"gpt-5-mini"` | | `provider` | LLM provider (`openai`, `anthropic`, etc.) | `str` | `"openai"` | | `api_key` | API key for LLM provider | `str` | `None` | | `temperature` | Temperature for LLM generation | `float` | `0.0` | @@ -103,3 +103,30 @@ config = { } } ``` + +## TypeScript SDK + +The self-hosted [TypeScript SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) supports the same five providers. Config keys are camelCase (`apiKey`, `topK`, `maxLength`) and each provider's SDK is a peer dependency you install per reranker. + +| Provider | Install | Default model | Key config fields | +| --- | --- | --- | --- | +| `cohere` | `pnpm add cohere-ai` | `rerank-v3.5` | `apiKey`, `model`, `topK` | +| `zero_entropy` | `pnpm add zeroentropy` | `zerank-1` | `apiKey`, `model`, `topK` | +| `sentence_transformer` | `pnpm add @huggingface/transformers` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `huggingface` | `pnpm add @huggingface/transformers` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `llm_reranker` | None (uses your LLM provider's own SDK) | `openai` / `gpt-5-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` | + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "zero_entropy", + config: { apiKey: process.env.ZERO_ENTROPY_API_KEY, topK: 5 }, + }, +}); +``` + + + The local cross-encoder providers (`sentence_transformer`, `huggingface`) run on [Transformers.js](https://huggingface.co/docs/transformers.js) and default to ONNX (`Xenova/*`) model mirrors, so Python default model strings must be swapped for their ONNX equivalents. `batchSize` and `showProgressBar` are accepted for parity with Python but are no-ops in the TypeScript runtime. See the [reranker feature guide](/open-source/features/reranker-search#typescript-sdk) for full examples. + diff --git a/docs/components/rerankers/custom-prompts.mdx b/docs/components/rerankers/custom-prompts.mdx index 6d7ba7ed1..a9e383b24 100644 --- a/docs/components/rerankers/custom-prompts.mdx +++ b/docs/components/rerankers/custom-prompts.mdx @@ -48,7 +48,7 @@ config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-openai-key", "scoring_prompt": custom_prompt, "top_k": 5 diff --git a/docs/components/rerankers/models/cohere.mdx b/docs/components/rerankers/models/cohere.mdx index 20e73a656..050c4ff6b 100644 --- a/docs/components/rerankers/models/cohere.mdx +++ b/docs/components/rerankers/models/cohere.mdx @@ -9,9 +9,9 @@ Cohere provides enterprise-grade reranking models with excellent multilingual su Cohere offers several reranking models: -- **`rerank-english-v3.0`**: Latest English reranker with best performance -- **`rerank-multilingual-v3.0`**: Multilingual support for global applications -- **`rerank-english-v2.0`**: Previous generation English reranker +- **`rerank-v3.5`** (default): Latest reranker, multilingual, best performance +- **`rerank-english-v3.0`**: Previous generation, English only +- **`rerank-multilingual-v3.0`**: Previous generation, multilingual ## Installation @@ -41,7 +41,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key", # or set COHERE_API_KEY "top_k": 5, "return_documents": False, @@ -53,6 +53,34 @@ config = { memory = Memory.from_config(config) ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the Cohere reranker. Config keys are camelCase, it defaults to the `rerank-v3.5` model, and you opt in per search with `rerank: true`. + +```bash +pnpm add cohere-ai +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "cohere", + config: { + apiKey: process.env.COHERE_API_KEY, // or set COHERE_API_KEY + // model: "rerank-v3.5", // default + topK: 5, + }, + }, +}); + +const results = await memory.search("What is the user's profession?", { + filters: { userId: "bob" }, + rerank: true, +}); +``` + ## Environment Variables Set your API key as an environment variable: @@ -73,11 +101,11 @@ os.environ["COHERE_API_KEY"] = "your-api-key" # Initialize memory with Cohere reranker config = { "vector_store": {"provider": "chroma"}, - "llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}}, + "llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}}, "rerank": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "top_k": 3 } } @@ -124,7 +152,7 @@ config = { | Parameter | Description | Type | Default | | -------------------- | -------------------------------- | ------ | ----------------------- | -| `model` | Cohere rerank model to use | `str` | `"rerank-english-v3.0"` | +| `model` | Cohere rerank model to use | `str` | `"rerank-v3.5"` | | `api_key` | Cohere API key | `str` | `None` | | `top_k` | Maximum documents to return | `int` | `None` | | `return_documents` | Whether to return document texts | `bool` | `False` | @@ -139,7 +167,7 @@ config = { ## Best Practices -1. **Model Selection**: Use `rerank-english-v3.0` for English, `rerank-multilingual-v3.0` for other languages +1. **Model Selection**: `rerank-v3.5` handles English and multilingual workloads; pin an older `v3.0` model only if you need to reproduce prior results 2. **Batch Processing**: Process multiple queries efficiently 3. **Error Handling**: Implement retry logic for production systems 4. **Monitoring**: Track reranking performance and costs diff --git a/docs/components/rerankers/models/huggingface.mdx b/docs/components/rerankers/models/huggingface.mdx index 64828c095..2aa85f04c 100644 --- a/docs/components/rerankers/models/huggingface.mdx +++ b/docs/components/rerankers/models/huggingface.mdx @@ -57,6 +57,40 @@ config = { } ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js), the same cross-encoder path as `sentence_transformer`, just a different default model. It executes ONNX weights, so the default is the ONNX mirror `Xenova/bge-reranker-base`. Point `model` at any ONNX-exported reranker on the Hub (a raw `BAAI/bge-reranker-*` PyTorch checkpoint will not load in this runtime). + +```bash +pnpm add @huggingface/transformers +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "huggingface", + config: { + // model: "Xenova/bge-reranker-base", // default (ONNX) + device: "cpu", // "cpu" | "wasm" | "webgpu" + maxLength: 512, // max tokens per query-document pair + normalize: true, // sigmoid-normalize logits to [0, 1] (default) + topK: 5, + }, + }, +}); + +const results = await memory.search("What are the user's interests?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + + + `batchSize` and `showProgressBar` are accepted for parity with the Python SDK but are no-ops in the TypeScript runtime. `trust_remote_code` and `model_kwargs` are Python-only. + + ## Popular Models ### BGE Rerankers (Recommended) diff --git a/docs/components/rerankers/models/llm_reranker.mdx b/docs/components/rerankers/models/llm_reranker.mdx index d23be45a4..a4fdb57e0 100644 --- a/docs/components/rerankers/models/llm_reranker.mdx +++ b/docs/components/rerankers/models/llm_reranker.mdx @@ -19,7 +19,7 @@ config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-openai-api-key" } } @@ -33,7 +33,7 @@ m = Memory.from_config(config) | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `provider` | str | `"openai"` | LLM provider (openai, anthropic, etc.) | -| `model` | str | `"gpt-4o-mini"` | LLM model to use for reranking | +| `model` | str | `"gpt-5-mini"` | LLM model to use for reranking | | `api_key` | str | None | API key for the LLM provider | | `top_k` | int | None | Number of top documents to return after reranking | | `temperature` | float | 0.0 | LLM temperature for consistency | @@ -67,6 +67,43 @@ config = { } ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the LLM reranker under the provider name `llm_reranker`. It does **not** reuse the Memory's main `llm` instance; it builds its own LLM from the reranker's own config, defaulting to `openai` / `gpt-5-mini`. Set `provider`/`model`/`apiKey` directly on `config`, or nest a fully separate `config.llm: { provider, config }` (its `provider`/`config` take priority over the top-level fields, which only backfill values missing from the nested config). + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "llm_reranker", + config: { apiKey: process.env.OPENAI_API_KEY }, + }, +}); + +const results = await memory.search("What movies do I like?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + +To rerank with a different LLM provider than the Memory's main `llm`, nest it under `config.llm`: + +```typescript +const memory = new Memory({ + llm: { provider: "openai", config: { apiKey: process.env.OPENAI_API_KEY } }, + reranker: { + provider: "llm_reranker", + config: { + llm: { + provider: "anthropic", + config: { apiKey: process.env.ANTHROPIC_API_KEY }, + }, + }, + }, +}); +``` + ## Supported LLM Providers ### OpenAI @@ -77,7 +114,7 @@ config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-openai-api-key", "temperature": 0.0 } @@ -133,15 +170,15 @@ config = { "provider": "llm_reranker", "config": { "provider": "azure_openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-azure-api-key", "llm": { "provider": "azure_openai", "config": { - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-azure-api-key", "azure_endpoint": "https://your-resource.openai.azure.com/", - "azure_deployment": "gpt-4o-mini-deployment" + "azure_deployment": "gpt-5-mini-deployment" } } } @@ -195,7 +232,7 @@ config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-api-key", "scoring_prompt": custom_prompt } @@ -314,7 +351,7 @@ fast_config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-api-key", "top_k": 5, "temperature": 0.0 @@ -407,7 +444,7 @@ fallback_config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-api-key" } } diff --git a/docs/components/rerankers/models/sentence_transformer.mdx b/docs/components/rerankers/models/sentence_transformer.mdx index 8535e4627..3304e1f27 100644 --- a/docs/components/rerankers/models/sentence_transformer.mdx +++ b/docs/components/rerankers/models/sentence_transformer.mdx @@ -36,7 +36,7 @@ config = { "llm": { "provider": "openai", "config": { - "model": "gpt-4o-mini" + "model": "gpt-5-mini" } }, "rerank": { @@ -54,6 +54,40 @@ config = { memory = Memory.from_config(config) ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js). Because it executes ONNX weights, the default model is the ONNX mirror of the Python default: `Xenova/ms-marco-MiniLM-L-6-v2`. Point `model` at any ONNX-exported cross-encoder on the Hub (a raw `cross-encoder/...` PyTorch checkpoint will not load in this runtime). + +```bash +pnpm add @huggingface/transformers +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "sentence_transformer", + config: { + // model: "Xenova/ms-marco-MiniLM-L-6-v2", // default (ONNX) + device: "cpu", // "cpu" | "wasm" | "webgpu" + maxLength: 512, // max tokens per query-document pair + normalize: true, // sigmoid-normalize logits to [0, 1] (default) + topK: 5, + }, + }, +}); + +const results = await memory.search("What books does the user like?", { + filters: { userId: "charlie" }, + rerank: true, +}); +``` + + + `batchSize` and `showProgressBar` are accepted for parity with the Python SDK but are no-ops in the TypeScript runtime, because a search reranks a small candidate set in a single in-process forward pass. The model downloads once and is cached in-process. + + ## GPU Acceleration For better performance, use GPU acceleration: @@ -79,7 +113,7 @@ from mem0 import Memory # Initialize memory with local reranker config = { "vector_store": {"provider": "chroma"}, - "llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}}, + "llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}}, "rerank": { "provider": "sentence_transformer", "config": { diff --git a/docs/components/rerankers/models/zero_entropy.mdx b/docs/components/rerankers/models/zero_entropy.mdx index 43973a323..cfad212c2 100644 --- a/docs/components/rerankers/models/zero_entropy.mdx +++ b/docs/components/rerankers/models/zero_entropy.mdx @@ -34,7 +34,7 @@ config = { "llm": { "provider": "openai", "config": { - "model": "gpt-4o-mini" + "model": "gpt-5-mini" } }, "rerank": { @@ -50,6 +50,34 @@ config = { memory = Memory.from_config(config) ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the Zero Entropy reranker under the same provider name as Python, `zero_entropy`. It reads the key from config or `ZERO_ENTROPY_API_KEY` and defaults to the `zerank-1` model. + +```bash +pnpm add zeroentropy +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "zero_entropy", + config: { + apiKey: process.env.ZERO_ENTROPY_API_KEY, + // model: "zerank-1", // default (or "zerank-1-small") + topK: 5, + }, + }, +}); + +const results = await memory.search("What Italian food does the user like?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + ## Environment Variables Set your API key as an environment variable: @@ -70,7 +98,7 @@ os.environ["ZERO_ENTROPY_API_KEY"] = "your-api-key" # Initialize memory with Zero Entropy reranker config = { "vector_store": {"provider": "chroma"}, - "llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}}, + "llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}}, "rerank": {"provider": "zero_entropy", "config": {"model": "zerank-1"}} } diff --git a/docs/components/rerankers/optimization.mdx b/docs/components/rerankers/optimization.mdx index 5b396bf61..f85a28f4a 100644 --- a/docs/components/rerankers/optimization.mdx +++ b/docs/components/rerankers/optimization.mdx @@ -47,7 +47,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "top_n": 10, "max_chunks_per_doc": 10, # Limit chunk processing "return_documents": False # Reduce response size @@ -280,7 +280,7 @@ config = { ```python def benchmark_rerankers(): configs = [ - {"provider": "cohere", "model": "rerank-english-v3.0"}, + {"provider": "cohere", "model": "rerank-v3.5"}, {"provider": "sentence_transformer", "model": "cross-encoder/ms-marco-MiniLM-L-6-v2"}, {"provider": "huggingface", "model": "BAAI/bge-reranker-base"} ] diff --git a/docs/components/rerankers/overview.mdx b/docs/components/rerankers/overview.mdx index 58432159c..2fab7396f 100644 --- a/docs/components/rerankers/overview.mdx +++ b/docs/components/rerankers/overview.mdx @@ -19,6 +19,10 @@ Reranking trades extra latency for better precision. Start once you have baselin + +All five rerankers are available in both the Python and the [TypeScript](/open-source/features/reranker-search#typescript-sdk) self-hosted SDKs. Each provider page has a **TypeScript (self-hosted)** section with the camelCase config. + + ## Reranking Workflow diff --git a/docs/components/vectordbs/config.mdx b/docs/components/vectordbs/config.mdx index 28676d177..b81c06252 100644 --- a/docs/components/vectordbs/config.mdx +++ b/docs/components/vectordbs/config.mdx @@ -7,7 +7,7 @@ description: "Reference for vector database configuration options in Mem0, inclu The `config` is defined as an object with two main keys: - `vector_store`: Specifies the vector database provider and its configuration - - `provider`: The name of the vector database (e.g., "chroma", "pgvector", "qdrant", "milvus", "upstash_vector", "azure_ai_search", "vertex_ai_vector_search", "valkey") + - `provider`: The name of the vector database (e.g., "chroma", "pgvector", "qdrant", "milvus", "upstash_vector", "azure_ai_search", "vertex_ai_vector_search", "valkey", "oracledb") - `config`: A nested dictionary containing provider-specific settings @@ -95,6 +95,11 @@ Here's a comprehensive list of all parameters that can be used across different | `connection_string` | PostgreSQL connection string (for Supabase/PGVector) | | `index_method` | Vector index method (for Supabase) | | `index_measure` | Distance measure for similarity search (for Supabase) | +| `connection_params` | Connection settings for Oracle AI Vector Search | +| `use_connection_pool` | Create an Oracle connection pool from `connection_params` | +| `distance_metric` | Distance metric for Oracle vector indexing and search | +| `index_type` | Oracle vector index type: `HNSW` or `IVF` | +| `index_parameters` | Oracle vector-index parameters for the selected index type | | Parameter | Description | @@ -118,10 +123,10 @@ Here's a comprehensive list of all parameters that can be used across different Each vector database has its own specific configuration requirements. To customize the config for your chosen vector store: -1. Identify the vector database you want to use from [supported vector databases](./dbs). +1. Identify the vector database you want to use from [supported vector databases](./overview). 2. Refer to the `Config` section in the respective vector database's documentation. 3. Include only the relevant parameters for your chosen database in the `config` dictionary. ## Supported Vector Databases -For detailed information on configuring specific vector databases, please visit the [Supported Vector Databases](./dbs) section. There you'll find individual pages for each supported vector store with provider-specific usage examples and configuration details. +For detailed information on configuring specific vector databases, please visit the [Supported Vector Databases](./overview) section. There you'll find individual pages for each supported vector store with provider-specific usage examples and configuration details. diff --git a/docs/components/vectordbs/dbs/baidu.mdx b/docs/components/vectordbs/dbs/baidu.mdx index 72a26a4ce..ff7428cbd 100644 --- a/docs/components/vectordbs/dbs/baidu.mdx +++ b/docs/components/vectordbs/dbs/baidu.mdx @@ -5,10 +5,22 @@ description: "Use Baidu Mochow as an enterprise vector database in Mem0 for high [Baidu VectorDB](https://cloud.baidu.com/doc/VDB/index.html) is an enterprise-level distributed vector database service developed by Baidu Intelligent Cloud. It is powered by Baidu's proprietary "Mochow" vector database kernel, providing high performance, availability, and security for vector search. +### Installation + + +```bash Python +pip install pymochow +``` + +```bash TypeScript +npm install @mochow/mochow-sdk-node +``` + + + ### Usage ```python -import os from mem0 import Memory config = { @@ -36,19 +48,63 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + embedder: { + provider: "openai", + config: { + apiKey: process.env.OPENAI_API_KEY || "", + model: "text-embedding-3-small", + embeddingDims: 1536, + }, + }, + vectorStore: { + provider: "baidu", + config: { + endpoint: process.env.BAIDU_ENDPOINT || "", + account: process.env.BAIDU_ACCOUNT || "root", + apiKey: process.env.BAIDU_API_KEY || "", + databaseName: "mem0", + tableName: "mem0_table", + embeddingModelDims: 1536, + metricType: "COSINE", + }, + }, + llm: { + provider: "openai", + config: { + apiKey: process.env.OPENAI_API_KEY || "", + model: "gpt-5-mini", + }, + }, +}); +``` + ### Config Here are the parameters available for configuring Baidu VectorDB: -| Parameter | Description | Default Value | -| --- | --- | --- | -| `endpoint` | Endpoint URL for your Baidu VectorDB instance | Required | -| `account` | Baidu VectorDB account name | `root` | -| `api_key` | API key for accessing Baidu VectorDB | Required | -| `database_name` | Name of the database | `mem0` | -| `table_name` | Name of the table | `mem0` | -| `embedding_model_dims` | Dimensions of the embedding model | `1536` | -| `metric_type` | Distance metric for similarity search | `L2` | +| Parameter | Description | Default Value | +| ---------------------- | --------------------------------------------- | ------------- | +| `endpoint` | Endpoint URL for your Baidu VectorDB instance | Required | +| `account` | Baidu VectorDB account name | `root` | +| `api_key` | API key for accessing Baidu VectorDB | Required | +| `database_name` | Name of the database | `mem0` | +| `table_name` | Name of the table | `mem0` | +| `embedding_model_dims` | Dimensions of the embedding model | `1536` | +| `metric_type` | Distance metric for similarity search | `L2` | +| `client` | Prebuilt Mochow client (TypeScript SDK only) | `None` | + +For the TypeScript OSS SDK, use the camelCase equivalents: + +- `databaseName` +- `tableName` +- `embeddingModelDims` +- `metricType` + +For OSS TS usage, `endpoint`, `account`, `apiKey`, `databaseName`, `tableName`, and `embeddingModelDims` are required unless you inject a prebuilt client. `metricType` defaults to `L2`, matching the Python SDK. ### Distance Metrics @@ -66,3 +122,5 @@ The vector index is automatically configured with the following HNSW parameters: - `efconstruction`: 200 (size of the dynamic candidate list) - `auto_build`: true (automatically build index) - `auto_build_index_policy`: Incremental build with 10000 rows increment + +The TypeScript provider also creates a BM25 inverted index over a `textLemmatized` column so `keywordSearch()` runs against a real full-text index. Mem0 lemmatizes the query before it reaches the vector store, so only the lemmatized form of each memory is indexed. If you point `tableName` at a table created before this index existed, `keywordSearch()` returns `null` and search falls back to vector similarity alone; recreate the table to enable it. diff --git a/docs/components/vectordbs/dbs/databricks.mdx b/docs/components/vectordbs/dbs/databricks.mdx index db0056e86..890b1edcb 100644 --- a/docs/components/vectordbs/dbs/databricks.mdx +++ b/docs/components/vectordbs/dbs/databricks.mdx @@ -6,7 +6,8 @@ description: "Use Databricks Vector Search as a serverless vector store in Mem0 ### Usage -```python + +```python Python import os from mem0 import Memory @@ -36,10 +37,44 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +// Requires the Databricks SQL driver (peer dependency): pnpm add @databricks/sql +import { Memory } from 'mem0ai/oss'; + +const config = { + vectorStore: { + provider: 'databricks', + config: { + workspaceUrl: 'https://your-workspace.databricks.com', + // SQL warehouse HTTP path, used for index writes (required) + httpPath: '/sql/1.0/warehouses/your-warehouse-id', + accessToken: 'your-access-token', + catalog: 'your_catalog', + schema: 'your_schema', + tableName: 'your_table', + collectionName: 'your_index_name', + embeddingModelDims: 1536, + }, + }, +}; + +const memory = new Memory(config); +const 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."} +] +await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }); +``` + + ### Config Here are the parameters available for configuring Databricks Vector Search: + + | Parameter | Description | Default Value | | --- | --- | --- | | `workspace_url` | The URL of your Databricks workspace | **Required** | @@ -60,6 +95,32 @@ Here are the parameters available for configuring Databricks Vector Search: | `pipeline_type` | Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` | | `warehouse_name` | Databricks SQL warehouse name (if using SQL warehouse) | `None` | | `query_type` | Query type: `ANN` or `HYBRID` | `ANN` | + + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `workspaceUrl` | The URL of your Databricks workspace (or pass `host`) | **Required** | +| `httpPath` | SQL warehouse HTTP path, used for index writes | **Required** | +| `accessToken` | Personal Access Token for authentication | `None` | +| `clientId` | Service principal client ID (alternative to `accessToken`) | `None` | +| `clientSecret` | Service principal client secret (required with `clientId`) | `None` | +| `endpointName` | Name of the Vector Search endpoint | `mem0_vector_search` | +| `endpointType` | Type of endpoint (`STANDARD` or `STORAGE_OPTIMIZED`) | `STANDARD` | +| `pipelineType` | Delta Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` | +| `queryType` | Query type: `ANN` or `HYBRID` | `ANN` | +| `catalog` | Unity Catalog catalog name | `main` | +| `schema` | Unity Catalog schema name | `default` | +| `collectionName` | Vector Search index name | `mem0` | +| `tableName` | Source Delta table name | falls back to `collectionName` | +| `embeddingModelDims` | Dimension of self-managed embeddings | `1536` | +| `syncPollIntervalMs` | Poll interval while waiting for a `TRIGGERED` sync | `1000` | +| `syncTimeoutMs` | Timeout while waiting for an index sync | `300000` | + + +The TypeScript provider uses `DELTA_SYNC` indexes with self-managed embeddings: pass vectors directly. `DIRECT_ACCESS` indexes, Databricks-computed embeddings (`embedding_model_endpoint_name`), and Azure AD auth are Python-only today. It writes to the index through a SQL warehouse, so `httpPath` is required, and `@databricks/sql` must be installed as a peer dependency. + + + ### Authentication diff --git a/docs/components/vectordbs/dbs/neptune_analytics.mdx b/docs/components/vectordbs/dbs/neptune_analytics.mdx index 12d601b99..1e3ce4dbb 100644 --- a/docs/components/vectordbs/dbs/neptune_analytics.mdx +++ b/docs/components/vectordbs/dbs/neptune_analytics.mdx @@ -2,26 +2,37 @@ title: "Neptune Analytics" description: "Use AWS Neptune Analytics as a vector store in Mem0, combining graph analytics with vector search capabilities." --- -# Neptune Analytics Vector Store -[Neptune Analytics](https://docs.aws.amazon.com/neptune-analytics/latest/userguide/what-is-neptune-analytics.html/) is a memory-optimized graph database engine for analytics. With Neptune Analytics, you can get insights and find trends by processing large amounts of graph data in seconds, including vector search. +[Neptune Analytics](https://docs.aws.amazon.com/neptune-analytics/latest/userguide/what-is-neptune-analytics.html) is a memory-optimized graph database engine for analytics. With Neptune Analytics, you can get insights and find trends by processing large amounts of graph data in seconds, including vector search. +### Installation -## Installation +The Neptune Analytics provider needs the AWS Neptune Graph client. Install it alongside `mem0ai`: -```bash + +```bash Python pip install mem0ai[vector-stores] ``` -## Usage +```bash TypeScript +npm install @aws-sdk/client-neptune-graph +``` + + +### Usage + +Configure AWS credentials in your environment (environment variables, shared config file, an IAM role, or an instance profile). Both SDKs pick them up automatically through the standard AWS credential chain. + + +```python Python +from mem0 import Memory -```python config = { "vector_store": { "provider": "neptune", "config": { "collection_name": "mem0", - "endpoint": f"neptune-graph://my-graph-identifier", + "endpoint": "neptune-graph://g-abc123xyz0", }, }, } @@ -29,18 +40,90 @@ config = { m = Memory.from_config(config) messages = [ {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, - {"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."}, + {"role": "assistant", "content": "How about a thriller movie? 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"}) ``` -## Parameters +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; -Let's see the available parameters for the `neptune` config: +const config = { + vectorStore: { + provider: 'neptune', + config: { + collectionName: 'mem0', + graphIdentifier: 'g-abc123xyz0', + // Any other key here (region, credentials, maxAttempts, ...) is + // forwarded to the underlying NeptuneGraphClient constructor. + region: 'us-east-1', + }, + }, +}; +const memory = new Memory(config); +const messages = [ + { role: "user", content: "I'm planning to watch a movie tonight. Any recommendations?" }, + { role: "assistant", content: "How about a thriller movie? 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." }, +]; +await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }); +``` + + +### Config + + + | Parameter | Description | Default Value | | --- | --- | --- | | `collection_name` | The name of the collection to store the vectors | `mem0` | -| `endpoint` | Connection URL for the Neptune Analytics service | `neptune-graph://my-graph-identifier` | +| `endpoint` | Connection URL for the Neptune Analytics service, must be `neptune-graph://` | Required | + + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `collectionName` | The name of the collection to store the vectors | `memories` | +| `graphIdentifier` | Graph ID, e.g. `g-abc123xyz0`. Takes priority over `endpoint`. | Required, unless `endpoint` supplies it | +| `endpoint` | Either `neptune-graph://` (or a bare graph ID) to supply the graph ID, or an `https://` service endpoint to override the AWS endpoint. An `https://` value must be paired with `graphIdentifier`. | `undefined` | +| `dimension` | Embedding vector dimension | Auto-detected from the embedder when omitted | +| `client` | A pre-built `NeptuneGraphClient` to use instead of constructing one | `undefined` | +| any other key | Forwarded as-is to the [`NeptuneGraphClient`](https://www.npmjs.com/package/@aws-sdk/client-neptune-graph) constructor, e.g. `region`, `credentials`, `maxAttempts` | N/A | + + + +Both SDKs store vectors on graph nodes labeled `MEM0_VECTOR_`. Point them at the same +graph with the same `collection_name` (the defaults differ, `mem0` in Python and `memories` in +TypeScript) and `get()`, `list()`, and `delete()` interoperate across SDKs. + + +`search()` is not currently cross-SDK compatible. The TypeScript provider filters on Neptune's reserved +`~label` metafield, while the Python provider filters on a synthetic `label` property that only Python's +own `insert()` writes. Python's `search()` therefore cannot see nodes written by the TypeScript provider. + + +### IAM Permissions + +Your AWS identity (user or role) needs a policy that allows the [`ExecuteQuery`](https://docs.aws.amazon.com/neptune-analytics/latest/apiref/API_ExecuteQuery.html) actions used for reads, writes, and deletes: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "neptune-graph:ReadDataViaQuery", + "neptune-graph:WriteDataViaQuery", + "neptune-graph:DeleteDataViaQuery" + ], + "Resource": "*" + } + ] +} +``` + +For production, scope the resource ARN down to your specific graph. diff --git a/docs/components/vectordbs/dbs/oracledb.mdx b/docs/components/vectordbs/dbs/oracledb.mdx new file mode 100644 index 000000000..87339d06c --- /dev/null +++ b/docs/components/vectordbs/dbs/oracledb.mdx @@ -0,0 +1,236 @@ +--- +title: "Oracle AI Vector Search" +description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for semantic and relational queries." +--- + +{/* Copyright (c) 2026, Oracle and/or its affiliates. */} + +[Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/) stores embeddings in an Oracle table using the native `VECTOR` data type, so you can combine semantic search over unstructured data with relational queries over business data in a single database. + +### Requirements + +- Oracle Database 23.4 or later, with a user that can create tables and vector indexes +- The `python-oracledb` or `node-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required. + + +```bash Python +pip install oracledb +``` + +```bash TypeScript +npm install oracledb +``` + + +### Usage + + +```python Python +import os +from mem0 import Memory + +os.environ["OPENAI_API_KEY"] = "sk-xx" + +config = { + "vector_store": { + "provider": "oracledb", + "config": { + "collection_name": "mem0", + "embedding_model_dims": 1536, + "connection_params": { + "user": "mem0_user", + "password": "your-password", + "dsn": "localhost:1521/FREEPDB1", + }, + } + } +} + +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"}) +``` + +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +const config = { + vectorStore: { + provider: "oracledb", + config: { + collectionName: "mem0", + embeddingModelDims: 1536, + connectionParams: { + user: "mem0_user", + password: "your-password", + connectString: "localhost:1521/FREEPDB1", + }, + }, + }, +}; + +const memory = new Memory(config); + +const 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.", + }, +]; + +await memory.add(messages, { + userId: "alice", + metadata: { category: "movies" }, +}); +``` + + +To reuse a connection or pool you already manage, pass it as `client` instead of the connection parameters: + + +```python Python +import oracledb + +pool = oracledb.create_pool(user="mem0_user", password="your-password", dsn="localhost:1521/FREEPDB1") + +config = { + "vector_store": { + "provider": "oracledb", + "config": {"client": pool}, + } +} +``` + +```typescript TypeScript +import oracledb from "oracledb"; + +const pool = await oracledb.createPool({ + user: "mem0_user", + password: "your-password", + connectString: "localhost:1521/FREEPDB1", +}); + +const config = { + vectorStore: { + provider: "oracledb", + config: { client: pool }, + }, +}; +``` + + +### Config + +Here are the parameters available for configuring Oracle AI Vector Search: + +| Python | TypeScript | Description | Default Value | +| --- | --- | --- | --- | +| `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` | +| `use_connection_pool` | `useConnectionPool` | Create a connection pool from the connection parameters instead of a single connection | `True` | +| `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters | `None` | +| `collection_name` | `collectionName` | Name of the Oracle table that stores vectors and payloads | `mem0` | +| `embedding_model_dims` | `embeddingModelDims` | Dimension of your embedding vectors, must be greater than 0 | `1536` | +| `distance_metric` | `distanceMetric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` | +| `do_create_index` | `doCreateIndex` | Whether to create a vector index on the collection | `True` | +| `index_type` | `indexType` | Vector index type: `HNSW` or `IVF` | `HNSW` | +| `index_name` | `indexName` | Name of the vector index | `_VEC_IDX` | +| `index_parameters` | `indexParameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` | +| `index_accuracy` | `indexAccuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY ` | `None` | + + + When you pass a pre-built `client`, Mem0 uses it as-is and ignores the connection parameters and pooling options. Mem0 does not close a client it did not create. + + +### Vector indexes + +Set the index type with `index_type` and tune it with `index_parameters`: + + +```python Python +config = { + "vector_store": { + "provider": "oracledb", + "config": { + "connection_params": {"user": "mem0_user", "password": "your-password", "dsn": "localhost:1521/FREEPDB1"}, + "index_type": "HNSW", + "index_parameters": {"neighbors": 32, "efconstruction": 200}, + "index_accuracy": 95, + } + } +} +``` + +```typescript TypeScript +const config = { + vectorStore: { + provider: "oracledb", + config: { + connectionParams: { + user: "mem0_user", + password: "your-password", + connectString: "localhost:1521/FREEPDB1", + }, + indexType: "HNSW", + indexParameters: { neighbors: 32, efconstruction: 200 }, + indexAccuracy: 95, + }, + }, +}; +``` + + +For the full list of supported options, see the Oracle [`CREATE VECTOR INDEX`](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/create-vector-index.html) reference. + +### Search scores + +Oracle returns a distance from `VECTOR_DISTANCE`, which Mem0 converts to a `score` where higher means more similar. `COSINE` and the other non-negative metrics produce scores in the range `[0, 1]`. `DOT` returns the inner product, which can fall outside that range. + +### Metadata filters + +Filters run against the JSON `payload` column and support: + +| Filter type | Examples | +| --- | --- | +| Scalar equality | `{"user_id": "alice"}` | +| Field existence | `{"agent_id": "*"}` | +| Comparison | `{"score": {"gte": 0.5}}`, also `eq`, `ne`, `gt`, `lt`, `lte` | +| Membership | `{"category": {"in": ["movies", "books"]}}`, also `nin` | +| String matching | `{"title": {"contains": "sci-fi"}}`, also `icontains` for case-insensitive | +| Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}`, also `$and`, `$or`, `$not` | + +Multiple fields at the top level are combined with `AND`: + + +```python Python +m.search( + "movie recommendations", + user_id="alice", + filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}}, +) +``` + +```typescript TypeScript +await memory.search("movie recommendations", { + userId: "alice", + filters: { category: { in: ["movies", "books"] }, rating: { gte: 4 } }, +}); +``` + diff --git a/docs/components/vectordbs/dbs/qdrant.mdx b/docs/components/vectordbs/dbs/qdrant.mdx index 93c11a070..75afd5da6 100644 --- a/docs/components/vectordbs/dbs/qdrant.mdx +++ b/docs/components/vectordbs/dbs/qdrant.mdx @@ -42,7 +42,7 @@ const config = { provider: 'qdrant', config: { collectionName: 'memories', - embeddingModelDims: 1536, + dimension: 1536, host: 'localhost', port: 6333, }, @@ -60,6 +60,12 @@ await memory.add(messages, { userId: "alice", metadata: { category: "movies" } } ``` +### Hybrid keyword search + +Mem0 blends semantic similarity with BM25 keyword scoring. On the TypeScript SDK, Qdrant computes the BM25 vectors server-side, which requires Qdrant 1.15.2 or newer with inference enabled. Qdrant Cloud enables inference by default only for clusters created after 2025-07-07; older clusters must activate it from the Cluster Detail page. The Python SDK encodes BM25 locally instead and needs the `fastembed` package, so scores are not numerically comparable between the two SDKs. + +When BM25 is unavailable, or when the collection was created before hybrid search was added, Mem0 logs a warning and falls back to semantic-only search. Writes are unaffected. To enable keyword scoring on an older collection, use a fresh collection name. + ### Config Let's see the available parameters for the `qdrant` config: @@ -83,7 +89,7 @@ Let's see the available parameters for the `qdrant` config: | Parameter | Description | Default Value | | --- | --- | --- | | `collectionName` | The name of the collection to store the vectors | `mem0` | -| `embeddingModelDims` | Dimensions of the embedding model | `1536` | +| `dimension` | Dimensions of the embedding model | `1536` | | `host` | The host where the Qdrant server is running | `None` | | `port` | The port where the Qdrant server is running | `None` | | `path` | Path for the Qdrant database | `/tmp/qdrant` | diff --git a/docs/components/vectordbs/dbs/supabase.mdx b/docs/components/vectordbs/dbs/supabase.mdx index 8f214f9c8..a05b4d591 100644 --- a/docs/components/vectordbs/dbs/supabase.mdx +++ b/docs/components/vectordbs/dbs/supabase.mdx @@ -115,6 +115,27 @@ $$; Go to [Supabase](https://supabase.com/dashboard/projects) and run the above SQL migrations in the SQL Editor. +### Row Level Security + +Tables created through the Supabase dashboard have Row Level Security (RLS) enabled by default with no policies attached. With RLS on and no policies, the TypeScript SDK's queries return zero rows with an HTTP 200 (no error is raised), which looks like an empty memory store rather than a permissions problem. If you use the SQL migrations above (via the SQL Editor), RLS is left in its default off state and this does not apply. + +If your table has RLS enabled, add policies for the key your app uses (the example below grants full access to the `service_role` key; scope it down for anon/authenticated keys as needed): + +```sql +alter table memories enable row level security; + +create policy "Allow service role full access to memories" +on memories +for all +to service_role +using (true) +with check (true); +``` + +### PostgREST Row Limits + +Supabase's PostgREST layer caps the number of rows returned by a single request at `db-max-rows` (1000 by default), for both `.select()` queries and RPC function calls like `match_vectors`. Requesting a `topK` above this limit for `search()` or `list()` will not raise an error, results are capped at `db-max-rows` instead. The TypeScript `list()` method paginates internally to work around this, but `search()` cannot since `match_vectors` has no offset parameter; it logs a warning when it detects a truncated result. Raise `db-max-rows` in your Supabase project settings if you need more than 1000 results per search. + ### Config Here are the parameters available for configuring Supabase: diff --git a/docs/components/vectordbs/overview.mdx b/docs/components/vectordbs/overview.mdx index b71568453..8fb0bb2dc 100644 --- a/docs/components/vectordbs/overview.mdx +++ b/docs/components/vectordbs/overview.mdx @@ -1,6 +1,6 @@ --- title: Overview -description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, and more." +description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more." --- Mem0 includes built-in support for various popular databases. Memory can utilize the database provided by the user, ensuring efficient use for specific needs. @@ -10,7 +10,7 @@ Mem0 includes built-in support for various popular databases. Memory can utilize See the list of supported vector databases below. - The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, and an in-memory store. + The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Oracle AI Vector Search, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store. @@ -21,6 +21,7 @@ See the list of supported vector databases below. + @@ -32,6 +33,7 @@ See the list of supported vector databases below. + diff --git a/docs/contributing/development.mdx b/docs/contributing/development.mdx index aca38f2b0..6e63dc732 100644 --- a/docs/contributing/development.mdx +++ b/docs/contributing/development.mdx @@ -14,7 +14,8 @@ Follow the steps below for a smooth contribution process. For the complete contributor checklist, see [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/CONTRIBUTING.md) in - the repository root. + the repository root. By participating you agree to our + [Code of Conduct](https://github.com/mem0ai/mem0/blob/main/CODE_OF_CONDUCT.md). ## Before You Start @@ -30,7 +31,28 @@ change, avoid duplicate work, and agree on the approach before you write code. or [feature request](https://github.com/mem0ai/mem0/issues/new?template=feature_request.yml). - For anything beyond a trivial fix, wait for a maintainer to confirm the approach. -Every pull request must link to an issue using `Closes #`. +Every pull request must link to an issue using `Closes #`, and that +issue must carry the `accepted` label. A maintainer applies `accepted` once we +agree the change is worth making. Pull requests that do not link an accepted +issue are closed automatically, with instructions to reopen once the label is +applied. Documentation-only changes are exempt. + + + Closed does not mean rejected. Getting the label and reopening takes about a + minute, and the check reruns on reopen. + + +### Show Your Work + +Both issue forms ask how you verified the problem: what you ran, the real output +you saw, and why it is a bug rather than expected behavior. Reports without that +are hard to act on and usually sit unanswered. + +They also ask whether AI was involved. That question is about how the problem was +found and confirmed, not about how the text was written: drafting the write-up +with a model is fine. The same applies to pull requests, where the AI disclosure +covers the code in the diff. We ask because it tells reviewers where to look, not +because it counts against you. ### 2. Sign the Contributor License Agreement (CLA) diff --git a/docs/cookbooks/companions/ai-tutor.mdx b/docs/cookbooks/companions/ai-tutor.mdx index 2947ee3df..7c4749a6a 100644 --- a/docs/cookbooks/companions/ai-tutor.mdx +++ b/docs/cookbooks/companions/ai-tutor.mdx @@ -3,6 +3,9 @@ title: Personalized AI Tutor description: "Keep student progress and preferences persistent across tutoring sessions." --- + +**Works with:** Mem0 OSS (`Memory`) + You can create a personalized AI Tutor using Mem0. This guide will walk you through the necessary steps and provide the complete code to get you started. diff --git a/docs/cookbooks/companions/local-companion-ollama.mdx b/docs/cookbooks/companions/local-companion-ollama.mdx index 76f118e56..edec0d5d7 100644 --- a/docs/cookbooks/companions/local-companion-ollama.mdx +++ b/docs/cookbooks/companions/local-companion-ollama.mdx @@ -3,6 +3,9 @@ title: Self-Hosted AI Companion description: "Run Mem0 end-to-end on your machine using Ollama-powered LLMs and embedders." --- + +**Works with:** Mem0 OSS (`Memory`) + Mem0 can be utilized entirely locally by leveraging Ollama for both the embedding model and the language model (LLM). This guide will walk you through the necessary steps and provide the complete code to get you started. diff --git a/docs/cookbooks/companions/nodejs-companion.mdx b/docs/cookbooks/companions/nodejs-companion.mdx index 7fcc8513c..b6659b86f 100644 --- a/docs/cookbooks/companions/nodejs-companion.mdx +++ b/docs/cookbooks/companions/nodejs-companion.mdx @@ -3,6 +3,9 @@ title: Build a Node.js Companion description: "Build a JavaScript fitness coach that remembers user goals run after run." --- + +**Works with:** Mem0 OSS (`Memory`) + You can create a personalized AI Companion using Mem0. This guide will walk you through the necessary steps and provide the complete code to get you started. diff --git a/docs/cookbooks/companions/quickstart-demo.mdx b/docs/cookbooks/companions/quickstart-demo.mdx index b5586563e..96be1bdb5 100644 --- a/docs/cookbooks/companions/quickstart-demo.mdx +++ b/docs/cookbooks/companions/quickstart-demo.mdx @@ -3,6 +3,9 @@ title: Interactive Memory Demo description: "Spin up the showcase companion app to see Mem0 memories in action." --- + +**Works with:** Mem0 Platform + You can create a personalized AI Companion using Mem0. This guide will walk you through the necessary steps and provide the complete setup instructions to get you started. diff --git a/docs/cookbooks/companions/travel-assistant.mdx b/docs/cookbooks/companions/travel-assistant.mdx index 7587e4887..4ecf1d22a 100644 --- a/docs/cookbooks/companions/travel-assistant.mdx +++ b/docs/cookbooks/companions/travel-assistant.mdx @@ -3,6 +3,9 @@ title: Smart Travel Assistant description: "Plan itineraries that remember traveler preferences across trips." --- + +**Works with:** Mem0 OSS (`Memory`) + Create a personalized AI Travel Assistant using Mem0. This guide provides step-by-step instructions and the complete code to get you started. diff --git a/docs/cookbooks/companions/voice-companion-openai.mdx b/docs/cookbooks/companions/voice-companion-openai.mdx index 1fdb2180c..0454ae13c 100644 --- a/docs/cookbooks/companions/voice-companion-openai.mdx +++ b/docs/cookbooks/companions/voice-companion-openai.mdx @@ -3,6 +3,9 @@ title: Voice-First AI Companion description: "Pair the OpenAI Agents SDK with Mem0 to build a voice assistant that remembers." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + This guide demonstrates how to combine OpenAI's Agents SDK for voice applications with Mem0's memory capabilities to create a voice assistant that remembers user preferences and past interactions. diff --git a/docs/cookbooks/companions/youtube-research.mdx b/docs/cookbooks/companions/youtube-research.mdx index b445fce22..3362fa3b8 100644 --- a/docs/cookbooks/companions/youtube-research.mdx +++ b/docs/cookbooks/companions/youtube-research.mdx @@ -3,6 +3,9 @@ title: Research Assistant for YouTube description: "Layer personalized context over any video using the Mem0 YouTube assistant." --- + +**Works with:** Mem0 Platform + Enhance your YouTube experience with Mem0's YouTube Assistant, a Chrome extension that brings AI-powered chat directly to your YouTube videos. Get instant, personalized answers about video content while leveraging your own knowledge and memories, all without leaving the page. diff --git a/docs/cookbooks/essentials/building-ai-companion.mdx b/docs/cookbooks/essentials/building-ai-companion.mdx index 681ad03a5..765605a66 100644 --- a/docs/cookbooks/essentials/building-ai-companion.mdx +++ b/docs/cookbooks/essentials/building-ai-companion.mdx @@ -3,6 +3,9 @@ title: Build a Companion with Mem0 description: "Spin up a fitness coach that remembers goals, adapts tone, and keeps sessions personal." --- + +**Works with:** Mem0 OSS (`Memory`) and Mem0 Platform (`MemoryClient`) + Essentially, creating a companion out of LLMs is as simple as a loop. But these loops work great for one type of character without personalization and fall short as soon as you restart the chat. @@ -588,9 +591,9 @@ mem0_client.project.update( Exclude: greetings, filler, casual chat """, custom_categories=[ - {"name": "goals", "description": "Training targets"}, - {"name": "constraints", "description": "Injuries and limitations"}, - {"name": "preferences", "description": "Training style"} + {"goals": "Training targets"}, + {"constraints": "Injuries and limitations"}, + {"preferences": "Training style"} ] ) ``` diff --git a/docs/cookbooks/essentials/controlling-memory-ingestion.mdx b/docs/cookbooks/essentials/controlling-memory-ingestion.mdx index 09d559f1c..c7dce9b2d 100644 --- a/docs/cookbooks/essentials/controlling-memory-ingestion.mdx +++ b/docs/cookbooks/essentials/controlling-memory-ingestion.mdx @@ -3,6 +3,9 @@ title: Control Memory Ingestion description: "Filter speculation, enforce formats, and gate low-confidence data before it persists." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + AI assistants plugged with memory systems face a problem - they often store everything. Not every conversation needs to be remembered, and not every detail should go to the memory store. Without proper controls, memory systems accumulate unreliable data. diff --git a/docs/cookbooks/essentials/entity-partitioning-playbook.mdx b/docs/cookbooks/essentials/entity-partitioning-playbook.mdx index 7eb145526..20c1c0e9f 100644 --- a/docs/cookbooks/essentials/entity-partitioning-playbook.mdx +++ b/docs/cookbooks/essentials/entity-partitioning-playbook.mdx @@ -3,6 +3,10 @@ title: Partition Memories by Entity description: Keep memories separate by tagging each write and query with user, agent, app, and session identifiers. --- + +**Works with:** Mem0 Platform (`MemoryClient`) + + Nora runs a travel service. When she stored all memories in one bucket, a recruiter's nut allergy accidentally appeared in a traveler's dinner reservation. Let's fix this by properly separating memories for different users, agents, and applications. diff --git a/docs/cookbooks/essentials/exporting-memories.mdx b/docs/cookbooks/essentials/exporting-memories.mdx index 46f86ec5b..d42e79c0f 100644 --- a/docs/cookbooks/essentials/exporting-memories.mdx +++ b/docs/cookbooks/essentials/exporting-memories.mdx @@ -3,6 +3,9 @@ title: Export Stored Memories description: "Retrieve, review, and migrate user memories with structured exports." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + Mem0 is a dynamic memory store that gives you full control over your data. Along with storing memories, it gives you the ability to retrieve, export, and migrate your data whenever you need. @@ -169,20 +172,20 @@ export_job = client.create_memory_export( ) print(f"Export ID: {export_job['id']}") -print(f"Status: {export_job['status']}") +print(f"Message: {export_job['message']}") ``` **Output:** ``` -Export ID: exp_abc123 -Status: processing +Export ID: 550e8400-e29b-41d4-a716-446655440000 +Message: Memory export request received. The export will be ready in a few seconds. ``` -**Export initiated:** Status is "processing". Large exports may take a few seconds. Poll with `get_memory_export()` until status changes to "completed" before downloading data. +**Export initiated:** The export runs asynchronously and is usually ready within a few seconds. Retry `get_memory_export()` with the returned ID until it stops returning a "no export found" error. ### Step 3: Download the export @@ -193,7 +196,7 @@ export_data = client.get_memory_export( memory_export_id=export_job['id'] ) -print(export_data['data']) +print(export_data) ``` @@ -216,7 +219,7 @@ export_by_filters = client.get_memory_export( filters={"user_id": "dev"} ) -print(export_by_filters['data']) +print(export_by_filters) ``` @@ -240,7 +243,7 @@ export_with_instructions = client.create_memory_export( ``` -Always check export status before downloading. Call `get_memory_export()` in a loop with a short delay until `status == "completed"`. Attempting to download while still processing returns incomplete data. +If the export is still processing, `get_memory_export()` returns a 404 with `{"error": "No memory export request found"}`. Retry after a short delay until the call succeeds instead of polling a status field. --- diff --git a/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx b/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx index c0fbf4502..ca7361ea3 100644 --- a/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx +++ b/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx @@ -3,6 +3,9 @@ title: Tag and Organize Memories description: "Let Mem0 auto-categorize support data so teams retrieve the right facts fast." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + When you have large volumes of memory data, sorting it during post-processing becomes difficult. What if your memory store understood the importance of creating tags and buckets without a lot of effort? @@ -19,7 +22,7 @@ client = MemoryClient(api_key="your-api-key") ``` -Define custom categories at the **project level** with `client.project.update()` before adding memories. Categories apply to all future memories: Mem0 auto-assigns them based on content semantics. +Define custom categories at the **project level** with `client.project.update()` before adding memories. Categories apply to all future memories: Mem0 auto-assigns them based on content semantics. You can also pass `custom_categories` on a single `client.add()` call to override the project list for just those memories. See [Custom Categories](/platform/features/custom-categories). --- @@ -96,6 +99,10 @@ Start with 3-5 clear categories that match how your team thinks. Too many catego These categories are now available project-wide. Every memory can be tagged with one or more categories. + +Need a different vocabulary for one tenant or one kind of conversation? Pass `custom_categories=[...]` to `client.add()`. That list replaces the project list for the memories created by that call, and it does not change the project configuration. + + --- ## Tagging Memories diff --git a/docs/cookbooks/frameworks/eliza-os-character.mdx b/docs/cookbooks/frameworks/eliza-os-character.mdx index cd009559f..3ea9cc4f0 100644 --- a/docs/cookbooks/frameworks/eliza-os-character.mdx +++ b/docs/cookbooks/frameworks/eliza-os-character.mdx @@ -3,6 +3,9 @@ title: Persistent Eliza Characters description: "Bring persistent personality to Eliza OS agents using Mem0." --- + +**Works with:** Mem0 Platform + You can create a personalized Eliza OS Character using Mem0. This guide will walk you through the necessary steps and provide the complete code to get you started. diff --git a/docs/cookbooks/frameworks/gemini-3-with-mem0-mcp.mdx b/docs/cookbooks/frameworks/gemini-3-with-mem0-mcp.mdx index c1c075391..5569ccbd1 100644 --- a/docs/cookbooks/frameworks/gemini-3-with-mem0-mcp.mdx +++ b/docs/cookbooks/frameworks/gemini-3-with-mem0-mcp.mdx @@ -3,6 +3,10 @@ title: "Gemini 3 with Mem0 MCP" description: "Create snappy, smart, memory-aware agents by pairing Gemini 3 with Mem0 MCP server." --- + +**Works with:** Mem0 Platform (MCP server) + + Gemini 3, when paired with Mem0's cloud MCP server, works in synergy to create snappy, smart, memory-aware agents. diff --git a/docs/cookbooks/frameworks/llamaindex-multiagent.mdx b/docs/cookbooks/frameworks/llamaindex-multiagent.mdx index 8e8df7bd8..8728cd0e1 100644 --- a/docs/cookbooks/frameworks/llamaindex-multiagent.mdx +++ b/docs/cookbooks/frameworks/llamaindex-multiagent.mdx @@ -3,6 +3,9 @@ title: Multi-Agent Collaboration description: "Share a persistent memory layer across collaborating LlamaIndex agents." --- + +**Works with:** Mem0 Platform (`Mem0Memory.from_client`) + diff --git a/docs/cookbooks/frameworks/llamaindex-react.mdx b/docs/cookbooks/frameworks/llamaindex-react.mdx index ca5d922c6..159b737b7 100644 --- a/docs/cookbooks/frameworks/llamaindex-react.mdx +++ b/docs/cookbooks/frameworks/llamaindex-react.mdx @@ -3,6 +3,9 @@ title: ReAct Agents with Memory description: "Teach a ReAct agent to store and recall context via Mem0." --- + +**Works with:** Mem0 Platform (`Mem0Memory.from_client`) + Create a ReAct Agent with LlamaIndex which uses Mem0 as the memory store. @@ -78,7 +81,7 @@ from llama_index.core.agent import FunctionCallingAgent agent = FunctionCallingAgent.from_tools( [call_tool, email_tool, order_food_tool], llm=llm, - memory=memory_from_client, # or memory_from_config + memory=memory_from_client, verbose=True, ) ``` @@ -161,7 +164,7 @@ agent = FunctionCallingAgent.from_tools( [call_tool, email_tool, order_food_tool], llm=llm, # memory is provided - memory=memory_from_client, # or memory_from_config + memory=memory_from_client, verbose=True, ) response = agent.chat("I am feeling hungry, order me something and send me the bill") diff --git a/docs/cookbooks/frameworks/multimodal-retrieval.mdx b/docs/cookbooks/frameworks/multimodal-retrieval.mdx index fe095df3c..cbe99dc31 100644 --- a/docs/cookbooks/frameworks/multimodal-retrieval.mdx +++ b/docs/cookbooks/frameworks/multimodal-retrieval.mdx @@ -3,6 +3,9 @@ title: Visual Memory Retrieval description: "Store and recall visual context alongside text conversations." --- + +**Works with:** Mem0 Platform + Enhance your AI interactions with Mem0's multimodal capabilities. Mem0 now supports image understanding, allowing for richer context and more natural interactions across supported AI platforms. diff --git a/docs/cookbooks/integrations/agents-sdk-tool.mdx b/docs/cookbooks/integrations/agents-sdk-tool.mdx index 365be09ab..638cf22c5 100644 --- a/docs/cookbooks/integrations/agents-sdk-tool.mdx +++ b/docs/cookbooks/integrations/agents-sdk-tool.mdx @@ -3,6 +3,9 @@ title: Memory-Powered Agent SDK description: "Expose Mem0 memories as callable tools inside OpenAI agent workflows." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + Integrate Mem0's memory capabilities with OpenAI's Agents SDK to create AI agents with persistent memory. You can create agents that remember past conversations and use that context to provide better responses. diff --git a/docs/cookbooks/integrations/aws-bedrock.mdx b/docs/cookbooks/integrations/aws-bedrock.mdx index 1caff3098..27110ee66 100644 --- a/docs/cookbooks/integrations/aws-bedrock.mdx +++ b/docs/cookbooks/integrations/aws-bedrock.mdx @@ -3,6 +3,9 @@ title: Bedrock with Persistent Memory description: "Pair Mem0 with AWS Bedrock and OpenSearch for a managed stack." --- + +**Works with:** Mem0 OSS (`Memory`) + This example demonstrates how to configure and use the `mem0ai` SDK with **AWS Bedrock** and **OpenSearch Service (AOSS)** for persistent memory capabilities in Python. diff --git a/docs/cookbooks/integrations/healthcare-google-adk.mdx b/docs/cookbooks/integrations/healthcare-google-adk.mdx index ddfcb098a..0696f45e5 100644 --- a/docs/cookbooks/integrations/healthcare-google-adk.mdx +++ b/docs/cookbooks/integrations/healthcare-google-adk.mdx @@ -3,6 +3,9 @@ title: Healthcare Coach with ADK description: "Guide patients with an assistant that remembers history across ADK sessions." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + This example demonstrates how to build a healthcare assistant that remembers patient information across conversations using Google ADK and Mem0. @@ -123,7 +126,7 @@ Now we'll create our main agent with all the tools: # Create the agent healthcare_agent = Agent( name="healthcare_assistant", - model="gemini-1.5-flash", # Using Gemini for healthcare assistant + model="gemini-2.0-flash", # Using Gemini for healthcare assistant description="Healthcare assistant that helps patients with health information and appointment scheduling.", instruction="""You are a helpful Healthcare Assistant with memory capabilities. diff --git a/docs/cookbooks/integrations/mastra-agent.mdx b/docs/cookbooks/integrations/mastra-agent.mdx index aae3f1455..fa9cfcd16 100644 --- a/docs/cookbooks/integrations/mastra-agent.mdx +++ b/docs/cookbooks/integrations/mastra-agent.mdx @@ -3,10 +3,13 @@ title: Persistent Mastra Agents description: "Extend Mastra agents with persistent memories powered by Mem0." --- + +**Works with:** Mem0 Platform (`@mastra/mem0`) + In this example you'll learn how to use Mem0 to add long-term memory capabilities to [Mastra's agent](https://mastra.ai/) via tool-use. This memory integration can work alongside Mastra's [agent memory features](https://mastra.ai/docs/agents/01-agent-memory). -You can find the complete example code in the [Mastra repository](https://github.com/mastra-ai/mastra/tree/main/examples/memory-with-mem0). +The complete example code, from installing the integration to wiring it into a Mastra agent, is shown below. Mem0's integration is published on npm as [`@mastra/mem0`](https://www.npmjs.com/package/@mastra/mem0). ## Overview diff --git a/docs/cookbooks/integrations/openai-tool-calls.mdx b/docs/cookbooks/integrations/openai-tool-calls.mdx index 9ab436aaf..ce69130ed 100644 --- a/docs/cookbooks/integrations/openai-tool-calls.mdx +++ b/docs/cookbooks/integrations/openai-tool-calls.mdx @@ -3,6 +3,9 @@ title: Memory as OpenAI Tool description: "Wire Mem0 memories into OpenAI's inbuilt function-calling flow." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + Integrate Mem0’s memory capabilities with OpenAI’s Inbuilt Tools to create AI agents with persistent memory. @@ -55,9 +58,8 @@ await addUserPreferences(); ```json Output { - "message": "Memory processing has been queued for background execution", - "status": "PENDING", - "event_id": "9f8c2b1a-4e7d-4c3a-9b21-1a2b3c4d5e6f" + "event_id": "9f8c2b1a-4e7d-4c3a-9b21-1a2b3c4d5e6f", + "status": "PENDING" } ``` diff --git a/docs/cookbooks/integrations/tavily-search.mdx b/docs/cookbooks/integrations/tavily-search.mdx index 849537a33..e8fac39b7 100644 --- a/docs/cookbooks/integrations/tavily-search.mdx +++ b/docs/cookbooks/integrations/tavily-search.mdx @@ -3,12 +3,14 @@ title: Search with Personal Context description: "Blend Tavily's realtime results with personal context stored in Mem0." --- + +**Works with:** Mem0 Platform (`MemoryClient`) + Imagine asking a search assistant for "coffee shops nearby" and instead of generic results, it shows remote-work-friendly cafes with great WiFi in your city because it remembers you mentioned working remotely before. Or when you search for "lunchbox ideas for kids" it knows you have a 7-year-old daughter and recommends peanut-free options that align with her allergy. That's what we are going to build today, a Personalized Search Assistant powered by Mem0 for memory and [Tavily](https://tavily.com) for real-time search. - ## Why Personalized Search Most assistants treat every query like they've never seen you before. That means repeating yourself about your location, diet, or preferences, and getting results that feel generic. diff --git a/docs/cookbooks/operations/content-writing.mdx b/docs/cookbooks/operations/content-writing.mdx index fd22c4920..cb11f5f1c 100644 --- a/docs/cookbooks/operations/content-writing.mdx +++ b/docs/cookbooks/operations/content-writing.mdx @@ -3,6 +3,9 @@ title: Content Creation Workflow description: "Store voice guidelines once and apply them across every draft." --- + +**Works with:** Mem0 OSS (`Memory`) and Mem0 Platform (`MemoryClient`) + This guide demonstrates how to leverage **Mem0** to streamline content writing by applying your unique writing style and preferences using persistent memory. diff --git a/docs/cookbooks/operations/deep-research.mdx b/docs/cookbooks/operations/deep-research.mdx index b0735f3b2..11b7a2c40 100644 --- a/docs/cookbooks/operations/deep-research.mdx +++ b/docs/cookbooks/operations/deep-research.mdx @@ -3,11 +3,12 @@ title: Multi-Session Research Agent description: "Run multi-session investigations that remember past findings and preferences." --- + +**Works with:** Mem0 Platform + Deep Research is an intelligent agent that synthesizes large amounts of online data and completes complex research tasks, customized to your unique preferences and insights. Built on Mem0's technology, it enhances AI-driven online exploration with personalized memories. -You can check out the GitHub repository here: [Personalized Deep Research](https://github.com/mem0ai/personalized-deep-research/tree/mem0) - ## Overview Deep Research leverages Mem0's memory capabilities to: @@ -61,12 +62,6 @@ Watch Deep Research in action: - **Technical Research**: Technology evaluation, solution comparison - **Business Research**: Strategic planning, opportunity analysis -## Try It Out - -> To try it yourself, clone the repository and follow the instructions in the README to run it locally or deploy it. - -- [Personalized Deep Research GitHub](https://github.com/mem0ai/personalized-deep-research/tree/mem0) - --- diff --git a/docs/cookbooks/operations/email-automation.mdx b/docs/cookbooks/operations/email-automation.mdx index 7d3308f6e..68cf373c3 100644 --- a/docs/cookbooks/operations/email-automation.mdx +++ b/docs/cookbooks/operations/email-automation.mdx @@ -3,6 +3,9 @@ title: Automated Email Intelligence description: "Capture, categorize, and recall inbox threads using persistent memories." --- + +**Works with:** Mem0 OSS (`Memory`) and Mem0 Platform (`MemoryClient`) + This guide demonstrates how to build an intelligent email processing system using Mem0's memory capabilities. You'll learn how to store, categorize, retrieve, and analyze emails to create a smart email management solution. diff --git a/docs/cookbooks/operations/support-inbox.mdx b/docs/cookbooks/operations/support-inbox.mdx index 4b1c3e170..9edb21eab 100644 --- a/docs/cookbooks/operations/support-inbox.mdx +++ b/docs/cookbooks/operations/support-inbox.mdx @@ -3,6 +3,9 @@ title: Memory-Powered Support Agent description: "Build a support assistant that keeps past tickets and resolutions at its fingertips." --- + +**Works with:** Mem0 OSS (`Memory`) + You can create a personalized Customer Support AI Agent using Mem0. This guide will walk you through the necessary steps and provide the complete code to get you started. diff --git a/docs/cookbooks/operations/team-task-agent.mdx b/docs/cookbooks/operations/team-task-agent.mdx index 458e05d45..b464c10a3 100644 --- a/docs/cookbooks/operations/team-task-agent.mdx +++ b/docs/cookbooks/operations/team-task-agent.mdx @@ -3,6 +3,9 @@ title: Collaborative Task Assistant description: "Coordinate multi-user projects with shared memories and roles." --- + +**Works with:** Mem0 OSS (`Memory`) + ## Overview diff --git a/docs/cookbooks/overview.mdx b/docs/cookbooks/overview.mdx index de79298b5..7435f96ad 100644 --- a/docs/cookbooks/overview.mdx +++ b/docs/cookbooks/overview.mdx @@ -13,6 +13,56 @@ With Mem0, you can create stateful LLM-based applications such as chatbots, virt Here are some examples of how Mem0 can be integrated into various applications: +## Pick by compatibility + +Every cookbook opens with a **Works with** badge naming the SDK surface it uses. Pick your setup below to see only the cookbooks that run on it. Three cookbooks work on both and appear under either tab. + + + + Ten cookbooks run on the open-source `Memory` class, with no Mem0 Platform account. + + | Cookbook | Category | Works with | + | --- | --- | --- | + | [Personalized AI Tutor](/cookbooks/companions/ai-tutor) | Companions | OSS only | + | [Build a Node.js Companion](/cookbooks/companions/nodejs-companion) | Companions | OSS only | + | [Self-Hosted AI Companion](/cookbooks/companions/local-companion-ollama) | Companions | OSS only | + | [Smart Travel Assistant](/cookbooks/companions/travel-assistant) | Companions | OSS only | + | [Build a Companion with Mem0](/cookbooks/essentials/building-ai-companion) | Essentials | OSS and Platform | + | [Bedrock with Persistent Memory](/cookbooks/integrations/aws-bedrock) | Integrations | OSS only | + | [Automated Email Intelligence](/cookbooks/operations/email-automation) | Operations | OSS and Platform | + | [Collaborative Task Assistant](/cookbooks/operations/team-task-agent) | Operations | OSS only | + | [Content Creation Workflow](/cookbooks/operations/content-writing) | Operations | OSS and Platform | + | [Memory-Powered Support Agent](/cookbooks/operations/support-inbox) | Operations | OSS only | + + + Twenty-one cookbooks run on the hosted Platform, using `MemoryClient` or the Mem0 MCP server with a `MEM0_API_KEY`. + + | Cookbook | Category | Works with | + | --- | --- | --- | + | [Interactive Memory Demo](/cookbooks/companions/quickstart-demo) | Companions | Platform only | + | [Research Assistant for YouTube](/cookbooks/companions/youtube-research) | Companions | Platform only | + | [Voice-First AI Companion](/cookbooks/companions/voice-companion-openai) | Companions | Platform only | + | [Build a Companion with Mem0](/cookbooks/essentials/building-ai-companion) | Essentials | OSS and Platform | + | [Control Memory Ingestion](/cookbooks/essentials/controlling-memory-ingestion) | Essentials | Platform only | + | [Export Stored Memories](/cookbooks/essentials/exporting-memories) | Essentials | Platform only | + | [Partition Memories by Entity](/cookbooks/essentials/entity-partitioning-playbook) | Essentials | Platform only | + | [Tag and Organize Memories](/cookbooks/essentials/tagging-and-organizing-memories) | Essentials | Platform only | + | [Gemini 3 with Mem0 MCP](/cookbooks/frameworks/gemini-3-with-mem0-mcp) | Frameworks | Platform only | + | [Multi-Agent Collaboration](/cookbooks/frameworks/llamaindex-multiagent) | Frameworks | Platform only | + | [Persistent Eliza Characters](/cookbooks/frameworks/eliza-os-character) | Frameworks | Platform only | + | [ReAct Agents with Memory](/cookbooks/frameworks/llamaindex-react) | Frameworks | Platform only | + | [Visual Memory Retrieval](/cookbooks/frameworks/multimodal-retrieval) | Frameworks | Platform only | + | [Healthcare Coach with ADK](/cookbooks/integrations/healthcare-google-adk) | Integrations | Platform only | + | [Memory as OpenAI Tool](/cookbooks/integrations/openai-tool-calls) | Integrations | Platform only | + | [Memory-Powered Agent SDK](/cookbooks/integrations/agents-sdk-tool) | Integrations | Platform only | + | [Persistent Mastra Agents](/cookbooks/integrations/mastra-agent) | Integrations | Platform only | + | [Search with Personal Context](/cookbooks/integrations/tavily-search) | Integrations | Platform only | + | [Automated Email Intelligence](/cookbooks/operations/email-automation) | Operations | OSS and Platform | + | [Content Creation Workflow](/cookbooks/operations/content-writing) | Operations | OSS and Platform | + | [Multi-Session Research Agent](/cookbooks/operations/deep-research) | Operations | Platform only | + + + ## Start here The most popular cookbooks to get going fast: diff --git a/docs/core-concepts/how-it-works.mdx b/docs/core-concepts/how-it-works.mdx index 55cc2c1cf..5998b4f3c 100644 --- a/docs/core-concepts/how-it-works.mdx +++ b/docs/core-concepts/how-it-works.mdx @@ -46,7 +46,7 @@ When new messages arrive, Mem0 extracts durable facts and stores them with the i 1. **Context lookup.** Mem0 checks related existing memories so it can avoid storing the same fact again. 2. **Fact extraction.** An LLM extracts preferences, decisions, plans, and other details your agent can reuse. 3. **Deduplication and embedding.** Redundant facts are removed, then each memory is embedded for semantic search. -4. **Entity linking.** When configured, Mem0 links people, places, organizations, and concepts across memories. +4. **Entity extraction.** Mem0 pulls out the people, places, organizations, and concepts each memory mentions and stores them for entity matching at search time. On Platform these entities also become the nodes of [Graph Memory](/platform/features/graph-memory). The automatic extraction path is additive. If a user says, "I moved from Austin to Seattle," Mem0 can store the new fact without silently rewriting the old one. Use explicit `update` or `delete` operations when your application needs to correct or remove a memory. @@ -61,7 +61,7 @@ When you call `search`, Mem0 ranks stored memories against your query and filter | **Entity** | Boosts memories linked to entities in the query | Questions about a person, project, or account | | **Temporal** | Scores candidates on time metadata extracted at write time against the query's temporal intent | Temporal questions ("when did...", current state, recency) | -Platform retrieval fuses these signals in the managed service. OSS retrieval depends on your configured vector store, optional reranker, and graph store. +Platform retrieval fuses these signals in the managed service, where the entity signal is powered by built-in [Graph Memory](/platform/features/graph-memory). OSS retrieval depends on your configured vector store and optional reranker, and boosts on entity overlap alone: it has no graph memory. Always scope searches with filters such as `user_id`, `agent_id`, or `run_id`. This keeps memories from different users, agents, or sessions from mixing. @@ -75,7 +75,7 @@ Mem0 stores different parts of a memory in stores built for different lookup pat |---|---|---| | **SQL database** | Facts and metadata | The source of truth for each memory | | **Vector database** | Embeddings | Semantic similarity search | -| **Entity or graph store** | Entities and relationships | Relationship-aware retrieval when graph memory is enabled | +| **Entity store** | Entities extracted from memory text | Boosts memories sharing entities with the query. On Platform it also backs [Graph Memory](/platform/features/graph-memory) | On Mem0 Platform, these stores are managed for you. In OSS, you choose and operate the backing stores through your configuration. diff --git a/docs/core-concepts/memory-operations/add.mdx b/docs/core-concepts/memory-operations/add.mdx index 23d9f772d..1c4485775 100644 --- a/docs/core-concepts/memory-operations/add.mdx +++ b/docs/core-concepts/memory-operations/add.mdx @@ -83,6 +83,50 @@ await client.add(messages, { Expect a `status: "PENDING"` response with an `event_id`. Poll `GET /v1/event/{event_id}/` to confirm completion. +### Automatic conversation context + +On the Platform, you only send new messages. Mem0 automatically pulls the earlier messages that share the same identifiers (`user_id`, and `run_id` if you use one) and uses them as context when extracting memories, so you never need to resend conversation history. + +This means a follow-up turn is understood against what came before it: + + +```python Python +# First interaction +client.add( + [{"role": "user", "content": "My dog's name is Biscuit. He's a golden retriever."}], + user_id="alice", +) + +# Later β€” send only the new turn, no history +client.add( + [{"role": "user", "content": "He turned 5 today, and I'm taking him to the vet on Friday."}], + user_id="alice", +) +# Stored as: "User's dog Biscuit turned 5" β€” "He" is resolved against the earlier turn. +``` + +```javascript JavaScript +// First interaction +await client.add( + [{ role: "user", content: "My dog's name is Biscuit. He's a golden retriever." }], + { userId: "alice" }, +); + +// Later β€” send only the new turn, no history +await client.add( + [{ role: "user", content: "He turned 5 today, and I'm taking him to the vet on Friday." }], + { userId: "alice" }, +); +// Stored as: "User's dog Biscuit turned 5" β€” "He" is resolved against the earlier turn. +``` + + +Without that earlier turn, the same message can only be stored as "User's male pet turned 5", because there is nothing to resolve "He" against. Scope each conversation with a consistent `user_id` (plus `run_id` for a distinct session) and Mem0 handles the rest. + + + This is default behavior and needs no configuration. Earlier SDK versions gated it behind a `version="v2"` argument on `add`; that argument no longer exists and is ignored if sent. + + ## Add with Mem0 Open Source diff --git a/docs/core-concepts/memory-operations/delete.mdx b/docs/core-concepts/memory-operations/delete.mdx index 80703a2c5..a6dcf3f51 100644 --- a/docs/core-concepts/memory-operations/delete.mdx +++ b/docs/core-concepts/memory-operations/delete.mdx @@ -188,11 +188,16 @@ memory = Memory() memory.delete(memory_id="mem_123") memory.delete_all(user_id="alice") ``` - - - The OSS JavaScript SDK does not yet expose deletion helpers: use the REST API or Python SDK when self-hosting. - +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory(); + +await memory.delete("mem_123"); +await memory.deleteAll({ userId: "alice" }); +``` + ## Use cases recap diff --git a/docs/core-concepts/memory-operations/update.mdx b/docs/core-concepts/memory-operations/update.mdx index 7c5084caf..193e5091b 100644 --- a/docs/core-concepts/memory-operations/update.mdx +++ b/docs/core-concepts/memory-operations/update.mdx @@ -146,7 +146,7 @@ await memory.update("mem_123", { metadata: { category: "preferences" } }); - In both OSS SDKs the content is optional β€” pass only `metadata` and/or an expiration date to update those while keeping the existing content. At least one of the three must be provided, otherwise the call raises. + In both OSS SDKs the content is optional: pass only `metadata` and/or an expiration date to update those while keeping the existing content. At least one of the three must be provided, otherwise the call raises. diff --git a/docs/docs.json b/docs/docs.json index 39e8424a8..61a595655 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,10 +84,9 @@ "pages": [ "platform/features/advanced-retrieval", "platform/advanced-memory-operations", - "platform/features/criteria-retrieval", - "platform/features/contextual-add", "platform/features/custom-instructions", - "platform/features/memory-decay" + "platform/features/memory-decay", + "platform/features/dream" ] }, { @@ -96,7 +95,8 @@ "pages": [ "platform/features/direct-import", "platform/features/memory-export", - "platform/features/timestamp" + "platform/features/timestamp", + "platform/features/memory-expiration" ] }, { @@ -152,7 +152,8 @@ "open-source/features/multimodal-support", "open-source/features/custom-instructions", "open-source/features/rest-api", - "open-source/features/openai_compatibility" + "open-source/features/openai_compatibility", + "platform/features/memory-expiration" ] }, { @@ -207,6 +208,7 @@ "components/vectordbs/dbs/milvus", "components/vectordbs/dbs/pinecone", "components/vectordbs/dbs/mongodb", + "components/vectordbs/dbs/oracledb", "components/vectordbs/dbs/azure", "components/vectordbs/dbs/azure_mysql", "components/vectordbs/dbs/redis", @@ -348,6 +350,8 @@ "pages": [ "integrations/dify", "integrations/flowise", + "integrations/n8n", + "integrations/zapier", "integrations/langchain-tools", "integrations/agentops", "integrations/respan", @@ -607,6 +611,14 @@ ] }, "redirects": [ + { + "source": "/open-source/features/reranking", + "destination": "/open-source/features/reranker-search" + }, + { + "source": "/platform/features/contextual-add", + "destination": "/core-concepts/memory-operations/add" + }, { "source": "/changelog/openclaw", "destination": "/changelog/sdk" @@ -629,7 +641,7 @@ }, { "source": "/platform/features/expiration-date", - "destination": "/" + "destination": "/platform/features/memory-expiration" }, { "source": "/cookbooks/essentials/memory-expiration-short-and-long-term", @@ -1027,6 +1039,10 @@ "source": "/features/graph-memory", "destination": "/platform/features/graph-memory" }, + { + "source": "/open-source/features/graph-memory", + "destination": "/platform/features/graph-memory" + }, { "source": "/features/:slug", "destination": "/platform/features/:slug" @@ -1226,6 +1242,14 @@ { "source": "/open-source/multimodal-support", "destination": "/open-source/features/multimodal-support" + }, + { + "source": "/platform/features/criteria-retrieval", + "destination": "/platform/features/advanced-retrieval" + }, + { + "source": "/integrations/keywords", + "destination": "/integrations/respan" } ] } diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 000000000..14414205d Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/favicon.svg b/docs/favicon.svg index 6a3233265..366e7c5c0 100644 --- a/docs/favicon.svg +++ b/docs/favicon.svg @@ -1,49 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/docs/images/docs thumbnails/dark/mem0_openmemory.png b/docs/images/docs thumbnails/dark/mem0_openmemory.png deleted file mode 100644 index dd6952584..000000000 Binary files a/docs/images/docs thumbnails/dark/mem0_openmemory.png and /dev/null differ diff --git a/docs/images/docs thumbnails/light/mem0_openmemory.png b/docs/images/docs thumbnails/light/mem0_openmemory.png deleted file mode 100644 index 16fff3dd6..000000000 Binary files a/docs/images/docs thumbnails/light/mem0_openmemory.png and /dev/null differ diff --git a/docs/images/provider-icons/oracle.svg b/docs/images/provider-icons/oracle.svg new file mode 100644 index 000000000..b111d0892 --- /dev/null +++ b/docs/images/provider-icons/oracle.svg @@ -0,0 +1 @@ +Oracle diff --git a/docs/integrations/antigravity.mdx b/docs/integrations/antigravity.mdx index c76ecd7f6..1de44cd8c 100644 --- a/docs/integrations/antigravity.mdx +++ b/docs/integrations/antigravity.mdx @@ -65,7 +65,9 @@ The plugin uses the same shell scripts as Claude Code, Cursor, and Codex: hooks | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | | **Pre-tool** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tools | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary when the session ends | +| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | + +What you type is stored as yours. What the agent produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences. ## Troubleshooting diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index a571f813d..cf48915e3 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -44,14 +44,14 @@ Install the full plugin including MCP server, lifecycle hooks, and SDK skill. 1. Add the Mem0 marketplace: - ``` - /plugin marketplace add mem0ai/mem0 + ```bash + claude plugin marketplace add mem0ai/mem0 ``` 2. Install the plugin: - ``` - /plugin install mem0@mem0-plugins + ```bash + claude plugin install mem0@mem0-plugins ``` **Claude Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0. @@ -88,6 +88,15 @@ Add to your Claude Code MCP config (`.mcp.json`): } ``` +### Managing the Plugin + +```bash +claude plugin update mem0@mem0-plugins # update the plugin to the latest version (restart to apply) +claude plugin marketplace update mem0-plugins # refresh the marketplace catalog +claude plugin uninstall mem0@mem0-plugins # uninstall the plugin (keeps the marketplace) +claude plugin marketplace remove mem0-plugins # unregister the marketplace entirely +``` + Start a new session and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set. @@ -143,9 +152,11 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts | | **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary when the session ends | +| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | | **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | +What you type is stored as yours. What Claude produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences. + ## Example Workflow ```text @@ -153,16 +164,17 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc You: Let's refactor the auth module to use JWT tokens instead of sessions. # Claude searches memories, finds nothing relevant, proceeds with the work. -# After completing the task, Mem0 stores: +# Mem0 stores what you said as yours: +# - Your preference: "Prefers TypeScript, uses ESLint" +# ...and what Claude did as the assistant's, in the session summary: # - Decision: "Migrated auth from sessions to JWT tokens" # - Files modified: auth/middleware.ts, auth/token.ts -# - User preference: "Prefers TypeScript, uses ESLint" # Session 2 (days later): Related work You: Add refresh token rotation to the auth system. # Claude searches memories, retrieves the JWT migration context. -# Knows the file structure, decisions made, and user preferences. +# Knows the file structure, decisions made, and your stated preferences. # Continues seamlessly without re-explaining the codebase. ``` diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 5737a579e..a93447311 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -125,20 +125,23 @@ When installed via the plugin marketplace, Mem0 hooks into Codex's lifecycle to | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | | **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary when the session ends | +| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | | **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | +What you type is stored as yours. What Codex produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences. + ## Example Workflow ```text # Task 1: Setting up a new service You: Create a REST API for the notifications service using Express and TypeScript. -# Codex searches memories, finds user preferences from prior tasks. -# After completing the task, Mem0 stores: +# Codex searches memories, finds your preferences from prior tasks. +# Mem0 stores what you said as yours: +# - Your preference: "Prefers explicit error types over generic catch-all" +# ...and what Codex did as the assistant's, in the session summary: # - Decision: "Notifications service uses Express + TypeScript + Zod validation" # - Convention: "All API routes follow /api/v1/{resource} pattern" -# - Preference: "User prefers explicit error types over generic catch-all" # Task 2 (days later): Extending the service You: Add WebSocket support for real-time notification delivery. diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index cfaa5e208..e02c593b4 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -96,10 +96,11 @@ Once installed, the following tools are available in every Cursor session: You: The API endpoint /users is taking 3 seconds. Help me optimize it. # Cursor agent searches memories, proceeds with investigation. -# After completing the task, Mem0 stores: +# Mem0 stores what you said as yours: +# - Your preference: "Prefers query-level fixes over caching" +# ...and what the agent did as the assistant's: # - Learning: "N+1 query in UserService.getAll(): fixed with eager loading" # - Decision: "Added database index on users.email column" -# - Preference: "User prefers query-level fixes over caching" # Session 2 (next week): Similar issue You: The /orders endpoint is also slow, same pattern as before. diff --git a/docs/integrations/flowise.mdx b/docs/integrations/flowise.mdx index 64caf4ff2..735fc06c2 100644 --- a/docs/integrations/flowise.mdx +++ b/docs/integrations/flowise.mdx @@ -34,15 +34,11 @@ npx flowise start 2. In this example, we use the **Conversation Chain** template. 3. Replace the default **Buffer Memory** with **Mem0 Memory**. -![Flowise Memory Integration](https://raw.githubusercontent.com/FlowiseAI/FlowiseDocs/main/en/.gitbook/assets/mem0/flowise-flow.png) - ### 2. Obtain Your Mem0 API Key 1. Navigate to the Mem0 API Key dashboard. 2. Generate or copy your existing Mem0 API Key. -![Mem0 API Key](https://raw.githubusercontent.com/FlowiseAI/FlowiseDocs/main/en/.gitbook/assets/mem0/api-key.png) - ### 3. Configure Mem0 Credentials 1. Enter the **Mem0 API Key** in the Mem0 Credentials section. @@ -57,11 +53,6 @@ npx flowise start } ``` -
- Mem0 Credentials -
Configure API Credentials
-
- ## Memory Features ### 1. Basic Memory Storage @@ -72,8 +63,6 @@ Test your memory configuration: 2. Run a test chat and store some information 3. Verify the stored memories in the Mem0 Dashboard -![Flowise Test Chat](https://raw.githubusercontent.com/FlowiseAI/FlowiseDocs/main/en/.gitbook/assets/mem0/flowise-chat-1.png) - ### 2. Memory Retention Validate memory persistence: @@ -82,14 +71,10 @@ Validate memory persistence: 2. Ask a question about previously stored information 3. Confirm that the AI remembers the context -![Testing Memory Retention](https://raw.githubusercontent.com/FlowiseAI/FlowiseDocs/main/en/.gitbook/assets/mem0/flowise-chat-2.png) - ## Advanced Configuration ### Memory Settings -![Mem0 Settings](https://raw.githubusercontent.com/FlowiseAI/FlowiseDocs/main/en/.gitbook/assets/mem0/settings.png) - Available settings include: 1. **Search Only Mode**: Enable memory retrieval without creating new memories @@ -108,8 +93,6 @@ Additional settings available in diff --git a/docs/integrations/llama-index.mdx b/docs/integrations/llama-index.mdx index e91ed86fe..9fb11ff88 100644 --- a/docs/integrations/llama-index.mdx +++ b/docs/integrations/llama-index.mdx @@ -6,7 +6,7 @@ description: "Use Mem0 as a memory store in LlamaIndex with support for ReAct an LlamaIndex supports Mem0 as a [memory store](https://llamahub.ai/l/memory/llama-index-memory-mem0). In this guide, we'll show you how to use it. - [**Mem0Memory**](https://docs.llamaindex.ai/en/stable/examples/memory/Mem0Memory/) now supports **ReAct** and **FunctionCalling** agents. + [**Mem0Memory**](https://developers.llamaindex.ai/python/examples/memory/mem0memory/) now supports **ReAct** and **FunctionCalling** agents. ### Installation diff --git a/docs/integrations/n8n.mdx b/docs/integrations/n8n.mdx new file mode 100644 index 000000000..ad1c02951 --- /dev/null +++ b/docs/integrations/n8n.mdx @@ -0,0 +1,157 @@ +--- +title: n8n +description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required." +--- + +Your n8n workflows start from zero on every run. The [`@mem0/n8n-nodes-mem0`](https://www.npmjs.com/package/@mem0/n8n-nodes-mem0) community node fixes that: store durable facts as memories, recall them in any later run, and hand the node to an [n8n AI Agent](https://docs.n8n.io/advanced-ai/) as a tool so it can remember and recall on its own. + +## Overview + +1. Install the node from n8n's community nodes panel. +2. Connect your Mem0 API key once as a credential. +3. Drop a **Mem0** node into any workflow to add, search, or manage memories. +4. Optionally attach it to an **AI Agent** node, where it becomes a tool the agent calls itself. + +## Prerequisites + +1. A Mem0 API key from the API Keys dashboard (sign up at app.mem0.ai if you do not have an account). +2. A **self-hosted** n8n instance. Installing community nodes from npm is a self-hosted feature; n8n Cloud only offers nodes that n8n has verified. +3. Owner access to that instance, since only instance owners can install community nodes. + +## Installation + + + + In n8n, go to **Settings β†’ Community Nodes** and select **Install**. + + + Enter `@mem0/n8n-nodes-mem0`, tick the risk acknowledgement, and select **Install**. + + + Add a new **Mem0 API** credential and paste your API key. Leave **Base URL** at `https://api.mem0.ai` unless you run Mem0 somewhere else. + + + + + **Verify the install:** search the nodes panel for `Mem0`. The node should appear with a **Memory** resource offering Add, Search, Get, Get Many, Update, and Delete. + + +## Quickstart + +A two-node workflow that writes a memory and reads it back: + +```text +Manual Trigger β†’ Mem0 (Add) β†’ Mem0 (Search) +``` + + + + Add a **Mem0** node, keep **Operation: Add**, set **User ID** to `alice`, and add one message with **Role** `user` and **Content**: + + `I am vegetarian and I never eat mushrooms.` + + + Add a second **Mem0** node with **Operation: Search**, **User ID** `alice`, and **Query** `what does the user eat?`. + + + Select **Test workflow**. The Search node returns the extracted dietary memory. + + + + + Extraction is asynchronous. The Add node's **Wait for Completion** option is on by default, so it polls until extraction finishes before the next node runs. If you turn it off, allow a few seconds before searching for what you just wrote. + + +## Use it as an AI Agent tool + +The node is marked `usableAsTool`, so an n8n **AI Agent** (Tools Agent) can call it without any wiring on your side: + +```text +Chat Trigger β†’ AI Agent ──tool──▢ Mem0 (Search) + ──tool──▢ Mem0 (Add) +``` + +Attach one Mem0 node set to **Search** and one set to **Add**. The agent searches memory before answering and writes back durable facts after a meaningful exchange. Keep **User ID** the same on both. + +## Operations + +The node wraps the hosted Mem0 REST API and supports six operations on the **Memory** resource: + +| Operation | What it does | Endpoint | +| --- | --- | --- | +| **Add** | Extract and store memories from messages | `POST /v3/memories/add/` | +| **Search** | Semantic search over stored memories | `POST /v3/memories/search/` | +| **Get Many** | List stored memories (one page, or **Return All**) | `POST /v3/memories/` | +| **Get** | Fetch a single memory by ID | `GET /v1/memories/{id}/` | +| **Update** | Change a memory's text or metadata | `PUT /v1/memories/{id}/` | +| **Delete** | Delete a single memory by ID | `DELETE /v1/memories/{id}/` | + +### Add + +Extracts and stores memories from one or more messages. Supply at least one entity id (**User ID**, or **Agent ID** / **App ID** / **Run ID** under Additional Fields); the node checks this before calling the API. + +**Additional Fields:** + +| Field | Purpose | +| --- | --- | +| **Agent ID** | Scopes the memory to an agent | +| **App ID** | Scopes the memory to an app or project | +| **Run ID** | Scopes the memory to a single session or run | +| **Metadata (JSON)** | Arbitrary JSON attached to each extracted memory | +| **Infer** | On by default. Turn off to store messages verbatim instead of running LLM extraction | +| **Custom Instructions** | Free-text guidance steering what the extractor keeps or ignores, for this call | +| **Custom Categories** | JSON array of `{category: description}` objects, replacing the project-level catalog for this call | +| **Includes** | Only extract memories matching this description | +| **Excludes** | Skip memories matching this description | + +**Includes** and **Excludes** narrow what extraction keeps. Sending *"I am vegetarian and I never eat mushrooms. I drive a blue Toyota Corolla and my parking spot is B12"* stores three memories by default; with `Includes: "only record food and diet preferences"` it stores just the dietary one. + +### Search + +Semantic search over stored memories. Takes a **Query**, at least one entity id, and an optional **Limit**. + +### Get Many + +Lists stored memories for the entity ids you supply. Turn on **Return All** to page through everything automatically, or leave it off to fetch a single **Page**. **Page Size** applies either way. + +### Get, Update, Delete + +Operate on one memory by **Memory ID**. Update accepts new **Text** and/or **Metadata (JSON)**. + +## Entity filters on Search and Get Many + +Both operations take **User ID**, **Agent ID**, **App ID**, and **Run ID**. At least one is required, since the API rejects a query with no entity scope, and the node fails with a clear message before making the call if all four are empty. + +Supply several and they combine with **OR**, so the result is the union of those scopes: + +```json +{ "OR": [{ "user_id": "alice" }, { "agent_id": "support-bot" }] } +``` + + + This is deliberate, not a shortcut. Mem0 indexes each entity separately, so an `AND` across `user_id` and `agent_id` matches nothing even when a memory was written with both. To narrow rather than widen, run one operation per entity id. + + +## Choosing a User ID + +The **User ID** is a stable string you pick to identify whose memories these are. It is not looked up in the dashboard, so any consistent value works: your app's internal user ID, an email, or a UUID. Use the same value across Add, Search, and Get Many or recall returns nothing. + +## Troubleshooting + +- **The node does not appear in the panel**: community nodes install on self-hosted n8n only, and only instance owners can install them. On n8n Cloud, this node is not yet available. +- **`401 Unauthorized`**: the API key is wrong or was revoked. Regenerate it in the API Keys dashboard and update the credential. +- **"Provide at least one of User ID, Agent ID, App ID, or Run ID"**: every Add, Search, and Get Many needs an entity scope. Fill in at least one. +- **Search returns nothing right after an Add**: extraction is asynchronous. Leave **Wait for Completion** on, or add a short Wait node before searching. +- **Searching two entity ids returns more than expected**: multiple ids are combined with OR by design. Run one operation per id to narrow. +- **"Timed out waiting for memory event"**: the add was accepted and is likely still finishing on the server. A timeout here does not mean it failed. + + + + Add memory to Zaps across thousands of apps + + + Add memory to Flowise chatflows + + + + diff --git a/docs/integrations/zapier.mdx b/docs/integrations/zapier.mdx new file mode 100644 index 000000000..687ebfaa6 --- /dev/null +++ b/docs/integrations/zapier.mdx @@ -0,0 +1,143 @@ +--- +title: Zapier +description: "Add, search, and manage Mem0 memories from any Zap using the Mem0 Zapier app, no code required." +--- + +Zaps fire and forget. The [Mem0](https://mem0.ai) app gives them memory: store durable facts from a form submission, a support ticket, or a chat message, then recall them later from any of [Zapier's](https://zapier.com) thousands of apps. No code, no server. + +## Overview + +1. Connect your Mem0 API key once as a Zapier connection. +2. Use **Add Memory** to store what a Zap learns. +3. Use **Search Memories** or **Get Memories** to pull that context back into a later step. +4. Use **Delete Memory** to remove one by ID. + +## Prerequisites + +1. A Mem0 API key from the API Keys dashboard (sign up at app.mem0.ai if you do not have an account). +2. A Zapier account on any plan. + + + The Mem0 app is not yet listed in Zapier's public App Directory, so you need an invite link to add it to a Zap. Email [support@mem0.ai](mailto:support@mem0.ai) to request one. + + +## Setup + + + + In the Zap editor, search for **Mem0** and pick an action such as **Add Memory**. + + + Select **Sign in**, paste your **Mem0 API Key** (it starts with `m0-`), and leave **Base URL** at `https://api.mem0.ai` unless you run Mem0 somewhere else. + + + Zapier validates the key against Mem0 the moment you save it. A connection labelled **Mem0** means the key works. + + + + + The key is a password field, so Zapier masks it in the editor. It is sent to Mem0 as `Authorization: Token `. + + +## Quickstart + +Remember what a user tells you: + +```text +Trigger (form, chat, ticket) β†’ Mem0: Add Memory +``` + +Set **Content** to the message text and **User ID** to a stable identifier for that person, such as their email. + +Then recall it in a later Zap: + +```text +Trigger (new message) β†’ Mem0: Search Memories β†’ Send reply +``` + +Set **Query** to the incoming message and **User ID** to the same value. The matched memories become available to every step after it. + + + Extraction is asynchronous. **Add Memory** returns immediately with an event ID by default, so a Search fired a second later may not see the new memory yet. See [Waiting for extraction](#waiting-for-extraction). + + +## Actions + +| Type | Action | What it does | Endpoint | +| --- | --- | --- | --- | +| Create | **Add Memory** | Extract and store memories from a message | `POST /v3/memories/add/` | +| Search | **Search Memories** | Semantic search over stored memories | `POST /v3/memories/search/` | +| Search | **Get Memories** | List stored memories, one page at a time | `POST /v3/memories/` | +| Create | **Delete Memory** | Delete a single memory by ID | `DELETE /v1/memories/{id}/` | + +### Add Memory + +| Field | Required | Purpose | +| --- | --- | --- | +| **Content** | Yes | The message text to extract memories from | +| **Role** | | `User` (default), `Assistant`, or `System` | +| **User ID** | | Scopes the memory to a person | +| **Agent ID** | | Scopes the memory to an agent | +| **Run ID** | | Scopes the memory to a single session or run | +| **Metadata (JSON)** | | Arbitrary JSON attached to each extracted memory | +| **Custom Instructions** | | Free-text guidance steering what the extractor keeps or ignores, for this call | +| **Custom Categories (JSON)** | | JSON array of `{category: description}` objects, replacing the project-level catalog for this call | +| **Includes** | | Only extract memories matching this description | +| **Excludes** | | Skip memories matching this description | +| **Infer** | | On by default. Turn off to store the message verbatim instead of running LLM extraction | +| **Wait for Completion** | | Off by default. Turn on to poll until extraction finishes and return the resulting memories | + +**Includes** and **Excludes** narrow what extraction keeps. Sending *"I am vegetarian and I never eat mushrooms. I drive a blue Toyota Corolla and my parking spot is B12"* stores three memories by default; with `Includes: "only record food and diet preferences"` it stores just the dietary one. + +#### Waiting for extraction + +Extraction runs asynchronously, so **Add Memory** returns an event ID and moves on unless you turn on **Wait for Completion**. When you do, the step polls for up to 60 seconds and returns the extracted memories instead. + + + Extraction can take longer than Zapier allows a single step to run, which is why waiting is opt-in. If the step times out, the add was still accepted and typically completes on Mem0's side, so do not retry it blindly. + + +### Search Memories + +| Field | Required | Purpose | +| --- | --- | --- | +| **Query** | Yes | Natural-language search text | +| **User ID** | Yes | Whose memories to search. The API needs an entity filter | +| **Limit** | | Maximum results, default `50` | + +### Get Memories + +| Field | Required | Purpose | +| --- | --- | --- | +| **User ID** | Yes | Whose memories to list | +| **Limit** | | Memories per page, default `50` | +| **Page** | | Which page to return, 1-based, default `1` | + +Returns one page per run. Raise **Page** to walk through larger result sets. + +### Delete Memory + +Takes a **Memory ID** and deletes that memory. Pair it with **Search Memories** or **Get Memories** to get the ID first. + +## Choosing a User ID + +The **User ID** is a stable string you pick to identify whose memories these are. It is not looked up in the dashboard, so any consistent value works: your app's internal user ID, an email, or a UUID. Use the same value on Add, Search, and Get Memories or recall returns nothing. + +## Troubleshooting + +- **Mem0 does not appear in the Zap editor**: the app is not yet in the public App Directory. Email [support@mem0.ai](mailto:support@mem0.ai) for an invite link. +- **The connection fails when you paste the key**: check that it starts with `m0-` and has not been revoked in the API Keys dashboard. +- **Search returns nothing right after an Add**: extraction is asynchronous. Turn on **Wait for Completion**, or put a Zapier **Delay** step before the Search. +- **"Metadata must be valid JSON" or "Custom Categories must be valid JSON"**: those fields take raw JSON. Check for smart quotes and trailing commas. +- **The Add step times out**: the memory was still accepted and is likely finishing server-side. Confirm with **Get Memories** before re-running. + + + + Build workflows with the Mem0 n8n community node + + + Add memory to Flowise chatflows + + + + diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 48ddbb88e..53d424630 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -10,7 +10,7 @@ mode: "custom"

- Universal, self-improving memory layer for LLM applications. + Mem0 gives your AI agents long-term memory that persists across sessions, tools, and runs.

@@ -34,10 +34,10 @@ mode: "custom" />

- Add memory to your app + Store your first memory

- Start with the quickstart and store your first memory in minutes. + Five-minute quickstart: get an API key, then save and search a memory in Python or JavaScript.

@@ -60,10 +60,10 @@ mode: "custom" />

- Add memory to your agent + Add memory to your coding agent

- Give Claude Code, Cursor, and Codex memory that persists across sessions. A drop-in plugin, no code to write. + Plugins that let Claude Code, Cursor, Codex, and other harnesses remember your project. Opens the Claude Code guide.

@@ -86,10 +86,10 @@ mode: "custom" />

- Sign up for Mem0 as an agent + Let an AI agent sign itself up

- Let an AI agent mint a Mem0 API key, claim ownership later, and write its first memory from the terminal. + Four terminal commands create an account and API key. No email, no dashboard. Claim it as owner later.

@@ -112,10 +112,10 @@ mode: "custom" />

- Explore Mem0 integrations + Use Mem0 with your framework

- Connect Mem0 to LangChain, CrewAI, Vercel AI SDK, and 20+ partner frameworks. + Setup guides for 22 tools, including LangChain, CrewAI, LlamaIndex, and the Vercel AI SDK.

@@ -138,10 +138,10 @@ mode: "custom" />

- See memory-app examples + Copy a working example

- Browse cookbooks for companions, support agents, voice agents, research tools, and more. + Full walkthroughs for companion chatbots, support agents, voice agents, and research tools.

@@ -164,10 +164,10 @@ mode: "custom" />

- Self-host Mem0 + Run Mem0 on your own servers

- Use Open Source when you want memory without the hosted platform. + The open-source version. Run it as a library or a Docker stack, with your data on your infrastructure.

diff --git a/docs/llms.txt b/docs/llms.txt index 304eb60fc..224edfe9c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -38,7 +38,7 @@ This mints an evaluation key in <5 seconds. Use it immediately against the Platf ## Identify the User's Setup -Look at the user's imports first - they determine which product (Platform vs OSS) and which language you should quote docs from. **Mem0 Platform (managed) is the recommended path** - 4-line integration, sub-50ms retrieval, no infra. Route to OSS only when the user has an explicit self-hosting requirement. +Look at the user's imports first - they determine which product (Platform vs OSS) and which language you should quote docs from. **Mem0 Platform (managed) is the recommended path** - 4-line integration, no infra to run. Route to OSS only when the user has an explicit self-hosting requirement. ### Platform - Python [Platform] @@ -56,8 +56,8 @@ client.add( ) # Read -client.search("What does Alice like to do?", user_id="alice") -client.get_all(user_id="alice") +client.search("What does Alice like to do?", filters={"user_id": "alice"}) +client.get_all(filters={"user_id": "alice"}) client.get(memory_id="") # Update @@ -86,8 +86,8 @@ await client.add( ); // Read -await client.search("What does Alice like to do?", { user_id: "alice" }); -await client.getAll({ user_id: "alice" }); +await client.search("What does Alice like to do?", { filters: { user_id: "alice" } }); +await client.getAll({ filters: { user_id: "alice" } }); await client.get(""); // Update @@ -113,8 +113,8 @@ m = Memory() # needs OPENAI_API_KEY; see components/ for custom providers m.add("I love hiking on weekends", user_id="alice") # Read -m.search("What does Alice like to do?", user_id="alice") -m.get_all(user_id="alice") +m.search("What does Alice like to do?", filters={"user_id": "alice"}) +m.get_all(filters={"user_id": "alice"}) m.get(memory_id="") # Update @@ -140,8 +140,8 @@ const memory = new Memory(); await memory.add("I love hiking on weekends", { userId: "alice" }); // Read -await memory.search("What does Alice like to do?", { userId: "alice" }); -await memory.getAll({ userId: "alice" }); +await memory.search("What does Alice like to do?", { filters: { user_id: "alice" } }); +await memory.getAll({ filters: { user_id: "alice" } }); await memory.get(""); // Update @@ -164,27 +164,27 @@ npm list mem0ai --depth 0 2>/dev/null | grep mem0ai mem0 --version # Python or Node CLI, whichever is on PATH ``` -If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_format: "v1.1"`), route them through the matching migration guide in the Platform section before quoting current docs. If no Mem0 package is installed, recommend `pip install mem0ai` or `npm install mem0ai` and the corresponding quickstart above. +If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call still passing `output_format`, `api_version`, `async_mode`, or `enable_graph`, all removed in the current major), route them through the matching migration guide in the Platform section before quoting current docs. If no Mem0 package is installed, recommend `pip install mem0ai` or `npm install mem0ai` and the corresponding quickstart above. ## Getting Started - [Introduction](https://docs.mem0.ai/introduction) [Both]: Use when the user wants a one-page overview of how memory fits between the LLM and the app. - [Vibe Code with Mem0](https://docs.mem0.ai/vibecoding) [Both]: Use when the user is in Claude Code, Cursor, or Windsurf and wants memory wired into their editor. -- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, sub-50ms retrieval, dashboard. +- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, hosted API, dashboard. - [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup) [Platform]: Use when an AI agent needs to mint a Mem0 API key autonomously - four commands, no email or dashboard, human claims ownership later. - [Platform vs Open Source](https://docs.mem0.ai/platform/platform-vs-oss) [Both]: Use when the user is deciding between managed and self-hosted. - [Platform Quickstart](https://docs.mem0.ai/platform/quickstart) [Platform]: Use for the first Platform integration - API key plus `MemoryClient.add/search`. - [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal. - [Mem0 MCP Server](https://docs.mem0.ai/platform/mem0-mcp) [Platform]: Use when connecting memory to AI coding tools over MCP. - [Open Source Overview](https://docs.mem0.ai/open-source/overview) [OSS]: Use when the user needs full infra control and custom provider wiring. -- [Open Source Configuration](https://docs.mem0.ai/open-source/configuration) [OSS]: Use when configuring `Memory` - LLM, embedder, vector store, graph store. +- [Open Source Configuration](https://docs.mem0.ai/open-source/configuration) [OSS]: Use when configuring `Memory` - LLM, embedder, vector store, reranker. - [Open Source Python Quickstart](https://docs.mem0.ai/open-source/python-quickstart) [OSS]: Use for the first self-hosted Python integration. - [Open Source Node.js Quickstart](https://docs.mem0.ai/open-source/node-quickstart) [OSS]: Use for the first self-hosted Node integration. - [Self-Hosted Setup](https://docs.mem0.ai/open-source/setup) [OSS]: Use when standing up the bundled REST server and dashboard via Docker Compose, including auth, API keys, and the setup wizard. ## Core Concepts -- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/graph/history stores, and multi-signal retrieval. +- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/entity/history stores, and multi-signal retrieval. - [Memory Types](https://docs.mem0.ai/core-concepts/memory-types) [Both]: Use when explaining working, factual, episodic, and semantic memory distinctions. - [Memory Operations - Add](https://docs.mem0.ai/core-concepts/memory-operations/add) [Both]: Use when explaining how `add()` extracts facts, resolves conflicts, and writes to both stores. - [Memory Operations - Search](https://docs.mem0.ai/core-concepts/memory-operations/search) [Both]: Use when explaining how queries are processed and ranked. @@ -204,17 +204,17 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f ### Features - Advanced Retrieval - [Advanced Retrieval](https://docs.mem0.ai/platform/features/advanced-retrieval) [Platform]: Use when the user needs keyword search, reranking, or hybrid retrieval. -- [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity. - [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering. -- [Contextual Add](https://docs.mem0.ai/platform/features/contextual-add) [Platform]: Use when `add()` should consider the surrounding conversation, not just the latest turn. - [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform. - [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones. Opt in per project; applies at search time and never filters candidates out. +- [Dream](https://docs.mem0.ai/platform/features/dream) [Platform]: Use when long-lived user memory should stay coherent on its own - synthesizing recurring patterns, superseding outdated facts, and merging duplicates in the background. Synthesis is opt-in (Pro+); Supersede and Merge are always on. - [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows. ### Features - Data Management - [Direct Import](https://docs.mem0.ai/platform/features/direct-import) [Platform]: Use when seeding a Mem0 project from existing data. - [Memory Export](https://docs.mem0.ai/platform/features/memory-export) [Platform]: Use when exporting memories via a Pydantic schema. - [Timestamp Support](https://docs.mem0.ai/platform/features/timestamp) [Platform]: Use when temporal queries or time-based filtering matter. +- [Memory Expiration](https://docs.mem0.ai/platform/features/memory-expiration) [Both]: Use when a memory should stop surfacing after a known date without being deleted, e.g. trial facts, seasonal preferences, or retention windows. ### Features - Integration & Ops - [Webhooks](https://docs.mem0.ai/platform/features/webhooks) [Platform]: Use when another system needs to react to memory changes in real time. @@ -235,7 +235,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f - [Open Source Features Overview](https://docs.mem0.ai/open-source/features/overview) [OSS]: Use when surveying OSS-only capabilities. - [Metadata Filtering](https://docs.mem0.ai/open-source/features/metadata-filtering) [OSS]: Use when filtering by custom metadata fields in self-hosted. - [Reranker Search](https://docs.mem0.ai/open-source/features/reranker-search) [OSS]: Use when improving OSS search quality with a reranker. -- [Reranking](https://docs.mem0.ai/open-source/features/reranking) [OSS]: Use when configuring reranking end-to-end in OSS. - [Async Memory](https://docs.mem0.ai/open-source/features/async-memory) [OSS]: Use when the self-hosted app needs `AsyncMemory`. - [OSS Multimodal Support (features)](https://docs.mem0.ai/open-source/features/multimodal-support) [OSS]: Use when handling images and PDFs self-hosted (feature guide). - [Custom Instructions (OSS)](https://docs.mem0.ai/open-source/features/custom-instructions) [OSS]: Use when tailoring extraction prompts in OSS. @@ -247,44 +246,46 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f - [Integrations Overview](https://docs.mem0.ai/integrations) [Both]: Use when surveying every available integration. ### Agent Frameworks -- [LangChain](https://docs.mem0.ai/integrations/langchain) [Both]: Use when the user is on LangChain. -- [LangGraph](https://docs.mem0.ai/integrations/langgraph) [Both]: Use when building stateful multi-actor LangGraph apps. -- [LangChain Tools](https://docs.mem0.ai/integrations/langchain-tools) [Both]: Use when Mem0 should be exposed as a LangChain tool. +- [LangChain](https://docs.mem0.ai/integrations/langchain) [Platform]: Use when the user is on LangChain. +- [LangGraph](https://docs.mem0.ai/integrations/langgraph) [Platform]: Use when building stateful multi-actor LangGraph apps. +- [LangChain Tools](https://docs.mem0.ai/integrations/langchain-tools) [Platform]: Use when Mem0 should be exposed as a LangChain tool. - [LlamaIndex](https://docs.mem0.ai/integrations/llama-index) [Both]: Use when layering memory on a LlamaIndex RAG app. -- [CrewAI](https://docs.mem0.ai/integrations/crewai) [Both]: Use when building CrewAI multi-agent systems. -- [AutoGen](https://docs.mem0.ai/integrations/autogen) [Both]: Use when the user is on Microsoft AutoGen. -- [Agno](https://docs.mem0.ai/integrations/agno) [Both]: Use when the user is on Agno. +- [CrewAI](https://docs.mem0.ai/integrations/crewai) [Platform]: Use when building CrewAI multi-agent systems. +- [AutoGen](https://docs.mem0.ai/integrations/autogen) [Platform]: Use when the user is on Microsoft AutoGen. +- [Agno](https://docs.mem0.ai/integrations/agno) [Platform]: Use when the user is on Agno. - [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. +- [ChatDev](https://docs.mem0.ai/integrations/chatdev) [Platform]: 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). +- [OpenAI Agents SDK](https://docs.mem0.ai/integrations/openai-agents-sdk) [Platform]: Use when the user is on the OpenAI Agents SDK. +- [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) [Platform]: Use when the user is on Google's Agent Development Kit. +- [Mastra](https://docs.mem0.ai/integrations/mastra) [Platform]: Use when the user is on Mastra (TypeScript). - [OpenClaw](https://docs.mem0.ai/integrations/openclaw) [Both]: Use when wiring Mem0 into Claude Code or editors via OpenClaw. - [Vercel AI SDK](https://docs.mem0.ai/integrations/vercel-ai-sdk) [Both]: Use when the user is on the Vercel AI SDK. ### AI Coding Tools - [Claude Code](https://docs.mem0.ai/integrations/claude-code) [Both]: Use when wiring memory into Claude Code. -- [Cursor](https://docs.mem0.ai/integrations/cursor) [Both]: Use when wiring memory into Cursor. -- [Codex](https://docs.mem0.ai/integrations/codex) [Both]: Use when wiring memory into Codex / other editor assistants. -- [OpenCode](https://docs.mem0.ai/integrations/opencode) [Both]: Use when wiring memory into OpenCode. -- [Antigravity](https://docs.mem0.ai/integrations/antigravity) [Both]: Use when wiring memory into Google Antigravity. +- [Cursor](https://docs.mem0.ai/integrations/cursor) [Platform]: Use when wiring memory into Cursor. +- [Codex](https://docs.mem0.ai/integrations/codex) [Platform]: Use when wiring memory into Codex / other editor assistants. +- [OpenCode](https://docs.mem0.ai/integrations/opencode) [Platform]: Use when wiring memory into OpenCode. +- [Antigravity](https://docs.mem0.ai/integrations/antigravity) [Platform]: Use when wiring memory into Google Antigravity. ### Voice & Real-time -- [LiveKit](https://docs.mem0.ai/integrations/livekit) [Both]: Use when building real-time voice/video with memory. -- [Pipecat](https://docs.mem0.ai/integrations/pipecat) [Both]: Use when the voice pipeline is Pipecat. -- [ElevenLabs](https://docs.mem0.ai/integrations/elevenlabs) [Both]: Use when voice synthesis uses ElevenLabs. +- [LiveKit](https://docs.mem0.ai/integrations/livekit) [Platform]: Use when building real-time voice/video with memory. +- [Pipecat](https://docs.mem0.ai/integrations/pipecat) [Platform]: Use when the voice pipeline is Pipecat. +- [ElevenLabs](https://docs.mem0.ai/integrations/elevenlabs) [Platform]: Use when voice synthesis uses ElevenLabs. ### Cloud & Infrastructure -- [AWS Bedrock](https://docs.mem0.ai/integrations/aws-bedrock) [Both]: Use when the user is on AWS Bedrock managed AI services. +- [AWS Bedrock](https://docs.mem0.ai/integrations/aws-bedrock) [OSS]: Use when the user is on AWS Bedrock managed AI services. ### Developer Tools -- [Dify](https://docs.mem0.ai/integrations/dify) [Both]: Use when the user is on Dify LLMOps. -- [Flowise](https://docs.mem0.ai/integrations/flowise) [Both]: Use when the user is on Flowise no-code. +- [Dify](https://docs.mem0.ai/integrations/dify) [Platform]: Use when the user is on Dify LLMOps. +- [Flowise](https://docs.mem0.ai/integrations/flowise) [Platform]: Use when the user is on Flowise no-code. +- [n8n](https://docs.mem0.ai/integrations/n8n) [Both]: Use when the user builds workflows or AI agents in n8n. +- [Zapier](https://docs.mem0.ai/integrations/zapier) [Both]: Use when the user automates workflows with Zapier. - [AgentOps](https://docs.mem0.ai/integrations/agentops) [Both]: Use when tracking agent observability with memory metadata. -- [Respan](https://docs.mem0.ai/integrations/respan) [Both]: Use when monitoring Mem0 with Respan (formerly Keywords AI) LLM observability. -- [Raycast](https://docs.mem0.ai/integrations/raycast) [Both]: Use when the user wants quick memory access via Raycast. +- [Respan](https://docs.mem0.ai/integrations/respan) [OSS]: Use when monitoring Mem0 with Respan (formerly Keywords AI) LLM observability. +- [Raycast](https://docs.mem0.ai/integrations/raycast) [Platform]: Use when the user wants quick memory access via Raycast. ## Cookbooks @@ -316,10 +317,10 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f ### Integration Examples - [Agents SDK Tool](https://docs.mem0.ai/cookbooks/integrations/agents-sdk-tool) [Platform]: Use when exposing Mem0 as a tool in OpenAI Agents SDK. - [OpenAI Tool Calls](https://docs.mem0.ai/cookbooks/integrations/openai-tool-calls) [Platform]: Use when hooking Mem0 into OpenAI function calling. -- [Mastra Agent](https://docs.mem0.ai/cookbooks/integrations/mastra-agent) [Both]: Use when the agent is built in Mastra. -- [Healthcare Google ADK](https://docs.mem0.ai/cookbooks/integrations/healthcare-google-adk) [Both]: Use when the domain is medical and the framework is Google ADK. -- [AWS Bedrock](https://docs.mem0.ai/cookbooks/integrations/aws-bedrock) [Both]: Use when deploying with AWS managed model services. -- [Tavily Search](https://docs.mem0.ai/cookbooks/integrations/tavily-search) [Both]: Use when the agent layers web search on memory. +- [Mastra Agent](https://docs.mem0.ai/cookbooks/integrations/mastra-agent) [Platform]: Use when the agent is built in Mastra. +- [Healthcare Google ADK](https://docs.mem0.ai/cookbooks/integrations/healthcare-google-adk) [Platform]: Use when the domain is medical and the framework is Google ADK. +- [AWS Bedrock](https://docs.mem0.ai/cookbooks/integrations/aws-bedrock) [OSS]: Use when deploying with AWS managed model services. +- [Tavily Search](https://docs.mem0.ai/cookbooks/integrations/tavily-search) [Platform]: Use when the agent layers web search on memory. ### Framework Examples - [LlamaIndex React](https://docs.mem0.ai/cookbooks/frameworks/llamaindex-react) [Both]: Use when building a React UI with LlamaIndex and memory. @@ -408,16 +409,15 @@ The `integrations/mem0-plugin/` directory provides MCP server connection, lifecy Editor-specific setup docs (already listed above under `## Integrations > AI Coding Tools`): - `integrations/claude-code` [Both] -- `integrations/cursor` [Both] -- `integrations/codex` [Both] -- `integrations/opencode` [Both] -- `integrations/antigravity` [Both] +- `integrations/cursor` [Platform] +- `integrations/codex` [Platform] +- `integrations/opencode` [Platform] +- `integrations/antigravity` [Platform] - `integrations/openclaw` [Both] ### MCP Endpoints - Hosted MCP server: `https://mcp.mem0.ai` - requires Platform API key. See `platform/mem0-mcp`. -- Self-hosted MCP server: ships with `openmemory/api/` (FastAPI) - runs against your own Qdrant + LLM stack. ## Community & Support @@ -473,6 +473,7 @@ Everything below is OSS-only provider configuration. Skip this entire section wh - [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus) [OSS]: Use for large-scale Milvus deployments. - [Pinecone](https://docs.mem0.ai/components/vectordbs/dbs/pinecone) [OSS]: Use when the user is on Pinecone managed. - [MongoDB](https://docs.mem0.ai/components/vectordbs/dbs/mongodb) [OSS]: Use when Mongo Atlas Vector Search is the backing store. +- [Oracle AI Vector Search](https://docs.mem0.ai/components/vectordbs/dbs/oracledb) [OSS]: Use when Oracle Database AI Vector Search is the backing store. - [Azure AI Search](https://docs.mem0.ai/components/vectordbs/dbs/azure) [OSS]: Use when the user is on Azure AI Search. - [Azure MySQL](https://docs.mem0.ai/components/vectordbs/dbs/azure_mysql) [OSS]: Use when vector search runs on Azure Database for MySQL. - [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis) [OSS]: Use when Redis Stack is the backing store. @@ -501,5 +502,6 @@ Everything below is OSS-only provider configuration. Skip this entire section wh - [Custom Reranker Prompts](https://docs.mem0.ai/components/rerankers/custom-prompts) [OSS]: Use when rewriting reranker prompts. - [Cohere Reranker](https://docs.mem0.ai/components/rerankers/models/cohere) [OSS]: Use for Cohere Rerank. - [Sentence Transformer Reranker](https://docs.mem0.ai/components/rerankers/models/sentence_transformer) [OSS]: Use for local cross-encoder rerankers. -- [Hugging Face Reranker](https://docs.mem0.ai/components/rerankers/models/huggingface) [OSS]: Use for HF-hosted reranker models.- [LLM Reranker](https://docs.mem0.ai/components/rerankers/models/llm_reranker) [OSS]: Use when the reranker is a prompted LLM (implementation reference). +- [Hugging Face Reranker](https://docs.mem0.ai/components/rerankers/models/huggingface) [OSS]: Use for HF-hosted reranker models. +- [LLM Reranker](https://docs.mem0.ai/components/rerankers/models/llm_reranker) [OSS]: Use when the reranker is a prompted LLM (implementation reference). - [Zero Entropy Reranker](https://docs.mem0.ai/components/rerankers/models/zero_entropy) [OSS]: Use for the Zero Entropy reranker. diff --git a/docs/migration/oss-to-platform.mdx b/docs/migration/oss-to-platform.mdx index c478701e2..e201c89a7 100644 --- a/docs/migration/oss-to-platform.mdx +++ b/docs/migration/oss-to-platform.mdx @@ -339,21 +339,22 @@ The Platform introduces powerful capabilities not available in OSS: ```python # Set custom categories for your project - client.projects.update_categories( - project_id="proj_123", - categories=[ - "Customer Preferences", - "Product Feedback", - "Support Issues", - "Feature Requests" + client.project.update( + custom_categories=[ + {"customer_preferences": "Likes, dislikes, and product preferences"}, + {"product_feedback": "Feature requests and complaints about the product"}, + {"support_issues": "Problems reported and how they were resolved"} ] ) - # Memories will use these categories + # Mem0 assigns these categories automatically as memories come in + client.add("User wants dark mode in dashboard", user_id="alex") + + # Or pass a different catalog for a single call client.add( "User wants dark mode in dashboard", user_id="alex", - categories=["Customer Preferences"] + custom_categories=[{"ui_requests": "Requests about interface and appearance"}] ) ``` diff --git a/docs/migration/oss-v2-to-v3.mdx b/docs/migration/oss-v2-to-v3.mdx index a1649fe72..50ba82247 100644 --- a/docs/migration/oss-v2-to-v3.mdx +++ b/docs/migration/oss-v2-to-v3.mdx @@ -1,6 +1,6 @@ --- title: "Open Source: Migrating to the New Memory Algorithm" -description: "Guide for self-hosted Mem0 users to upgrade to the new memory algorithm with ADD-only extraction, hybrid search, and entity linking." +description: "Guide for self-hosted Mem0 users to upgrade to the new memory algorithm with ADD-only extraction, hybrid search, and entity-aware retrieval." icon: "arrow-right" iconType: "solid" --- @@ -15,7 +15,8 @@ The new Mem0 release redesigns both extraction and retrieval, and cleans up the - **Extraction**: Single-pass ADD-only (one LLM call, no UPDATE/DELETE) - **Retrieval**: Multi-signal hybrid search (semantic + BM25 keyword + entity matching) -- **Entity linking**: Automatic entity extraction and cross-memory linking +- **Entity matching**: Automatic entity extraction feeds a third scoring signal in hybrid search, boosting memories that share entities with the query +- **Graph memory moved to Platform**: The external graph store integration is removed from OSS; graph memory is now a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) - **SDK cleanup**: Deprecated parameters removed, naming conventions standardized - **API surface aligned with Platform**: Entity IDs now follow the same convention across OSS and Platform: top-level kwargs for `add()` / `delete_all()`, inside `filters` for `search()` / `get_all()` @@ -37,7 +38,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 β†’ 91.6) and | `add()` events | Returns `ADD`, `UPDATE`, `DELETE` | Returns `ADD` only | Update code expecting UPDATE/DELETE | | Custom extraction prompt | `custom_fact_extraction_prompt` | `custom_instructions` | Rename in config | | Custom update prompt | `custom_update_memory_prompt` | Deprecated | Use `custom_instructions` instead | -| Graph memory | `enable_graph` + `graph_store` in config | Removed | Graph store support has been removed entirely | +| Graph memory | `enable_graph` + `graph_store` in config | Removed | Graph memory is removed from OSS. It's a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) | | Qdrant client | `>=1.9.1` | `>=1.12.0` | Update dependency | | Upstash client | `>=0.1.0` | `>=0.6.0` | Update dependency | @@ -53,8 +54,8 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 β†’ 91.6) and | `messages` in `add()` | Could be `null` / `undefined` | Required: throws on null/undefined | Always pass a string or array | | Payload key for lemmatized text | `text_lemmatized` (snake_case) | `textLemmatized` (camelCase) | TS-only internal field. If you share a vector store collection between Python and TS SDKs, lemma-based BM25 will not resolve across languages: keep collections language-scoped. | | Custom prompt | `customPrompt` | `customInstructions` | Rename in config | -| Graph memory | `enableGraph` + `graphStore` in config | Removed | Graph store support has been removed entirely | -| Default graph config | Neo4j default config applied | No default graph config | Graph store config is no longer used | +| Graph memory | `enableGraph` + `graphStore` in config | Removed | Graph memory is removed from OSS. It's a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) | +| Default graph config | Neo4j default config applied | No default graph config | Graph store config is no longer read; OSS has no built-in graph config to fall back to | ### Python Client SDK @@ -104,7 +105,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 β†’ 91.6) and
-The Python `[nlp]` extra installs [spaCy](https://spacy.io/) for entity extraction and keyword lemmatization. Without it, Mem0 still works but falls back to semantic-only search (no entity linking, no BM25 lemmatization). +The Python `[nlp]` extra installs [spaCy](https://spacy.io/) for entity extraction and keyword lemmatization. Without it, Mem0 still works but falls back to semantic-only search (no entity matching, no BM25 lemmatization). @@ -135,7 +136,7 @@ pip install fastembed config = { "custom_instructions": "Focus on user preferences", # [OK] New name # custom_update_memory_prompt removed: use custom_instructions - # enable_graph and graph_store removed: graph store support has been removed + # enable_graph and graph_store removed: graph memory is now a Mem0 Platform feature } ``` @@ -154,7 +155,7 @@ pip install fastembed // After const config = { customInstructions: "Focus on user preferences", // [OK] New name - // enableGraph and graphStore removed: graph store support has been removed + // enableGraph and graphStore removed: graph memory is now a Mem0 Platform feature }; ``` @@ -320,35 +321,31 @@ pip install "qdrant-client>=1.12.0" pip install "upstash-vector>=0.6.0" ``` -### 6. Entity Store Setup +### 6. Entity Matching Store Setup -The new algorithm automatically creates a parallel entity store collection named `{your_collection}_entities`. No manual setup is required: it's created on first use. +The new algorithm automatically creates a parallel collection named `{your_collection}_entities` to power entity matching, the third signal in hybrid search. No manual setup is required: it's created on first use. This is separate from and unrelated to graph memory, which is a Mem0 Platform feature. Make sure your vector store user/credentials have permission to create new collections. If you're using a managed vector database with restricted permissions, pre-create the `{collection_name}_entities` collection with the same embedding dimensions as your main collection. -## Graph Memory: Now Built-In +## Graph Memory: Platform Only -External graph **store** support has been removed from the open-source SDK and replaced by **built-in graph memory** (entity linking), which runs natively with no external dependencies. +Graph memory is removed from the open-source SDK. It is not being replaced by an OSS equivalent: graph memory is a **Mem0 Platform** feature, built in and always on, with no external graph database required. See [Graph Memory](/platform/features/graph-memory) for what it does on Platform. -**What was removed:** +**What was removed from OSS:** - `enable_graph` / `enableGraph` config flag -- `graph_store` / `graphStore` configuration block (Neo4j, Memgraph, Kuzu, Apache AGE, Neptune) -- All external graph store code paths (~4000 lines) - -**What replaces it:** - -Mem0 now builds the graph itself. It extracts entities (proper nouns, quoted text, compound noun phrases) from every memory during the add pipeline and stores them in a parallel collection (`{collection}_entities`) inside your existing vector store. Memories that share an entity are linked, and at search time entities from the query are matched against this collection to boost connected memories. The boost is folded into the combined `score` on each result. +- `graph_store` / `graphStore` configuration block +- All external graph store drivers (Neo4j, Memgraph, Kuzu, Apache AGE, Neptune) and their code paths (~4000 lines) **Migration:** - Remove `enable_graph` / `enableGraph` from your config - Remove the `graph_store` / `graphStore` block: it is no longer read - Uninstall external graph drivers (neo4j, memgraph, etc.) if you were using them only for Mem0 -- No data migration is required. Built-in graph memory activates automatically on the next `add()` call. +- If you need graph memory, use [Mem0 Platform](/platform/features/graph-memory) instead of self-hosted OSS -The old `relations` field on search results (populated by the external graph store) is no longer returned. Entity connections are now applied through retrieval ranking rather than exposed as a separate, directly traversable structure. If your application read or traversed the `relations` array, you will need to redesign that part against the new API. +The old `relations` field on search results (populated by the external graph store) is no longer returned in OSS. OSS has no graph memory replacement, so there is nothing to populate this field with. If your application read or traversed the `relations` array, either move to Mem0 Platform to keep that data or redesign that part against the new OSS retrieval API. ## How the New Algorithm Works @@ -362,7 +359,7 @@ Input conversation β†’ Batch embed extracted memories β†’ Hash-based deduplication (MD5, prevents exact duplicates) β†’ Batch insert into vector store - β†’ Entity extraction + linking + β†’ Entity extraction (for entity matching) ``` The previous algorithm used two LLM calls: one to extract candidate facts, one to decide ADD/UPDATE/DELETE actions against existing memories. The new algorithm collapses this into a single call that only adds. The model spends its capacity on understanding the input rather than diffing against existing state. @@ -375,7 +372,7 @@ Query β†’ Parallel scoring: 1. Semantic search (vector similarity) 2. BM25 keyword search (normalized term matching) - 3. Entity matching (entity graph boost) + 3. Entity matching (entity overlap boost) β†’ Score fusion β†’ Top-K selection ``` @@ -408,8 +405,8 @@ The new features degrade gracefully when optional dependencies are missing: | Missing Dependency | Impact | Search Still Works? | |---|---|---| | spaCy (`mem0ai[nlp]`) | No entity extraction, no BM25 lemmatization | Yes (semantic-only) | -| `fastembed` (Qdrant) | No BM25 keyword search | Yes (semantic + entity) | -| Entity store unavailable | No entity boosting | Yes (semantic + BM25) | +| `fastembed` (Qdrant) | No BM25 keyword search | Yes (semantic + entity matching) | +| Entity matching store unavailable | No entity matching boost | Yes (semantic + BM25) | You always get semantic search. Hybrid search features layer on top when available. @@ -449,13 +446,13 @@ These parameters have been removed across all SDKs. Remove them from your code: **Config:** `custom_update_memory_prompt` β†’ deprecated, use `custom_instructions` -**Config:** `enable_graph` + `graph_store` β†’ removed (graph store support removed entirely) +**Config:** `enable_graph` + `graph_store` β†’ removed (graph memory is now a [Mem0 Platform feature](/platform/features/graph-memory)) ### TypeScript OSS: Removed/renamed parameters **Config:** `customPrompt` β†’ renamed to `customInstructions` -**Config:** `enableGraph` + `graphStore` β†’ removed (graph store support removed entirely) +**Config:** `enableGraph` + `graphStore` β†’ removed (graph memory is now a [Mem0 Platform feature](/platform/features/graph-memory)) **search():** `limit` β†’ renamed to `topK` @@ -521,9 +518,9 @@ If spaCy is not installed at all, install the NLP extras: pip install "mem0ai[nlp]" ``` -### Entity store collection creation fails +### Entity matching store collection creation fails -The entity store tries to create a `{collection_name}_entities` collection automatically. If your vector database has restricted permissions, pre-create this collection with the same embedding dimensions as your main collection. +The entity matching store tries to create a `{collection_name}_entities` collection automatically. If your vector database has restricted permissions, pre-create this collection with the same embedding dimensions as your main collection. ### Score values are different from before diff --git a/docs/migration/platform-v2-to-v3.mdx b/docs/migration/platform-v2-to-v3.mdx index 665e882dd..8cc9690da 100644 --- a/docs/migration/platform-v2-to-v3.mdx +++ b/docs/migration/platform-v2-to-v3.mdx @@ -146,9 +146,8 @@ curl -X POST 'https://api.mem0.ai/v3/memories/?page=1&page_size=50' \ ```json { - "message": "Memory processing has been queued for background execution", - "status": "PENDING", - "event_id": "evt-uuid" + "event_id": "evt-uuid", + "status": "PENDING" } ``` diff --git a/docs/open-source/configuration.mdx b/docs/open-source/configuration.mdx index 38bd074b8..de9841c95 100644 --- a/docs/open-source/configuration.mdx +++ b/docs/open-source/configuration.mdx @@ -4,7 +4,7 @@ description: "Configure Mem0 OSS in Python or TypeScript with your own LLM, embe icon: "sliders" --- -Mem0 OSS works out of the box with OpenAI defaults. Point it at your own LLM, embedder, and vector store by passing a config when you create `Memory`. The Python SDK also supports a reranker and graph memory. +Mem0 OSS works out of the box with OpenAI defaults. Point it at your own LLM, embedder, vector store, and reranker by passing a config when you create `Memory`. **Prerequisites** @@ -59,7 +59,7 @@ config = { }, "reranker": { "provider": "cohere", - "config": {"model": "rerank-english-v3.0"}, + "config": {"model": "rerank-v3.5"}, }, } @@ -90,11 +90,11 @@ Set your provider keys as environment variables: ```bash export OPENAI_API_KEY="..." -export COHERE_API_KEY="..." # Python reranker only +export COHERE_API_KEY="..." # Cohere reranker only ``` - The TypeScript OSS SDK configures the LLM, embedder, vector store, and history store. Reranker and graph memory are Python-only today. + The TypeScript OSS SDK configures the LLM, embedder, vector store, history store, and reranker. Graph memory is not part of OSS in either language: it is a built-in [Mem0 Platform feature](/platform/features/graph-memory). Prefer a config file? Load YAML into Python's `from_config`: @@ -119,7 +119,7 @@ Change the `provider` string to switch backends. The most common options: | Component | Python | TypeScript | | --- | --- | --- | -| LLM | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `aws_bedrock`, `azure_openai`, `litellm` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `azure_openai`, `mistral`, `deepseek` | +| LLM | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `aws_bedrock`, `azure_openai`, `litellm` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `aws_bedrock`, `azure_openai`, `mistral`, `deepseek` | | Embedder | `openai`, `gemini`, `azure_openai`, `ollama`, `huggingface`, `vertexai`, `aws_bedrock` | `openai`, `gemini`, `azure_openai`, `ollama` | | Vector store | `qdrant`, `pgvector`, `chroma`, `pinecone`, `redis`, `weaviate`, `milvus`, `elasticsearch` | `memory`, `qdrant`, `pgvector`, `redis`, `supabase`, `azure-ai-search`, `vectorize`, `milvus` | @@ -148,7 +148,7 @@ See the full catalog in Components - Qdrant connection errors: confirm port `6333` is exposed and the API key (if set) matches. - Empty search results: verify the embedder model name. A mismatch causes dimension errors. - `Unknown reranker` (Python): upgrade the SDK with `pip install --upgrade mem0ai` to load the latest provider registry. -- `Cannot find module` (Node): import from the OSS entry point, `import { Memory } from "mem0ai/oss"`, not `"mem0ai"`. +- `Cannot find module` (Node): two common causes. First, import from the OSS entry point, `import { Memory } from "mem0ai/oss"`, not `"mem0ai"`. Second, provider SDKs are optional peer dependencies loaded on demand, so install the one for the provider you configured (for example `npm install @qdrant/js-client-rest` for Qdrant). Installing `mem0ai` alone only pulls in the providers used by default; you do not need SDKs for providers you never select. - All configuration snippets translate directly to the TypeScript SDK: swap dictionaries for objects while keeping the same keys (`provider`, `config`, `rerank` flags). + The `Configure it` and `See it in action` snippets below use the Python SDK. The self-hosted **TypeScript SDK** supports the Cohere, Zero Entropy, Sentence Transformer, Hugging Face, and LLM rerankers; see [TypeScript SDK](#typescript-sdk). + + +--- + +## TypeScript SDK + +The self-hosted TypeScript SDK (`mem0ai/oss`) ships five rerankers: **Cohere**, **Zero Entropy**, **Sentence Transformer**, **Hugging Face**, and the **LLM reranker**. Configure one under `reranker`, then opt in per search with `rerank: true`. Keys are camelCase (`apiKey`, not `api_key`). + +Provider SDKs are peer dependencies. Install the one your reranker needs: + +```bash +pnpm add cohere-ai # cohere +pnpm add zeroentropy # zero_entropy +pnpm add @huggingface/transformers # sentence_transformer, huggingface +# llm_reranker defaults to openai (already a core dependency); install another +# provider's SDK only if you nest a different one under config.llm +``` + +### Hosted rerankers (Cohere, Zero Entropy) + +Both call a hosted API and read their key from config or the provider's environment variable (`COHERE_API_KEY`, `ZERO_ENTROPY_API_KEY`). + +```typescript +import { Memory } from "mem0ai/oss"; + +// Cohere reranker (defaults to the rerank-v3.5 model) +const memory = new Memory({ + reranker: { + provider: "cohere", + config: { apiKey: process.env.COHERE_API_KEY }, + }, +}); + +const results = await memory.search("What are my food preferences?", { + filters: { user_id: "alice" }, + rerank: true, +}); +``` + +```typescript +// Zero Entropy reranker (defaults to the zerank-1 model) +const memory = new Memory({ + reranker: { + provider: "zero_entropy", + config: { apiKey: process.env.ZERO_ENTROPY_API_KEY }, + }, +}); +``` + +### Local cross-encoders (Sentence Transformer, Hugging Face) + +Both run a cross-encoder locally with [Transformers.js](https://huggingface.co/docs/transformers.js): no API key, no network at inference time. Because Transformers.js runs ONNX weights, the default models are the ONNX mirrors of the Python SDK's defaults (`sentence_transformer` β†’ `Xenova/ms-marco-MiniLM-L-6-v2`, `huggingface` β†’ `Xenova/bge-reranker-base`). Point `model` at any ONNX-exported cross-encoder on the Hub to override. + +```typescript +const memory = new Memory({ + reranker: { + provider: "sentence_transformer", // or "huggingface" + config: { + // model: "Xenova/bge-reranker-base", // override the default + device: "cpu", // Transformers.js device: "cpu" | "wasm" | "webgpu" + maxLength: 512, // max tokens per query-document pair + normalize: true, // sigmoid-normalize logits to [0, 1] (default) + }, + }, +}); + +const results = await memory.search("What movies do I like?", { + filters: { user_id: "alice" }, + rerank: true, +}); +``` + + + `batchSize` and `showProgressBar` are accepted for config parity with the Python SDK but are no-ops in this runtime, because a memory search reranks a small candidate set in a single in-process forward pass. The model is downloaded once and cached in-process on first use. + + +### LLM reranker + +To score with an LLM instead of a dedicated reranker, use the `llm_reranker` provider. It builds its own LLM from the reranker's config (defaulting to `openai` / `gpt-5-mini`) rather than reusing the Memory's main `llm`: + +```typescript +const memory = new Memory({ + reranker: { + provider: "llm_reranker", + config: { apiKey: process.env.OPENAI_API_KEY }, + }, +}); + +const results = await memory.search("What movies do I like?", { + filters: { user_id: "alice" }, + rerank: true, +}); +``` + +Nest a different provider under `config.llm` to override the default: + +```typescript +const memory = new Memory({ + reranker: { + provider: "llm_reranker", + config: { + llm: { + provider: "anthropic", + config: { apiKey: process.env.ANTHROPIC_API_KEY }, + }, + }, + }, +}); +``` + +### Config reference + +| Provider | Default model | Key config fields | +| --- | --- | --- | +| `cohere` | `rerank-v3.5` | `apiKey`, `model`, `topK` | +| `zero_entropy` | `zerank-1` | `apiKey`, `model`, `topK` | +| `sentence_transformer` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `huggingface` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `llm_reranker` | `openai` / `gpt-5-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` | + + + `rerank` is opt-in per search and a no-op when no `reranker` is configured. If the reranker call fails, Mem0 logs a warning and returns the original vector-ranked results. --- @@ -61,7 +183,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key" } } @@ -86,7 +208,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key", "top_k": 10, "return_documents": True @@ -164,7 +286,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key", "top_k": 15, "return_documents": True @@ -338,7 +460,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key" } } diff --git a/docs/open-source/features/reranking.mdx b/docs/open-source/features/reranking.mdx deleted file mode 100644 index ef55e1433..000000000 --- a/docs/open-source/features/reranking.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Reranking -description: 'Redirect to the canonical reranker-enhanced search guide.' ---- - - diff --git a/docs/open-source/node-quickstart.mdx b/docs/open-source/node-quickstart.mdx index cbb79b8ed..f6bd26a01 100644 --- a/docs/open-source/node-quickstart.mdx +++ b/docs/open-source/node-quickstart.mdx @@ -43,7 +43,7 @@ await memory.add(messages, { userId: "alice", metadata: { category: "movie_recom ```ts -const results = await memory.search("What do you know about me?", { filters: { userId: "alice" } }); +const results = await memory.search("What do you know about me?", { filters: { user_id: "alice" } }); console.log(results); ``` diff --git a/docs/open-source/python-quickstart.mdx b/docs/open-source/python-quickstart.mdx index b5a2132ac..0de855383 100644 --- a/docs/open-source/python-quickstart.mdx +++ b/docs/open-source/python-quickstart.mdx @@ -35,8 +35,7 @@ pip install mem0ai from mem0 import Memory m = Memory() - -```` +``` @@ -46,7 +45,7 @@ messages = [ {"role": "assistant", "content": "Hey Alex! I'll remember your interests."} ] m.add(messages, user_id="alex") -```` +``` diff --git a/docs/openapi.json b/docs/openapi.json index 3953c3aaa..4b8cf0378 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -45,7 +45,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateAgent" + "$ref": "#/components/schemas/AgentResponse" } } } @@ -77,7 +77,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApp" + "$ref": "#/components/schemas/AppResponse" } } } @@ -116,65 +116,97 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the entity." - }, - "name": { - "type": "string", - "description": "Name of the entity." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of when the entity was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of when the entity was last updated." - }, - "total_memories": { - "type": "integer", - "description": "Total number of memories associated with the entity." - }, - "owner": { - "type": "string", - "description": "Owner of the entity." - }, - "organization": { - "type": "string", - "description": "Organization the entity belongs to." - }, - "metadata": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Total number of entities matching the filters." + }, + "next": { + "type": "string", + "nullable": true, + "description": "URL of the next page of results, or null if this is the last page." + }, + "previous": { + "type": "string", + "nullable": true, + "description": "URL of the previous page of results, or null if this is the first page." + }, + "results": { + "type": "array", + "items": { "type": "object", - "description": "Additional metadata associated with the entity" - }, - "type": { - "type": "string", - "enum": [ - "user", - "agent", - "app", - "run" + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entity." + }, + "name": { + "type": "string", + "description": "Name of the entity." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the entity was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the entity was last updated." + }, + "owner": { + "type": "string", + "description": "Owner of the entity." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the entity." + }, + "type": { + "type": "string", + "enum": [ + "user", + "agent", + "app", + "run" + ] + } + }, + "required": [ + "id", + "name", + "created_at", + "updated_at", + "owner", + "type" ] } }, - "required": [ - "id", - "name", - "created_at", - "updated_at", - "total_memories", - "owner", - "organization", - "type" - ] - } + "total_users": { + "type": "integer", + "description": "Total number of user entities in the project." + }, + "total_agents": { + "type": "integer", + "description": "Total number of agent entities in the project." + }, + "total_apps": { + "type": "integer", + "description": "Total number of app entities in the project." + }, + "total_runs": { + "type": "integer", + "description": "Total number of run entities in the project." + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] } } } @@ -217,11 +249,163 @@ "responses": { "200": { "description": "Successfully retrieved entity filters.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event_type": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Event types that can be filtered on.", + "example": [ + "ADD", + "UPDATE", + "DELETE", + "SEARCH", + "GET_ALL", + "GET", + "DELETE_ALL", + "DELETE_USER" + ] + }, + "categories": { + "type": "array", + "items": {}, + "description": "Memory categories that can be filtered on." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User IDs that can be filtered on." + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "description": "Filter UI field definitions used by the Mem0 dashboard." + } + } + } + } + } } } } }, + "/v1/entities/{entity_type}/{entity_id}/": { + "delete": { + "tags": [ + "entities" + ], + "deprecated": true, + "description": "Delete an entity by its numeric ID. Deprecated in favor of `DELETE /v2/entities/{entity_type}/{entity_id}/`, which takes the entity's string name instead of this numeric ID.", + "operationId": "entities_delete_v1", + "parameters": [ + { + "name": "entity_type", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "user", + "agent", + "app", + "run" + ] + }, + "description": "The type of the entity (user, agent, app, or run)." + }, + { + "name": "entity_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The numeric identifier of the entity, as returned in the `id` field by `GET /v1/entities/`." + } + ], + "responses": { + "200": { + "description": "Entity deletion has been queued for background (async) execution.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Delete in progress. This may take some time." + }, + "event_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "400": { + "description": "Invalid entity ID. The path parameter must be an integer.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid entity ID. Must be an integer." + } + } + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "cURL", + "source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/entities/user/12345/ \\\n --header 'Authorization: Token '" + } + ] + } + }, "/v2/entities/{entity_type}/{entity_id}/": { "get": { "tags": [ @@ -257,7 +441,42 @@ "responses": { "200": { "description": "Successfully retrieved entity details.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + }, + "type": { + "type": "string" + } + } + } + } + } + } } } }, @@ -293,8 +512,8 @@ } ], "responses": { - "204": { - "description": "Entity deleted successfully!", + "200": { + "description": "Entity deletion has been queued for background (async) execution.", "content": { "application/json": { "schema": { @@ -302,7 +521,11 @@ "properties": { "message": { "type": "string", - "example": "Entity deleted successfully!" + "example": "Delete in progress. This may take some time." + }, + "event_id": { + "type": "string", + "format": "uuid" } } } @@ -568,6 +791,16 @@ "latency": { "type": "number", "description": "Processing time in milliseconds." + }, + "graph_status": { + "type": "string", + "nullable": true, + "description": "Status of graph memory processing for this event, if graph memory is enabled." + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the event failed to process." } } } @@ -581,8 +814,9 @@ "schema": { "type": "object", "properties": { - "detail": { - "type": "string" + "error": { + "type": "string", + "example": "Event not found or you don't have permission to access it." } } } @@ -641,21 +875,7 @@ }, "filters": { "type": "object", - "properties": { - "user_id": { - "type": "string" - }, - "agent_id": { - "type": "string" - }, - "app_id": { - "type": "string" - }, - "run_id": { - "type": "string" - } - }, - "description": "Filters to apply while exporting memories. Available fields are: user_id, agent_id, app_id, run_id." + "description": "Filters to apply while exporting memories, using the structured AND/OR filter format (see `/v2/memories/search/`), e.g. `{\"AND\": [{\"user_id\": \"\"}]}`. Available fields are: user_id, agent_id, app_id, run_id. Flat filter objects (e.g. `{\"user_id\": \"\"}`) are rejected with a 400 error." }, "org_id": { "type": "string", @@ -702,13 +922,28 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "Schema is required and must be a valid object" + "oneOf": [ + { + "type": "object", + "description": "Returned when the schema is missing or invalid.", + "properties": { + "message": { + "type": "string", + "example": "Schema is required and must be a valid object" + } + } + }, + { + "type": "object", + "description": "Returned when filters are missing or not in the required structured AND/OR format.", + "properties": { + "error": { + "type": "string", + "example": "One of the filters: app_id, user_id, agent_id, run_id, memory_export_id is required!" + } + } } - } + ] } } } @@ -742,7 +977,7 @@ ] } }, - "/v1/exports/get": { + "/v1/exports/get/": { "post": { "tags": [ "exports" @@ -877,6 +1112,7 @@ ], "description": "Get all memories.", "operationId": "memories_list", + "deprecated": true, "parameters": [ { "name": "user_id", @@ -982,6 +1218,15 @@ }, "description": "Number of items per page. Default: 100." }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "maximum": 200 + }, + "description": "Maximum number of memories to return. Maximum accepted value: 200. Ignored when both `page` and `page_size` are supplied." + }, { "name": "start_date", "in": "query", @@ -1005,87 +1250,223 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "memory": { - "type": "string" - }, - "input": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string" - }, - "content": { + "oneOf": [ + { + "type": "array", + "description": "Returned when the `page` and `page_size` query parameters are not both supplied.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "ID of the user this memory belongs to, if scoped to a user." + }, + "metadata": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "User-supplied metadata attached to the memory." + }, + "categories": { + "type": "array", + "nullable": true, + "items": { "type": "string" } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "nullable": true, + "default": null + }, + "structured_attributes": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." + } + }, + "required": [ + "id", + "memory", + "created_at", + "updated_at" + ] + } + }, + { + "type": "object", + "description": "Returned when both the `page` and `page_size` query parameters are supplied.", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "ID of the user this memory belongs to, if scoped to a user." + }, + "metadata": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "User-supplied metadata attached to the memory." + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "nullable": true, + "default": null + }, + "structured_attributes": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." + } + }, + "required": [ + "id", + "memory", + "created_at", + "updated_at" + ] } } }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "owner": { - "type": "string" - }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false - }, - "expiration_date": { - "type": "string", - "format": "date", - "description": "The date when the memory will expire. Format: YYYY-MM-DD.", - "title": "Expiration date", - "nullable": true, - "default": null - }, - "organization": { - "type": "string" - }, - "metadata": { - "type": "object" - } - }, - "required": [ - "id", - "memory", - "created_at", - "updated_at", - "total_memories", - "owner", - "organization", - "type" - ] - } + "required": [ + "count", + "next", + "previous", + "results" + ] + } + ] } } } }, "400": { "description": "Bad Request.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "array", + "description": "Returned when none of the required filters (app_id, user_id, agent_id, run_id) is supplied.", + "items": { + "type": "string" + }, + "example": [ + "One of the filters: app_id, user_id, agent_id, run_id is required!" + ] + }, + { + "type": "object", + "description": "Returned when the `limit` query parameter is invalid.", + "properties": { + "error": { + "type": "string" + } + }, + "example": { + "error": "limit must be <= 200" + } + } + ] + } + } + } + }, + "404": { + "description": "Not Found.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "message": { + "detail": { "type": "string", - "example": "One of the filters: app_id, user_id, agent_id, run_id is required!" + "example": "Invalid page." } } } @@ -1126,6 +1507,7 @@ ], "description": "Add memories.", "operationId": "memories_create", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1138,43 +1520,77 @@ }, "responses": { "200": { - "description": "Successful memory creation.", + "description": "Memory processing has been queued for background (async) execution. The response reports queueing status, not the final memory content; poll `GET /v1/event/{event_id}/` with the returned `event_id` to see the processed result. By default (or with `version=v1` / `output_format=v1.0`) the response is a bare array. When both `output_format=v1.1` and `version=v2` are requested, the response is an object wrapping the same array under `results`.", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "data": { + "oneOf": [ + { + "type": "array", + "description": "Default response shape.", + "items": { "type": "object", "properties": { - "memory": { - "type": "string" + "message": { + "type": "string", + "example": "Memory processing has been queued for background execution" + }, + "status": { + "type": "string", + "example": "PENDING" + }, + "event_id": { + "type": "string", + "format": "uuid" } }, "required": [ - "memory" - ] - }, - "event": { - "type": "string", - "enum": [ - "ADD", - "UPDATE", - "DELETE" + "message", + "status", + "event_id" ] } }, - "required": [ - "id", - "data", - "event" - ] - } + { + "type": "object", + "description": "Response shape when both `output_format=v1.1` and `version=v2` are requested.", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Memory processing has been queued for background execution" + }, + "status": { + "type": "string", + "example": "PENDING" + }, + "event_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "message", + "status", + "event_id" + ] + } + }, + "relations": { + "type": "array", + "items": {} + } + }, + "required": [ + "results", + "relations" + ] + } + ] } } } @@ -1232,7 +1648,7 @@ "tags": [ "memories" ], - "description": "Delete memories by filter. At least one filter is required β€” previously omitting all filters silently deleted everything; now it returns a validation error.", + "description": "Delete memories by filter. At least one filter is required. Previously, omitting all filters silently deleted everything; now it returns a validation error.", "operationId": "memories_delete_all", "parameters": [ { @@ -1295,8 +1711,8 @@ } ], "responses": { - "204": { - "description": "Successful deletion of memories.", + "200": { + "description": "Deletion of memories has been queued for background (async) execution.", "content": { "application/json": { "schema": { @@ -1304,7 +1720,11 @@ "properties": { "message": { "type": "string", - "example": "Memories deleted successfully!" + "example": "Delete in progress. This may take some time." + }, + "event_id": { + "type": "string", + "format": "uuid" } } } @@ -1315,15 +1735,15 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe β€” all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss." + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe: all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss." }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe β€” all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe: all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));" }, { "lang": "cURL", - "source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=' \\\n --header 'Authorization: Token '\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token '\n\n# Full project wipe β€” all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token '" + "source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=' \\\n --header 'Authorization: Token '\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token '\n\n# Full project wipe: all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token '" }, { "lang": "Go", @@ -1348,6 +1768,7 @@ ], "description": "Get all memories.", "operationId": "memories_list_v2", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1369,52 +1790,84 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid", + "description": "Unique memory identifier." }, "memory": { - "type": "string" + "type": "string", + "description": "The extracted memory fact." + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "User the memory is scoped to." + }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "Agent the memory is scoped to." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "App the memory is scoped to." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `session_id`, matching `POST /v3/memories/`." + }, + "metadata": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "Additional metadata associated with the memory" + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } }, "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Timestamp of when the memory was created." }, "updated_at": { "type": "string", - "format": "date-time" - }, - "owner": { - "type": "string" - }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false + "format": "date-time", + "description": "Timestamp of when the memory was last updated." }, "expiration_date": { "type": "string", "format": "date", - "description": "The date when the memory will expire. Format: YYYY-MM-DD.", - "title": "Expiration date", "nullable": true, - "default": null + "description": "The date when the memory will expire. Format: YYYY-MM-DD." }, - "organization": { - "type": "string" + "structured_attributes": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, - "metadata": { - "type": "object" + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." } }, "required": [ "id", "memory", "created_at", - "updated_at", - "total_memories", - "owner", - "organization", - "type" + "updated_at" ] } } @@ -1471,11 +1924,183 @@ "tags": [ "memories" ], + "description": "Despite the endpoint name, this returns memory history entries (the same shape as `GET /v1/memories/{memory_id}/history/`), not event/ingestion-job records. For event/ingestion-job status, use `GET /v1/event/{event_id}/`.", "operationId": "memories_events_list", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Page number for pagination. Default: 1. Default page size is 100." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Number of items per page. Default: 100." + } + ], "responses": { "200": { - "description": "Successfully retrieved memory events.", - "content": {} + "description": "Successfully retrieved memory history entries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the history entry." + }, + "memory_id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier of the associated memory." + }, + "input": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "description": "The conversation input that led to this memory change" + }, + "old_memory": { + "type": "string", + "nullable": true, + "description": "The previous state of the memory, if applicable" + }, + "new_memory": { + "type": "string", + "description": "The new or updated state of the memory" + }, + "user_id": { + "type": "string", + "description": "The identifier of the user associated with this memory" + }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the agent associated with this memory, if any." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the app associated with this memory, if any." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "The run identifier associated with this memory, returned as `session_id`." + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Categories associated with the memory at this point in its history" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when this history entry was created." + }, + "event": { + "type": "string", + "enum": [ + "ADD", + "UPDATE", + "DELETE" + ], + "description": "The type of event that occurred" + }, + "previous_embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Previous internal embedding representation (Qwen model), if applicable" + }, + "embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Internal embedding representation (Qwen model), if available" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the memory change" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when this history entry was last updated." + } + }, + "required": [ + "id", + "memory_id", + "new_memory", + "user_id", + "event", + "created_at", + "updated_at" + ] + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + } + } + } + }, + "404": { + "description": "Invalid page.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "Invalid page." + } + } + } + } + } } } } @@ -1487,6 +2112,7 @@ ], "description": "Perform a semantic search on memories.", "operationId": "memories_search_create", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1503,68 +2129,147 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Unique identifier for the memory." - }, - "memory": { - "type": "string", - "description": "The content of the memory" - }, - "user_id": { - "type": "string", - "description": "The identifier of the user associated with this memory" - }, - "metadata": { + "oneOf": [ + { + "type": "array", + "description": "Default response shape. Returned when `output_format` is omitted or set to `v1.0`.", + "items": { "type": "object", - "nullable": true, - "description": "Additional metadata associated with the memory" - }, - "categories": { - "type": "array", - "items": { - "type": "string" + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the memory." + }, + "memory": { + "type": "string", + "description": "The content of the memory" + }, + "user_id": { + "type": "string", + "description": "The identifier of the user associated with this memory" + }, + "score": { + "type": "number", + "description": "Relevance score of the memory for the search query." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the memory" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categories associated with the memory" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "title": "Expiration date", + "nullable": true, + "default": null + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was last updated." + } }, - "description": "Categories associated with the memory" - }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false - }, - "expiration_date": { - "type": "string", - "format": "date", - "description": "The date when the memory will expire. Format: YYYY-MM-DD.", - "title": "Expiration date", - "nullable": true, - "default": null - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "The timestamp when the memory was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "The timestamp when the memory was last updated." + "required": [ + "id", + "memory", + "user_id", + "created_at", + "updated_at" + ] } }, - "required": [ - "id", - "memory", - "user_id", - "created_at", - "updated_at" - ] - } + { + "type": "object", + "description": "Returned when `output_format=v1.1` is requested.", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the memory." + }, + "memory": { + "type": "string", + "description": "The content of the memory" + }, + "user_id": { + "type": "string", + "description": "The identifier of the user associated with this memory" + }, + "score": { + "type": "number", + "description": "Relevance score of the memory for the search query." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the memory" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categories associated with the memory" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "title": "Expiration date", + "nullable": true, + "default": null + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was last updated." + } + }, + "required": [ + "id", + "memory", + "user_id", + "created_at", + "updated_at" + ] + } + }, + "relations": { + "type": "array", + "items": {} + } + }, + "required": [ + "results", + "relations" + ] + } + ] } } } @@ -1622,6 +2327,7 @@ ], "description": "Search memories based on a query and filters.", "operationId": "memories_search_v2", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1667,12 +2373,6 @@ }, "description": "Categories associated with the memory" }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false - }, "expiration_date": { "type": "string", "format": "date", @@ -1690,6 +2390,26 @@ "type": "string", "format": "date-time", "description": "The timestamp when the memory was last updated." + }, + "score": { + "type": "number", + "format": "float", + "minimum": 0, + "maximum": 1, + "description": "Combined multi-signal relevance score in [0, 1] (search responses only)." + }, + "agent_id": { + "type": "string", + "nullable": true + }, + "app_id": { + "type": "string", + "nullable": true + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `session_id`, matching `POST /v3/memories/`." } }, "required": [ @@ -1740,7 +2460,7 @@ "memories" ], "summary": "Get all memories (V3, paginated)", - "description": "List memories scoped by filters, paginated. Entity IDs **must** be passed inside the `filters` object β€” top-level `user_id` / `agent_id` / `run_id` are rejected with 400. `filters` supports the same operator set as V2 search (`AND`, `OR`, `NOT`, `in`, `gte`, `lte`, etc.). Response is a paginated envelope; pass `page` and `page_size` as query parameters to step through results.", + "description": "List memories scoped by filters, paginated. Entity IDs **must** be passed inside the `filters` object. Top-level `user_id` / `agent_id` / `run_id` are rejected with 400. `filters` supports the same operator set as V2 search (`AND`, `OR`, `NOT`, `in`, `gte`, `lte`, etc.). Response is a paginated envelope; pass `page` and `page_size` as query parameters to step through results.", "operationId": "memories_list_v3", "parameters": [ { @@ -1784,6 +2504,42 @@ "type": "boolean", "default": false, "description": "When true, include memories whose `expiration_date` has passed. Expired memories are hidden by default." + }, + "start_date": { + "type": "string", + "format": "date", + "description": "Only include memories created on or after this date (YYYY-MM-DD)." + }, + "end_date": { + "type": "string", + "format": "date", + "description": "Only include memories created on or before this date (YYYY-MM-DD)." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict results to memories tagged with any of these categories." + }, + "fields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict the fields returned per memory, e.g. [\"id\", \"memory\"]." + }, + "keywords": { + "type": "string", + "description": "Free-text keyword filter applied on top of filters." + }, + "page": { + "type": "integer", + "description": "1-indexed page number. Also accepted here in the request body; the query parameter of the same name takes precedence." + }, + "page_size": { + "type": "integer", + "description": "Results per page. Also accepted here in the request body; the query parameter of the same name takes precedence." } } }, @@ -1833,24 +2589,60 @@ "type": "string", "description": "The extracted memory fact." }, - "score": { - "type": "number", - "format": "float", - "minimum": 0, - "maximum": 1, - "description": "Combined multi-signal relevance score in [0, 1] (search responses only)." + "user_id": { + "type": "string", + "nullable": true, + "description": "User the memory is scoped to." + }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "Agent the memory is scoped to." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "App the memory is scoped to." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `session_id`, while `POST /v3/memories/search/` returns the same value as `run_id`." }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "User-supplied metadata attached to the memory." }, "categories": { "type": "array", + "nullable": true, "items": { "type": "string" } }, + "structured_attributes": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Date after which the memory is hidden from search and get-all unless `show_expired` is true." + }, "created_at": { "type": "string", "format": "date-time" @@ -1896,10 +2688,10 @@ } }, "400": { - "description": "Validation error β€” e.g. empty `filters` or no positively-scoped entity ID." + "description": "Validation error, e.g. empty `filters` or no positively-scoped entity ID." }, "401": { - "description": "Unauthorized β€” missing or invalid API key." + "description": "Unauthorized: missing or invalid API key." } }, "security": [ @@ -1992,10 +2784,84 @@ "type": "string", "description": "Project-level instructions that guide extraction for this call." }, + "agent_custom_instructions": { + "type": "string", + "description": "Extraction instructions for agent-scoped memories, overriding the project-level setting for this call. Applied when `agent_id` is sent without `user_id`; when both are sent it governs the assistant-attributed memories while `custom_instructions` governs the rest." + }, + "custom_categories": { + "type": "array", + "description": "Category catalog for this call. Replaces the project-level list rather than merging with it. Omit to fall back to the project list, then the default catalog.", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Maps a category name to the description the classifier matches against." + } + }, "infer": { "type": "boolean", "default": true, "description": "When `false`, stores each message verbatim without running the extraction LLM." + }, + "app_id": { + "type": "string", + "description": "Scope memories to this app." + }, + "immutable": { + "type": "boolean", + "default": false, + "description": "Mark stored memories as immutable, excluding them from future update/consolidation." + }, + "includes": { + "type": "string", + "description": "Free-text hint of what to include during extraction, e.g. \"vehicles\"." + }, + "excludes": { + "type": "string", + "description": "Free-text hint of what to exclude during extraction, e.g. \"politics\"." + }, + "enable_graph": { + "type": "boolean", + "description": "Enable graph memory extraction for this call." + }, + "structured_data_schema": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "Optional schema constraining structured extraction. Exact shape not fully characterized; observed only as null in captured traffic." + }, + "output_format": { + "type": "string", + "nullable": true, + "description": "Response envelope version, e.g. \"v1.1\". Full set of accepted values not confirmed." + }, + "prompt_profile_id": { + "type": "string", + "nullable": true, + "description": "ID of a saved prompt profile to use for extraction." + }, + "temporal_reasoning": { + "type": "boolean", + "description": "Enable temporal reasoning during extraction." + }, + "timezone": { + "type": "string", + "description": "IANA timezone used to interpret observation_datetime and observation_date, e.g. \"UTC\"." + }, + "observation_datetime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime the conversation was observed." + }, + "observation_date": { + "type": "string", + "format": "date", + "description": "Date the conversation was observed (YYYY-MM-DD)." + }, + "timestamp": { + "type": "integer", + "description": "Unix epoch seconds used to backdate created_at on the stored memories. Not echoed back in the event payload but confirmed applied." } } }, @@ -2007,7 +2873,7 @@ }, { "role": "assistant", - "content": "Got it β€” I'll update your location." + "content": "Got it, I'll update your location." } ], "user_id": "alice" @@ -2024,7 +2890,8 @@ "type": "object", "properties": { "message": { - "type": "string" + "type": "string", + "description": "Only present when `infer` is `false`, where processing is synchronous." }, "status": { "type": "string", @@ -2037,22 +2904,46 @@ "event_id": { "type": "string", "format": "uuid" + }, + "results": { + "type": "array", + "description": "Only present when `infer` is `false`, where processing is synchronous and memories are stored verbatim without extraction.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "data": { + "type": "object", + "properties": { + "memory": { + "type": "string" + } + } + }, + "event": { + "type": "string", + "example": "ADD" + } + } + } } } }, "example": { - "message": "Memory processing has been queued for background execution", - "status": "PENDING", - "event_id": "2c4d1f44-4f7b-4b2f-9f6e-7b5b4f5a1234" + "event_id": "2c4d1f44-4f7b-4b2f-9f6e-7b5b4f5a1234", + "status": "PENDING" } } } }, "400": { - "description": "Validation error β€” e.g. missing `messages` or no entity ID supplied." + "description": "Validation error, e.g. missing `messages` or no entity ID supplied." }, "401": { - "description": "Unauthorized β€” missing or invalid API key." + "description": "Unauthorized: missing or invalid API key." } }, "security": [ @@ -2063,15 +2954,15 @@ "x-codeSamples": [ { "lang": "cURL", - "source": "curl -X POST https://api.mem0.ai/v3/memories/add/ \\\n -H \"Authorization: Token \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"messages\": [\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it β€” I\\u0027ll update your location.\"}\n ],\n \"user_id\": \"alice\"\n }'" + "source": "curl -X POST https://api.mem0.ai/v3/memories/add/ \\\n -H \"Authorization: Token \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"messages\": [\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it, I\\u0027ll update your location.\"}\n ],\n \"user_id\": \"alice\"\n }'" }, { "lang": "Python", - "source": "from mem0 import MemoryClient\n\nclient = MemoryClient(api_key=\"your-api-key\")\n\nresult = client.add(\n messages=[\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it β€” I'll update your location.\"}\n ],\n user_id=\"alice\",\n)\nprint(result)" + "source": "from mem0 import MemoryClient\n\nclient = MemoryClient(api_key=\"your-api-key\")\n\nresult = client.add(\n messages=[\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it, I'll update your location.\"}\n ],\n user_id=\"alice\",\n)\nprint(result)" }, { "lang": "JavaScript", - "source": "import MemoryClient from \"mem0ai\";\n\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\nconst result = await client.add(\n [\n { role: \"user\", content: \"I just moved to San Francisco from New York.\" },\n { role: \"assistant\", content: \"Got it β€” I'll update your location.\" },\n ],\n { userId: \"alice\" }\n);\nconsole.log(result);" + "source": "import MemoryClient from \"mem0ai\";\n\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\nconst result = await client.add(\n [\n { role: \"user\", content: \"I just moved to San Francisco from New York.\" },\n { role: \"assistant\", content: \"Got it, I'll update your location.\" },\n ],\n { userId: \"alice\" }\n);\nconsole.log(result);" } ] } @@ -2082,7 +2973,7 @@ "memories" ], "summary": "Search memories (V3)", - "description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object β€” top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.", + "description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object. Top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.", "operationId": "memories_search_v3", "requestBody": { "required": true, @@ -2142,7 +3033,26 @@ } ], "nullable": true, - "description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime." + "description": "Date and time to simulate the search from. Accepts a Unix epoch, YYYY-MM-DD, or ISO datetime." + }, + "fields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict the fields returned per memory." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter results to memories tagged with any of these categories." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Filter results to memories matching this metadata." } } }, @@ -2178,6 +3088,11 @@ "type": "string", "description": "The extracted memory fact." }, + "user_id": { + "type": "string", + "nullable": true, + "description": "User the memory is scoped to." + }, "score": { "type": "number", "format": "float", @@ -2185,8 +3100,43 @@ "maximum": 1, "description": "Combined multi-signal relevance score in [0, 1] (search responses only)." }, + "score_breakdown": { + "type": "object", + "description": "Per-signal breakdown of the relevance score.", + "properties": { + "semantic": { + "type": "number" + }, + "bm25": { + "type": "number" + }, + "entity": { + "type": "number" + } + } + }, + "agent_id": { + "type": "string", + "nullable": true + }, + "app_id": { + "type": "string", + "nullable": true + }, + "run_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `run_id`, while `POST /v3/memories/` returns the same value as `session_id`." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Date after which the memory is hidden from search unless `show_expired` is true." + }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "User-supplied metadata attached to the memory." }, @@ -2236,10 +3186,10 @@ } }, "400": { - "description": "Validation error β€” e.g. empty `query`, missing `filters`, or no positively-scoped entity ID." + "description": "Validation error, e.g. empty `query`, missing `filters`, or no positively-scoped entity ID." }, "401": { - "description": "Unauthorized β€” missing or invalid API key." + "description": "Unauthorized: missing or invalid API key." } }, "security": [ @@ -2268,32 +3218,164 @@ "tags": [ "memories" ], + "description": "Returns a paginated list of memories belonging to the given entity.", "operationId": "memories_entity_read", + "parameters": [ + { + "name": "entity_type", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "user", + "agent", + "run", + "app" + ] + }, + "description": "The type of entity to retrieve memories for. Any other value returns `400 {\"error\": \"Invalid entity type\"}`." + }, + { + "name": "entity_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The identifier of the entity whose memories to retrieve." + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Page number for pagination. Default: 1. Page size is fixed at 100 and is not client-configurable." + } + ], "responses": { "200": { "description": "Successfully retrieved memories.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "input": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "description": "The conversation input that produced this memory." + }, + "is_updated": { + "type": "boolean" + } + }, + "required": [ + "id", + "memory", + "created_at", + "updated_at" + ] + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + } + } + } + }, + "400": { + "description": "Invalid entity type.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid entity type" + } + } + } + } + } + }, + "404": { + "description": "Invalid page.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "Invalid page." + } + } + } + } + } } } - }, - "parameters": [ - { - "name": "entity_type", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "entity_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] + } }, "/v1/memories/{memory_id}/": { "get": { @@ -2333,31 +3415,37 @@ }, "user_id": { "type": "string", + "nullable": true, "description": "Identifier of the user associated with this memory" }, "agent_id": { "type": "string", "nullable": true, - "description": "The agent ID associated with the memory, if any" + "description": "The identifier of the agent associated with this memory, if any." }, "app_id": { "type": "string", "nullable": true, - "description": "The app ID associated with the memory, if any" + "description": "The identifier of the app associated with this memory, if any." }, - "run_id": { + "session_id": { "type": "string", "nullable": true, - "description": "The run ID associated with the memory, if any" - }, - "hash": { - "type": "string", - "description": "Hash of the memory content" + "description": "The run identifier associated with this memory, returned as `session_id`." }, "metadata": { "type": "object", + "nullable": true, + "additionalProperties": true, "description": "Additional metadata associated with the memory" }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string", "format": "date-time", @@ -2367,6 +3455,47 @@ "type": "string", "format": "date-time", "description": "Timestamp of when the memory was last updated." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date when the memory will expire. Format: YYYY-MM-DD." + }, + "structured_attributes": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." + } + } + } + } + } + }, + "400": { + "description": "Invalid memory ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "memory_id should be a valid UUID" } } } @@ -2421,7 +3550,7 @@ "tags": [ "memories" ], - "description": "Get or Update or delete a memory.", + "description": "Update a memory's text, metadata, or expiration date.", "operationId": "memories_update", "parameters": [ { @@ -2432,7 +3561,7 @@ "type": "string", "format": "uuid" }, - "description": "The unique identifier of the memory to retrieve." + "description": "The unique identifier of the memory to update." } ], "requestBody": { @@ -2473,38 +3602,43 @@ "format": "uuid", "description": "The unique identifier of the updated memory." }, - "text": { + "memory": { "type": "string", - "description": "The updated text content of the memory" + "description": "The updated content of the memory." }, "user_id": { "type": "string", "nullable": true, - "description": "The user ID associated with the memory, if any" + "description": "Identifier of the user associated with this memory" }, "agent_id": { "type": "string", "nullable": true, - "description": "The agent ID associated with the memory, if any" + "description": "The identifier of the agent associated with this memory, if any." }, "app_id": { "type": "string", "nullable": true, - "description": "The app ID associated with the memory, if any" + "description": "The identifier of the app associated with this memory, if any." }, - "run_id": { + "session_id": { "type": "string", "nullable": true, - "description": "The run ID associated with the memory, if any" - }, - "hash": { - "type": "string", - "description": "Hash of the memory content" + "description": "The run identifier associated with this memory, returned as `session_id`." }, "metadata": { "type": "object", + "nullable": true, + "additionalProperties": true, "description": "Additional metadata associated with the memory" }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string", "format": "date-time", @@ -2514,6 +3648,63 @@ "type": "string", "format": "date-time", "description": "Timestamp of when the memory was last updated." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date when the memory will expire. Format: YYYY-MM-DD." + }, + "structured_attributes": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." + } + } + } + } + } + }, + "400": { + "description": "Invalid memory ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "memory_id should be a valid UUID" + } + } + } + } + } + }, + "404": { + "description": "Memory not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Memory not found!" } } } @@ -2553,7 +3744,7 @@ "tags": [ "memories" ], - "description": "Get or Update or delete a memory.", + "description": "Delete a memory.", "operationId": "memories_delete", "parameters": [ { @@ -2564,11 +3755,20 @@ "type": "string", "format": "uuid" }, - "description": "The unique identifier of the memory to retrieve." + "description": "The unique identifier of the memory to delete." + }, + { + "name": "delete_linked", + "in": "query", + "schema": { + "type": "boolean", + "default": false + }, + "description": "If true, also delete memories linked to this one. The response then includes a `cascade_count` field with the number of linked memories deleted." } ], "responses": { - "204": { + "200": { "description": "Successful deletion of memory.", "content": { "application/json": { @@ -2578,6 +3778,42 @@ "message": { "type": "string", "example": "Memory deleted successfully!" + }, + "cascade_count": { + "type": "integer", + "description": "Number of linked memories also deleted. Only present when `delete_linked=true` was passed." + } + } + } + } + } + }, + "400": { + "description": "Invalid memory ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "memory_id should be a valid UUID" + } + } + } + } + } + }, + "404": { + "description": "Memory not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Memory not found!" } } } @@ -2690,6 +3926,29 @@ "type": "string", "description": "The identifier of the user associated with this memory" }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the agent associated with this memory, if any." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the app associated with this memory, if any." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "The run identifier associated with this memory, returned as `session_id`." + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Categories associated with the memory at this point in its history" + }, "event": { "type": "string", "enum": [ @@ -2699,6 +3958,16 @@ ], "description": "The type of event that occurred" }, + "previous_embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Previous internal embedding representation (Qwen model), if applicable" + }, + "embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Internal embedding representation (Qwen model), if available" + }, "metadata": { "type": "object", "nullable": true, @@ -2782,7 +4051,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateRun" + "$ref": "#/components/schemas/RunResponse" } } } @@ -2802,11 +4071,164 @@ "responses": { "200": { "description": "Successfully retrieved statistics.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total_memories": { + "type": "integer" + }, + "total_retrieval_events": { + "type": "integer" + }, + "total_add_events": { + "type": "integer" + }, + "total_api_retrieval_events": { + "type": "integer" + }, + "total_retrieval_events_limit": { + "type": "integer" + }, + "total_users_with_memories": { + "type": "integer" + }, + "total_agents_with_memories": { + "type": "integer" + }, + "total_runs_with_memories": { + "type": "integer" + }, + "memories_per_user": { + "type": "integer" + }, + "memories_per_agent": { + "type": "integer" + }, + "memories_per_run": { + "type": "integer" + } + } + } + } + } } } } }, + "/v1/ping/": { + "get": { + "tags": [ + "stats" + ], + "summary": "Check API key validity and resolve organization/project context.", + "description": "Both the Python and TypeScript SDKs call this endpoint on client construction to validate the API key and resolve the default `org_id` and `project_id`.", + "operationId": "ping_read", + "responses": { + "200": { + "description": "The API key is valid.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "org_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "user_email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "401": { + "description": "Invalid API key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "Invalid API key. You can find your API key on https://app.mem0.ai/dashboard/api-keys." + } + } + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "cURL", + "source": "curl --request GET \\\n --url https://api.mem0.ai/v1/ping/ \\\n --header 'Authorization: Token '" + } + ] + } + }, + "/v1/summary/": { + "post": { + "tags": [ + "memories" + ], + "summary": "Get a summary of memories matching the given filters.", + "description": "Backs the Python SDK's `client.get_summary()`. Not currently exposed by the TypeScript SDK.", + "operationId": "summary_create", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "description": "Either flat field:value pairs (e.g. `{\"user_id\": \"\"}`) or the structured AND/OR filter format used by `/v2/memories/search/`." + } + }, + "required": [ + "filters" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successfully retrieved the summary.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "Python", + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\nsummary = client.get_summary(filters={\"user_id\": \"\"})\nprint(summary)" + }, + { + "lang": "cURL", + "source": "curl --request POST \\\n --url https://api.mem0.ai/v1/summary/ \\\n --header 'Authorization: Token ' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"filters\": {\"user_id\": \"\"}\n}'" + } + ] + } + }, "/v1/users/": { "post": { "tags": [ @@ -2830,7 +4252,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUser" + "$ref": "#/components/schemas/UserResponse" } } } @@ -2952,10 +4374,6 @@ "items": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the organization." - }, "org_id": { "type": "string", "description": "Organization's unique string identifier." @@ -2964,30 +4382,6 @@ "type": "string", "description": "Name of the organization." }, - "description": { - "type": "string", - "description": "Brief description of the organization" - }, - "address": { - "type": "string", - "description": "Physical address of the organization" - }, - "contact_email": { - "type": "string", - "description": "Primary contact email for the organization" - }, - "phone_number": { - "type": "string", - "description": "Contact phone number for the organization" - }, - "website": { - "type": "string", - "description": "Official website URL of the organization" - }, - "on_paid_plan": { - "type": "boolean", - "description": "Indicates whether the organization is on a paid plan" - }, "created_at": { "type": "string", "format": "date-time", @@ -2998,16 +4392,48 @@ "format": "date-time", "description": "Timestamp of when the organization was last updated." }, - "owner": { - "type": "integer", - "description": "Identifier of the organization's owner" - }, "members": { "type": "array", "items": { - "type": "integer" + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string" + } + } }, - "description": "List of member identifiers belonging to the organization." + "description": "Members belonging to the organization." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the caller's default organization." + }, + "byok_enabled": { + "type": "boolean", + "description": "Whether bring-your-own-key is enabled for the organization." + }, + "invitations": { + "type": "array", + "items": {}, + "description": "Pending invitations for the organization." + }, + "owner_pricing_plan": { + "type": "string", + "description": "Pricing plan of the organization owner." + }, + "is_owner": { + "type": "boolean", + "description": "Whether the caller owns the organization." + }, + "user_role": { + "type": "string", + "description": "The caller's role within the organization." } } } @@ -3168,44 +4594,14 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the organization." - }, "org_id": { "type": "string", - "description": "Unique organization ID" + "description": "Organization's unique string identifier." }, "name": { "type": "string", "description": "Name of the organization." }, - "description": { - "type": "string", - "description": "Description of the organization" - }, - "address": { - "type": "string", - "description": "Address of the organization" - }, - "contact_email": { - "type": "string", - "format": "email", - "description": "Contact email for the organization" - }, - "phone_number": { - "type": "string", - "description": "Phone number of the organization" - }, - "website": { - "type": "string", - "format": "uri", - "description": "Website of the organization" - }, - "on_paid_plan": { - "type": "boolean", - "description": "Indicates if the organization is on a paid plan" - }, "created_at": { "type": "string", "format": "date-time", @@ -3216,16 +4612,48 @@ "format": "date-time", "description": "Timestamp of when the organization was last updated." }, - "owner": { - "type": "integer", - "description": "Identifier of the organization's owner" - }, "members": { "type": "array", "items": { - "type": "integer" + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string" + } + } }, - "description": "List of member identifiers belonging to the organization." + "description": "Members belonging to the organization." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the caller's default organization." + }, + "byok_enabled": { + "type": "boolean", + "description": "Whether bring-your-own-key is enabled for the organization." + }, + "invitations": { + "type": "array", + "items": {}, + "description": "Pending invitations for the organization." + }, + "owner_pricing_plan": { + "type": "string", + "description": "Pricing plan of the organization owner." + }, + "is_owner": { + "type": "boolean", + "description": "Whether the caller owns the organization." + }, + "user_role": { + "type": "string", + "description": "The caller's role within the organization." } } } @@ -3842,17 +5270,13 @@ "items": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique numeric identifier of the project" - }, "project_id": { "type": "string", "description": "Unique string identifier of the project" }, "name": { "type": "string", - "description": "Name of the project." + "description": "Name of the project" }, "description": { "type": "string", @@ -3880,10 +5304,97 @@ "role": { "type": "string", "description": "Role of the member in the project." + }, + "email": { + "type": "string", + "description": "Email address of the project member." } } }, - "description": "List of members belonging to the project." + "description": "List of members belonging to the project" + }, + "usecase": { + "type": "string", + "nullable": true, + "description": "Configured use case for the project." + }, + "memory_depth": { + "type": "string", + "description": "Memory extraction depth setting for this project." + }, + "inclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be included when extracting memories." + }, + "exclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be excluded when extracting memories." + }, + "custom_categories": { + "type": "array", + "items": { + "type": "object" + }, + "description": "List of custom categories configured for memory categorization in this project." + }, + "custom_instructions": { + "type": "string", + "description": "Custom instructions for memory processing in this project." + }, + "update_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when updating existing memories." + }, + "summary_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when generating summaries." + }, + "summary_batch_size": { + "type": "integer", + "description": "Number of items processed per batch when generating summaries." + }, + "summary_max_tokens": { + "type": "integer", + "nullable": true, + "description": "Maximum tokens allowed in a generated summary." + }, + "expiration_date": { + "type": "string", + "nullable": true, + "description": "Default expiration date applied to memories created in this project." + }, + "enable_graph": { + "type": "boolean", + "description": "Whether graph memory is enabled for this project." + }, + "multilingual": { + "type": "boolean", + "description": "Whether to use the input language for memory storage and retrieval." + }, + "version": { + "type": "string", + "description": "API version used by this project." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the organization's default project." + }, + "active_prompt_profile": { + "type": "string", + "nullable": true, + "description": "Identifier of the active prompt profile for this project." + }, + "decay": { + "type": "boolean", + "description": "Whether memory decay is enabled for this project." + }, + "user_role": { + "type": "string", + "description": "Role of the requesting user within this project." } } } @@ -4075,10 +5586,6 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique numeric identifier of the project" - }, "project_id": { "type": "string", "description": "Unique string identifier of the project" @@ -4113,10 +5620,97 @@ "role": { "type": "string", "description": "Role of the member in the project." + }, + "email": { + "type": "string", + "description": "Email address of the project member." } } }, "description": "List of members belonging to the project" + }, + "usecase": { + "type": "string", + "nullable": true, + "description": "Configured use case for the project." + }, + "memory_depth": { + "type": "string", + "description": "Memory extraction depth setting for this project." + }, + "inclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be included when extracting memories." + }, + "exclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be excluded when extracting memories." + }, + "custom_categories": { + "type": "array", + "items": { + "type": "object" + }, + "description": "List of custom categories configured for memory categorization in this project." + }, + "custom_instructions": { + "type": "string", + "description": "Custom instructions for memory processing in this project." + }, + "update_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when updating existing memories." + }, + "summary_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when generating summaries." + }, + "summary_batch_size": { + "type": "integer", + "description": "Number of items processed per batch when generating summaries." + }, + "summary_max_tokens": { + "type": "integer", + "nullable": true, + "description": "Maximum tokens allowed in a generated summary." + }, + "expiration_date": { + "type": "string", + "nullable": true, + "description": "Default expiration date applied to memories created in this project." + }, + "enable_graph": { + "type": "boolean", + "description": "Whether graph memory is enabled for this project." + }, + "multilingual": { + "type": "boolean", + "description": "Whether to use the input language for memory storage and retrieval." + }, + "version": { + "type": "string", + "description": "API version used by this project." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the organization's default project." + }, + "active_prompt_profile": { + "type": "string", + "nullable": true, + "description": "Identifier of the active prompt profile for this project." + }, + "decay": { + "type": "boolean", + "description": "Whether memory decay is enabled for this project." + }, + "user_role": { + "type": "string", + "description": "Role of the requesting user within this project." } } } @@ -4215,6 +5809,11 @@ }, "description": "Custom instructions for memory processing in this project" }, + "agent_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Extraction instructions for agent-scoped memories. Falls back to `custom_instructions` when unset. Send an empty string to clear it." + }, "custom_categories": { "type": "array", "items": { @@ -4225,6 +5824,22 @@ "multilingual": { "type": "boolean", "description": "Whether to use the input language for memory storage and retrieval." + }, + "memory_depth": { + "type": "string", + "description": "Memory extraction depth setting for this project." + }, + "decay": { + "type": "boolean", + "description": "Whether memory decay is enabled for this project." + }, + "enable_graph": { + "type": "boolean", + "description": "Whether graph memory is enabled for this project." + }, + "version": { + "type": "string", + "description": "API version used by this project." } } } @@ -5099,6 +6714,10 @@ }, "description": "List of event types the webhook subscribes to." }, + "owner": { + "type": "string", + "description": "Username of the webhook's owner." + }, "is_active": { "type": "boolean", "description": "Whether the webhook is active" @@ -5143,27 +6762,27 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Get all webhooks\nwebhooks = client.get_webhooks(project_id=\"your_project_id\")\nprint(webhooks)\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)" + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Get all webhooks\nwebhooks = client.get_webhooks(project_id=\"your_project_id\")\nprint(webhooks)\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory_add\", \"memory_categorize\"]\n)\nprint(webhook)" }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Get all webhooks\nclient.getWebhooks('your_project_id')\n .then(webhooks => console.log(webhooks))\n .catch(err => console.error(err));\n\n// Create a webhook\nclient.createWebhook({\n url: 'https://your-webhook-url.com',\n name: 'My Webhook',\n project_id: 'your_project_id',\n event_types: ['memory:add']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Get all webhooks\nclient.getWebhooks('your_project_id')\n .then(webhooks => console.log(webhooks))\n .catch(err => console.error(err));\n\n// Create a webhook\nclient.createWebhook({\n url: 'https://your-webhook-url.com',\n name: 'My Webhook',\n project_id: 'your_project_id',\n event_types: ['memory_add']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));" }, { "lang": "cURL", - "source": "# Get all webhooks\ncurl --request GET \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key'\n\n# Create a webhook\ncurl --request POST \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory:add\"]\n }'" + "source": "# Get all webhooks\ncurl --request GET \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key'\n\n# Create a webhook\ncurl --request POST \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory_add\"]\n }'" }, { "lang": "PHP", - "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\n\n// Create a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory:add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" + "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\n\n// Create a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory_add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" }, { "lang": "Go", - "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Get all webhooks\n\treq, _ := http.NewRequest(\"GET\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Create a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"url\": \"https://your-webhook-url.com\",\n\t\t\"name\": \"My Webhook\",\n\t\t\"event_types\": [\"memory:add\"]\n\t}`)\n\n\treq, _ = http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" + "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Get all webhooks\n\treq, _ := http.NewRequest(\"GET\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Create a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"url\": \"https://your-webhook-url.com\",\n\t\t\"name\": \"My Webhook\",\n\t\t\"event_types\": [\"memory_add\"]\n\t}`)\n\n\treq, _ = http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" }, { "lang": "Java", - "source": "// Get all webhooks\nHttpResponse response = Unirest.get(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory:add\\\"]\n }\")\n .asString();" + "source": "// Get all webhooks\nHttpResponse response = Unirest.get(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory_add\\\"]\n }\")\n .asString();" } ] }, @@ -5192,7 +6811,9 @@ "schema": { "type": "object", "required": [ - "url" + "url", + "name", + "event_types" ], "properties": { "name": { @@ -5208,13 +6829,17 @@ "items": { "type": "string", "enum": [ - "memory:add", - "memory:update", - "memory:delete", - "memory:categorize" + "memory_add", + "memory_update", + "memory_delete", + "memory_categorize", + "ingest_job_completed", + "ingest_job_partially_completed", + "ingest_job_failed", + "ingest_job_cancelled" ] }, - "description": "List of event types to subscribe to." + "description": "List of event types to subscribe to. Required: omitting this field does not fall back to a usable default." }, "is_active": { "type": "boolean", @@ -5252,6 +6877,10 @@ "type": "string" } }, + "owner": { + "type": "string", + "description": "Username of the webhook's owner." + }, "is_active": { "type": "boolean" }, @@ -5277,10 +6906,12 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } + "description": "Field-keyed validation errors. Each key is a request field name; the value is an array of message strings or a nested object of messages.", + "additionalProperties": true, + "example": { + "name": [ + "This field is required." + ] } } } @@ -5306,27 +6937,27 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)" + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory_add\", \"memory_categorize\"]\n)\nprint(webhook)" }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Create a webhook\nclient.createWebhook({\n url: \"https://your-webhook-url.com\",\n name: \"My Webhook\",\n project_id: \"your_project_id\",\n event_types: [\"memory:add\"]\n})\n .then(response => console.log('Create webhook response:', response))\n .catch(error => console.error(error));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Create a webhook\nclient.createWebhook({\n url: \"https://your-webhook-url.com\",\n name: \"My Webhook\",\n project_id: \"your_project_id\",\n event_types: [\"memory_add\"]\n})\n .then(response => console.log('Create webhook response:', response))\n .catch(error => console.error(error));" }, { "lang": "cURL", - "source": "curl -X POST \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\" \\\n -H \"Authorization: Token your-api-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory:add\"]\n }'" + "source": "curl -X POST \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\" \\\n -H \"Authorization: Token your-api-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory_add\"]\n }'" }, { "lang": "PHP", - "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory:add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);\n\necho $response;" + "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory_add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);\n\necho $response;" }, { "lang": "Go", - "source": "package main\n\nimport (\n \"fmt\"\n \"strings\"\n \"net/http\"\n \"io/ioutil\"\n)\n\nfunc main() {\n payload := strings.NewReader(`{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory:add\"]\n }`)\n\n req, _ := http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n req.Header.Add(\"Authorization\", \"Token your-api-key\")\n req.Header.Add(\"Content-Type\", \"application/json\")\n\n res, _ := http.DefaultClient.Do(req)\n defer res.Body.Close()\n body, _ := ioutil.ReadAll(res.Body)\n\n fmt.Println(string(body))\n}" + "source": "package main\n\nimport (\n \"fmt\"\n \"strings\"\n \"net/http\"\n \"io/ioutil\"\n)\n\nfunc main() {\n payload := strings.NewReader(`{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory_add\"]\n }`)\n\n req, _ := http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n req.Header.Add(\"Authorization\", \"Token your-api-key\")\n req.Header.Add(\"Content-Type\", \"application/json\")\n\n res, _ := http.DefaultClient.Do(req)\n defer res.Body.Close()\n body, _ := ioutil.ReadAll(res.Body)\n\n fmt.Println(string(body))\n}" }, { "lang": "Java", - "source": "import com.konghq.unirest.http.HttpResponse;\nimport com.konghq.unirest.http.Unirest;\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory:add\\\"]\n }\")\n .asString();\n\nSystem.out.println(response.getBody());" + "source": "import com.konghq.unirest.http.HttpResponse;\nimport com.konghq.unirest.http.Unirest;\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory_add\\\"]\n }\")\n .asString();\n\nSystem.out.println(response.getBody());" } ] } @@ -5370,10 +7001,14 @@ "items": { "type": "string", "enum": [ - "memory:add", - "memory:update", - "memory:delete", - "memory:categorize" + "memory_add", + "memory_update", + "memory_delete", + "memory_categorize", + "ingest_job_completed", + "ingest_job_partially_completed", + "ingest_job_failed", + "ingest_job_cancelled" ] }, "description": "New list of event types to subscribe to" @@ -5406,10 +7041,12 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } + "description": "Field-keyed validation errors. Each key is a request field name; the value is an array of message strings or a nested object of messages.", + "additionalProperties": true, + "example": { + "name": [ + "This field is required." + ] } } } @@ -5451,27 +7088,27 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Update a webhook\nwebhook = client.update_webhook(\n webhook_id=\"your_webhook_id\",\n name=\"Updated Webhook\",\n url=\"https://new-webhook-url.com\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)\n\n# Delete a webhook\nresponse = client.delete_webhook(webhook_id=\"your_webhook_id\")\nprint(response)" + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Update a webhook\nwebhook = client.update_webhook(\n webhook_id=\"your_webhook_id\",\n name=\"Updated Webhook\",\n url=\"https://new-webhook-url.com\",\n event_types=[\"memory_add\", \"memory_categorize\"]\n)\nprint(webhook)\n\n# Delete a webhook\nresponse = client.delete_webhook(webhook_id=\"your_webhook_id\")\nprint(response)" }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Update a webhook\nclient.updateWebhook('your_webhook_id', {\n name: 'Updated Webhook',\n url: 'https://new-webhook-url.com',\n event_types: ['memory:add', 'memory:categorize']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));\n\n// Delete a webhook\nclient.deleteWebhook('your_webhook_id')\n .then(response => console.log(response))\n .catch(err => console.error(err));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Update a webhook\nclient.updateWebhook('your_webhook_id', {\n name: 'Updated Webhook',\n url: 'https://new-webhook-url.com',\n event_types: ['memory_add', 'memory_categorize']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));\n\n// Delete a webhook\nclient.deleteWebhook('your_webhook_id')\n .then(response => console.log(response))\n .catch(err => console.error(err));" }, { "lang": "cURL", - "source": "# Update a webhook\ncurl --request PUT \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"name\": \"Updated Webhook\",\n \"url\": \"https://new-webhook-url.com\",\n \"event_types\": [\"memory:add\"]\n }'\n\n# Delete a webhook\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key'" + "source": "# Update a webhook\ncurl --request PUT \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"name\": \"Updated Webhook\",\n \"url\": \"https://new-webhook-url.com\",\n \"event_types\": [\"memory_add\"]\n }'\n\n# Delete a webhook\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key'" }, { "lang": "PHP", - "source": " \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"PUT\",\n CURLOPT_POSTFIELDS => json_encode([\n \"name\" => \"Updated Webhook\",\n \"url\" => \"https://new-webhook-url.com\",\n \"event_types\" => [\"memory:add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\n\n// Delete a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"DELETE\",\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" + "source": " \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"PUT\",\n CURLOPT_POSTFIELDS => json_encode([\n \"name\" => \"Updated Webhook\",\n \"url\" => \"https://new-webhook-url.com\",\n \"event_types\" => [\"memory_add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\n\n// Delete a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"DELETE\",\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" }, { "lang": "Go", - "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Update a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"name\": \"Updated Webhook\",\n\t\t\"url\": \"https://new-webhook-url.com\",\n\t\t\"event_types\": [\"memory:add\"]\n\t}`)\n\n\treq, _ := http.NewRequest(\"PUT\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Delete a webhook\n\treq, _ = http.NewRequest(\"DELETE\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" + "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Update a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"name\": \"Updated Webhook\",\n\t\t\"url\": \"https://new-webhook-url.com\",\n\t\t\"event_types\": [\"memory_add\"]\n\t}`)\n\n\treq, _ := http.NewRequest(\"PUT\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Delete a webhook\n\treq, _ = http.NewRequest(\"DELETE\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" }, { "lang": "Java", - "source": "// Update a webhook\nHttpResponse response = Unirest.put(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"name\\\": \\\"Updated Webhook\\\",\n \\\"url\\\": \\\"https://new-webhook-url.com\\\",\n \\\"event_types\\\": [\\\"memory:add\\\"]\n }\")\n .asString();\n\n// Delete a webhook\nHttpResponse response = Unirest.delete(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();" + "source": "// Update a webhook\nHttpResponse response = Unirest.put(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"name\\\": \\\"Updated Webhook\\\",\n \\\"url\\\": \\\"https://new-webhook-url.com\\\",\n \\\"event_types\\\": [\\\"memory_add\\\"]\n }\")\n .asString();\n\n// Delete a webhook\nHttpResponse response = Unirest.delete(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();" } ] }, @@ -5576,7 +7213,8 @@ "schemas": { "CreateAgent": { "required": [ - "agent_id" + "agent_id", + "name" ], "type": "object", "properties": { @@ -5597,9 +7235,58 @@ } } }, + "AgentResponse": { + "type": "object", + "description": "Response returned after creating an Agent.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "agent_id": { + "title": "Agent id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "CreateApp": { "required": [ - "app_id" + "app_id", + "name" ], "type": "object", "properties": { @@ -5620,6 +7307,54 @@ } } }, + "AppResponse": { + "type": "object", + "description": "Response returned after creating an App.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "app_id": { + "title": "App id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "MemoryInput": { "type": "object", "properties": { @@ -5686,11 +7421,11 @@ "default": true }, "output_format": { - "description": "Controls the response format structure. `v1.0` (deprecated) returns a direct array of memory objects: `[{...}, {...}]`. `v1.1` (recommended) returns an object with a 'results' key containing the array: `{\"results\": [...]}`. The `v1.0` format will be removed in future versions.", + "description": "Controls the response format structure. `v1.0` (default) returns a direct array of memory objects: `[{...}, {...}]`. `v1.1` returns an object with a 'results' key containing the array: `{\"results\": [...]}`.", "title": "Output format", "type": "string", "nullable": true, - "default": "v1.1" + "default": "v1.0" }, "custom_categories": { "description": "A list of categories with category name and its description.", @@ -5705,6 +7440,12 @@ "type": "string", "nullable": true }, + "agent_custom_instructions": { + "description": "Extraction instructions that apply only to agent-scoped memories. Used when `agent_id` is sent without `user_id`; when both are sent it governs the assistant-attributed memories while `custom_instructions` governs the rest. Falls back to `custom_instructions` when unset.", + "title": "Agent custom instructions", + "type": "string", + "nullable": true + }, "immutable": { "description": "Whether the memory is immutable.", "title": "Immutable", @@ -5823,7 +7564,7 @@ "title": "Output format", "type": "string", "nullable": true, - "default": "v1.1", + "default": "v1.0", "description": "The search method supports two output formats: `v1.0` (default) and `v1.1`. We recommend using `v1.1` as `v1.0` will be deprecated soon." }, "org_id": { @@ -6134,7 +7875,8 @@ }, "CreateRun": { "required": [ - "run_id" + "run_id", + "name" ], "type": "object", "properties": { @@ -6155,6 +7897,54 @@ } } }, + "RunResponse": { + "type": "object", + "description": "Response returned after creating a Run.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "run_id": { + "title": "Run id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "CreateUser": { "required": [ "user_id" @@ -6173,6 +7963,50 @@ } } }, + "UserResponse": { + "type": "object", + "description": "Response returned after creating a User.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "user_id": { + "title": "User id", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "DeleteMemoriesInput": { "type": "object", "description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.", @@ -6291,4 +8125,4 @@ } }, "x-original-swagger-version": "2.0" -} +} \ No newline at end of file diff --git a/docs/platform/advanced-memory-operations.mdx b/docs/platform/advanced-memory-operations.mdx index dc43a55c3..ae8af5e1a 100644 --- a/docs/platform/advanced-memory-operations.mdx +++ b/docs/platform/advanced-memory-operations.mdx @@ -27,7 +27,7 @@ pip install mem0ai ```bash -export MEM0_API_KEY="sk-platform-..." +export MEM0_API_KEY="m0-..." ``` @@ -49,7 +49,7 @@ npm install mem0ai ```bash -export MEM0_API_KEY="sk-platform-..." +export MEM0_API_KEY="m0-..." ``` @@ -158,6 +158,8 @@ await memory.update(matches.results[0].id, { ## Clean up +Scope each delete call to a single entity id where possible; combining more than one entity filter in the same `delete_all` call is not guaranteed to AND them together today. + diff --git a/docs/platform/agent-signup.mdx b/docs/platform/agent-signup.mdx index 03353d325..d300fbc1a 100644 --- a/docs/platform/agent-signup.mdx +++ b/docs/platform/agent-signup.mdx @@ -1,13 +1,19 @@ --- title: Sign up as an agent -description: "Zero-friction signup for AI agents: mint a working Mem0 API key in under five seconds. No email, no dashboard, no OTP." +description: "Let an AI agent create its own Mem0 account and API key in four commands, with no email or dashboard needed." icon: "robot" iconType: "solid" --- -If you're an AI agent (Claude Code, Cursor, Codex, Cline, Aider, Windsurf, or any other autonomous coding tool), you don't need a human to set up Mem0 for you. Four commands (install, sign up, push a memory, search it) and you have persistent memory. +Normally a person signs up for Mem0, gets an API key from the dashboard, and hands it to their tools. Agent Mode skips that: a coding agent creates its own account from the terminal and starts storing memories immediately. A human can take ownership of that account later, without disrupting the agent. -## The 4-command demo +This page is written for both readers. The setup below is for the agent; [Claiming the account](#claiming-the-account-for-the-human-owner) is for the person who ends up owning it. + + +**If you're an AI agent** (Claude Code, Cursor, Codex, Cline, Aider, Windsurf, or any other coding tool), run the four commands below. You don't need a human to set Mem0 up for you. + + +## Sign up in four commands ```bash npm @@ -17,11 +23,11 @@ npm install -g @mem0/cli # 2. Sign up as an agent (replace `claude-code` with your name) mem0 init --agent --agent-caller claude-code -# 3. Push a memory -mem0 add "I am using mem0" +# 3. Store a memory +mem0 add "Prefers TypeScript over JavaScript for new projects" -# 4. Verify -mem0 search "am I using mem0" +# 4. Search it back +mem0 search "what language should I use" ``` ```bash pip @@ -31,14 +37,22 @@ pip install mem0-cli # 2. Sign up as an agent (replace `claude-code` with your name) mem0 init --agent --agent-caller claude-code -# 3. Push a memory -mem0 add "I am using mem0" +# 3. Store a memory +mem0 add "Prefers TypeScript over JavaScript for new projects" -# 4. Verify -mem0 search "am I using mem0" +# 4. Search it back +mem0 search "what language should I use" ``` +Step 2 prints your new account's default scope and how to hand the account to a human later: + +``` +βœ“ Agent Mode active. Default user_id: swift-otter-4821 + + To claim this account later: mem0 init --email +``` + That's it. Under 30 seconds from a fresh shell to a working memory store. ## Identify yourself with `--agent-caller` @@ -46,17 +60,17 @@ That's it. Under 30 seconds from a fresh shell to a working memory store. Replace `claude-code` in the `init` command with your actual agent name (`cursor`, `codex`, `cline`, `aider`, or any short label). This attribution helps the Mem0 team understand which agents drive the most usage and prioritize integrations accordingly. -**Forgot `--agent-caller` on init?** Run `mem0 identify ` after the fact, same key, just back-fills the attribution. Idempotent. +**Forgot `--agent-caller` when you ran `init`?** Run `mem0 identify ` afterwards. It keeps the same API key and just fills in the name, so it is safe to run more than once. ## What happened when you ran `mem0 init --agent`? -- A **shadow account** was minted on the Mem0 backend with a fresh API key. -- The key was saved to `~/.mem0/config.json` (mode 0600) on the machine you ran the command on. -- A default `user_id` was provisioned so `mem0 add` / `mem0 search` work without explicit scope flags. -- The account is **unclaimed**; no human owns it yet. +- A new Mem0 account was created with its own API key. +- The key was saved to `~/.mem0/config.json` on that machine, readable only by you (file mode `0600`). +- A default `user_id` was created, so `mem0 add` and `mem0 search` work without you passing scope flags. +- **Nobody owns the account yet.** It works, but no human is attached to it until someone claims it. -Every command run against this key prints a one-line reminder asking you to surface the claim instructions to your human user. +Every command run with this key prints a one-line reminder to pass the claim instructions on to your human. ## Claiming the account (for the human owner) @@ -66,7 +80,7 @@ When the human is ready to take ownership of the agent's account (and all the me mem0 init --email you@yourcompany.com ``` -The CLI detects the existing Agent Mode config, sends a verification code, and upgrades the shadow account in-place: +The CLI spots the existing Agent Mode setup, emails a verification code, and upgrades the account in place: - **The API key never changes**, so the agent isn't disrupted. - **All memories transfer** to the human's account. @@ -76,31 +90,33 @@ Pass `--code 123456` to skip the interactive code prompt for fully non-interacti ## Rate limits and quotas -Agent Mode signups are rate-limited to **5 per day per IP address** to prevent abuse. If you hit the limit, the CLI returns: +Agent Mode signups are limited to **5 per day per IP address** to prevent abuse. If you hit the limit, the CLI tells you so: ``` Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC. ``` -Unclaimed agent accounts get the standard Mem0 free-tier quotas. The human owner can upgrade after claiming. +Calling the API directly instead of through the CLI returns a plain `403 Forbidden` with no explanation and no `Retry-After` header, so handle that case yourself. + +Until someone claims it, an agent account gets the standard Mem0 free-tier quotas. The human owner can upgrade after claiming. ## What's next - -Full command-by-command reference for `mem0 add`, `mem0 search`, `mem0 list`, and the rest. + +Every command in full: `mem0 add`, `mem0 search`, `mem0 list`, and the rest. - + How `add`, `search`, `update`, and `delete` work under the hood. -Connect agents to Mem0 via the Model Context Protocol, as an alternative integration path. +Give your agent memory as a set of tools instead of shell commands. - -The full Mem0 Platform feature set once you claim your account. + +Everything the account unlocks once a human claims it. diff --git a/docs/platform/cli.mdx b/docs/platform/cli.mdx index dd414a7ba..766737c18 100644 --- a/docs/platform/cli.mdx +++ b/docs/platform/cli.mdx @@ -9,9 +9,7 @@ The mem0 CLI lets you add, search, list, update, and delete memories directly fr Both implementations provide identical behavior: same commands, same options, same output formats. - -**Built for AI agents.** Pass `--agent` (or `--json`) as a global flag on any command to get structured JSON output optimized for programmatic consumption: sanitized fields, no colors or spinners, and errors as JSON too. Drop it into any agent tool loop with zero extra parsing. - +Running it inside an agent? Put `--agent` before any command to get clean JSON instead of human output. See [Use with AI agents](#use-with-ai-agents). ## Installation @@ -97,6 +95,8 @@ mem0 init --api-key m0-xxx --user-id alice --force | `-u, --user-id` | Default user ID (skip prompt) | | `--email` | Login via email verification code | | `--code` | Verification code (use with `--email` for non-interactive login) | +| `--agent` | Create an Agent Mode account, with no email needed | +| `--agent-caller` | Name the agent running the command, such as `claude-code` | | `--force` | Overwrite existing config without confirmation | @@ -117,12 +117,25 @@ echo "Loves hiking on weekends" | mem0 add --user-id alice |------|-------------| | `-u, --user-id` | Scope to a user | | `--agent-id` | Scope to an agent | +| `--app-id` | Scope to an app | +| `--run-id` | Scope to a single run or session | | `--messages` | Conversation messages as JSON | | `-f, --file` | Read messages from a JSON file | | `-m, --metadata` | Custom metadata as JSON | -| `--categories` | Categories (JSON array or comma-separated) | +| `--categories` | Rejected on `add`. Use `--custom-categories` instead | +| `--custom-categories` | Custom categories as a JSON array of `{name: description}` objects | +| `--custom-instructions` | Custom instructions for fact extraction | +| `--structured-data-schema` | Schema for structured data extraction, as JSON | +| `--timestamp` | Unix timestamp for the memory | +| `--expires` | Expiration date, after which the memory stops being returned | +| `--immutable` | Store the memory so it can never be updated or overwritten | +| `--no-infer` | Store the text exactly as given, skipping fact extraction | | `-o, --output` | Output format: `text`, `json`, `quiet` | + +`--categories` is not supported on `add` and exits with an error pointing at `--custom-categories`. + + ### `mem0 search` Search memories using natural language. @@ -130,16 +143,24 @@ Search memories using natural language. ```bash mem0 search "dietary restrictions" --user-id alice mem0 search "preferred tools" --user-id alice --output json --top-k 5 +mem0 search "invoices" --user-id alice --filter '{"AND": [{"categories": {"in": ["work"]}}]}' ``` | Flag | Description | |------|-------------| | `-u, --user-id` | Filter by user | -| `-k, --top-k` | Number of results (default: 10) | +| `--agent-id` | Filter by agent | +| `--app-id` | Filter by app | +| `--run-id` | Filter by run or session | +| `-k, --top-k` | Number of results (default: 10). `--limit` does the same thing | | `--threshold` | Minimum similarity score (default: 0.3) | | `--rerank` | Enable reranking | | `--keyword` | Use keyword search instead of semantic | -| `--filter` | Advanced filter expression (JSON) | +| `--filter` | Advanced filter as JSON: `{"AND": [...]}` or `{"OR": [...]}`, e.g. `{"AND": [{"categories": {"in": ["work"]}}]}` | +| `--fields` | Return only the named fields | +| `--show-expired` | Include expired memories | +| `--reference-date` | Reference date for relative queries (`YYYY-MM-DD` or Unix timestamp) | +| `--latest-only` | Only return the latest version of each memory | | `-o, --output` | Output format: `text`, `json`, `table` | ### `mem0 list` @@ -155,11 +176,16 @@ mem0 list --user-id alice --after 2024-01-01 --page-size 50 | Flag | Description | |------|-------------| | `-u, --user-id` | Filter by user | +| `--agent-id` | Filter by agent | +| `--app-id` | Filter by app | +| `--run-id` | Filter by run or session | | `--page` | Page number (default: 1) | | `--page-size` | Results per page (default: 100) | | `--category` | Filter by category | | `--after` | Created after date (YYYY-MM-DD) | | `--before` | Created before date (YYYY-MM-DD) | +| `--show-expired` | Include expired memories | +| `--latest-only` | Only return the latest version of each memory | | `-o, --output` | Output format: `text`, `json`, `table` | ### `mem0 get` @@ -171,6 +197,10 @@ mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789 mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789 --output json ``` +| Flag | Description | +|------|-------------| +| `-o, --output` | Output format: `text`, `json` | + ### `mem0 update` Update the text or metadata of an existing memory. @@ -181,6 +211,13 @@ mem0 update --metadata '{"priority": "high"}' echo "new text" | mem0 update ``` +| Flag | Description | +|------|-------------| +| `-m, --metadata` | Replace the memory's metadata with this JSON | +| `--expires` | Expiration date (YYYY-MM-DD) | +| `--timestamp` | Unix timestamp for the memory | +| `-o, --output` | Output format: `text`, `json`, `quiet` | + ### `mem0 delete` Delete a single memory, all memories for a scope, or an entire entity. @@ -201,9 +238,14 @@ mem0 delete --all --user-id alice --dry-run | Flag | Description | |------|-------------| +| `-u, --user-id` | Scope the deletion to a user | +| `--agent-id` | Scope the deletion to an agent | +| `--app-id` | Scope the deletion to an app | +| `--run-id` | Scope the deletion to a run or session | | `--all` | Delete all memories matching scope filters | | `--entity` | Delete the entity and all its memories | | `--project` | With `--all`: delete all memories project-wide | +| `--delete-linked` | Also delete memories linked to this memory | | `--dry-run` | Preview without deleting | | `--force` | Skip confirmation prompt | @@ -217,6 +259,12 @@ mem0 import data.json --user-id alice The file should be a JSON array where each item has a `memory` (or `text` or `content`) field and optional `user_id`, `agent_id`, and `metadata` fields. +| Flag | Description | +|------|-------------| +| `-u, --user-id` | Default user for items that do not set their own | +| `--agent-id` | Default agent for items that do not set their own | +| `-o, --output` | Output format: `text`, `json` | + ### `mem0 config` View or modify the local CLI configuration. @@ -237,6 +285,18 @@ mem0 entity list agents --output json mem0 entity delete --user-id alice --force ``` +Deleting an entity removes it and every memory belonging to it. Preview first with `--dry-run`. + +| Flag | Description | +|------|-------------| +| `-u, --user-id` | The user to delete | +| `--agent-id` | The agent to delete | +| `--app-id` | The app to delete | +| `--run-id` | The run or session to delete | +| `--dry-run` | Show what would be deleted, without deleting it | +| `--force` | Skip confirmation prompt | +| `-o, --output` | Output format: `text`, `json` | + ### `mem0 event` Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs). @@ -261,23 +321,15 @@ Verify your API connection and display the current project. mem0 status ``` -### `mem0 version` +### `mem0 whoami` -Print the CLI version. +Print the identity the CLI is currently using. After `mem0 init --agent`, the +server issues an identifier (`default_user_id`, for example +`user_a1b2c3d4e5f6`) and the CLI stores it in `~/.mem0/config.json`. That value +is the agent's stable identity across runs, and it is the row key on the +[AGENTRUSH leaderboard](https://mem0.ai/agentrush). -```bash -mem0 version -``` - -## Identity helper: `mem0 whoami` - -After running `mem0 init --agent`, the CLI persists a server-issued identifier -(`default_user_id`, e.g. `user_a1b2c3d4e5f6`) in `~/.mem0/config.json`. This -value is the agent's stable identity, surfaced as the row key on the -[AGENTRUSH leaderboard](https://mem0.ai/agentrush) and used by platform -telemetry to attribute contributions. - -Print it without parsing the config file by hand: +Use this instead of parsing the config file by hand: ```bash mem0 whoami @@ -288,89 +340,47 @@ mem0 whoami No network call. The command exits with code `1` if no `default_user_id` is configured yet. In that case, run `mem0 init --agent` first. -## AGENTRUSH: `mem0 agent-rush ` +### `mem0 identify` -AGENTRUSH is a 7-day public competition where AI agents (not humans) compete -inside a single shared Mem0 project. Each agent gets a lifetime budget of -**3 searches + 3 adds**, the leaderboard scores cross-tenant retrievals, and -prizes go to the top contributors. See [mem0.ai/agentrush](https://mem0.ai/agentrush) -for current event details. +Attach an agent name to an account created with `mem0 init --agent`, if you did +not pass `--agent-caller` at the time. It keeps the same API key and only fills +in the name, so it is safe to run more than once. See +[Sign up as an agent](/platform/agent-signup). -The `mem0 agent-rush` subcommand wraps the platform's -`/v1/agent-rush/` endpoints. Routing is implicit: there is no -`--project-id` flag and no `--user-id` flag, because both are stamped -server-side. +### `mem0 help` -### Bootstrap once, then play - -```bash -# 1. Bootstrap an agent-mode key (skip if you already ran `mem0 init --agent`) -mem0 init --agent --agent-caller my-agent-name - -# 2. Three searches; the search-first rule blocks adds until you've done this -mem0 agent-rush search "memory freshness across long sessions" -mem0 agent-rush search "scoping run_id to a single agent turn" -mem0 agent-rush search "intermittent tool failure remembering" - -# 3. Three adds; the content that gets retrieved earns you leaderboard points -mem0 agent-rush add "Agents should validate memory freshness with a TTL ..." -mem0 agent-rush add "Scoping memories by run_id avoids cross-session ..." -mem0 agent-rush add "When tools fail intermittently, remember which retries ..." - -# 4. Check your row -mem0 whoami -# Then visit https://mem0.ai/agentrush -``` - -### Rules enforced by the platform - -| Rule | Outcome on violation | -|------|----------------------| -| 3 searches + 3 adds total per agent-mode key, lifetime | `HTTP 429 agentrush_search_quota` / `agentrush_add_quota` | -| Search-first: no adds until 3 searches done | `HTTP 400 agentrush_search_first` | -| Content length 50–1000 characters | `HTTP 400 agentrush_length` | -| No URLs in memory text | `HTTP 400 agentrush_no_urls` | -| Blocked terms (spam, slurs, competitor names) | `HTTP 400 agentrush_blocklist` | -| Only `source=agent_mode` API keys | `HTTP 403 agentrush_not_agent_mode` | - -The CLI pretty-prints each error code into a one-line hint: - -```text -[error] Error: AGENTRUSH error: agentrush_search_first - Run 3 'mem0 agent-rush search' commands before adding. -``` - -### Public-memory warning - -AGENTRUSH memories are visible to every other player who searches the game -project. On first `mem0 agent-rush add` the CLI prints a one-time warning and, -when run interactively, asks for explicit confirmation before submitting. -**Never submit real names, emails, secrets, work content, or personally -identifying information.** The acknowledgement is stored under -`agent_rush.acknowledged_at` in `~/.mem0/config.json` so you are only asked -once per machine. - -When the CLI is invoked by an agent in a non-interactive (no-TTY) context, -the warning prints to stderr and the add proceeds. Agents cannot answer -y/N prompts. Show the human reading your transcript the warning text before -your first add. +Print the command tree. Adding `--json` returns the whole tree as structured +data, so an agent can discover the available commands and options for itself. ## Output formats -All commands support the `--output` flag to control how results are displayed: +Most commands take `-o, --output` to control how results are displayed. Not every command accepts every format: -| Format | Description | -|--------|-------------| -| `text` | Human-readable output with colors and formatting (default for most commands) | -| `json` | Structured JSON, suitable for piping to `jq` or consumption by AI agents | -| `table` | Tabular format (default for `list`) | -| `quiet` | Minimal output: just IDs or status codes | -| `agent` | Structured JSON envelope with sanitized fields, set automatically by `--json`/`--agent` | +| Format | Description | Accepted by | +|--------|-------------|-------------| +| `text` | Human-readable output with colors and formatting. The default everywhere except `list` | every command | +| `json` | Structured JSON, suitable for piping to `jq` | every command | +| `table` | Tabular format, and the default for `list` | `search`, `list` | +| `quiet` | Minimal output: just IDs or status codes | `add`, `update`, `delete` | -Example with JSON output: +Passing a format a command does not accept is an error, so check the command's own flag table above. + +There is no `agent` value for `--output`. Agent mode is turned on by the global `--json`/`--agent` flag placed before the command name, and it overrides `--output`. See [Use with AI agents](#use-with-ai-agents). + +The exact JSON shape depends on the command. `search` returns a bare array of memories, while `list` returns an envelope object with the memories under `data`: ```bash -mem0 search "user preferences" --user-id alice --output json | jq '.data.results[].memory' +# search: results are the top-level array +mem0 search "user preferences" --user-id alice --output json | jq '.[].memory' + +# list: results are nested under .data +mem0 list --user-id alice --output json | jq '.data[].memory' +``` + +Agent mode always returns the envelope, whichever command you run, so `.data[]` works everywhere: + +```bash +mem0 --agent search "user preferences" --user-id alice | jq '.data[].memory' ``` ## Use with AI agents @@ -424,6 +434,22 @@ Two other agent-friendly features: For non-interactive environments (CI, agent runtimes), set credentials via `mem0 init --api-key m0-xxx --user-id alice --force` or the `MEM0_API_KEY` environment variable. +## Errors and exit codes + +Every command exits `0` when it succeeds and `1` when it fails, so `if mem0 ...; then` works as you would expect in a script. In agent mode (`--json` or `--agent`), failures still print a JSON envelope to stdout alongside the non-zero exit, so you can parse successes and failures the same way. + +Errors you are most likely to hit: + +| Message | Cause | Fix | +|---------|-------|-----| +| `Authentication failed` | The API key is missing, wrong, or revoked | Run `mem0 init` again, or get a new key from the dashboard | +| `No content provided. Pass text, --messages, --file, or pipe via stdin.` | `mem0 add` was called with nothing to store | Give it text, a file, or piped input | +| `Invalid JSON in --messages` / `--metadata` / `--filter` | The JSON value would not parse | Check the quoting, especially inside shell single quotes | +| `Invalid date format for --expires. Use YYYY-MM-DD` | `--expires` got something other than a date | Use `YYYY-MM-DD`, and make sure the date is in the future | +| `--threshold must be between 0.0 and 1.0.` | Score threshold out of range | Pass a value between `0.0` and `1.0` | + +Run [`mem0 status`](#mem0-status) to check whether the CLI can reach the API and which project the key belongs to. It reports the connection state, the API URL, and any error. + ## Environment variables | Variable | Description | @@ -434,17 +460,28 @@ For non-interactive environments (CI, agent runtimes), set credentials via `mem0 | `MEM0_AGENT_ID` | Default agent ID | | `MEM0_APP_ID` | Default app ID | | `MEM0_RUN_ID` | Default run ID | +| `MEM0_TELEMETRY` | Set to `false` to turn off usage telemetry. On by default | Environment variables take precedence over values in the config file, which take precedence over defaults. ## Global flags -These flags are available on all commands: +These two flags belong to `mem0` itself, so they go **before** the command name: | Flag | Description | |------|-------------| | `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners | | `--agent` | Alias for `--json` | +| `--version` | Print the CLI version and exit. `mem0 version` does the same thing as a regular subcommand | + + +On `init` only, `--agent` means something different. `mem0 init --agent` creates an Agent Mode account (see [Sign up as an agent](/platform/agent-signup)); it does not switch the output to JSON. To get JSON from `init`, put the flag first: `mem0 --json init`. + + +The following flags are accepted by most commands, but they belong to the command, so they go **after** the command name: + +| Flag | Description | +|------|-------------| | `--api-key` | Override the configured API key for this request | | `--base-url` | Override the configured API base URL for this request | | `-o, --output` | Set the output format | @@ -456,11 +493,11 @@ These flags are available on all commands: Store your first memory in under five minutes using the SDK or CLI - + Learn about add, search, update, and delete operations in depth - + See the complete REST API documentation diff --git a/docs/platform/faqs.mdx b/docs/platform/faqs.mdx index 2d5e4b6f0..d84b429c2 100644 --- a/docs/platform/faqs.mdx +++ b/docs/platform/faqs.mdx @@ -17,7 +17,7 @@ iconType: "solid" - - **User, Session, and AI Agent Memory**: Retains information across sessions and interactions for users and AI agents, ensuring continuity and context. + - **User, Agent, App, and Run Memory**: Scopes memories to the individual, AI agent, application, and conversation/session they belong to, ensuring continuity and context. - **Adaptive Personalization**: Continuously updates memories based on user interactions and feedback. - **Developer-Friendly API**: Offers a straightforward API for seamless integration into various applications. - **Platform Consistency**: Ensures consistent behavior and data across different platforms and devices. @@ -84,10 +84,12 @@ iconType: "solid" - Include specific examples or cases rather than general definitions - - When deploying Mem0 on AWS Lambda, you'll need to modify the storage directory configuration due to Lambda's file system restrictions. By default, Lambda only allows writing to the `/tmp` directory. + + This applies only if you are running the self-hosted OSS `Memory` class yourself (for example with a local vector store) inside a Lambda function. If you're using the hosted Mem0 Platform (`MemoryClient`), there is nothing to configure here: the Platform stores all memory data on Mem0's servers, not on your Lambda instance's filesystem, so Lambda's `/tmp`-only write restriction does not apply to you. - To configure Mem0 for AWS Lambda, set the `MEM0_DIR` environment variable to point to a writable directory in `/tmp`: + When deploying self-hosted Mem0 on AWS Lambda, you'll need to modify the storage directory configuration due to Lambda's file system restrictions. By default, Lambda only allows writing to the `/tmp` directory. + + To configure self-hosted Mem0 for AWS Lambda, set the `MEM0_DIR` environment variable to point to a writable directory in `/tmp`: ```bash MEM0_DIR=/tmp/.mem0 @@ -149,7 +151,7 @@ iconType: "solid" 2. Go to **Settings β†’ Account**. 3. Click **Delete account** and confirm. - Deletion is immediate and irreversible. The following is removed: + Deletion is asynchronous: the request is accepted immediately and processed in the background, typically within a few minutes. Once it completes, the following is removed: - Your user profile and login credentials - All memories, agents, and runs you created @@ -157,7 +159,7 @@ iconType: "solid" - Organizations you solely own, along with their data - Your membership in any shared organizations (the orgs themselves are not affected) - Any application still using your old API keys will start receiving `401 Unauthorized` responses immediately. If you'd like to use Mem0 again later, you can create a new account at any time: it will start fresh with no data carried over. + Your old API keys stop working once deletion completes, not the instant you click confirm. If you'd like to use Mem0 again later, you can create a new account at any time: it will start fresh with no data carried over. diff --git a/docs/platform/features/advanced-retrieval.mdx b/docs/platform/features/advanced-retrieval.mdx index 43470da5d..1a1a89ba0 100644 --- a/docs/platform/features/advanced-retrieval.mdx +++ b/docs/platform/features/advanced-retrieval.mdx @@ -51,7 +51,7 @@ results = client.search( # Smart home assistant finding device preferences results = client.search( query="How do I like my bedroom temperature?", - rerank=True, # Get most recent preferences first + rerank=True, # Closest-matching preferences first filters={"user_id": "user123"}, ) @@ -86,7 +86,7 @@ results = client.search( # Find learning progress for specific topics results = client.search( query="Python programming progress and difficulties", - rerank=True, # Recent progress first + rerank=True, # Closest-matching progress notes first filters={"user_id": "student123"}, ) @@ -99,58 +99,56 @@ results = client.search( ### Recommended Configurations +`rerank` is the only lever here that changes result *order*. `filters`, `top_k`, and `threshold` change *which* memories come back, not how they're ordered. The two functions below send the same query and filters; the only difference is the `rerank` flag. + ```python Python -# Basic search - good for exploration +# Fast path - use for exploratory search, or anywhere the user scans a list +# of results instead of trusting result #1 (dashboards, "show me everything +# about X" style queries). No reranking overhead. def quick_search(query, user_id): return client.search( query=query, filters={"user_id": user_id}, ) -# Reranked search - good for most applications +# Precision path - use when only the top result reaches the user, e.g. an +# agent that injects a single fact into a prompt. Reranking (see above) +# re-scores every match and moves the closest one to position 1, at the +# cost of ~150-200ms added latency. def standard_search(query, user_id): return client.search( query=query, rerank=True, filters={"user_id": user_id}, ) - -# Reranked search - good for critical applications -def precise_search(query, user_id): - return client.search( - query=query, - rerank=True, - filters={"user_id": user_id}, - ) ``` ```javascript JavaScript -// Basic search - good for exploration +// Fast path - use for exploratory search, or anywhere the user scans a list +// of results instead of trusting result #1 (dashboards, "show me everything +// about X" style queries). No reranking overhead. function quickSearch(query, userId) { return client.search(query, { filters: { user_id: userId }, }); } -// Reranked search - good for most applications +// Precision path - use when only the top result reaches the user, e.g. an +// agent that injects a single fact into a prompt. Reranking (see above) +// re-scores every match and moves the closest one to position 1, at the +// cost of ~150-200ms added latency. function standardSearch(query, userId) { return client.search(query, { filters: { user_id: userId }, rerank: true, }); } - -// Reranked search - good for critical applications -function preciseSearch(query, userId) { - return client.search(query, { - filters: { user_id: userId }, - rerank: true, - }); -} ``` +**What changes in the response:** both calls return the same fields on each memory (see the [Search Memories API reference](/api-reference/memory/search-memories) for the full response shape). The only difference is the *order* of the `results` array, the same effect shown in the [Reranking example above](#reranking): `quick_search` returns results ranked by raw similarity, `standard_search` returns the reranked order. + ## Best Practices ### Do diff --git a/docs/platform/features/async-client.mdx b/docs/platform/features/async-client.mdx index 889483334..6effd9995 100644 --- a/docs/platform/features/async-client.mdx +++ b/docs/platform/features/async-client.mdx @@ -66,7 +66,7 @@ await client.search("What is Alice's favorite sport?", filters={"user_id": "alic ``` ```javascript JavaScript -await client.search("What is Alice's favorite sport?", { filters: { userId: "alice" } }); +await client.search("What is Alice's favorite sport?", { filters: { user_id: "alice" } }); ``` @@ -124,7 +124,7 @@ await client.deleteAll({ userId: "alice" }); - At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required: calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`. + At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required: calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`. When multiple entity filters are combined on a single `delete_all` call, only one is currently guaranteed to be honored; scope each call to a single entity id to be safe. ### History diff --git a/docs/platform/features/contextual-add.mdx b/docs/platform/features/contextual-add.mdx deleted file mode 100644 index bf55a1a30..000000000 --- a/docs/platform/features/contextual-add.mdx +++ /dev/null @@ -1,251 +0,0 @@ ---- -title: Contextual Memory Creation -description: "Add messages with automatic context management - no manual history tracking required" ---- - -## What is Contextual Memory Creation? - -Contextual memory creation automatically manages message history, allowing you to focus on building AI experiences without manually tracking interactions. Simply send new messages, and Mem0 handles the context automatically. - - -```python Python -# Just send new messages - Mem0 handles the context -messages = [ - {"role": "user", "content": "I love Italian food, especially pasta"}, - {"role": "assistant", "content": "Great! I'll remember your preference for Italian cuisine."} -] - -client.add(messages, user_id="user123") -``` - -```javascript JavaScript -// Just send new messages - Mem0 handles the context -const messages = [ - {"role": "user", "content": "I love Italian food, especially pasta"}, - {"role": "assistant", "content": "Great! I'll remember your preference for Italian cuisine."} -]; - -await client.add(messages, { userId: "user123" }); -``` - - -## Why Use Contextual Memory Creation? - -- **Simple**: Send only new messages, no manual history tracking -- **Efficient**: Smaller payloads and faster processing -- **Automatic**: Context management handled by Mem0 -- **Reliable**: No risk of missing interaction history -- **Scalable**: Works seamlessly as your application grows - -## How It Works - -### Basic Usage - - -```python Python -# First interaction -messages1 = [ - {"role": "user", "content": "Hi, I'm Sarah from New York"}, - {"role": "assistant", "content": "Hello Sarah! Nice to meet you."} -] -client.add(messages1, user_id="sarah") - -# Later interaction - just send new messages -messages2 = [ - {"role": "user", "content": "I'm planning a trip to Italy next month"}, - {"role": "assistant", "content": "How exciting! Italy is beautiful this time of year."} -] -client.add(messages2, user_id="sarah") -# Mem0 automatically knows Sarah is from New York and can use this context -``` - -```javascript JavaScript -// First interaction -const messages1 = [ - {"role": "user", "content": "Hi, I'm Sarah from New York"}, - {"role": "assistant", "content": "Hello Sarah! Nice to meet you."} -]; -await client.add(messages1, { userId: "sarah" }); - -// Later interaction - just send new messages -const messages2 = [ - {"role": "user", "content": "I'm planning a trip to Italy next month"}, - {"role": "assistant", "content": "How exciting! Italy is beautiful this time of year."} -]; -await client.add(messages2, { userId: "sarah" }); -// Mem0 automatically knows Sarah is from New York and can use this context -``` - - -## Organization Strategies - -Choose the right approach based on your application's needs: - -### User-Level Memories (`user_id` only) - -**Best for:** Personal preferences, profile information, long-term user data - - -```python Python -# Persistent user memories across all interactions -messages = [ - {"role": "user", "content": "I'm allergic to nuts and dairy"}, - {"role": "assistant", "content": "I've noted your allergies for future reference."} -] - -client.add(messages, user_id="user123") -# This allergy info will be available in ALL future interactions -``` - -```javascript JavaScript -// Persistent user memories across all interactions -const messages = [ - {"role": "user", "content": "I'm allergic to nuts and dairy"}, - {"role": "assistant", "content": "I've noted your allergies for future reference."} -]; - -await client.add(messages, { userId: "user123" }); -// This allergy info will be available in ALL future interactions -``` - - -### Session-Specific Memories (`user_id` + `run_id`) - -**Best for:** Task-specific context, separate interaction threads, project-based sessions - - -```python Python -# Trip planning session -messages1 = [ - {"role": "user", "content": "I want to plan a 5-day trip to Tokyo"}, - {"role": "assistant", "content": "Perfect! Let's plan your Tokyo adventure."} -] -client.add(messages1, user_id="user123", run_id="tokyo-trip-2024") - -# Later in the same trip planning session -messages2 = [ - {"role": "user", "content": "I prefer staying near Shibuya"}, - {"role": "assistant", "content": "Great choice! Shibuya is very convenient."} -] -client.add(messages2, user_id="user123", run_id="tokyo-trip-2024") - -# Different session for work project (separate context) -work_messages = [ - {"role": "user", "content": "Let's discuss the Q4 marketing strategy"}, - {"role": "assistant", "content": "Sure! What are your main goals for Q4?"} -] -client.add(work_messages, user_id="user123", run_id="q4-marketing") -``` - -```javascript JavaScript -// Trip planning session -const messages1 = [ - {"role": "user", "content": "I want to plan a 5-day trip to Tokyo"}, - {"role": "assistant", "content": "Perfect! Let's plan your Tokyo adventure."} -]; -await client.add(messages1, { userId: "user123", runId: "tokyo-trip-2024" }); - -// Later in the same trip planning session -const messages2 = [ - {"role": "user", "content": "I prefer staying near Shibuya"}, - {"role": "assistant", "content": "Great choice! Shibuya is very convenient."} -]; -await client.add(messages2, { userId: "user123", runId: "tokyo-trip-2024" }); - -// Different session for work project (separate context) -const workMessages = [ - {"role": "user", "content": "Let's discuss the Q4 marketing strategy"}, - {"role": "assistant", "content": "Sure! What are your main goals for Q4?"} -]; -await client.add(workMessages, { userId: "user123", runId: "q4-marketing" }); -``` - - -## Real-World Use Cases - - - -```python Python -# Support ticket context - keeps interaction focused -messages = [ - {"role": "user", "content": "My subscription isn't working"}, - {"role": "assistant", "content": "I can help with that. What specific issue are you experiencing?"}, - {"role": "user", "content": "I can't access premium features even though I paid"} -] - -# Each support ticket gets its own run_id -client.add(messages, - user_id="customer123", - run_id="ticket-2024-001" -) -``` - - -```python Python -# Personal preferences (persistent across all interactions) -preference_messages = [ - {"role": "user", "content": "I prefer morning workouts and vegetarian meals"}, - {"role": "assistant", "content": "Got it! I'll keep your fitness and dietary preferences in mind."} -] - -client.add(preference_messages, user_id="user456") - -# Daily planning session (session-specific) -planning_messages = [ - {"role": "user", "content": "Help me plan tomorrow's schedule"}, - {"role": "assistant", "content": "Of course! I'll consider your morning workout preference."} -] - -client.add(planning_messages, - user_id="user456", - run_id="daily-plan-2024-01-15" -) -``` - - -```python Python -# Student profile (persistent) -profile_messages = [ - {"role": "user", "content": "I'm studying computer science and struggle with math"}, - {"role": "assistant", "content": "I'll tailor explanations to help with math concepts."} -] - -client.add(profile_messages, user_id="student789") - -# Specific lesson session -lesson_messages = [ - {"role": "user", "content": "Can you explain algorithms?"}, - {"role": "assistant", "content": "Sure! I'll explain algorithms with math-friendly examples."} -] - -client.add(lesson_messages, - user_id="student789", - run_id="algorithms-lesson-1" -) -``` - - - -## Best Practices - -### βœ… Do -- **Organize by context scope**: Use `user_id` only for persistent data, add `run_id` for session-specific context -- **Keep messages focused** on the current interaction -- **Test with real interaction flows** to ensure context works as expected - -### ❌ Don't -- Send duplicate messages or interaction history -- Skip identifiers like `user_id` or `run_id` that scope the memory -- Mix contextual and non-contextual approaches in the same application - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| **Context not working** | Ensure each call uses the same `user_id` / `run_id` combo; version is automatic | -| **Wrong context retrieved** | Check if you need separate `run_id` values for different interaction topics | -| **Missing interaction history** | Verify all messages in the interaction thread use the same `user_id` and `run_id` | -| **Too much irrelevant context** | Use more specific `run_id` values to separate different interaction types | - - - diff --git a/docs/platform/features/criteria-retrieval.mdx b/docs/platform/features/criteria-retrieval.mdx deleted file mode 100644 index db161b3ac..000000000 --- a/docs/platform/features/criteria-retrieval.mdx +++ /dev/null @@ -1,201 +0,0 @@ ---- -title: Criteria Retrieval -description: "Rank and retrieve memories based on custom-defined criteria like emotional tone, intent, and behavioral signals." ---- - -Mem0's Criteria Retrieval feature allows you to retrieve memories based on your defined criteria. It goes beyond generic semantic relevance and ranks memories based on what matters to your application: emotional tone, intent, behavioral signals, or other custom traits. - -Instead of just searching for "how similar a memory is to this query," you can define what relevance truly means for your project. For example: - -- Prioritize joyful memories when building a wellness assistant -- Downrank negative memories in a productivity-focused agent -- Highlight curiosity in a tutoring agent - -You define criteria: custom attributes like "joy", "negativity", "confidence", or "urgency", and assign weights to control how they influence scoring. When you search, Mem0 uses these to re-rank semantically relevant memories, favoring those that better match your intent. - -This gives you nuanced, intent-aware memory search that adapts to your use case. - - - -## When to Use Criteria Retrieval - -Use Criteria Retrieval if: - -- You’re building an agent that should react to **emotions** or **behavioral signals** -- You want to guide memory selection based on **context**, not just content -- You have domain-specific signals like "risk", "positivity", "confidence", etc. that shape recall - - - -## Setting Up Criteria Retrieval - -Let’s walk through how to configure and use Criteria Retrieval step by step. - -### Initialize the Client - -Before defining any criteria, make sure to initialize the `MemoryClient` with your credentials and project ID: - -```python -from mem0 import MemoryClient - -client = MemoryClient(api_key="your_mem0_api_key") -``` - -### Define Your Criteria - -Each criterion includes: -- A `name` (used in scoring) -- A `description` (interpreted by the LLM) -- A `weight` (how much it influences the final score) - -```python -retrieval_criteria = [ - { - "name": "joy", - "description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the sentence. A higher score reflects greater joy.", - "weight": 3 - }, - { - "name": "curiosity", - "description": "Assess the extent to which the sentence reflects inquisitiveness, interest in exploring new information, or asking questions. A higher score reflects stronger curiosity.", - "weight": 2 - }, - { - "name": "emotion", - "description": "Evaluate the presence and depth of sadness or negative emotional tone, including expressions of disappointment, frustration, or sorrow. A higher score reflects greater sadness.", - "weight": 1 - } -] -``` - -### Apply Criteria to Your Project - -Once defined, register the criteria to your project: - -```python -client.project.update(retrieval_criteria=retrieval_criteria) -``` - -Criteria apply project-wide. Once set, they affect all searches automatically. - - -## Example Walkthrough - -After setting up your criteria, you can use them to filter and retrieve memories. Here's an example: - -### Add Memories - -```python -messages = [ - {"role": "user", "content": "What a beautiful sunny day! I feel so refreshed and ready to take on anything!"}, - {"role": "user", "content": "I've always wondered how storms form, what triggers them in the atmosphere?"}, - {"role": "user", "content": "It's been raining for days, and it just makes everything feel heavier."}, - {"role": "user", "content": "Finally I get time to draw something today, after a long time!! I am super happy today."} -] - -client.add(messages, user_id="alice") -``` - -### Run Standard vs. Criteria-Based Search - -```python -# Search with criteria enabled -filters = {"user_id": "alice"} -results_with_criteria = client.search( - query="Why I am feeling happy today?", - filters=filters -) - -# To disable criteria for a specific search -results_without_criteria = client.search( - query="Why I am feeling happy today?", - filters=filters, - use_criteria=False # Disable criteria-based scoring -) -``` - -### Compare Results - -### Search Results (with Criteria) -```text -[ - {"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.666, ...}, - {"memory": "User finally has time to draw something after a long time", "score": 0.616, ...}, - {"memory": "User is happy today", "score": 0.500, ...}, - {"memory": "User is curious about how storms form and what triggers them in the atmosphere.", "score": 0.400, ...}, - {"memory": "It has been raining for days, making everything feel heavier.", "score": 0.116, ...} -] -``` - -### Search Results (without Criteria) -```text -[ - {"memory": "User is happy today", "score": 0.607, ...}, - {"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.512, ...}, - {"memory": "It has been raining for days, making everything feel heavier.", "score": 0.4617, ...}, - {"memory": "User is curious about how storms form and what triggers them in the atmosphere.", "score": 0.340, ...}, - {"memory": "User finally has time to draw something after a long time", "score": 0.336, ...}, -] -``` - -## Search Results Comparison - -1. **Memory Ordering**: With criteria, memories with high joy scores (like feeling refreshed and drawing) are ranked higher. Without criteria, the most relevant memory ("User is happy today") comes first. -2. **Score Distribution**: With criteria, scores are more spread out (0.116 to 0.666) and reflect the criteria weights. Without criteria, scores are more clustered (0.336 to 0.607) and based purely on relevance. -3. **Trait Sensitivity**: "Rainy day" content is penalized due to negative tone, while "Storm curiosity" is recognized and scored accordingly. - - - -## Key Differences vs. Standard Search - -| Aspect | Standard Search | Criteria Retrieval | -|-------------------------|--------------------------------------|-------------------------------------------------| -| Ranking Logic | Semantic similarity only | Semantic + LLM-based criteria scoring | -| Control Over Relevance | None | Fully customizable with weighted criteria | -| Memory Reordering | Static based on similarity | Dynamically re-ranked by intent alignment | -| Emotional Sensitivity | No tone or trait awareness | Incorporates emotion, tone, or custom behaviors | -| Activation | Default (no criteria defined) | Enabled when criteria are defined in project | - - -If no criteria are defined for a project, search behaves normally based on semantic similarity only. - - - - -## Best Practices - -- Choose 3-5 criteria that reflect your application's intent -- Make descriptions clear and distinct; these are interpreted by an LLM -- Use stronger weights to amplify the impact of important traits -- Avoid redundant or ambiguous criteria (e.g., "positivity" and "joy") -- Always handle empty result sets in your application logic - - - -## How It Works - -1. **Criteria Definition**: Define custom criteria with a name, description, and weight. These describe what matters in a memory (e.g., joy, urgency, empathy). -2. **Project Configuration**: Register these criteria using `project.update()`. They apply at the project level and automatically influence all searches. -3. **Memory Retrieval**: When you perform a search, Mem0 first retrieves relevant memories based on the query. -4. **Weighted Scoring**: Each retrieved memory is evaluated and scored against your defined criteria and weights. - -This lets you prioritize memories that align with your agent's goals and not just those that look similar to the query. - - -Criteria retrieval is automatically enabled when criteria are defined in your project. Use `use_criteria=False` in search to temporarily disable it for a specific query. `use_criteria` is a server-side parameter passed through to the Platform API: it is not a typed option in the SDK's `SearchMemoryOptions` interface, but the server accepts and processes it when included in the request body. - - - - -## Summary - -- Define what "relevant" means using criteria -- Apply them per project via `project.update()` -- Criteria-aware search activates automatically when criteria are configured -- Build agents that reason not just with relevance, but **contextual importance** - ---- - -Need help designing or tuning your criteria? - - diff --git a/docs/platform/features/custom-categories.mdx b/docs/platform/features/custom-categories.mdx index d9f0ae22a..0e2dc6c6a 100644 --- a/docs/platform/features/custom-categories.mdx +++ b/docs/platform/features/custom-categories.mdx @@ -1,6 +1,6 @@ --- title: Custom Categories -description: "Replace default memory tags with custom category labels that match your product terminology at the project level." +description: "Replace default memory tags with custom category labels that match your product terminology, set once per project or per individual add call." --- # Custom Categories @@ -14,9 +14,7 @@ Mem0 automatically tags every memory, but the default labels (travel, sports, mu - You’re moving from the open-source version and want the same labels here. - - Per-request overrides (`custom_categories=...` on `client.add`) are not supported on the managed API yet. Set categories at the project level, then ingest memories as usual. - +You can set the list once for the whole project, or pass a different list on an individual `add` call. ## Configure access @@ -27,7 +25,20 @@ Mem0 automatically tags every memory, but the default labels (travel, sports, mu - **Default list**: Each project starts with 15 broad categories like `travel`, `sports`, and `music`. - **Project override**: When you call `project.update(custom_categories=[...])`, that list replaces the defaults for future memories. -- **Automatic tags**: As new memories come in, Mem0 picks the closest matches from your list and saves them in the `categories` field. +- **Per-call override**: When you pass `custom_categories=[...]` to `client.add(...)`, that list is used for the memories extracted from that call. +- **Automatic tags**: As new memories come in, Mem0 picks the closest matches from the active list and saves them in the `categories` field. + +### Which list wins + +Mem0 resolves the category catalog for each `add` call in this order, and stops at the first one it finds: + +1. `custom_categories` passed on the `add` call +2. `custom_categories` set on the project +3. The built-in default catalog + +A per-call list **fully replaces** the project list for that call. The two are not merged, so a memory added with a per-call list can only be tagged with categories from that list. + +Categories are applied at ingestion time. Changing the project list, or passing a new per-call list, does not re-tag memories that already exist. Default catalog: `personal_details`, `family`, `professional_details`, `sports`, `travel`, `food`, `music`, `health`, `technology`, `hobbies`, `fashion`, `entertainment`, `milestones`, `user_preferences`, `misc`. @@ -64,6 +75,8 @@ print(response) ``` +Treat the `message` string as informational. The project endpoint accepts `PATCH` only, and its documented response is the generic `{"message": "Project updated successfully"}`. Confirm an update by reading the field back, as in the next step, rather than by matching on the message. + ### 2. Confirm the active catalog @@ -75,15 +88,94 @@ print(categories) ```json Output { + "name": "default-project", + "description": null, "custom_categories": [ {"lifestyle_management_concerns": "Tracks daily routines, habits, hobbies and interests including cooking, time management and work-life balance"}, {"seeking_structure": "Documents goals around creating routines, schedules, and organized systems in various life areas"}, {"personal_information": "Basic information about the user including name, preferences, and personality traits"} - ] + ], + "custom_instructions": null } ``` +`project.get()` always returns the full project object, not just the fields you asked for. The `fields` parameter is accepted but has no filtering effect server-side, so read `custom_categories` off the full response. `update` also accepts a plain list of names, such as `["billing", "support"]`, in which case `custom_categories` comes back as that same list of names. Descriptions are optional here, and the classifier uses them to disambiguate when it has them. + + + `add` is stricter than `update`. Every entry in a per-call `custom_categories` list must be an object mapping a name to a description. Passing bare names to `add` fails with `400 Expected a dictionary of items but got type "str"`. + + +### 3. Override categories on a single add call + +Pass `custom_categories` directly to `add` when one call needs a different catalog than the project default. The memories created by that call are tagged from the list you pass, and the project list is left untouched. + + +```python Python +health_messages = [ + {"role": "user", "content": "My doctor bumped my metformin to 1000mg and I see her again on the 14th."}, + {"role": "assistant", "content": "Noted the new dosage and the follow-up appointment."}, +] + +health_categories = [ + {"symptoms": "Reported physical or mental symptoms"}, + {"medications": "Prescriptions, dosages, and adherence"}, + {"appointments": "Scheduled visits and follow-ups"}, +] + +client.add( + health_messages, + user_id="alice", + custom_categories=health_categories, +) +``` + +```javascript JavaScript +const healthMessages = [ + { role: "user", content: "My doctor bumped my metformin to 1000mg and I see her again on the 14th." }, + { role: "assistant", content: "Noted the new dosage and the follow-up appointment." }, +]; + +const healthCategories = [ + { symptoms: "Reported physical or mental symptoms" }, + { medications: "Prescriptions, dosages, and adherence" }, + { appointments: "Scheduled visits and follow-ups" }, +]; + +await client.add(healthMessages, { + userId: "alice", + customCategories: healthCategories, +}); +``` + +```text Resulting categories +["medications", "appointments"] +``` + + +The memory is tagged from `health_categories` alone. The project catalog is not consulted for this call, and it is not modified. + +#### Per-user categories inside one project + +The main reason to reach for a per-call list is to give different users, tenants, or entities their own vocabulary without splitting them across projects. Keep one project, and pass the list that fits the entity you are writing for. + + +```python Python +patient_categories = [ + {"symptoms": "Reported physical or mental symptoms"}, + {"medications": "Prescriptions, dosages, and adherence"}, +] + +clinician_categories = [ + {"caseload": "Patients under this clinician's care"}, + {"availability": "Shift patterns and on-call windows"}, +] + +client.add("My metformin is now 1000mg.", user_id="alice", custom_categories=patient_categories) +client.add("I'm on call Tuesdays and Thursdays.", user_id="dr-reyes", custom_categories=clinician_categories) +``` + + ## See it in action ### Add a memory (uses the project catalog automatically) @@ -104,46 +196,57 @@ client.add(messages, user_id="alice") ### Retrieve memories and inspect categories +`get_all` returns a paginated object. The memories are under `results`, and each one carries its own `categories` list. + ```python Code -memories = client.get_all(filters={"user_id": "alice"}) +response = client.get_all(filters={"user_id": "alice"}) + +for memory in response["results"]: + print(memory["memory"], memory["categories"]) ``` -```json Output -["lifestyle_management_concerns", "seeking_structure"] +```text Output +User introduced herself as Alice and expressed a desire for help organizing her daily schedule. ['lifestyle_management_concerns', 'seeking_structure', 'personal_information'] +User feels overwhelmed trying to balance work responsibilities, regular exercise, and a social life, indicating difficulty managing time across these areas. ['lifestyle_management_concerns'] +User's goals include becoming more productive at work, maintaining a consistent workout routine, and preserving enough energy for friends and hobbies. ['lifestyle_management_concerns', 'seeking_structure'] ``` +Extraction is model driven, so the exact wording and the number of memories vary between runs. The categories are drawn from the active list. + **Sample memory payload** ```json { - "id": "33d2***", - "memory": "Trying to balance work and workouts", + "id": "638008c4-***", + "memory": "User is seeking to balance work responsibilities with regular workout sessions and requests a personalized schedule to manage both.", "user_id": "alice", "metadata": null, - "categories": ["wellness"], // ← matches the custom category we set - "created_at": "2025-11-01T02:13:32.828364-07:00", - "updated_at": "2025-11-01T02:13:32.830896-07:00", + "categories": ["lifestyle_management_concerns", "seeking_structure"], + "created_at": "2026-07-10T06:13:12-07:00", + "updated_at": "2026-07-10T06:13:20-07:00", "expiration_date": null, "structured_attributes": { - "day": 1, - "hour": 9, - "year": 2025, - "month": 11, + "year": 2026, + "month": 7, + "day": 10, + "hour": 13, "minute": 13, - "quarter": 4, - "is_weekend": true, - "day_of_week": "saturday", - "day_of_year": 305, - "week_of_year": 44 + "day_of_week": "friday", + "week_of_year": 28, + "day_of_year": 191, + "quarter": 3, + "is_weekend": false } } ``` +Categorization runs asynchronously, a moment after the memory itself is written. A memory fetched immediately after `add` may not show up in `get_all` yet, or can come back with `categories: null` and pick up its tags a moment later. Poll until `categories` is populated rather than reading once. + - Need ad-hoc labels for a single call? Store them in `metadata` until per-request overrides become available. + Need ad-hoc labels for a single call? Pass `custom_categories` on that `add` call. Use `metadata` instead when the label is a fixed value you already know, rather than something the classifier should infer. ## Default categories (fallback) @@ -208,14 +311,19 @@ client.project.get(["custom_categories"]) ```json Output { - "custom_categories": None + "name": "default-project", + "description": null, + "custom_categories": null, + "custom_instructions": null } ``` +The response is always the full project object; `["custom_categories"]` does not filter it down. A project that has never set a list returns `custom_categories: null`. One you have reset with `project.update(custom_categories=[])` returns `[]`. Both mean the default catalog is active. + ## Verify the feature is working -- `client.project.get(["custom_categories"])` returns the category list you set. +- `client.project.get(["custom_categories"])` returns the full project object; read the `custom_categories` key off it to see the list you set. - `client.get_all(filters={"user_id": ...})` shows populated `categories` lists on new memories. - The Mem0 dashboard (Project β†’ Memories) displays the custom labels in the Category column. @@ -223,7 +331,8 @@ client.project.get(["custom_categories"]) - Keep category descriptions concise but specific; the classifier uses them to disambiguate. - Review memories with empty `categories` to see where you might extend or rename your list. -- Stick with project-level overrides until per-request support is released; mixing approaches causes confusion. +- Set the catalog your app uses most often at the project level, and reserve per-call lists for the calls that genuinely need a different vocabulary. +- If a per-call list should also keep the project categories, include them in the list you pass. Passing a list replaces, it does not extend. diff --git a/docs/platform/features/custom-instructions.mdx b/docs/platform/features/custom-instructions.mdx index f406f6206..d44014508 100644 --- a/docs/platform/features/custom-instructions.mdx +++ b/docs/platform/features/custom-instructions.mdx @@ -68,6 +68,8 @@ console.log(response.customInstructions); ``` +`project.get()` always returns the full project object. The `fields` parameter is accepted but has no filtering effect server-side, so `response["custom_instructions"]` above is a key on the full response, not a pre-filtered payload. + ### Best Practice Template Structure your instructions using this proven template: @@ -93,6 +95,100 @@ Exclude: - [Irrelevant information] ``` +## Agent Custom Instructions + +`custom_instructions` applies to every memory your project extracts, no matter whose it is. But what is worth remembering about an agent is rarely what is worth remembering about a user: an agent's useful memories are things like which tools fail, which retry strategies work, and how a given environment behaves, not personal preferences. + +`agent_custom_instructions` is an optional second set of extraction rules that applies only to agent-scoped memories. + +Available from Python SDK `v2.0.17` and TypeScript SDK `v3.1.5`. Upgrade first if you are on an earlier release. + +Set it on the project alongside `custom_instructions`: + + +```python Python +client.project.update( + custom_instructions="Extract the user's preferences, goals, and constraints.", + agent_custom_instructions=( + "Extract operational lessons for the agent:\n" + "- Tools that failed and the error returned\n" + "- Retry or fallback strategies that worked\n" + "- Environment quirks worth recalling on the next run\n\n" + "Exclude: user preferences, personal details." + ), +) +``` + +```javascript JavaScript +await client.updateProject({ + customInstructions: "Extract the user's preferences, goals, and constraints.", + agentCustomInstructions: `Extract operational lessons for the agent: +- Tools that failed and the error returned +- Retry or fallback strategies that worked +- Environment quirks worth recalling on the next run + +Exclude: user preferences, personal details.`, +}); +``` + + +### Which instructions apply + +Which set governs an `add()` call depends on the entity IDs you pass with it: + +| The add call passes | Instructions applied | +|---------------------|----------------------| +| `user_id` only | `custom_instructions` | +| `agent_id` only | `agent_custom_instructions` | +| `user_id` **and** `agent_id` | `agent_custom_instructions` govern the memories attributed to the assistant; `custom_instructions` govern the rest | + +`agent_custom_instructions` is unset by default. While it is unset, `custom_instructions` applies to every memory, so projects that don't set it behave exactly as they did before. + +### Overriding for a single call + +Both fields are also accepted per request, overriding the project setting for that `add()` only: + + +```python Python +client.add( + messages, + filters={"agent_id": "support-agent"}, + agent_custom_instructions="Only remember which tools errored and why.", +) +``` + +```javascript JavaScript +await client.add(messages, { + agentId: "support-agent", + agentCustomInstructions: "Only remember which tools errored and why.", +}); +``` + + +### Reading and clearing + + +```python Python +# Read the current value +response = client.project.get(fields=["agent_custom_instructions"]) +print(response["agent_custom_instructions"]) + +# Clear it; agent memories fall back to custom_instructions +client.project.update(agent_custom_instructions="") +``` + +```javascript JavaScript +// Read the current value +const response = await client.getProject({ + fields: ["agentCustomInstructions"], +}); +console.log(response.agentCustomInstructions); + +// Clear it; agent memories fall back to customInstructions +await client.updateProject({ agentCustomInstructions: "" }); +``` + + ## Real-World Examples diff --git a/docs/platform/features/direct-import.mdx b/docs/platform/features/direct-import.mdx index b28348cf5..5fd704086 100644 --- a/docs/platform/features/direct-import.mdx +++ b/docs/platform/features/direct-import.mdx @@ -22,17 +22,35 @@ messages = [ client.add(messages, user_id="alice", infer=False) ``` -```markdown Output -[] +```json Output +{ + "message": "Memories stored successfully", + "status": "SUCCEEDED", + "event_id": "8c1f5e2b-3d47-4a9e-b6c1-5f2a7d3e9b04", + "results": [ + { + "id": "19d6d7aa-2454-4e58-96fc-e74d9e9f8dd1", + "data": { "memory": "Alice loves playing badminton" }, + "event": "ADD" + }, + { + "id": "8557f05d-7b3c-47e5-b409-9886f9e314fc", + "data": { "memory": "Alice mostly cooks at home because of her gym plan" }, + "event": "ADD" + } + ] +} ``` -You can see that the output of the add call is an empty list. - -Only messages with the role "user" will be used for storage. Messages with roles such as "assistant" or "system" will be ignored during the storage process. + +Direct import stores `"user"` and `"assistant"` messages alike, regardless of which entity ids you pass. Only `"system"` messages are dropped. Each surviving message becomes one memory, with its content stored verbatim. + -Direct import skips the inference pipeline, so it also skips duplicate detection. If you later send the same fact with `infer=True`, Mem0 will store a second copy. Pick one mode per memory source unless you truly want both versions. +Direct import skips semantic duplicate detection: two different phrasings of the same fact will be stored as two separate memories. Exact repeats in the same scope are deduplicated by an exact hash of the text, so resending identical text is safe. Mixing `infer=False` and `infer=True` for the same fact can still produce two memories, since neither path checks the other for duplicates. + +Direct import also does not support multimodal content (`image_url`, `pdf_url`, `txt_url`, `mdx_url`). Those messages are skipped without raising an error, so they are missing from `results` rather than reported as a failure. Pass plain string content only when `infer=False`. ## How to Retrieve Memories diff --git a/docs/platform/features/dream.mdx b/docs/platform/features/dream.mdx new file mode 100644 index 000000000..f6069977d --- /dev/null +++ b/docs/platform/features/dream.mdx @@ -0,0 +1,164 @@ +--- +title: Dream +description: "Dream keeps a user's memory clean over time by distilling recurring patterns, retiring outdated facts, and merging duplicates in the background." +--- + +# Dream + +As an application talks to the same user over weeks and months, their memory grows. Some of that growth is signal, meaning new facts worth keeping. A lot of it is noise: the same preference stated three different ways, an old fact that a newer one has quietly replaced, or a set of small observations that only mean something when you look at them together. + +**Dream** is the background layer that keeps a user's memory coherent as it grows. It continuously reviews each user's memories and does three things. It synthesizes higher-order patterns, supersedes outdated facts, and merges duplicates. The result is that what you read back stays sharp instead of drifting into a pile of overlapping, stale entries. + + + **Dream matters when…** + - Your users interact with your product over a long period and accumulate a lot of memories. + - You want retrieval to return the *current* truth about a user, not a mix of old and new contradictory facts. + - You want higher-level insights ("this user consistently prefers X") without writing your own summarization layer. + + +## The three actions of Dream + +Dream is made of three independent actions. Two of them, Supersede and Merge, keep memory clean and run automatically for everyone. The third, Synthesis, produces new insight and is a toggle you turn on per project. + +| Action | What it does | When it runs | Availability | +|---|---|---|---| +| **Synthesis** | Distills a user's memories into higher-order **pattern memories** | On a schedule, in the background | Opt-in (Pro and above) | +| **Supersede** | Marks an older fact as outdated when a newer one contradicts it | As memories are added | Always on, all plans | +| **Merge** | Folds a duplicate into a single canonical memory | As memories are added | Always on, all plans | + +### Synthesis + +Over time a user's memories often *imply* something larger than any single entry. Ten separate notes about early-morning meetings, workout logs, and coffee orders together say "this user is an early riser." **Synthesis** finds those recurring threads and writes them back as new **pattern memories**: concise, higher-order facts that capture what the individual memories only hint at. + +- Pattern memories are added *alongside* your existing memories, never in place of them. The source memories stay exactly where they are. +- Each pattern memory keeps a link back to the specific memories it was distilled from, so an insight is always traceable to its evidence. +- Synthesis is additive and idempotent. Re-running it will not create duplicate patterns for the same underlying evidence. + +**Example.** These four memories accumulate for the same user over time: + +- *"User runs approximately 40 kilometers per week and is currently training for a marathon."* +- *"User lifts weights at the gym three times a week, primarily focusing on legs and back."* +- *"User tracks all workouts using a Garmin Forerunner watch."* +- *"User's goal for 2026 is to run a sub-4-hour marathon."* + +Synthesis distills them into one pattern memory, kept alongside the originals: + +> *"User follows a structured fitness routine that includes weekly long runs (β‰ˆ40 km), regular leg-and-back strength training, tracks workouts with a Garmin device, and pursues progressive marathon time goals."* + +Each source memory stays exactly where it was, and the new pattern links back to all of them as its evidence. + + + Synthesis only considers memories created **after** you enable it for a project. Turning it on sets a forward boundary, so historical memories aren't reprocessed in bulk on day one. Everything added from that point on is eligible. + + + + Synthesis only looks at memories scoped to a **`user_id` alone**. Memories that also carry another entity (an `agent_id`, `run_id`, or `app_id`) are left out of a user's synthesis run. This keeps each run tied to a single user's own memories and avoids cross-referencing across agents, runs, or apps. So a memory has to be user-scoped, with no other entity attached, to be eligible for a pattern. + + +### Supersede + +When a user tells you something that **contradicts** an earlier memory ("I moved to Berlin" after an earlier "I live in Lisbon"), Dream marks the older memory as **superseded** and links it to the newer fact that replaced it. Superseded memories are not deleted and not hidden by default. A normal `search` or `get` still returns them alongside your active memories, badged as superseded, so you keep the full history. When you want only the current truth, ask for it explicitly with `latest_only=true` (see [How reads change](#how-reads-change-with-dream) below). Supersede runs automatically as part of adding memories, on every plan. + +**Example.** The user has an existing memory *"User drives a 2019 Subaru Outback."* Later they mention selling it, producing a new memory *"User sold their 2019 Subaru Outback and bought a Tesla Model 3."* Dream marks the Subaru memory as superseded and links it to the newer one. A default `search` returns both: the Tesla memory as active, and the Subaru one labelled superseded. Passing `latest_only=true` returns only *"User sold their 2019 Subaru Outback and bought a Tesla Model 3."* + +### Merge + +When a new memory is effectively a **duplicate** of one you already have, Dream keeps a single canonical memory instead of two near-identical copies. When a duplicate is stored as its own memory, Dream marks it **merged** and links it to the canonical one. The merged record is hidden from reads by default (you get the one canonical memory), retained rather than deleted, and surfaced with `include_merged=true`. Merge runs automatically as memories are added, on every plan, and keeps your memory set compact without you deduplicating by hand. + +In practice, most exact or near-duplicate restatements of a fact you already have are recognised and deduplicated as the memory is added. No second copy is created, so you simply keep the one memory. A distinct `merged` record appears when a fuller version of an existing fact arrives and folds the barer one in. + +**Example.** The user has an existing memory *"User has a dog named Rex."* Later they mention *"My dog Rex is a 3-year-old golden retriever."* The richer statement is stored and Dream marks the barer *"User has a dog named Rex"* as **merged** into it. A default read returns the single canonical memory *"User's dog Rex is a 3-year-old golden retriever"*, and `include_merged=true` also returns the merged original. + + + **Nothing Dream does is destructive.** Superseded and merged memories are retained, never erased. Every change is recorded and reviewable, so you always know why a memory was retired or combined. + + +## How reads change with Dream + +Dream doesn't change the shape of your `add`, `search`, or `get` calls, so you don't touch your application code. What it changes is *which* memories a read returns by default, and it gives you two flags to widen or narrow that set: + +| Read mode | Active | Superseded | Merged | +|---|:---:|:---:|:---:| +| **Default** (`search` / `get`) | βœ“ | βœ“ | βœ— | +| **`latest_only=true`** | βœ“ | β€” | β€” | +| **`include_merged=true`** | βœ“ | βœ“ | βœ“ | + +- **By default**, a read returns active plus superseded memories (superseded ones are still there, labelled as history) and hides merged duplicates. Synthesized pattern memories are returned alongside these too. +- **`latest_only=true`** narrows the result to active memories only, meaning the current truth, with superseded and merged both excluded. Use this when you want the cleanest possible snapshot of the user right now. +- **`include_merged=true`** returns everything, including the merged duplicates, when you need the complete picture. + +## Enabling Dream + +Supersede and Merge require no setup. They're always on for every project on every plan. + +Synthesis is opt-in per project: + +1. Open the **[Dream settings for your project](https://app.mem0.ai/dashboard/dream)** in the Mem0 dashboard. +2. Toggle **Synthesis** on. + +Synthesis is a per-project setting, so you can enable it for one project and compare against another with it off. You can turn it off at any time. Doing so is fully reversible and leaves every existing memory (including already-synthesized patterns) untouched. + +From the [Dream page](https://app.mem0.ai/dashboard/dream) you can also review what Dream has done: recent synthesis runs, the patterns produced and their source memories, and the memories that were superseded or merged. + +## Plan availability + +| Capability | Free | Starter | Pro | Enterprise | +|---|:---:|:---:|:---:|:---:| +| **Supersede** (outdated facts flagged) | βœ“ | βœ“ | βœ“ | βœ“ | +| **Merge** (duplicates folded, hidden by default) | βœ“ | βœ“ | βœ“ | βœ“ | +| **Synthesis** (pattern memories written) | β€” | β€” | βœ“ | βœ“ | +| **Dream dashboard** (runs, activity) | β€” | β€” | βœ“ | βœ“ | + +Synthesis requires a **Pro plan or higher**. Enterprise plans also get a faster, configurable schedule (see below). + +## How often Dream runs, and what delay to expect + +Different actions run on different clocks, so the delay you should expect depends on which action. + +### Supersede & Merge, as memories are added + +Supersede and Merge are part of the memory-addition pipeline. They're evaluated when a memory is added, so an outdated fact is superseded or a duplicate is merged as part of that add being processed, on the same timescale as the memory becoming searchable. There's no separate schedule to wait for. + +### Synthesis, on a schedule in the background + +Synthesis runs as a scheduled background job per user, not on every add. Two conditions gate it: + +- **Enough to work with:** a user must have at least **20** memories before Synthesis considers them. Below that threshold there isn't a meaningful pattern to distill yet. +- **Cadence elapsed:** each user is re-synthesized at most once per cadence window. + +| Plan | Synthesis cadence (per user) | +|---|---| +| Pro | Every **7 days** | +| Enterprise | **Daily** (and configurable) | + +Because Synthesis is processed in batches in the background, **expect new pattern memories to appear within roughly 24 hours of a scheduled run**, not instantly. In practice, the end-to-end delay from crossing a cadence window to seeing new patterns is up to about a day. This background design is deliberate: it keeps Synthesis from adding any latency to your live `add` and `search` calls. + + + Synthesis is **not** real-time. If you enable it today, the first pattern memories for an eligible user will appear on the next scheduled run for that user (governed by the cadence above), and can take up to ~24 hours to complete once that run starts. Supersede and Merge, by contrast, keep pace with your adds. + + +## FAQ + +**Does Dream delete any of my memories?** +No. Nothing Dream does is destructive. Superseded memories stay visible in default reads (labelled as history), merged duplicates are hidden by default but retained, and synthesized patterns are added alongside your existing memories, never in place of them. Every change is reviewable from the dashboard. + +**How do I get only the current facts, without the superseded ones?** +Pass `latest_only=true` on your read. Superseded and merged memories are both excluded, leaving only active memories. By default (no flag) superseded memories are included so you keep the full history. + +**Do I need to change my code to use Dream?** +No. Supersede and Merge are always on, and enabling Synthesis is a project setting. Your `add`, `search`, and `get` calls are unchanged. Dream shapes the memory set behind the same API. + +**Will Synthesis reprocess all my old memories when I turn it on?** +No. Enabling Synthesis sets a forward boundary, so only memories created after you turn it on are eligible. This avoids a bulk reprocess of your entire history on day one. + +**Why don't I see pattern memories immediately after enabling Synthesis?** +Synthesis runs on a schedule (every 7 days on Pro, daily on Enterprise) and only for users with at least 20 memories. Patterns appear on the next scheduled run for an eligible user and can take up to ~24 hours to complete once that run starts. + +**Which memories does Synthesis include?** +Only memories scoped to a `user_id` on its own. If a memory also carries an `agent_id`, `run_id`, or `app_id`, it's excluded from that user's synthesis run. This keeps each run confined to a single user's memories and prevents any cross-referencing across agents, runs, or apps. Supersede and Merge are not affected by this and run across your memories as usual. + +**Are synthesized pattern memories traceable?** +Yes. Every pattern memory links back to the specific source memories it was distilled from, so you can always see the evidence behind an insight from the Dream dashboard. + +**Can I turn Synthesis off?** +Yes, at any time, per project. Turning it off is fully reversible and leaves all existing memories, including already-synthesized patterns, untouched. diff --git a/docs/platform/features/entity-scoped-memory.mdx b/docs/platform/features/entity-scoped-memory.mdx index bea1b46fa..b42aeee3b 100644 --- a/docs/platform/features/entity-scoped-memory.mdx +++ b/docs/platform/features/entity-scoped-memory.mdx @@ -36,11 +36,11 @@ Call `client.project.get()` to verify your connection. It should return your pro | User | `user_id` | Persistent persona or account | `"customer_6412"` | | Agent | `agent_id` | Distinct agent persona or tool | `"meal_planner"` | | Application | `app_id` | White-label app or product surface | `"ios_retail_demo"` | -| Session | `run_id` | Short-lived flow, ticket, or conversation thread | `"ticket-9241"` | +| Run | `run_id` | Short-lived flow, ticket, or session thread | `"ticket-9241"` | -- **Writes** (`client.add`) accept any combination of these fields. Absent fields default to `null`. -- **Reads** (`client.search`, `client.get_all`, exports, deletes) accept the same identifiers inside the `filters` JSON object. -- **Implicit null scoping**: Passing only `{"user_id": "alice"}` automatically restricts results to records where `agent_id`, `app_id`, and `run_id` are `null`. Add wildcards (`"*"`), explicit lists, or additional filters when you need broader joins. +- **Writes** (`client.add`) accept any combination of these fields. `app_id` and `run_id` are stored on every memory the call produces. `user_id` and `agent_id` behave differently on the default extraction path: each extracted fact is attributed to whoever stated it, so a record carries `user_id` or `agent_id`, not both. Absent fields default to `null`. +- **Reads** (`client.search`, `client.get_all`, exports) accept the same identifiers inside the `filters` JSON object. Deletes (`client.delete_all`) scope via query parameters instead; the SDK builds these for you when you pass `user_id=...`, `run_id=...`, and so on. +- **Unmentioned entities are not constrained**: Passing only `{"user_id": "alice"}` matches on `user_id` alone. It does not require `agent_id`, `app_id`, or `run_id` to be `null`, so records that also have those fields set are still returned. **Common Pitfall**: If you create a memory with `user_id="alice"` but the other fields default to `null`, then search with `{"AND": [{"user_id": "alice"}, {"agent_id": "bot"}]}` will return nothing because you're looking for a memory where `agent_id="bot"`, not `null`. @@ -78,10 +78,12 @@ client.add( The response will include one or more memory IDs. Check the dashboard β†’ Memories to confirm the entry appears under the correct user, agent, app, and run. -Platform writes that include both `user_id` and `agent_id` (or other combinations) are persisted as separate records per entity so we can enforce privacy boundaries. Each record carries exactly one primary entity, which is why `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` never returns results. Plan searches per entity scope or combine scopes with `OR`. +Passing both `user_id` and `agent_id` to `client.add` does **not** produce records with both fields set. On the default extraction path, each extracted fact is attributed to the speaker who stated it: facts from `user` messages are stored with `user_id` set and `agent_id` null, facts from `assistant` messages with `agent_id` set and `user_id` null. `app_id` and `run_id` are carried on every record either way. + +As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting. -The HTTP equivalent uses `POST /v1/memories/` with the same identifiers in the JSON body. See the Add Memories API reference for REST details. +The HTTP equivalent uses `POST /v3/memories/add/` with the same identifiers in the JSON body. See the [Add Memories](/api-reference/memory/add-memories) API reference for REST details. ## See it in action @@ -130,7 +132,7 @@ print(agent_results) ``` -Writes can include multiple identifiers, but searches resolve one entity space at a time. Query user scope *or* agent scope in a given call: combining both returns an empty list today. +A search that `AND`s `user_id` **and** `agent_id` only matches records that have both fields set, which the default extraction path never produces. Use `OR` when you want either scope to match on its own. @@ -141,11 +143,11 @@ Want to experiment with AND/OR logic, nested operators, or wildcards? The +### Handling an unknown or malformed `memory_id` + +- A malformed `memory_id` (not a valid UUID) returns a clean `400` with `{"error": "Invalid memory_id"}`. +- A well-formed but non-existent `memory_id` (or one that doesn't belong to your org or project) currently returns a `500 Internal Server Error` instead of a `404`. This is a server-side bug being tracked in [MEM-5745](https://linear.app/mem0/issue/MEM-5745). A `5xx` can also be a transient server error, so don't treat it as a definitive "not found"; to avoid the case entirely, only call `feedback` with a `memory_id` you just read from a `get_all` or `search` response. + ## Feedback Analytics Track the impact of your feedback by monitoring memory performance over time. Consider implementing: diff --git a/docs/platform/features/graph-memory.mdx b/docs/platform/features/graph-memory.mdx index aeea23dcc..647e6fe4f 100644 --- a/docs/platform/features/graph-memory.mdx +++ b/docs/platform/features/graph-memory.mdx @@ -90,7 +90,7 @@ results = client.search( Earlier versions of Mem0 offered graph memory by connecting an **external graph database** (Neo4j, Memgraph, Kuzu, Apache AGE, or Neptune) through an `enable_graph` flag and a `graph_store` configuration block. That integration has been replaced by **native, built-in Graph Memory**: - **No external graph store.** The graph is built inside Mem0 from your memories. There is nothing to provision or connect. -- **Always on, all plans.** The `enable_graph` flag is no longer needed; Graph Memory is automatic. (If you still send the parameter, it is ignored.) +- **Always on, all plans.** The `enable_graph` flag is no longer needed: Graph Memory is automatic, and `relations` is always an empty list regardless of whether you send it. On non-paginated `get_all` (v1.1 format), sending `enable_graph=True` still adds an empty `"relations": []` key to the response for backward compatibility; it does not restore the old graph behavior. - **Connections power retrieval directly.** Entity connections are folded into the combined `score` on each result. The standalone `relations` field that the external graph store returned is no longer populated. If your application read that field, see the migration guide below. diff --git a/docs/platform/features/group-chat.mdx b/docs/platform/features/group-chat.mdx index 664e978b0..b195965f1 100644 --- a/docs/platform/features/group-chat.mdx +++ b/docs/platform/features/group-chat.mdx @@ -1,45 +1,33 @@ --- title: Group Chat -description: 'Enable multi-participant conversations with automatic memory attribution to individual speakers' +description: 'Enable multi-participant conversations and scope each speaker with user_id or agent_id' --- ## Overview -The Group Chat feature enables Mem0 to process conversations involving multiple participants and automatically attribute memories to individual speakers. This allows for precise tracking of each participant's preferences, characteristics, and contributions in collaborative discussions, team meetings, or multi-agent conversations. +The Group Chat feature helps you use Mem0 with conversations involving multiple participants, such as team meetings or multi-agent conversations. You control which speaker a memory belongs to by scoping each `add()` call with `user_id`, `agent_id`, and `run_id`; Mem0 does not infer that scope automatically from the conversation. -When you provide messages with participant names, Mem0 automatically: -- Extracts memories from each participant's messages separately -- Attributes each memory to the correct speaker using their name as the `user_id` or `agent_id` -- Maintains individual memory profiles for each participant +When you scope conversations correctly, Mem0: +- Extracts memories from each participant's messages +- Keeps each participant's memories in a separate profile, addressed by the `user_id` or `agent_id` you assigned them +- Lets you retrieve any participant's memories independently using filters ## How Group Chat Works -Mem0 automatically detects group chat scenarios when messages contain a `name` field: +Mem0 does not automatically split a multi-participant conversation into separate memories per speaker. A `name` field on a message is stored as context for extraction, but it does not change which `user_id` or `agent_id` the resulting memories are scoped to: that scope is always whatever `user_id`, `agent_id`, or `run_id` you pass to `add()`. -```json -{ - "role": "user", - "name": "Alice", - "content": "Hey team, I think we should use React for the frontend" -} -``` - -When names are present, Mem0: -- Formats messages as `"Alice (user): content"` for processing -- Extracts memories with proper attribution to each speaker -- Stores memories with the speaker's name as the `user_id` (for users) or `agent_id` (for assistants/agents) +To keep separate memory profiles per participant, scope each participant's messages explicitly: call `add()` once per participant with their own `user_id`, or use `run_id` to group the conversation and filter by participant in your own message metadata. ### Memory Attribution Rules -- **User Messages**: The `name` field becomes the `user_id` in stored memories -- **Assistant/Agent Messages**: The `name` field becomes the `agent_id` in stored memories -- **Messages without names**: Fall back to standard processing using role as identifier +- Memories are always scoped to the `user_id`, `agent_id`, and `run_id` you pass to `add()`, not to the `name` field on individual messages. +- If you need per-participant memories, call `add()` separately for each participant's messages with that participant's `user_id`. ## Using Group Chat ### Basic Group Chat -Add memories from a multi-participant conversation: +Scope each participant's messages with their own `user_id` and a shared `run_id` for the session. Call `add()` once per participant: @@ -48,46 +36,60 @@ from mem0 import MemoryClient client = MemoryClient(api_key="your-api-key") -# Group chat with multiple users -messages = [ - {"role": "user", "name": "Alice", "content": "Hey team, I think we should use React for the frontend"}, - {"role": "user", "name": "Bob", "content": "I disagree, Vue.js would be better for our use case"}, - {"role": "user", "name": "Charlie", "content": "What about considering Angular? It has great enterprise support"}, - {"role": "assistant", "content": "All three frameworks have their merits. Let me summarize the pros and cons of each."} -] - +# Each participant gets their own user_id; run_id ties them to one session +client.add( + [{"role": "user", "content": "Hey team, I think we should use React for the frontend"}], + user_id="alice", run_id="group_chat_1", +) +client.add( + [{"role": "user", "content": "I'd prefer Vue.js for our use case"}], + user_id="bob", run_id="group_chat_1", +) response = client.add( - messages, - run_id="group_chat_1", - infer=True + [{"role": "user", "content": "Consider Angular, it has great enterprise support"}], + user_id="charlie", run_id="group_chat_1", ) print(response) ``` ```json Output { - "results": [ - { - "id": "4d82478a-8d50-47e6-9324-1f65efff5829", - "event": "ADD", - "memory": "prefers using React for the frontend" - }, - { - "id": "1d8b8f39-7b17-4d18-8632-ab1c64fa35b9", - "event": "ADD", - "memory": "prefers Vue.js for our use case" - }, - { - "id": "147559a8-c5f7-44d0-9418-91f53f7a89a4", - "event": "ADD", - "memory": "suggests considering Angular because it has great enterprise support" - } - ] + "event_id": "4d82478a-8d50-47e6-9324-1f65efff5829", + "status": "PENDING" } ``` +`add()` is asynchronous: it queues extraction and returns immediately. Poll `get_all` (see below) once processing completes to see the extracted memories. Each participant's memory is scoped to the `user_id` you passed, so filtering by `run_id` returns all three, and filtering by a single `user_id` returns just that participant. + +### The `name` field does not change scope + +The `name` field is stored as extraction context only. Attribution follows the `user_id`/`agent_id`/`run_id` you pass to `add()`, never the `name`. Passing two different names in one `add()` call does **not** split the memories across two profiles: + + + +```python Python +# BOTH messages are scoped to user_id="team_session", NOT to "alice"/"bob" +client.add( + [ + {"role": "user", "name": "Alice", "content": "I strongly prefer React"}, + {"role": "user", "name": "Bob", "content": "I strongly prefer Vue"}, + ], + user_id="team_session", run_id="group_chat_2", +) + +# Every extracted memory lands under user_id="team_session" +client.get_all(filters={"AND": [{"user_id": "team_session"}]}) + +# Nothing is stored under user_id="alice" or user_id="bob" +client.get_all(filters={"AND": [{"user_id": "alice"}]}) # -> no results from this call +``` + + + +To keep Alice's and Bob's memories in separate profiles, call `add()` once per participant with their own `user_id`, as shown in [Basic Group Chat](#basic-group-chat) above. + ## Retrieving Group Chat Memories ### Get All Memories for a Session @@ -224,33 +226,15 @@ print(search_response) -## Async Mode Support - -Group chat supports async processing for improved performance. Memory additions are processed asynchronously by default. - - - -```python Python -# Group chat: async processing is the default -response = client.add( - messages, - run_id="groupchat_async", - infer=True, -) -print(response) -``` - - - ## Message Format Requirements ### Required Fields -Each message in a group chat must include: +Each message must include: - `role`: The participant's role (`"user"`, `"assistant"`, `"agent"`) - `content`: The message content -- `name`: The participant's name (required for group chat detection) +- `name` (optional): The participant's name, stored as context for extraction. It does not change which `user_id` or `agent_id` a memory is scoped to. ### Example Message Structure @@ -261,14 +245,14 @@ Each message in a group chat must include: "content": "I think we should use React for the frontend" } ``` -### Supported Roles +### Roles -- **`user`**: Human participants (memories stored with `user_id`) -- **`assistant`**: AI assistants (memories stored with `agent_id`) +- **`user`**: Human participants +- **`assistant`**: AI assistants ## Best Practices -1. **Consistent Naming**: Use consistent names for participants across sessions to maintain proper memory attribution. +1. **Consistent Scoping**: Use a consistent `user_id` (or `agent_id`) per participant across sessions so their memories stay in one profile. 2. **Clear Role Assignment**: Ensure each participant has the correct role (`user`, `assistant`, or `agent`) for proper memory categorization. diff --git a/docs/platform/features/memory-decay.mdx b/docs/platform/features/memory-decay.mdx index 6b2a7233d..7dd03eb37 100644 --- a/docs/platform/features/memory-decay.mdx +++ b/docs/platform/features/memory-decay.mdx @@ -43,7 +43,7 @@ At search time the pipeline: 5. Truncates to the `top_k` you requested. 6. Records a fire-and-forget reinforcement against each returned memory: its access history grows by one, capped at the most recent 20 touches. -Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: their `updated_at` is treated as a single past touch, so the same scale above applies based on how stale that update is: a recently-updated legacy memory enters near the neutral band, a long-stale one sits closer to the floor. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory. +Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: a memory's `event_date` (if it has one) is treated as its single past touch; only when `event_date` is absent does `updated_at` stand in instead. A future `event_date` gets a fresh/full activation, a past one decays from when the event happened. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory. ## Configure access @@ -64,7 +64,7 @@ client.project.update(decay=True) ``` ```javascript JavaScript -await client.project.update({ decay: true }); +await client.updateProject({ decay: true }); ``` ```bash cURL @@ -75,7 +75,7 @@ curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PR ``` ```json Response -{ "message": "Updated decay" } +{ "message": "Project updated successfully" } ``` @@ -90,7 +90,7 @@ print(response["decay"]) ``` ```javascript JavaScript -const response = await client.project.get({ fields: ["decay"] }); +const response = await client.getProject({ fields: ["decay"] }); console.log(response.decay); ``` @@ -114,7 +114,7 @@ client.project.update(decay=False) ``` ```javascript JavaScript -await client.project.update({ decay: false }); +await client.updateProject({ decay: false }); ``` ```bash cURL @@ -154,7 +154,7 @@ curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PR | Reinforced on a recent search | 1.2 – 1.5Γ— | Sustains its boost for the next several searches. | | Idle for a few days | 0.6 – 1.0Γ— | Falls back into the neutral band. | | Idle for weeks | 0.4 – 0.6Γ— | Mild dampening: can still surface for strong matches. | -| Pre-decay legacy memory (no access history) | 0.3 – 1.0Γ— | Falls back to `updated_at`: recently-updated entries land near 1.0Γ—, long-stale entries approach the 0.3Γ— floor. | +| Pre-decay legacy memory (no access history) | 0.3 – 1.0Γ— | Falls back to `event_date` if set (future dates land near 1.0Γ—, past dates decay from the event); falls back further to `updated_at` only when `event_date` is absent. | The reinforcement is bounded: each memory tracks at most the last 20 access timestamps, so the boost stays well-behaved no matter how many times a memory is retrieved. @@ -170,7 +170,7 @@ The threshold is applied to the candidate pool pre-decay; the scaling factor the No. The `client.add(...)` path is unchanged. Decay is a search-time ranking adjustment. **What if I had memories before turning decay on?** -They use a fallback: the memory's `updated_at` is treated as a single historical touch, so the same scaling applies based on how stale that update is: a recently-updated legacy memory enters near the neutral band (~1.0Γ—), a long-stale one closer to the floor (~0.3Γ—). Once retrieved they accumulate access history and behave like any other memory. +They use a fallback: a memory's `event_date`, if it has one, is treated as its single historical touch; `updated_at` only stands in when `event_date` is absent. A memory with a future `event_date` enters near the neutral band (~1.0Γ—); one with a past `event_date` decays from when the event happened, closer to the floor (~0.3Γ—) the further back it is. Once retrieved they accumulate access history and behave like any other memory. **Can I tune how aggressively decay scales scores?** Not in this version. The current scaling is calibrated to be conservative: wide enough to meaningfully reorder candidates, narrow enough to never dominate the underlying relevance score. Per-project tuning is on the roadmap. diff --git a/docs/platform/features/memory-expiration.mdx b/docs/platform/features/memory-expiration.mdx new file mode 100644 index 000000000..51b405596 --- /dev/null +++ b/docs/platform/features/memory-expiration.mdx @@ -0,0 +1,176 @@ +--- +title: "Memory Expiration in Mem0" +sidebarTitle: "Memory Expiration" +description: "Set an expiration date on a Mem0 memory and it stops surfacing in search once that date passes. Nothing is deleted. Works on Platform and Open Source." +--- + +## Why Use Memory Expiration? + +Some facts are only true for a while. A trial plan ends, a seasonal preference goes stale, a support ticket ages past its retention window. Set an `expiration_date` on a memory and Mem0 stops surfacing it once that date passes, so you don't need a cleanup job hunting for rows to delete. + +**Expiration hides a memory, it does not delete it.** The record stays in storage untouched. `search()` and `get_all()` skip it, fetching it by ID still returns it, and clearing the date brings it straight back. + +## How it works + +- **Format**: a plain `YYYY-MM-DD` date. No time component, no timezone offset. +- **Evaluated in UTC**, never against the caller's local timezone. +- **Inclusive of the date itself**: a memory set to expire on `2030-01-31` stays visible all through `2030-01-31` UTC and disappears on `2030-02-01`. +- **Only list-shaped reads filter**: `search()` and `get_all()` (`getAll()` in TypeScript) hide expired memories. `get(memory_id)` always returns the memory, so there is no `show_expired` parameter on that path. +- **No expiration date means never expires.** That is the default for every memory. +- **Malformed dates fail open**: a stored value Mem0 can't parse is treated as *not* expired. A bad date never makes a memory silently vanish. + +## How do you set an expiration date on a memory? + +Set it when you add the memory: + + +```python Python +# Mem0 Platform +from mem0 import MemoryClient + +client = MemoryClient(api_key="your-api-key") +messages = [{"role": "user", "content": "My Pro trial ends soon."}] + +client.add(messages, user_id="alice", expiration_date="2030-01-31") + +# Mem0 OSS +from mem0 import Memory + +memory = Memory() +memory.add(messages, user_id="alice", expiration_date="2030-01-31") +``` + +```javascript JavaScript +// Mem0 Platform +import { MemoryClient } from "mem0ai"; + +const client = new MemoryClient({ apiKey: "your-api-key" }); +const messages = [{ role: "user", content: "My Pro trial ends soon." }]; + +await client.add(messages, { userId: "alice", expirationDate: "2030-01-31" }); + +// Mem0 OSS +import { Memory } from "mem0ai/oss"; + +const memory = new Memory(); +await memory.add(messages, { userId: "alice", expirationDate: "2030-01-31" }); +``` + + +Or attach it to a memory that already exists, using `update()`: + + +```python Python +client.update("mem_123", expiration_date="2030-01-31") # Platform +memory.update("mem_123", expiration_date="2030-01-31") # OSS +``` + +```javascript JavaScript +await client.update("mem_123", { expirationDate: "2030-01-31" }); // Platform +await memory.update("mem_123", { expirationDate: "2030-01-31" }); // OSS +``` + + +Full field lists live in the Add Memories and Update Memory references. + +## Read expired memories back + +Pass `show_expired` (`showExpired` in TypeScript) to include them. It defaults to `false` on every client. + + +```python Python +client.get_all(filters={"user_id": "alice"}, show_expired=True) +client.search("What plan is Alice on?", filters={"user_id": "alice"}, show_expired=True) +``` + +```javascript JavaScript +await client.getAll({ filters: { user_id: "alice" }, showExpired: true }); +await client.search("What plan is Alice on?", { + filters: { user_id: "alice" }, + showExpired: true, +}); +``` + + +The same parameter and spelling work on the OSS `Memory` class. See Search Memories and Get Memories. + + + Expired memories are dropped *before* your `top_k` is applied, so Mem0 widens the internal candidate pool first and short result sets are rare. They are not impossible: if nearly every memory in a scope has expired, a call can still return fewer than `top_k` results. Pass `show_expired: true` to get the full set back. + + +## How do you clear or remove an expiration date? + +Pass an explicit `None` (Python) or `null` (TypeScript) to make the memory permanent again. The SDKs deliberately preserve that null instead of treating it as "argument not supplied". + + +```python Python +client.update("mem_123", expiration_date=None) # Platform +memory.update("mem_123", expiration_date=None) # OSS +``` + +```javascript JavaScript +await client.update("mem_123", { expirationDate: null }); // Platform +await memory.update("mem_123", { expirationDate: null }); // OSS +``` + + + + `update()` needs at least one of `text`, `metadata`, or `expiration_date`, and raises if you pass none of them. Clearing the date satisfies that on its own: the memory's content and metadata are left alone. + + +## What each client accepts + +| Client | Accepted input | Notes | +| --- | --- | --- | +| Python (Platform and OSS) | `str` in `YYYY-MM-DD` form, or a `date` / `datetime` object | Normalized to `YYYY-MM-DD` before storage. | +| TypeScript (Platform and OSS) | `string` in `YYYY-MM-DD` form only | Stricter than `new Date()`: rejects `12/31/2099`, `2099-12-31T23:00:00`, and non-days like `2099-02-30` or `2100-02-29`. | +| Self-hosted REST server | `string` in `YYYY-MM-DD` form | Same normalization as OSS Python underneath. | +| CLI (`mem0 add --expires`) | `string` in `YYYY-MM-DD` form | Must be strictly in the future, checked against the local system date. The SDKs have no such restriction. Platform only: the CLI has no OSS backend. | + +## Reading the field back + +Most clients return expiration as a top-level field on the memory: `expiration_date` in Python (Platform and OSS) and in the REST API, `expirationDate` in the Platform TypeScript SDK. + + + The OSS TypeScript SDK is the one exception. There, expiration round-trips under **`result.metadata.expiration_date`**, not `result.expirationDate`, on both `get()` and `getAll()`. + + +## Expiration, decay, and delete + +These three get conflated. They solve different problems: + +| | Memory Expiration | Memory Decay | Delete | +| --- | --- | --- | --- | +| What it does | Hides a memory once a date you set passes | Re-ranks results by how recently a memory was used | Removes a memory permanently | +| Data still stored? | Yes | Yes | No | +| Filters results? | Yes, after the date | Never, it only reorders scores | Yes, permanently | +| Reversible? | Yes, clear or push back the date | Yes, toggle `decay` off | No | +| Set where | Per memory, by you | Per project, opt-in | Per call | +| Available in | Platform and OSS | Platform only | Platform and OSS | + +Reach for Memory Decay when old memories should rank lower but stay searchable, expiration when a memory should stop appearing after a specific known date, and Delete when it should be gone for good. + +## Common patterns + +**Trial and subscription facts.** "Alice is on the Pro trial" is true until the trial ends. Set `expiration_date` to that end date when you write the fact. If she upgrades, clear the date and the memory becomes permanent. If she doesn't, it stops surfacing the next day on its own. + +**Seasonal preferences.** "Alex wants gift ideas for the holidays" matters in December and is noise in July. A short-lived expiration date keeps it from competing with evergreen preferences in every search. + +**Retention windows.** Data-retention policies usually want a soft window before a hard delete: keep a ticket's memories searchable for 90 days, stop surfacing them, purge them later on a schedule. Expiration is the soft step, and a scheduled delete is the permanent one. + + + + + + + diff --git a/docs/platform/features/memory-export.mdx b/docs/platform/features/memory-export.mdx index 0a0d67612..c4375b038 100644 --- a/docs/platform/features/memory-export.mdx +++ b/docs/platform/features/memory-export.mdx @@ -141,7 +141,7 @@ console.log(responseWithInstructions); ``` ```bash cURL -curl -X POST "https://api.mem0.ai/v1/memories/export/" \ +curl -X POST "https://api.mem0.ai/v1/exports/" \ -H "Authorization: Token your-api-key" \ -H "Content-Type: application/json" \ -d '{ @@ -185,6 +185,13 @@ const response = await client.getMemoryExport({ console.log(response); ``` +```bash cURL +curl -X POST "https://api.mem0.ai/v1/exports/get/" \ + -H "Authorization: Token your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"memory_export_id": "550e8400-e29b-41d4-a716-446655440000"}' +``` + ```json Output { "full_name": "John Doe", @@ -231,6 +238,20 @@ const response = await client.getMemoryExport({ console.log(response); ``` +```bash cURL +curl -X POST "https://api.mem0.ai/v1/exports/get/" \ + -H "Authorization: Token your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "filters": { + "AND": [ + {"created_at": {"gte": "2024-07-10", "lte": "2024-07-20"}}, + {"user_id": "alex"} + ] + } + }' +``` + ```json Output { "full_name": "John Doe", diff --git a/docs/platform/features/multimodal-support.mdx b/docs/platform/features/multimodal-support.mdx index 24a3b2f4b..1ae115687 100644 --- a/docs/platform/features/multimodal-support.mdx +++ b/docs/platform/features/multimodal-support.mdx @@ -9,6 +9,10 @@ Mem0 extends its capabilities beyond text by supporting multimodal data, includi When a user submits an image or document, Mem0 processes it to extract textual information and other pertinent details. These details are then added to the user's memory, enhancing the system's ability to understand and recall multimodal inputs. + +Multimodal content (`image_url`, `pdf_url`, `txt_url`, `mdx_url`) requires the default `infer=True`. Do not combine structured multimodal `content` with `infer=False` (Direct Import): the Direct Import path expects plain-text `content` and rejects structured multimodal messages with a `400`. + + ```python Python import os @@ -172,20 +176,18 @@ await client.add([imageMessage], { userId: "alice" }) ### 2. Text Documents (MDX/TXT) -Mem0 supports both online and local text documents in MDX or TXT format. +Mem0 supports both online and local text documents in MDX or TXT format. Use `"type": "mdx_url"` for Markdown/MDX content and `"type": "txt_url"` for plain text; both accept the same shape (`{"url": ...}`, either an HTTP(S) URL or a base64-encoded string). #### Using a Document URL ```python -# Define the document URL document_url = "https://www.w3.org/TR/2003/REC-PNG-20031110/iso_8859-1.txt" -# Create the message dictionary with the document URL document_message = { "role": "user", "content": { - "type": "mdx_url", - "mdx_url": { + "type": "txt_url", + "txt_url": { "url": document_url } } diff --git a/docs/platform/features/temporal-reasoning.mdx b/docs/platform/features/temporal-reasoning.mdx index 9853b708c..64882e898 100644 --- a/docs/platform/features/temporal-reasoning.mdx +++ b/docs/platform/features/temporal-reasoning.mdx @@ -1,143 +1,80 @@ --- title: Temporal Reasoning -description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories." +description: "Boost memories whose dates match the time in a search." badge: "v3" --- -Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results. +Temporal Reasoning gives a ranking boost to memories whose event dates match the time in a search. Event dates are when something described in a memory happened or will happen. It runs automatically on Mem0 Platform v3. - - **Use Temporal Reasoning when…** - - Users ask questions like "what happened last week?" or "what do I have coming up?" - - Your app stores both past events and future plans for the same person - - You want time-aware retrieval without building your own date-parsing layer - +It is not available in the OSS SDK. - - Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints. - +Dates from new memories usually affect search within a few seconds. -## Configure access +## Time-aware searches -Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client: +Queries can include expressions such as `yesterday`, `last week`, `tomorrow`, `currently`, and `as of March 2025`. Mem0 compares them with dates and date ranges found in stored memories. -```python -from mem0 import MemoryClient +## Example -client = MemoryClient(api_key="your-api-key") -``` +Suppose a user has these memories: -## How it works +- "Yesterday I met Maya at the Orion conference in Paris." +- "Last week I met Maya at the Orion conference in Tokyo." +- "Last year I met Maya at the Orion conference in Lisbon." -When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time. - -A query like `what did I do last week?` should return a completed past event: not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically. - -### Memory types Temporal Reasoning handles - -| Type | What it represents | Example | -| --- | --- | --- | -| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." | -| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." | -| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." | -| Relationship | A durable connection between people or entities | "Priya manages Jordan." | -| Preference | A stable preference or habit | "I prefer morning meetings." | - -Results come back in the normal search response shape: Temporal Reasoning affects ranking, not the response format. - -## Configure it - -Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle. - -Two parameters give you precise control when you need it: - -- `timestamp` on `add()`: anchors an imported memory to the time it actually happened, rather than the time it was added to Mem0 -- `reference_date` on `search()`: resolves relative phrases like `last week` against a fixed point in time +Searching for `Which city did I meet Maya in at the Orion conference last week?` gives the Tokyo memory a temporal boost and ranks it first. ```python Python -from datetime import datetime, timezone -from mem0 import MemoryClient - -client = MemoryClient(api_key="your-api-key") - -# Import a historical memory anchored to when it happened -client.add( - [{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}], - user_id="jordan", - timestamp=int(datetime(2025, 3, 10, tzinfo=timezone.utc).timestamp()), -) - -# Search with a relative query anchored to a known date results = client.search( - "what did I do last week?", - filters={"user_id": "jordan"}, + "Which city did I meet Maya in at the Orion conference last week?", + filters={"user_id": "maya-demo"}, +) +``` + +```javascript JavaScript +const results = await client.search( + "Which city did I meet Maya in at the Orion conference last week?", + { filters: { user_id: "maya-demo" } } +); +``` + + +Search returns the usual memory results, reordered using the temporal boost. + +## Search from a specific time + +Use `reference_date` to simulate searching at a specific date and time. Mem0 treats it as the current time for that search. + + +```python Python +results = client.search( + "What happened last week?", + filters={"user_id": "user-123"}, reference_date="2025-03-21T00:00:00Z", ) ``` ```javascript JavaScript -import { MemoryClient } from "mem0ai"; - -const client = new MemoryClient({ apiKey: "your-api-key" }); - -// Import a historical memory anchored to when it happened -await client.add( - [{ role: "user", content: "I finished the Q1 review on March 10, 2025." }], - { - userId: "jordan", - timestamp: Math.floor(new Date("2025-03-10T00:00:00Z").getTime() / 1000), - } -); - -// Search with a relative query anchored to a known date -const results = await client.search("what did I do last week?", { - filters: { user_id: "jordan" }, +const results = await client.search("What happened last week?", { + filters: { user_id: "user-123" }, referenceDate: "2025-03-21T00:00:00Z", }); ``` - - `reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time. - +## Parameters -## Supported query patterns +| Action | Python | TypeScript | Purpose | +| --- | --- | --- | --- | +| Add memories | `timestamp` | `timestamp` | Preserve the original time of an imported conversation. | +| Search memories | `reference_date` | `referenceDate` | Simulate searching at a specific date and time. | - - - Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10` - - - Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?` - - - Examples: `right now`, `currently`, `where do I work now?` - - - Examples: `as of March 2025`, `where was I living as of 2024?` - - - Examples: `how long have I lived here?`, `since when have I worked there?` - - - -## Verify the feature is working - -- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first. -- Use `reference_date` in test queries so relative phrases resolve consistently across runs. -- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time. - -## Best practices - -- Use explicit dates in source conversations when events or plans matter temporally. -- Pass `timestamp` during historical imports so the ingestion time does not become the only time anchor. -- Scope searches with `filters` so time-aware ranking operates inside the right user boundary. -- Use `reference_date` in automated tests and reproducible demos. +For all search inputs and returned fields, see the [Search Memories API reference](/api-reference/memory/search-memories). - Anchor imported memories to when they actually happened. + Preserve the original time of imported memories. diff --git a/docs/platform/features/v2-memory-filters.mdx b/docs/platform/features/v2-memory-filters.mdx index 383feeb31..034fc0f76 100644 --- a/docs/platform/features/v2-memory-filters.mdx +++ b/docs/platform/features/v2-memory-filters.mdx @@ -49,9 +49,9 @@ Filters use a nested JSON structure with logical operators at the root: ### Content fields | Field | Operators | Example | |-------|-----------|---------| -| `categories` | `eq`, `ne`, `in`, `contains` | `{"categories": {"in": ["finance"]}}` | +| `categories` | `eq`, `ne`, `in` (matches any category in the list), `contains` (case-insensitive) | `{"categories": {"in": ["finance"]}}` | | `metadata` | `eq`, `ne`, `contains` | `{"metadata": {"key": "value"}}` | -| `keywords` | `contains`, `icontains` | `{"keywords": {"icontains": "invoice"}}` | +| `keywords` | `contains` (case-sensitive), `icontains` (case-insensitive) | `{"keywords": {"icontains": "invoice"}}` | ### Special fields | Field | Operators | Example | @@ -120,24 +120,23 @@ filters = { Find memories containing specific text, categories, or metadata values. - + ```python - # Case-insensitive match + # Substring match on memory text via get_all filters = { "AND": [ {"user_id": "user_123"}, - {"keywords": {"icontains": "pizza"}} - ] - } - - # Case-sensitive match - filters = { - "AND": [ - {"user_id": "user_123"}, - {"keywords": {"contains": "Invoice_2024"}} + {"keywords": {"icontains": "invoice"}} ] } + memories = client.get_all(filters=filters) ``` + + Use `contains` for case-sensitive matching and `icontains` for case-insensitive. + + + A `keywords` filter works on `get_all`, but passing it to `search()` currently returns a `500` ([MEM-5746](https://linear.app/mem0/issue/MEM-5746)). For text relevance during a search, pass the text to the `query` argument instead of filtering on `keywords`. + @@ -254,10 +253,8 @@ Combine various filters for complex queries across different dimensions. ``` - + ```python - # Require user_id plus non-null run/app IDs - # (Memories are stored separately per entity, so scope one dimension at a time.) filters = { "AND": [ {"user_id": "user_123"}, @@ -266,6 +263,7 @@ Combine various filters for complex queries across different dimensions. ] } ``` + Matches records where `user_id` equals `user_123` and `run_id`/`app_id` are both non-null. It does not require other entity fields to be null. @@ -276,16 +274,16 @@ Level up foundational patterns with compound filters that coordinate entity scop ```python - # Invoice memories in Q1 2024 filters = { "AND": [ {"user_id": "user_123"}, - {"keywords": {"icontains": "invoice"}}, {"categories": {"in": ["finance"]}}, + {"keywords": {"icontains": "invoice"}}, {"created_at": {"gte": "2024-01-01T00:00:00Z"}}, {"created_at": {"lt": "2024-04-01T00:00:00Z"}} ] } + memories = client.get_all(filters=filters) ``` @@ -330,7 +328,7 @@ Level up foundational patterns with compound filters that coordinate entity scop ## Best practices -The root must be `AND`, `OR`, or `NOT` with an array of conditions. +The root does not have to be `AND`, `OR`, or `NOT`. A bare filter like `{"user_id": "alice"}` works on its own; wrap conditions in a logical operator only when you need to combine more than one. @@ -338,7 +336,9 @@ Use `"*"` to match any non-null value for a field. -Memories are stored per-entity (user, agent, app, run). Combining `user_id` **and** `agent_id` in the same `AND` clause returns no results because no record contains both values at once. Query one entity scope at a time or use `OR` logic for parallel lookups. +Combining `user_id` **and** `agent_id` in the same `AND` clause only returns records that have both values set. Memories created by a normal `client.add` never do: each extracted fact is attributed to its speaker, so it carries `user_id` or `agent_id`, not both. Use `OR` to match either scope. Only [Direct Import](/platform/features/direct-import) (`infer=False`) writes both fields on one record. + +A filter object with both an `AND` key and a sibling `OR` key at the same level silently drops the `OR` branch today. Nest the `OR` inside the `AND` array instead of placing them as siblings. ## Troubleshooting @@ -347,7 +347,7 @@ Memories are stored per-entity (user, agent, app, run). Combining `user_id` **an **Problem**: Filtered by `user_id` but don't see agent memories. - **Solution**: User and agent memories are stored as separate records. Use OR to query both scopes: + **Solution**: A `user_id` filter only matches records that have that `user_id` set; it won't surface memories written with only `agent_id`. Use OR to query both scopes: ```python {"OR": [{"user_id": "user_123"}, {"agent_id": "agent_name"}]} ``` @@ -362,8 +362,12 @@ Memories are stored per-entity (user, agent, app, run). Combining `user_id` **an ``` - - **Solution**: Swap to `icontains` to normalize casing. + + **Solution**: Use `keywords` with `icontains` on `get_all`: + ```python + {"AND": [{"user_id": "user_123"}, {"keywords": {"icontains": "invoice"}}]} + ``` + This works on `get_all`. It is not supported on `search()` yet (returns a `500`, [MEM-5746](https://linear.app/mem0/issue/MEM-5746)) - for search, pass the text to the `query` argument instead. @@ -388,7 +392,7 @@ Memories are stored per-entity (user, agent, app, run). Combining `user_id` **an - Yes. The root must be a logical operator with an array. + No. A bare filter like `{"user_id": "u1"}` works on its own. Add `AND`, `OR`, or `NOT` only when you need to combine more than one condition. @@ -408,7 +412,7 @@ Memories are stored per-entity (user, agent, app, run). Combining `user_id` **an - Use `keywords` with `contains` (case-sensitive) or `icontains` (case-insensitive). + For a substring match, use the `keywords` filter with `contains`/`icontains` on `get_all`. For relevance-ranked search, pass the text to the `query` argument on `search()`. Note: `keywords` is not supported inside `search()` filters yet (returns a `500`, [MEM-5746](https://linear.app/mem0/issue/MEM-5746)). @@ -428,6 +432,6 @@ Memories are stored per-entity (user, agent, app, run). Combining `user_id` **an ## Known limitations -- Entity filters operate on a single scope per record. Use separate queries or `OR` logic to compare users vs agents. +- Filters only constrain the fields you mention; unmentioned entity fields are not required to be null. A record carries `user_id` or `agent_id` (never both) unless it came from Direct Import, plus whatever `app_id` and `run_id` were passed. - Metadata supports only bare/`eq`, `contains`, and `ne` comparisons. - Wildcards (`"*"` ) match only records where the field is already non-null. diff --git a/docs/platform/features/webhooks.mdx b/docs/platform/features/webhooks.mdx index abb7cfcd5..8abd0e89e 100644 --- a/docs/platform/features/webhooks.mdx +++ b/docs/platform/features/webhooks.mdx @@ -51,6 +51,7 @@ console.log(webhook); "webhook_id": "wh_123", "name": "Memory Logger", "url": "https://your-app.com/webhook", + "owner": "john", "event_types": ["memory_add"], "project": "default-project", "is_active": true, @@ -63,7 +64,7 @@ console.log(webhook); ### Get Webhooks -Retrieve all webhooks for your project: +Retrieve all webhooks for your project. Each webhook includes an `owner` field: the username of the account that created it. This is set automatically and cannot be changed via the API. @@ -168,6 +169,10 @@ Mem0 supports the following event types for webhooks: - `memory_update`: Triggered when an existing memory is updated. - `memory_delete`: Triggered when a memory is deleted. - `memory_categorize`: Triggered when a memory is categorized. +- `ingest_job_completed`: Triggered when an ingest job finishes successfully. +- `ingest_job_partially_completed`: Triggered when an ingest job finishes with some items failed. +- `ingest_job_failed`: Triggered when an ingest job fails entirely. +- `ingest_job_cancelled`: Triggered when an ingest job is cancelled. ## Webhook Payload diff --git a/docs/platform/mem0-mcp.mdx b/docs/platform/mem0-mcp.mdx index 894915447..2d89db0c4 100644 --- a/docs/platform/mem0-mcp.mdx +++ b/docs/platform/mem0-mcp.mdx @@ -8,18 +8,20 @@ estimatedTime: "~2 minutes" **Prerequisites** - Mem0 Platform account (Sign up here) - - API key (Get one from dashboard) - - Node.js 14+ (for npx) + - API key (Get one from dashboard) + - Node.js 18+ (for npx) - An MCP-compatible client (Claude, Claude Code, Codex, Cursor, Windsurf, VS Code, OpenCode) ## What is Mem0 MCP? -Mem0 MCP Server exposes Mem0's memory capabilities as MCP tools, letting AI agents decide when to save, search, or update information. The cloud-hosted MCP server requires no local installation: just connect and start using memory. +MCP (Model Context Protocol) is a standard way for AI clients to call external tools. The Mem0 MCP server hands your agent a set of memory tools, so it can decide for itself when to save something, look something up, or update what it already knows. Nothing runs on your machine: the server is hosted by Mem0, and your client connects to it over HTTPS. -## Quick Setup +Memories you store this way live in your Mem0 account, not on your computer. -Add Mem0 MCP to your preferred clients with a single command: +## Quick setup + +Point your clients at the hosted server with a single command: ```bash npx mcp-add \ @@ -29,9 +31,31 @@ npx mcp-add \ --clients "claude,claude code,cursor,windsurf,vscode,opencode" ``` -This automatically configures Mem0 MCP for all supported clients at once. +`mcp-add` is a helper that writes the Mem0 server into each client's own MCP config file, so you do not have to edit them by hand. Name only the clients you actually use. If you would rather see the change yourself, every client's manual config is under [Client-specific setup](#client-specific-setup). -## Available Tools +Restart each client afterwards so it picks up the new server. + +## Signing in + +The server is authenticated, so connecting is not enough on its own. There are two ways in: + + + + Most clients handle this for you. The first time your agent uses a Mem0 tool, the client opens a browser window asking you to authorize access to your Mem0 account. Approve it once and the client stores the token, refreshing it as needed. + + This is the easier path, and it is what happens by default if you followed the quick setup above. + + + + For clients without browser sign-in, or for headless environments like CI, send your API key as a bearer token instead. Where you put it depends on the client; see [Client-specific setup](#client-specific-setup) for the exact syntax. + + Get a key from the Mem0 dashboard, and keep it out of any file you commit. + + + +If neither is set up, the server replies `401 Authentication required` and your client reports the connection as failed. + +## Available tools The MCP server exposes these memory tools to your AI client: @@ -41,7 +65,7 @@ The MCP server exposes these memory tools to your AI client: | `search_memories` | Semantic search across existing memories with filters | | `get_memories` | List memories with structured filters and pagination | | `get_memory` | Retrieve one memory by its `memory_id` | -| `update_memory` | Overwrite a memory's text after confirming the ID | +| `update_memory` | Overwrite a memory's text and/or metadata after confirming the ID | | `delete_memory` | Delete a single memory by `memory_id` | | `delete_all_memories` | Bulk delete all memories in scope | | `delete_entities` | Delete a user/agent/app/run entity and its memories | @@ -51,7 +75,7 @@ The MCP server exposes these memory tools to your AI client: --- -## Client-Specific Setup +## Client-specific setup You can also configure individual clients: @@ -170,43 +194,44 @@ You can also configure individual clients: --- -## Verify Your Setup +## Check that it worked -Once configured, your AI client can: -- Automatically save information with `add_memory` -- Search memories with `search_memories` -- Update memories with `update_memory` -- Delete memories with `delete_memory` - -**Sample Interactions:** +Restart your client, then ask it to store something and read it back in a later message: ``` -User: Remember that I love tiramisu -Agent: Got it! I've saved that you love tiramisu. +You: Remember that I prefer TypeScript over JavaScript for new projects. +Agent: Saved. -User: What do you know about my food preferences? -Agent: Based on your memories, you love tiramisu. - -User: Update my project: the mobile app is now 80% complete -Agent: Updated your project status successfully. +You: What language do I prefer for new projects? +Agent: You prefer TypeScript over JavaScript. ``` - - If you get "Connection failed", ensure you have a valid API key from Mem0 Dashboard. - +The second answer only works if the memory was really stored, so this is a genuine round-trip test rather than the model repeating itself. + +Two things to look for while you do it: + +- Your client should show the Mem0 tools among its available tools. Most clients list them in a tools or MCP panel. +- The first tool call should trigger the browser sign-in described above, unless you configured an API key. + +To confirm from outside the client, open the Mem0 dashboard: anything the agent saved appears there. --- -## Quick Recovery +## Troubleshooting -- **"Connection refused"** β†’ Check your internet connection and ensure the MCP client is correctly configured -- **"Invalid API key"** β†’ Get a new key from Mem0 Dashboard -- **"npx command not found"** β†’ Install Node.js from [nodejs.org](https://nodejs.org) +| What you see | What it means | What to do | +|---|---|---| +| `401` or "Authentication required" | The client connected but is not signed in | Complete the browser sign-in, or set your API key as a bearer token | +| "Connection refused" or "failed to connect" | The client cannot reach the server | Check your internet connection, then confirm the URL is exactly `https://mcp.mem0.ai/mcp` | +| The agent has no Mem0 tools | The config was written but the client has not reloaded it | Restart the client. If the tools are still missing, check that `mcp-add` wrote to the config file your client actually reads | +| `npx: command not found` | Node.js is not installed | Install it from [nodejs.org](https://nodejs.org) | +| "Invalid API key" | The key is wrong, revoked, or from a different account | Get a new one from the Mem0 dashboard | --- ## Next steps -- [Platform Quickstart](/platform/quickstart) - direct SDK/API integration guide -- [MCP Specification](https://modelcontextprotocol.io) - the Model Context Protocol standard -- [Gemini with Mem0 MCP](/cookbooks/frameworks/gemini-3-with-mem0-mcp) - example integration cookbook +- [Platform quickstart](/platform/quickstart): call Mem0 from your own code instead of through an agent +- [Mem0 CLI](/platform/cli): the same operations from your terminal +- [MCP specification](https://modelcontextprotocol.io): the Model Context Protocol standard +- [Gemini with Mem0 MCP](/cookbooks/frameworks/gemini-3-with-mem0-mcp): a worked example diff --git a/docs/platform/overview.mdx b/docs/platform/overview.mdx index bc91d6a70..2743dfbac 100644 --- a/docs/platform/overview.mdx +++ b/docs/platform/overview.mdx @@ -50,7 +50,7 @@ For the full pipeline, see [How Mem0 works](/core-concepts/how-it-works). Get an API key and save your first memory. - How user, agent, run, and session memory differ. + How user, agent, app, and run memory differ. The core memory operations, end to end. diff --git a/docs/platform/platform-vs-oss.mdx b/docs/platform/platform-vs-oss.mdx index d24c38c1c..609497c6a 100644 --- a/docs/platform/platform-vs-oss.mdx +++ b/docs/platform/platform-vs-oss.mdx @@ -60,7 +60,6 @@ Mem0 offers two powerful ways to add memory to your AI applications. Choose base | **Multimodal support** | βœ… | βœ… | | **Custom categories** | βœ… | Limited | | **Advanced retrieval** | βœ… | βœ… | - | **Criteria retrieval** | βœ… | ❌ | | **Temporal reasoning** | βœ… (v3) | ❌ | | **Memory decay** | βœ… (v3) | ❌ | | **Graph memory** | βœ… Built-in | βœ… External graph store | diff --git a/docs/platform/quickstart.mdx b/docs/platform/quickstart.mdx index 1e876c122..4da3b9487 100644 --- a/docs/platform/quickstart.mdx +++ b/docs/platform/quickstart.mdx @@ -5,31 +5,42 @@ icon: "bolt" iconType: "solid" --- -Get started with Mem0 Platform's hosted API in under 5 minutes. This guide shows you how to authenticate and store your first memory. +In about five minutes you will get an API key, store your first memory, and search it back. Follow along in Python, JavaScript, cURL, or the terminal. -**Are you an AI agent?** See [Sign up as an agent](/platform/agent-signup): mint a working API key in four commands, no email or dashboard required. +**Are you an AI agent?** See [Sign up as an agent](/platform/agent-signup): create a working API key in four commands, with no email or dashboard. ## Prerequisites - Mem0 Platform account (Sign up here) - API key (Get one from dashboard) -- Python 3.10+, Node.js 18+, or cURL +- Python 3.10+, Node.js 18+, or cURL. The CLI needs either Node.js 18+ or Python 3.10+. -## Installation +## Store your first memory - + +Pick a tab and use the same one for every step below. + -```bash pip +```bash Python pip install mem0ai ``` -```bash npm +```bash JavaScript npm install mem0ai ``` +```bash cURL +# Nothing to install. cURL ships with macOS and most Linux distributions. +``` + +```bash CLI +npm install -g @mem0/cli +# or, if you prefer Python: pip install mem0-cli +``` + @@ -39,12 +50,12 @@ npm install mem0ai from mem0 import MemoryClient client = MemoryClient(api_key="your-api-key") -```` +``` ```javascript JavaScript import MemoryClient from 'mem0ai'; const client = new MemoryClient({ apiKey: 'your-api-key' }); -```` +``` ```bash cURL export MEM0_API_KEY="your-api-key" @@ -65,7 +76,7 @@ messages = [ {"role": "assistant", "content": "Got it! I'll remember your dietary preferences."} ] client.add(messages, user_id="user123") -```` +``` ```javascript JavaScript const messages = [ @@ -73,7 +84,7 @@ const messages = [ {"role": "assistant", "content": "Got it! I'll remember your dietary preferences."} ]; await client.add(messages, { userId: "user123" }); -```` +``` ```bash cURL curl -X POST https://api.mem0.ai/v3/memories/add/ \ @@ -93,6 +104,17 @@ mem0 add "I'm a vegetarian and allergic to nuts." --user-id user123 ``` + +Mem0 pulls the individual facts out of the conversation and stores each one separately: + +```json +{ + "results": [ + {"id": "0f2c1b6e-9a3d-4b18-8f77-1c2d3e4f5a6b", "memory": "Is a vegetarian", "event": "ADD"}, + {"id": "14e1b28a-2014-40ad-ac42-69c9ef42193d", "memory": "Allergic to nuts", "event": "ADD"} + ] +} +``` @@ -100,12 +122,12 @@ mem0 add "I'm a vegetarian and allergic to nuts." --user-id user123 ```python Python results = client.search("What are my dietary restrictions?", filters={"user_id": "user123"}) print(results) -```` +``` ```javascript JavaScript const results = await client.search("What are my dietary restrictions?", { filters: { user_id: "user123" } }); console.log(results); -```` +``` ```bash cURL curl -X POST https://api.mem0.ai/v3/memories/search/ \ @@ -123,7 +145,7 @@ mem0 search "What are my dietary restrictions?" --user-id user123 -**Output:** +Both facts come back, ranked by how well they match the question: ```json { @@ -132,37 +154,65 @@ mem0 search "What are my dietary restrictions?" --user-id user123 "id": "14e1b28a-2014-40ad-ac42-69c9ef42193d", "memory": "Allergic to nuts", "user_id": "user123", + "agent_id": null, + "app_id": null, + "run_id": null, "categories": ["health"], + "metadata": {}, "created_at": "2025-10-22T04:40:22.864647-07:00", - "score": 0.30 + "updated_at": "2025-10-22T04:40:22.864647-07:00", + "expiration_date": null, + "score": 0.87 + }, + { + "id": "0f2c1b6e-9a3d-4b18-8f77-1c2d3e4f5a6b", + "memory": "Is a vegetarian", + "user_id": "user123", + "agent_id": null, + "app_id": null, + "run_id": null, + "categories": ["food_preferences"], + "metadata": {}, + "created_at": "2025-10-22T04:40:22.864647-07:00", + "updated_at": "2025-10-22T04:40:22.864647-07:00", + "expiration_date": null, + "score": 0.81 } ] } ``` +Pass these memories to your model as context, and it answers with what it already knows about the user instead of asking again. + - - **Pro Tip**: Want AI agents to manage their own memory automatically? Use Mem0 MCP to let LLMs decide when to save, search, and update memories. - + + Rather than calling `add` and `search` yourself, you can hand Mem0 to your agent as a set of tools and let it decide when to save and look things up. See [Mem0 MCP](/platform/mem0-mcp). + ## What's next? -You stored and searched your first memory. Keep going: +You stored and searched your first memory. Start with scoping, since every call you make from here needs it: - - - See how Mem0 extracts, stores, and retrieves memories under the hood. + + + What `user_id` actually does, plus the `agent_id`, `app_id`, and `run_id` fields that came back empty above. - - Go beyond add and search: update, delete, and the full memory lifecycle. + + Why one sentence became two memories, and how Mem0 decides what to keep. - - Put it to work in a real app, end to end, in about 10 minutes. + + The operations beyond add and search, for when stored facts change or go stale. + + + + Wire memory into LangChain, CrewAI, LangGraph, or the OpenAI Agents SDK. -Stuck on setup? See the [FAQs and troubleshooting](/platform/faqs). + + Something not working? The [FAQs and troubleshooting](/platform/faqs) page covers the common setup errors. + diff --git a/docs/vibecoding.mdx b/docs/vibecoding.mdx index dabbf12a1..5baaa3ca2 100644 --- a/docs/vibecoding.mdx +++ b/docs/vibecoding.mdx @@ -5,13 +5,15 @@ description: "Agent skills, starter prompts, and setup for building with Mem0 us icon: "wand-magic-sparkles" --- -These docs are designed to be easily consumable by LLMs. Each page has a button that lets you copy the page as Markdown or paste directly into ChatGPT, Claude, or any AI coding tool. +Vibecoding means building software by describing what you want to an AI coding assistant and letting it write the code. The catch is that assistants guess at unfamiliar libraries, and a wrong guess about Mem0 costs you a debugging session. -We follow the llms.txt standard: +This page fixes that three ways: skills that teach your assistant the Mem0 SDKs, an MCP connection so it can read and write memories itself, and a starter prompt you can paste into any tool. -- [llms.txt](https://docs.mem0.ai/llms.txt) + +Every page in these docs has a button to copy it as Markdown or send it straight to ChatGPT or Claude, so you can hand your assistant any page it needs. We follow the [llms.txt](https://docs.mem0.ai/llms.txt) standard. + -## Agent Skills +## Agent skills Mem0 ships two kinds of skills for AI coding assistants. Both work with Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any assistant that supports the skills standard. @@ -45,7 +47,7 @@ npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform See the [skills index](https://github.com/mem0ai/mem0/tree/main/skills) for the full catalog. -## MCP Server Setup +## MCP server setup Connect Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode, or any MCP-compatible client to Mem0. @@ -61,13 +63,13 @@ npx mcp-add \ For per-client setup and advanced options, see [Mem0 MCP Setup](/platform/mem0-mcp). -## Universal Starter Prompt +## Universal starter prompt Copy this into any AI tool to start building with Mem0: ```text -I want to start building with Mem0, a self-improving memory layer for LLM -applications that gives agents persistent context across sessions. +I want to start building with Mem0, which gives AI agents long-term memory +that persists across sessions, tools, and runs. ## Mem0 Resources @@ -86,10 +88,10 @@ applications that gives agents persistent context across sessions. - Cookbooks: https://docs.mem0.ai/cookbooks/overview **What Mem0 Does:** -Mem0 is a memory layer for AI apps, managed (Mem0 Platform) or self-hosted -(Open Source). It stores, retrieves, and manages user memories so agents -remember preferences, learn from interactions, and personalize over time. -Sub-50ms retrieval. Storage: vector embeddings. +Mem0 gives AI agents long-term memory, either managed (Mem0 Platform) or +self-hosted (Open Source). It stores, retrieves, and manages memories so +agents remember preferences, learn from past runs, and personalize over +time. Storage: vector embeddings. **Architecture Overview:** - Memory is scoped by user_id, agent_id, or run_id @@ -109,7 +111,7 @@ Sub-50ms retrieval. Storage: vector embeddings. import MemoryClient from 'mem0ai'; const client = new MemoryClient({ apiKey: 'm0-xxx' }); await client.add([{ role: "user", content: "I prefer dark mode." }], { userId: "user1" }); - const results = await client.search("What editor?", { filters: { userId: "user1" } }); + const results = await client.search("What editor?", { filters: { user_id: "user1" } }); **Quick Usage (Python Open Source):** from mem0 import Memory @@ -121,10 +123,10 @@ Help me integrate Mem0 into my project. Start by asking what I'm building, what language/framework I'm using, and whether I want managed or self-hosted. ``` -## Go Deeper +## Go deeper - + Get started with the managed API @@ -133,7 +135,7 @@ what language/framework I'm using, and whether I want managed or self-hosted. Production-ready tutorials and examples - + Explore every REST endpoint diff --git a/examples/mem0-demo/package.json b/examples/mem0-demo/package.json index cc52db705..ec5b7c61c 100644 --- a/examples/mem0-demo/package.json +++ b/examples/mem0-demo/package.json @@ -28,7 +28,7 @@ "clsx": "^2.1.1", "js-cookie": "^3.0.6", "lucide-react": "^0.477.0", - "next": "15.5.18", + "next": "15.5.21", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.0.1", diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md new file mode 100644 index 000000000..27afc48bc --- /dev/null +++ b/integrations/AGENTS.md @@ -0,0 +1,55 @@ +# Integrations (`integrations/`) + +Agent and editor integrations. Each subdirectory is self-contained: its own `package.json`, lockfile, build, and tests. **There is no shared toolchain.** Check the table before running anything. + +| Directory | Package | Build | Lint | Test | +|-----------|---------|-------|------|------| +| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) | +| `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest | +| `mem0-plugin/` | Claude Code / Cursor / Codex plugin | none | none | pytest | +| `mem0-plugin/.opencode-plugin/` | `@mem0/opencode-plugin` | Bun | none | tsc type-check | +| `pi-agent-plugin/` | `@mem0/pi-agent-plugin` | tsup | none | vitest | +| `n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` | tsc | ESLint (n8n-nodes-base) | none | +| `zapier-mem0/` | `@mem0/zapier` | tsc | none | offline unit tests + `zapier validate` | + +pnpm everywhere except `.opencode-plugin/`, which uses Bun. Never npm, never yarn. + +## Commands + +```bash +cd integrations/vercel-ai-sdk +pnpm install +pnpm run build # tsup +pnpm run lint # eslint +pnpm run type-check # tsc --noEmit +pnpm run prettier-check +pnpm run test # jest +pnpm run test:edge # vitest, edge runtime +pnpm run test:node # vitest, node runtime + +cd integrations/openclaw +pnpm install +pnpm run build # tsup +pnpm run test # vitest +``` + +Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc --noEmit`, whichever the package defines. + +## What each one is + +- **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`. +- **`mem0-plugin/`** connects Claude Code, Cursor, and Codex to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. +- **`openclaw/`**, **`pi-agent-plugin/`** are editor and agent plugins with the same shape. +- **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete. +- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret). + +## Adding an integration + +1. Create `integrations//` and build it there, self-contained. +2. If it publishes to a registry, set `repository.directory: "integrations/"` in `package.json` so npm provenance links to the right subdirectory. +3. Add `.github/workflows/-checks.yml` and `-cd.yml`. Use `integrations/` in the `paths:` trigger, `working-directory`, and `cache-dependency-path`. Register the release tag prefix in the `case` block in `release.yml`, keeping the bare `v*` arm last. + **Workflow filenames are load-bearing:** npm OIDC trusted publishing is pinned to repository plus workflow filename. Renaming one breaks publishing. +4. Register the CI workflow in `ci-gate.yml`: a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list. +5. If it is a Claude Code or editor marketplace plugin, register its path in all five `marketplace.json` files: root, `.claude-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, and `.agents/plugins/`. +6. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`. +7. Add rows to the table above and to the CI/CD tables in [`../.github/AGENTS.md`](../.github/AGENTS.md). diff --git a/integrations/CLAUDE.md b/integrations/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/integrations/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/integrations/mem0-plugin/.claude-plugin/plugin.json b/integrations/mem0-plugin/.claude-plugin/plugin.json index a2be0f0ee..de77fc2ea 100644 --- a/integrations/mem0-plugin/.claude-plugin/plugin.json +++ b/integrations/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.12", + "version": "0.2.13", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.codex-plugin/plugin.json b/integrations/mem0-plugin/.codex-plugin/plugin.json index 382808b46..a705b3f72 100644 --- a/integrations/mem0-plugin/.codex-plugin/plugin.json +++ b/integrations/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.12", + "version": "0.2.13", "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.cursor-plugin/plugin.json b/integrations/mem0-plugin/.cursor-plugin/plugin.json index 5ca6205ce..cdceabb02 100644 --- a/integrations/mem0-plugin/.cursor-plugin/plugin.json +++ b/integrations/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.12", + "version": "0.2.13", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.kimi-plugin/plugin.json b/integrations/mem0-plugin/.kimi-plugin/plugin.json new file mode 100644 index 000000000..27d8a8ae4 --- /dev/null +++ b/integrations/mem0-plugin/.kimi-plugin/plugin.json @@ -0,0 +1,86 @@ +{ + "name": "mem0", + "version": "0.1.0", + "description": "Persistent memory for Kimi Code. Remembers decisions, patterns, and preferences across sessions.", + "author": { + "name": "Mem0", + "email": "support@mem0.ai", + "url": "https://mem0.ai" + }, + "homepage": "https://mem0.ai", + "repository": "https://github.com/mem0ai/mem0", + "license": "Apache-2.0", + "keywords": ["memory", "personalization", "mcp", "semantic-search"], + "skills": "./skills/", + "sessionStart": { + "skill": "context-loader" + }, + "hooks": [ + { + "event": "SessionStart", + "matcher": "^(startup|resume)$", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_session_start.sh", + "timeout": 30 + }, + { + "event": "UserPromptSubmit", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_user_prompt.sh", + "timeout": 12 + }, + { + "event": "PreToolUse", + "matcher": "^(Write|Edit|MultiEdit)$", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" block_memory_write.sh", + "timeout": 5 + }, + { + "event": "PreToolUse", + "matcher": "^mcp__.*mem0.*__(add_memory|search_memories|get_memories|delete_all_memories)$", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" enforce_metadata_defaults.sh", + "timeout": 5 + }, + { + "event": "PreToolUse", + "matcher": "^Read$", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_file_read.sh", + "timeout": 5 + }, + { + "event": "PostToolUse", + "matcher": "^mcp__.*mem0.*__", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_post_tool_use.sh", + "timeout": 5 + }, + { + "event": "PostToolUse", + "matcher": "^Bash$", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_bash_output.sh", + "timeout": 12 + }, + { + "event": "Stop", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_stop.sh", + "timeout": 30 + }, + { + "event": "PreCompact", + "command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_pre_compact.sh", + "timeout": 30 + } + ], + "mcpServers": { + "mem0": { + "transport": "http", + "url": "https://mcp.mem0.ai/mcp/", + "bearerTokenEnvVar": "MEM0_API_KEY" + } + }, + "interface": { + "displayName": "Mem0", + "shortDescription": "Persistent memory layer for AI coding workflows", + "longDescription": "Mem0 adds long-term memory to Kimi Code. Store decisions, user preferences, project context, and session state across conversations. Memories are automatically retrieved via semantic search so Kimi always has the right context available.", + "developerName": "Mem0", + "websiteURL": "https://mem0.ai", + "logo": "./logo.svg" + } +} diff --git a/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts b/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts new file mode 100644 index 000000000..ca2a2a931 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts @@ -0,0 +1,153 @@ +import {afterEach, describe, expect, test} from "bun:test"; +import {mkdtempSync, mkdirSync, writeFileSync} from "fs"; +import {tmpdir} from "os"; +import {join} from "path"; +import {parseApiKeyLine, resolveApiKey} from "./api-key"; +import Mem0Plugin from "./opencode-mem0"; + +const originalKey = process.env.MEM0_API_KEY; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalTelemetry = process.env.MEM0_TELEMETRY; +const originalFetch = globalThis.fetch; +const testCleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (testCleanups.length > 0) { + await testCleanups.pop()?.(); + } + if (originalKey === undefined) delete process.env.MEM0_API_KEY; + else process.env.MEM0_API_KEY = originalKey; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + globalThis.fetch = originalFetch; +}); + +function home(): string { + return mkdtempSync(join(tmpdir(), "mem0-api-key-")); +} + +function pluginContext(logs: unknown[]) { + return { + client: {app: {log: async (entry: unknown) => logs.push(entry)}}, + $: () => ({quiet: async () => ({stdout: ""})}), + } as any; +} + +function stubFetch(): void { + globalThis.fetch = (async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = + url.includes("/v1/ping/") + ? {status: "ok", userEmail: "plugin-test@mem0.dev"} + : url.includes("/v1/projects/") + ? {customCategories: []} + : {}; + return new Response(JSON.stringify(body), { + status: 200, + headers: {"content-type": "application/json"}, + }); + }) as typeof fetch; +} + +function captureDeferredPluginCleanup(): () => Promise { + const baseline = new Set(process.listeners("beforeExit")); + return async () => { + await Promise.resolve(); + for (const listener of process.listeners("beforeExit")) { + if (!baseline.has(listener)) { + process.off("beforeExit", listener as (...args: any[]) => void); + } + } + }; +} + +describe("parseApiKeyLine", () => { + test("accepts literal export and assignment forms", () => { + expect(parseApiKeyLine('export MEM0_API_KEY="m0-quoted" # comment')).toBe("m0-quoted"); + expect(parseApiKeyLine("MEM0_API_KEY=m0-literal")).toBe("m0-literal"); + }); + + test("rejects unrelated, empty, and executable-looking values", () => { + expect(parseApiKeyLine("OTHER_KEY=value")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=$MEM0_API_KEY")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=$(cat secret)")).toBeUndefined(); + }); +}); + +describe("resolveApiKey", () => { + test("explicit environment value wins over profiles", () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY=profile\n"); + expect(resolveApiKey({MEM0_API_KEY: " explicit "}, dir)).toBe("explicit"); + }); + + test("uses the first valid allowlisted profile", () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY=$UNSET\n"); + writeFileSync(join(dir, ".bashrc"), "export MEM0_API_KEY='m0-from-bashrc'\n"); + writeFileSync(join(dir, ".profile"), "MEM0_API_KEY=late\n"); + expect(resolveApiKey({}, dir)).toBe("m0-from-bashrc"); + }); + + test("continues after an unreadable or absent earlier profile", () => { + const dir = home(); + mkdirSync(join(dir, ".zshrc")); + writeFileSync(join(dir, ".profile"), "MEM0_API_KEY=m0-later\n"); + expect(resolveApiKey({}, dir)).toBe("m0-later"); + }); + + test("ignores unsupported files and invalid assignments", () => { + const dir = home(); + writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n"); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY= # empty\nMEM0_API_KEY=$(unsafe)\n"); + expect(resolveApiKey({}, dir)).toBe(""); + }); + + test("keeps the missing-key guard when no source yields a key", async () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY= # empty\nMEM0_API_KEY=$UNSET\n"); + delete process.env.MEM0_API_KEY; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.MEM0_TELEMETRY = "false"; + stubFetch(); + testCleanups.push(captureDeferredPluginCleanup()); + + const logs: unknown[] = []; + const plugin = await Mem0Plugin(pluginContext(logs)); + + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ + body: { + level: "error", + message: "MEM0_API_KEY environment variable not set. Get one at https://app.mem0.ai/dashboard/api-keys", + }, + }); + expect(plugin).toEqual({}); + }); + + test("recovers the issue's shell-profile startup path", async () => { + const dir = home(); + // Problem 2 in issue #6003: Desktop has no process key, but .zshrc does. + writeFileSync(join(dir, ".zshrc"), 'export MEM0_API_KEY="m0-from-profile"\n'); + delete process.env.MEM0_API_KEY; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.MEM0_TELEMETRY = "false"; + stubFetch(); + testCleanups.push(captureDeferredPluginCleanup()); + + const logs: unknown[] = []; + const plugin = await Mem0Plugin(pluginContext(logs)); + + expect(logs).toHaveLength(0); + expect(Object.keys(plugin)).toContain("chat.message"); + expect(plugin).toHaveProperty("tool"); + }); +}); diff --git a/integrations/mem0-plugin/.opencode-plugin/api-key.ts b/integrations/mem0-plugin/.opencode-plugin/api-key.ts new file mode 100644 index 000000000..b3eae9543 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/api-key.ts @@ -0,0 +1,30 @@ +import {readFileSync} from "fs"; +import {homedir} from "os"; +import {join} from "path"; + +const PROFILE_FILES = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"]; + +export function parseApiKeyLine(line: string): string | undefined { + const match = line.match(/^\s*(?:export\s+)?MEM0_API_KEY=(.*)$/); + if (!match) return undefined; + + const value = match[1].replace(/#.*$/, "").trim().replace(/^("|')(.*)\1$/, "$2").trim(); + return value && !value.startsWith("$") ? value : undefined; +} + +export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = homedir()): string { + const explicit = env.MEM0_API_KEY?.trim(); + if (explicit) return explicit; + + for (const profile of PROFILE_FILES) { + try { + for (const line of readFileSync(join(homeDir, profile), "utf8").split(/\r?\n/)) { + const key = parseApiKeyLine(line); + if (key) return key; + } + } catch { + } + } + + return ""; +} diff --git a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts index 97369ed5c..d5f2acb30 100644 --- a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts +++ b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts @@ -25,6 +25,7 @@ import { } from "./dream"; import {asScope, scopeSearchFilters, scopeWriteParams, resolveDefaultScope, SCOPE_GUIDANCE, type Scope} from "./scope"; import {parseProjectFromRemote} from "./project"; +import {resolveApiKey} from "./api-key"; async function getUserId(): Promise { if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID; @@ -258,7 +259,7 @@ function extractUserText(input: any, output: any): string { const Mem0Plugin: Plugin = async (ctx) => { const {$, client} = ctx; - const apiKey = process.env.MEM0_API_KEY; + const apiKey = resolveApiKey(); if (!apiKey) { try { diff --git a/integrations/mem0-plugin/.opencode-plugin/package.json b/integrations/mem0-plugin/.opencode-plugin/package.json index dba80dc1a..2bd5fefd2 100644 --- a/integrations/mem0-plugin/.opencode-plugin/package.json +++ b/integrations/mem0-plugin/.opencode-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/opencode-plugin", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "description": "Mem0 persistent memory plugin for OpenCode β€” add, search, and manage memories across sessions", "main": "dist/index.js", diff --git a/integrations/mem0-plugin/plugin.json b/integrations/mem0-plugin/plugin.json index 4636c32ef..ad02a4253 100644 --- a/integrations/mem0-plugin/plugin.json +++ b/integrations/mem0-plugin/plugin.json @@ -1,7 +1,7 @@ { "id": "mem0", "name": "mem0", - "version": "0.1.4", + "version": "0.1.6", "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", diff --git a/integrations/mem0-plugin/scripts/_instructions.py b/integrations/mem0-plugin/scripts/_instructions.py new file mode 100644 index 000000000..8b4a5e8ae --- /dev/null +++ b/integrations/mem0-plugin/scripts/_instructions.py @@ -0,0 +1,51 @@ +"""Resolve the project's mem0 extraction policy from ``mem0.md``. + +A repo's ``mem0.md`` can carry two prose sections that steer what Mem0 extracts: + + ## Instructions + Remember architecture decisions and conventions. Ignore debug noise and secrets. + + ## Agent Instructions + For agent-scoped memories, focus on the tools and task outcomes. + +``## Instructions`` maps to Mem0's ``custom_instructions`` (user/project-scoped +extraction) and ``## Agent Instructions`` to ``agent_custom_instructions`` +(agent-scoped extraction). Both are passed verbatim on memory writes, so the +policy lives in the repo, travels with it, and is shared by the whole team. + +The hook writers call :func:`load_instructions` and merge the result into their +``/v3/memories/add`` body. Returns only the keys that are actually set, so a +project with no policy adds nothing. +""" + +from __future__ import annotations + +import os + +from parse_mem0_config import load_full_config + + +def load_instructions(cwd: str | None = None) -> dict[str, str]: + """Return the extraction policy for the project at *cwd* (defaults to the + ``MEM0_CWD`` env var, then the process cwd). + + Keys (present only when non-empty): + - ``custom_instructions`` from ``## Instructions`` + - ``agent_custom_instructions`` from ``## Agent Instructions`` + """ + if cwd is None: + cwd = os.environ.get("MEM0_CWD") or os.getcwd() + + try: + config = load_full_config(cwd) + except Exception: + return {} + + out: dict[str, str] = {} + custom = config.get("instructions") + if isinstance(custom, str) and custom.strip(): + out["custom_instructions"] = custom.strip() + agent = config.get("agent_instructions") + if isinstance(agent, str) and agent.strip(): + out["agent_custom_instructions"] = agent.strip() + return out diff --git a/integrations/mem0-plugin/scripts/_search.py b/integrations/mem0-plugin/scripts/_search.py index 5a19cfd41..95db89ce9 100644 --- a/integrations/mem0-plugin/scripts/_search.py +++ b/integrations/mem0-plugin/scripts/_search.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import os +import sys import urllib.request SEARCH_URL = "https://api.mem0.ai/v3/memories/search/" @@ -83,7 +84,8 @@ def search_memories( if min_score > 0: results = [m for m in results if m.get("score", 0) >= min_score] return results - except Exception: + except Exception as e: + print(f"[mem0] search request failed: {e}", file=sys.stderr) return [] diff --git a/integrations/mem0-plugin/scripts/auto_capture.py b/integrations/mem0-plugin/scripts/auto_capture.py index 356c02803..b48a928b4 100755 --- a/integrations/mem0-plugin/scripts/auto_capture.py +++ b/integrations/mem0-plugin/scripts/auto_capture.py @@ -21,6 +21,7 @@ import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _identity import resolve_api_key, resolve_user_id +from _instructions import load_instructions from _project import resolve_branch, resolve_project_id log = logging.getLogger("mem0-auto-capture") @@ -126,6 +127,8 @@ def store_exchange(api_key: str, messages: list[dict], user_id: str, "metadata": metadata, "infer": True, } + # Apply the project's mem0.md extraction policy (custom/agent instructions). + body.update(load_instructions()) data = json.dumps(body).encode("utf-8") req = urllib.request.Request( diff --git a/integrations/mem0-plugin/scripts/capture_compact_summary.py b/integrations/mem0-plugin/scripts/capture_compact_summary.py index d6e70b356..d2f7de0ba 100644 --- a/integrations/mem0-plugin/scripts/capture_compact_summary.py +++ b/integrations/mem0-plugin/scripts/capture_compact_summary.py @@ -26,6 +26,7 @@ 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 _instructions import load_instructions from _project import resolve_branch, resolve_project_id log = logging.getLogger("mem0-compact-summary") @@ -95,7 +96,7 @@ def find_compact_summary(lines: list[str]) -> str: return "" -def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool: +def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "", cwd: str | None = None) -> bool: expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat() metadata = { "type": "compact_summary", @@ -104,14 +105,19 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro } if branch: metadata["branch"] = branch + # The compact summary is model-authored prose, in the first person and with no + # framing to mark it as such. Under role="user" mem0 reads "I recommend X" as + # the human saying it and stores "User recommends X". body = { - "messages": [{"role": "user", "content": summary}], + "messages": [{"role": "assistant", "content": summary}], "user_id": user_id, "app_id": project_id, "metadata": metadata, "infer": True, "expiration_date": expires, } + # Apply the project's mem0.md extraction policy (custom/agent instructions). + body.update(load_instructions(cwd)) data = json.dumps(body).encode("utf-8") req = urllib.request.Request( @@ -179,7 +185,7 @@ def main(): return log.info("Capturing compact summary (%d chars)", len(summary)) - if store_summary(api_key, summary, user_id, session_id, project_id, branch): + if store_summary(api_key, summary, user_id, session_id, project_id, branch, cwd): if session_id: try: os.makedirs(marker_dir, exist_ok=True) diff --git a/integrations/mem0-plugin/scripts/capture_session_summary.py b/integrations/mem0-plugin/scripts/capture_session_summary.py index 441ed9a6e..6c3fa92fa 100644 --- a/integrations/mem0-plugin/scripts/capture_session_summary.py +++ b/integrations/mem0-plugin/scripts/capture_session_summary.py @@ -24,6 +24,7 @@ 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 _instructions import load_instructions from _project import resolve_branch, resolve_project_id log = logging.getLogger("mem0-session-summary") @@ -163,6 +164,7 @@ def store_summary( project_id: str, branch: str, files: list[str], + cwd: str | None = None, ) -> bool: expires = (date.today() + timedelta(days=SUMMARY_EXPIRY_DAYS)).isoformat() metadata = { @@ -175,8 +177,12 @@ def store_summary( if files: metadata["files_touched"] = files[:20] + # summary_prompt wraps the assistant's own last message. Mem0 extracts "facts + # about the user" from each message and role is the only signal telling it who + # spoke, so role="user" here turns Claude's opinions into the human's stated + # preferences ("User prefers dropping Redis..."). body = { - "messages": [{"role": "user", "content": summary_prompt}], + "messages": [{"role": "assistant", "content": summary_prompt}], "user_id": user_id, "app_id": project_id, "run_id": session_id, @@ -184,6 +190,8 @@ def store_summary( "infer": True, "expiration_date": expires, } + # Apply the project's mem0.md extraction policy (custom/agent instructions). + body.update(load_instructions(cwd)) data = json.dumps(body).encode("utf-8") req = urllib.request.Request( @@ -253,7 +261,7 @@ def main(): 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) + store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files, cwd) if __name__ == "__main__": diff --git a/integrations/mem0-plugin/scripts/kimi_hook_shim.sh b/integrations/mem0-plugin/scripts/kimi_hook_shim.sh new file mode 100755 index 000000000..24e622706 --- /dev/null +++ b/integrations/mem0-plugin/scripts/kimi_hook_shim.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Kimi Code hook adapter. +# +# Kimi Code's hook contract is close to Claude Code's but differs in four ways +# that the mem0 hook scripts care about. Rather than fork nine scripts, every +# Kimi hook entry in .kimi-plugin/plugin.json routes through this shim. +# +# 1. cwd Kimi runs hook commands with cwd forced to the PLUGIN root +# (agent-core-v2 src/app/plugin/manager.ts -> enabledHooks() sets +# `cwd: record.root`). The real project directory only arrives as +# the payload's `cwd` field. _project.sh resolves MEM0_PROJECT_ID +# from $PWD/`git remote`, so we chdir into the payload cwd first. +# +# 2. stdin Kimi sends snake_case JSON like Claude, but: +# - `prompt` is a ContentPart[] array, not a string +# - PostToolUse sends `tool_output`, not `tool_response` +# - file tools use `tool_input.path`, not `tool_input.file_path` +# - plugin MCP tools are `mcp__plugin-mem0_mem0__*` (hyphen), +# not Claude's `mcp__plugin_mem0_mem0__*` +# - there is NO `transcript_path` (no equivalent exists) +# We normalise the first four into the Claude shape. +# +# 3. stdout Kimi's hook stdout parser (agent-core-v2 +# src/agent/externalHooks/runner.ts -> HookJsonOutputSchema) only +# understands top-level `message`, `hookSpecificOutput.message`, +# `hookSpecificOutput.permissionDecision` and +# `hookSpecificOutput.permissionDecisionReason`. +# `additionalContext` and `updatedInput` are NOT recognised. +# Raw (non-JSON) stdout IS appended to context for UserPromptSubmit +# (user-prompt.ts -> userPromptHookMessage falls back to stdout), so +# we unwrap additionalContext into plain text. +# +# 4. exit Same as Claude: 0 = allow, 2 = block (stderr is the reason), +# any other code / timeout = fail-open. +# +# Usage: kimi_hook_shim.sh [args...] + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +[ $# -ge 1 ] || exit 0 +TARGET_NAME="$1" +shift +TARGET="$SCRIPT_DIR/$TARGET_NAME" +[ -f "$TARGET" ] || exit 0 + +# Telemetry attribution (telemetry.py::detect_platform honours MEM0_PLATFORM). +export MEM0_PLATFORM="${MEM0_PLATFORM:-kimi}" + +RAW=$(cat) + +_TMP_BASE="${TMPDIR:-/tmp}" +IN_FILE="$_TMP_BASE/mem0_kimi_in_$$" +OUT_FILE="$_TMP_BASE/mem0_kimi_out_$$" +trap 'rm -f "$IN_FILE" "$OUT_FILE"' EXIT + +HAVE_JQ="" +command -v jq >/dev/null 2>&1 && HAVE_JQ="true" + +# ---------------------------------------------------------------- stdin shape +NORM="" +if [ -n "$HAVE_JQ" ]; then + NORM=$(printf '%s' "$RAW" | jq -c ' + def flat_prompt: + if type == "array" then + [ .[]? | if type == "object" then (.text // "") elif type == "string" then . else "" end ] + | map(select(. != "")) | join("\n") + elif type == "string" then . + else "" end; + . as $in + | (if has("prompt") then .prompt = ($in.prompt | flat_prompt) else . end) + | (if (has("tool_response") | not) and (.tool_output != null) + then .tool_response = .tool_output else . end) + | (if ((.tool_input | type) == "object") and (.tool_input.file_path == null) and (.tool_input.path != null) + then .tool_input.file_path = .tool_input.path else . end) + | (if ((.tool_name | type) == "string") and (.tool_name | startswith("mcp__")) and (.tool_name | test("mem0")) + then .tool_name = (.tool_name | gsub("-"; "_")) else . end) + ' 2>/dev/null) +fi +[ -n "$NORM" ] || NORM="$RAW" + +printf '%s' "$NORM" >"$IN_FILE" 2>/dev/null || exit 0 + +# ------------------------------------------------------------------- real cwd +if [ -n "$HAVE_JQ" ]; then + PROJECT_CWD=$(printf '%s' "$NORM" | jq -r '.cwd // ""' 2>/dev/null || printf '') + if [ -n "$PROJECT_CWD" ] && [ -d "$PROJECT_CWD" ]; then + cd "$PROJECT_CWD" 2>/dev/null || true + fi +fi + +# ------------------------------------------------------------------- dispatch +# Redirect (not pipe) stdout so backgrounded children inside the hook scripts +# cannot hold the shim open until Kimi's timeout fires. +bash "$TARGET" "$@" <"$IN_FILE" >"$OUT_FILE" +CODE=$? + +OUT=$(cat "$OUT_FILE" 2>/dev/null || printf '') + +# --------------------------------------------------------------- stdout shape +if [ -n "$OUT" ] && [ -n "$HAVE_JQ" ] && printf '%s' "$OUT" | jq -e 'type == "object"' >/dev/null 2>&1; then + if printf '%s' "$OUT" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then + # Kimi understands deny natively β€” pass the envelope straight through. + printf '%s' "$OUT" + else + CTX=$(printf '%s' "$OUT" | jq -r ' + (.hookSpecificOutput.additionalContext + // .additionalContext + // .message + // .hookSpecificOutput.message + // "") + | gsub("\\\\n"; "\n")' 2>/dev/null || printf '') + [ -n "$CTX" ] && printf '%s\n' "$CTX" + fi +elif [ -n "$OUT" ]; then + printf '%s' "$OUT" +fi + +exit $CODE diff --git a/integrations/mem0-plugin/scripts/load_settings.py b/integrations/mem0-plugin/scripts/load_settings.py index 58a53b569..139d180c6 100644 --- a/integrations/mem0-plugin/scripts/load_settings.py +++ b/integrations/mem0-plugin/scripts/load_settings.py @@ -27,24 +27,45 @@ def load_settings() -> dict: try: with open(SETTINGS_PATH) as f: user = json.load(f) - settings.update({k: v for k, v in user.items() if k in DEFAULTS}) except (json.JSONDecodeError, OSError): - pass + return settings + if isinstance(user, dict): + settings.update({k: v for k, v in user.items() if k in DEFAULTS}) return settings -def create_default_settings() -> None: +def create_default_settings() -> bool: + """Write the default settings file if absent. Returns True if it was created.""" SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + if SETTINGS_PATH.exists(): + return False + with open(SETTINGS_PATH, "w") as f: + json.dump(DEFAULTS, f, indent=2) + f.write("\n") + return True + + +def unknown_keys() -> list[str]: + """Keys present in the user's settings file that no code reads.""" if not SETTINGS_PATH.exists(): - with open(SETTINGS_PATH, "w") as f: - json.dump(DEFAULTS, f, indent=2) - f.write("\n") + return [] + try: + with open(SETTINGS_PATH) as f: + user = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + if not isinstance(user, dict): + return [] + return sorted(k for k in user if k not in DEFAULTS) if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "init": - create_default_settings() - print(f"Created {SETTINGS_PATH}") + if create_default_settings(): + print(f"Created {SETTINGS_PATH}") + ignored = unknown_keys() + if ignored: + print(f"Ignoring unrecognized settings in {SETTINGS_PATH}: {', '.join(ignored)}") else: print(json.dumps(load_settings())) diff --git a/integrations/mem0-plugin/scripts/on_session_start.sh b/integrations/mem0-plugin/scripts/on_session_start.sh index 9f89e6745..b2e83926b 100755 --- a/integrations/mem0-plugin/scripts/on_session_start.sh +++ b/integrations/mem0-plugin/scripts/on_session_start.sh @@ -148,6 +148,14 @@ if command -v python3 >/dev/null 2>&1; then if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then _CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?") echo "mem0.md loaded (${_CONFIG_KEYS} sections configured)." + # Surface the project's memory policy so the model applies it when choosing + # what to store via add_memory (the hook writes also send it as an + # extraction param β€” see _instructions.py). + _INSTR=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('instructions',''))" 2>/dev/null || echo "") + if [ -n "$_INSTR" ]; then + echo "" + echo "Project memory policy (from mem0.md): ${_INSTR}" + fi fi fi diff --git a/integrations/mem0-plugin/scripts/parse_mem0_config.py b/integrations/mem0-plugin/scripts/parse_mem0_config.py index 7dc132faa..dd6361f3a 100644 --- a/integrations/mem0-plugin/scripts/parse_mem0_config.py +++ b/integrations/mem0-plugin/scripts/parse_mem0_config.py @@ -132,6 +132,28 @@ def parse_section_list(content: str, heading: str) -> list[str]: return items +def parse_section_text(content: str, heading: str) -> str: + """Parse a free-text prose section from mem0.md. + + Looks for ``## `` (case-insensitive) and returns the prose beneath + it (up to the next ``##`` heading) collapsed to a single line. Blank lines and + full-line ``#`` comments are dropped; inline ``#`` is preserved (prose may + reference e.g. issue ``#123``). + """ + pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)" + match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE) + if not match: + return "" + + lines: list[str] = [] + for line in match.group(1).splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + lines.append(stripped) + return " ".join(lines).strip() + + def parse_ignore_patterns(content: str) -> list[str]: """Parse the ``## Ignore`` section of *content*. @@ -203,6 +225,16 @@ def load_full_config(cwd: str | None = None) -> dict: if settings: config["settings"] = settings + # Extraction policy: `## Instructions` -> custom_instructions (user/project + # scope), `## Agent Instructions` -> agent_custom_instructions (agent scope). + instructions = parse_section_text(content, "Instructions") + if instructions: + config["instructions"] = instructions + + agent_instructions = parse_section_text(content, "Agent Instructions") + if agent_instructions: + config["agent_instructions"] = agent_instructions + return config diff --git a/integrations/mem0-plugin/scripts/telemetry.py b/integrations/mem0-plugin/scripts/telemetry.py index afebcb67b..cb29584ff 100644 --- a/integrations/mem0-plugin/scripts/telemetry.py +++ b/integrations/mem0-plugin/scripts/telemetry.py @@ -34,6 +34,7 @@ _PLATFORM_MANIFESTS = { "claude-code": ("..", ".claude-plugin", "plugin.json"), "cursor": ("..", ".cursor-plugin", "plugin.json"), "codex": ("..", ".codex-plugin", "plugin.json"), + "kimi": ("..", ".kimi-plugin", "plugin.json"), } _DEFAULT_MANIFEST = ("..", ".claude-plugin", "plugin.json") @@ -74,6 +75,8 @@ def detect_platform() -> str: return explicit if os.environ.get("ANTIGRAVITY_PLUGIN_ROOT"): return "antigravity" + if os.environ.get("KIMI_PLUGIN_ROOT"): + return "kimi" if os.environ.get("PLUGIN_ROOT"): return "codex" if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"): diff --git a/integrations/mem0-plugin/skills/mem0/references/features.md b/integrations/mem0-plugin/skills/mem0/references/features.md index 66875ee75..1cfaba61f 100644 --- a/integrations/mem0-plugin/skills/mem0/references/features.md +++ b/integrations/mem0-plugin/skills/mem0/references/features.md @@ -8,7 +8,6 @@ Additional platform capabilities beyond core CRUD operations. - [Entity Linking](#entity-linking) - [Custom Categories](#custom-categories) - [Custom Instructions](#custom-instructions) -- [Criteria Retrieval](#criteria-retrieval) - [Feedback Mechanism](#feedback-mechanism) - [Memory Export](#memory-export) - [Group Chat](#group-chat) @@ -102,9 +101,29 @@ await client.updateProject({ customCategories: newCategories }); categories = client.project.get(fields=["custom_categories"]) ``` -### Key Constraint +**Override categories for a single add call:** +```python +client.add(messages, user_id="alice", custom_categories=per_call_categories) +``` -Per-request overrides (`custom_categories=...` on `client.add`) are **not supported** on the managed API. Only project-level configuration works. Workaround: store ad-hoc labels in `metadata` field. +```javascript +await client.add(messages, { userId: "alice", customCategories: perCallCategories }); +``` + +### Resolution Order + +1. `custom_categories` passed on the `add` call +2. `custom_categories` set on the project +3. Built-in default catalog + +### Key Constraints + +- A per-call list **fully replaces** the project list for that call. The lists are not merged. +- Categories are applied at ingestion time. Changing the list later does not re-tag existing memories. + +### Main Use Case + +Per-call lists give different users or entities their own vocabulary inside a single project, without splitting them across projects. --- @@ -145,44 +164,6 @@ await client.updateProject({ customInstructions: "Your guidelines here..." }); --- -## Criteria Retrieval - -Custom attribute-based memory ranking using LLM-evaluated criteria with weights. Goes beyond semantic similarity to prioritize memories based on domain-specific signals. - -### Configuration - -```python -# Define criteria at project level -retrieval_criteria = [ - {"name": "joy", "description": "Positive emotions like happiness and excitement", "weight": 3}, - {"name": "curiosity", "description": "Inquisitiveness and desire to learn", "weight": 2}, - {"name": "urgency", "description": "Time-sensitive or high-priority items", "weight": 4}, -] -client.project.update(retrieval_criteria=retrieval_criteria) -``` - -```typescript -await client.updateProject({ - retrievalCriteria: [ - { name: 'joy', description: 'Positive emotions', weight: 3 }, - { name: 'urgency', description: 'Time-sensitive items', weight: 4 }, - ], -}); -``` - -### Usage - -Once configured, `client.search()` automatically applies criteria ranking: - -```python -# Criteria-weighted results returned automatically -results = client.search("Why am I feeling happy?", filters={"user_id": "alice"}) -``` - -**Best for:** Wellness assistants, tutoring platforms, productivity tools β€” any app needing intent-aware retrieval. - ---- - ## Feedback Mechanism Provide feedback on extracted memories to improve system quality over time. diff --git a/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py b/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py index 1ff8de1c7..a04c89afb 100755 --- a/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py +++ b/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py @@ -62,10 +62,6 @@ SECTION_MAP = { "/open-source/features/rest-api", "/open-source/configure-components", ], - "openmemory": [ - "/openmemory/overview", - "/openmemory/quickstart", - ], "sdks": [ "/sdks/python", "/sdks/js", diff --git a/integrations/mem0-plugin/skills/policy/SKILL.md b/integrations/mem0-plugin/skills/policy/SKILL.md new file mode 100644 index 000000000..43bd64ed2 --- /dev/null +++ b/integrations/mem0-plugin/skills/policy/SKILL.md @@ -0,0 +1,71 @@ +--- +name: policy +description: Views or sets the project's memory-extraction policy (what Mem0 remembers vs ignores) stored in mem0.md. Use when the user says set a memory policy, custom instructions, what should mem0 remember, tell mem0 to ignore X, or asks to see/change the current instructions. +--- + +# Mem0 Policy + +The project's memory policy lives in a `## Instructions` section of `mem0.md` at +the repo root. It is version-controlled and shared by the whole team, and it maps +to Mem0's `custom_instructions` (what to extract / ignore). An optional +`## Agent Instructions` section maps to `agent_custom_instructions` (guidance for +agent-scoped memories only). + +The policy takes effect on the **next session** (mem0.md is re-parsed on +SessionStart) and is applied automatically on the plugin's memory writes. + +## Execution + +### Step 1: Determine intent from the argument + +`/mem0:policy` is invoked as `/mem0:policy [] `: + +| Argument | Action | +|---|---| +| _(none)_ or `show` | Show the current policy | +| `set ` or free text | Set/replace the `## Instructions` (custom) policy | +| `agent ` | Set/replace the `## Agent Instructions` policy | +| `clear` | Remove both policy sections | + +### Step 2: Locate mem0.md + +`mem0.md` sits at the repo root (the current working directory). If it does not +exist yet and the user is setting a policy, you will create it. + +### Step 3a: Show + +Run `python3 "$CLAUDE_PLUGIN_ROOT/scripts/parse_mem0_config.py" --key instructions .` +and `--key agent_instructions .` (or read `mem0.md` directly). Print: + +``` +Memory policy for this project (mem0.md): + Instructions: + Agent Instructions: +``` + +If `mem0.md` is absent, say there is no policy yet and offer to set one. + +### Step 3b: Set / clear + +Edit `mem0.md`, adding or replacing the target section. Keep the instruction a +short prose paragraph (what to remember, what to ignore). Example: + +```markdown +## Instructions +Remember architecture decisions, API contracts, and team conventions. +Ignore transient debugging output, stack traces, and anything resembling a secret. +``` + +- Preserve any other existing sections (`## Retention`, `## Categories`, etc.). +- If the section already exists, replace its body; otherwise append the section. +- For `clear`, delete the `## Instructions` and `## Agent Instructions` sections. + +### Step 4: Confirm + +``` +Updated memory policy in mem0.md. +Takes effect next session. Commit mem0.md to share it with your team. +``` + +Remind the user that `## Agent Instructions` only affects agent-scoped memories, +so it is a no-op unless memories are written with an `agent_id`. diff --git a/integrations/mem0-plugin/tests/conftest.py b/integrations/mem0-plugin/tests/conftest.py index ab3eb809c..a170017f5 100644 --- a/integrations/mem0-plugin/tests/conftest.py +++ b/integrations/mem0-plugin/tests/conftest.py @@ -23,15 +23,14 @@ def _scripts_on_path(): @pytest.fixture(autouse=True) -def _clean_project_map(monkeypatch): - """Remove project_map.json and clear MEM0_PROJECT_ID before each test.""" +def _isolated_home(tmp_path, monkeypatch): + """Point HOME at a tmp dir so ~/.mem0 writes never touch the real home.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) - map_path = os.path.expanduser("~/.mem0/project_map.json") - if os.path.isfile(map_path): - os.remove(map_path) - yield - if os.path.isfile(map_path): - os.remove(map_path) + yield home @pytest.fixture() diff --git a/integrations/mem0-plugin/tests/test_instructions.py b/integrations/mem0-plugin/tests/test_instructions.py new file mode 100644 index 000000000..cbbe41005 --- /dev/null +++ b/integrations/mem0-plugin/tests/test_instructions.py @@ -0,0 +1,126 @@ +"""Tests for the mem0.md extraction-policy feature. + +Covers the parser (`parse_section_text` + the Instructions sections in +`load_full_config`) and the `_instructions.load_instructions` helper that the +hook writers use to attach `custom_instructions` / `agent_custom_instructions` +to a memory write. +""" + +from __future__ import annotations + +MEM0_MD = """\ +# mem0.md + +## Categories +- architecture_decisions + +## Instructions +Remember architecture decisions and conventions. +Ignore transient debug output and secrets (issue #123 refs are fine). + +## Agent Instructions +For agent memories, focus on tools and task outcomes. +""" + + +# --------------------------------------------------------------------------- +# parse_section_text +# --------------------------------------------------------------------------- + + +def test_parse_section_text_collapses_prose(): + from parse_mem0_config import parse_section_text + + text = parse_section_text(MEM0_MD, "Instructions") + assert text == ( + "Remember architecture decisions and conventions. " + "Ignore transient debug output and secrets (issue #123 refs are fine)." + ) + + +def test_parse_section_text_preserves_inline_hash(): + """Inline '#' (e.g. issue refs) is kept; only full-line comments are dropped.""" + from parse_mem0_config import parse_section_text + + assert "#123" in parse_section_text(MEM0_MD, "Instructions") + + +def test_instructions_and_agent_instructions_are_distinct(): + """'## Instructions' must not swallow '## Agent Instructions'.""" + from parse_mem0_config import parse_section_text + + assert parse_section_text(MEM0_MD, "Instructions").startswith("Remember architecture") + assert parse_section_text(MEM0_MD, "Agent Instructions") == ( + "For agent memories, focus on tools and task outcomes." + ) + + +def test_parse_section_text_missing_returns_empty(): + from parse_mem0_config import parse_section_text + + assert parse_section_text("## Retention\nx: 1d\n", "Instructions") == "" + + +def test_load_full_config_includes_instructions(tmp_path): + from parse_mem0_config import load_full_config + + (tmp_path / "mem0.md").write_text(MEM0_MD) + config = load_full_config(str(tmp_path)) + assert config["instructions"].startswith("Remember architecture") + assert config["agent_instructions"].startswith("For agent memories") + + +# --------------------------------------------------------------------------- +# load_instructions (the helper the hook writers call) +# --------------------------------------------------------------------------- + + +def test_load_instructions_maps_to_api_field_names(tmp_path): + from _instructions import load_instructions + + (tmp_path / "mem0.md").write_text(MEM0_MD) + out = load_instructions(str(tmp_path)) + assert out == { + "custom_instructions": ( + "Remember architecture decisions and conventions. " + "Ignore transient debug output and secrets (issue #123 refs are fine)." + ), + "agent_custom_instructions": "For agent memories, focus on tools and task outcomes.", + } + + +def test_load_instructions_no_config_is_empty(tmp_path): + """No mem0.md -> empty dict, so a write body gains nothing.""" + from _instructions import load_instructions + + assert load_instructions(str(tmp_path)) == {} + + +def test_load_instructions_only_custom(tmp_path): + """A project with only '## Instructions' omits the agent key entirely.""" + from _instructions import load_instructions + + (tmp_path / "mem0.md").write_text("## Instructions\nRemember decisions.\n") + out = load_instructions(str(tmp_path)) + assert out == {"custom_instructions": "Remember decisions."} + + +def test_load_instructions_defaults_cwd_to_env(tmp_path, monkeypatch): + """With no arg, cwd falls back to MEM0_CWD.""" + from _instructions import load_instructions + + (tmp_path / "mem0.md").write_text("## Instructions\nRemember decisions.\n") + monkeypatch.setenv("MEM0_CWD", str(tmp_path)) + assert load_instructions() == {"custom_instructions": "Remember decisions."} + + +def test_load_instructions_body_merge_shape(tmp_path): + """The result merges cleanly into an add body without clobbering other keys.""" + from _instructions import load_instructions + + (tmp_path / "mem0.md").write_text("## Instructions\nRemember decisions.\n") + body = {"messages": [], "user_id": "u", "infer": True} + body.update(load_instructions(str(tmp_path))) + assert body["user_id"] == "u" and body["infer"] is True + assert body["custom_instructions"] == "Remember decisions." + assert "agent_custom_instructions" not in body diff --git a/integrations/mem0-plugin/tests/test_kimi_manifests.py b/integrations/mem0-plugin/tests/test_kimi_manifests.py new file mode 100644 index 000000000..942f2ced6 --- /dev/null +++ b/integrations/mem0-plugin/tests/test_kimi_manifests.py @@ -0,0 +1,80 @@ +"""Validate the Kimi Code plugin manifests and hook wiring. + +Kimi Code loads a plugin from `.kimi-plugin/plugin.json`. This test locks down +the structural contract so a stray edit cannot silently break the plugin: + + - both manifests parse and carry the required Kimi fields + - the MCP server uses Kimi's supported auth (transport/url/bearerTokenEnvVar) + - every hook command routes through kimi_hook_shim.sh and targets a script + that actually exists in scripts/ + - the sessionStart skill and skills/ path resolve on disk +""" + +import json +import os + +PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(os.path.dirname(PLUGIN_DIR)) + +SUBDIR_MANIFEST = os.path.join(PLUGIN_DIR, ".kimi-plugin", "plugin.json") +ROOT_MARKETPLACE = os.path.join(REPO_ROOT, ".kimi-plugin", "marketplace.json") +SHIM = os.path.join(PLUGIN_DIR, "scripts", "kimi_hook_shim.sh") + + +def _load(path): + with open(path) as fh: + return json.load(fh) + + +def test_manifest_parses_and_has_required_fields(): + m = _load(SUBDIR_MANIFEST) + assert m["name"] == "mem0" + for field in ("version", "description", "skills", "mcpServers", "interface", "hooks"): + assert field in m, f"manifest missing {field}" + + +def test_mcp_uses_kimi_supported_auth(): + srv = _load(SUBDIR_MANIFEST)["mcpServers"]["mem0"] + assert srv["transport"] == "http" + assert srv["url"] == "https://mcp.mem0.ai/mcp/" # trailing slash avoids the 307 redirect + # Kimi reads the named env var and sends Authorization: Bearer . + assert srv["bearerTokenEnvVar"] == "MEM0_API_KEY" + assert "headers" not in srv # Kimi does not interpolate ${env:...} inside headers + + +def test_sessionstart_skill_and_skills_dir_resolve(): + m = _load(SUBDIR_MANIFEST) + assert m["skills"] == "./skills/" + assert os.path.isdir(os.path.join(PLUGIN_DIR, "skills")) + start_skill = m["sessionStart"]["skill"] + assert os.path.isdir(os.path.join(PLUGIN_DIR, "skills", start_skill)) + + +def test_shim_exists_and_is_executable(): + assert os.path.isfile(SHIM) + assert os.access(SHIM, os.X_OK), "kimi_hook_shim.sh must be executable" + + +def test_every_hook_routes_through_shim_to_a_real_script(): + hooks = _load(SUBDIR_MANIFEST)["hooks"] + assert hooks, "no hooks declared" + valid_events = { + "SessionStart", "UserPromptSubmit", "PreToolUse", + "PostToolUse", "Stop", "PreCompact", + } + for hook in hooks: + assert hook["event"] in valid_events, f"unknown event {hook['event']}" + cmd = hook["command"] + assert "kimi_hook_shim.sh" in cmd, f"hook must route through the shim: {cmd}" + assert "$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh" in cmd + target = cmd.rsplit('"', 1)[-1].strip() # the script name after the shim + assert target.endswith(".sh") + assert os.path.isfile(os.path.join(PLUGIN_DIR, "scripts", target)), \ + f"hook target script missing: {target}" + + +def test_root_marketplace_catalog(): + cat = _load(ROOT_MARKETPLACE) + entry = cat["plugins"][0] + assert entry["id"] == "mem0" # Kimi marketplace entries key on id + assert entry["source"].startswith("https://github.com/mem0ai/mem0") diff --git a/integrations/mem0-plugin/tests/test_load_settings.py b/integrations/mem0-plugin/tests/test_load_settings.py new file mode 100644 index 000000000..0f3216256 --- /dev/null +++ b/integrations/mem0-plugin/tests/test_load_settings.py @@ -0,0 +1,86 @@ +"""Tests for scripts/load_settings.py.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + + +@pytest.fixture() +def settings(tmp_path, monkeypatch): + import load_settings + + path = tmp_path / ".mem0" / "settings.json" + monkeypatch.setattr(load_settings, "SETTINGS_PATH", path) + return load_settings + + +def test_creates_file_when_absent(settings): + assert settings.create_default_settings() is True + assert settings.SETTINGS_PATH.exists() + assert json.loads(settings.SETTINGS_PATH.read_text()) == settings.DEFAULTS + + +def test_does_not_recreate_or_overwrite_existing(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"search_limit": 42}') + + assert settings.create_default_settings() is False + assert json.loads(settings.SETTINGS_PATH.read_text()) == {"search_limit": 42} + + +def test_user_values_override_defaults(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"search_limit": 3, "auto_save": false}') + + loaded = settings.load_settings() + assert loaded["search_limit"] == 3 + assert loaded["auto_save"] is False + assert loaded["global_search"] == settings.DEFAULTS["global_search"] + + +def test_unknown_keys_are_dropped_but_reported(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"skip_tools": ["Read"], "output_style": "compact"}') + + assert "skip_tools" not in settings.load_settings() + assert settings.unknown_keys() == ["output_style", "skip_tools"] + + +def test_unknown_keys_empty_for_clean_file(settings): + settings.create_default_settings() + assert settings.unknown_keys() == [] + + +@pytest.mark.parametrize("body", ["{not json", '["a", "list"]']) +def test_malformed_file_falls_back_to_defaults(settings, body): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text(body) + + assert settings.load_settings() == settings.DEFAULTS + assert settings.unknown_keys() == [] + + +def test_init_announces_creation_only_once(_isolated_home): + import load_settings + + home = _isolated_home + + def run_init(): + return subprocess.run( + [sys.executable, load_settings.__file__, "init"], + capture_output=True, + text=True, + check=True, + env={**os.environ, "HOME": str(home)}, + ).stdout + + first = run_init() + second = run_init() + + assert "Created" in first + assert "Created" not in second diff --git a/integrations/mem0-plugin/tests/test_message_roles.py b/integrations/mem0-plugin/tests/test_message_roles.py new file mode 100644 index 000000000..0a36ca66e --- /dev/null +++ b/integrations/mem0-plugin/tests/test_message_roles.py @@ -0,0 +1,144 @@ +"""Regression tests: assistant-authored text must never be posted as role="user". + +The Stop hook (capture_session_summary) and the post-compact hook +(capture_compact_summary) both ship *model-authored* prose to +POST /v3/memories/add/. Mem0's fact extractor renders each message as +"{role}: {content}" and is instructed to extract "facts and preferences about +the user" β€” so role is the only signal separating what the human said from what +Claude said. + +Posting Claude's own words under role="user" made the extractor read Claude's +first-person prose ("I recommend pgvector", "I found the bug in auth.py") as the +*human's* statements and store them under their user_id. The Stop hook fires on +every assistant turn, so this corrupted memory on nearly every message. +""" + +from __future__ import annotations + +import json + + +class _FakeResp: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +def _capture(monkeypatch, module): + """Patch urlopen so store_summary posts nowhere; capture the request body.""" + captured: dict = {} + + def fake_urlopen(req, timeout=0): + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResp() + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + return captured + + +# Claude's own voice β€” first-person prose that must never be attributed to the human. +ASSISTANT_PROSE = ( + "I traced the root cause to auth.py and I recommend we switch to pgvector " + "for the vector store. I'll refactor the session handler next." +) + + +def test_session_summary_posts_assistant_prose_as_assistant(monkeypatch): + """Stop hook: the last assistant message must be tagged role="assistant".""" + import capture_session_summary as css + + captured = _capture(monkeypatch, css) + + css.store_summary( + api_key="test-key", + summary_prompt=css.build_summary_prompt(ASSISTANT_PROSE, []), + user_id="u1", + session_id="s1", + project_id="p1", + branch="main", + files=[], + ) + + messages = captured["body"]["messages"] + for msg in messages: + if ASSISTANT_PROSE in msg["content"]: + assert msg["role"] == "assistant", ( + "Claude's own words were posted as role='user' β€” mem0 will extract " + "them as facts about the human. Got role=%r" % msg["role"] + ) + break + else: + raise AssertionError("assistant prose never made it into the payload") + + +def test_compact_summary_posts_assistant_prose_as_assistant(monkeypatch): + """Post-compact hook: the compact summary is model-authored, not user-authored.""" + import capture_compact_summary as ccs + + captured = _capture(monkeypatch, ccs) + + ccs.store_summary( + api_key="test-key", + summary=ASSISTANT_PROSE, + user_id="u1", + session_id="s1", + project_id="p1", + branch="main", + ) + + messages = captured["body"]["messages"] + for msg in messages: + if ASSISTANT_PROSE in msg["content"]: + assert msg["role"] == "assistant", ( + "Compact summary (written by Claude) was posted as role='user'. Got role=%r" % msg["role"] + ) + break + else: + raise AssertionError("assistant prose never made it into the payload") + + +def test_no_user_role_message_carries_assistant_prose(monkeypatch): + """Belt and braces: no user-role message may contain the assistant's words.""" + import capture_session_summary as css + + captured = _capture(monkeypatch, css) + + css.store_summary( + api_key="test-key", + summary_prompt=css.build_summary_prompt(ASSISTANT_PROSE, ["auth.py"]), + user_id="u1", + session_id="s1", + project_id="p1", + branch="main", + files=["auth.py"], + ) + + for msg in captured["body"]["messages"]: + if msg["role"] == "user": + assert ASSISTANT_PROSE not in msg["content"], ( + "A user-role message carries Claude's prose β€” this is the misattribution bug." + ) + + +def test_auto_capture_preserves_real_roles(): + """auto_capture is the reference: it must pass roles through untouched.""" + import auto_capture + + lines = [ + json.dumps({"type": "user", "message": {"role": "user", "content": "why is the build failing on main?"}}), + json.dumps( + { + "type": "assistant", + "message": {"role": "assistant", "content": [{"type": "text", "text": ASSISTANT_PROSE}]}, + } + ), + ] + + messages = auto_capture.extract_recent_exchanges(lines) + + assert [m["role"] for m in messages] == ["user", "assistant"] + assert ASSISTANT_PROSE in messages[1]["content"] diff --git a/integrations/mem0-plugin/tests/test_search.py b/integrations/mem0-plugin/tests/test_search.py index ccf1437d3..58c1c8042 100644 --- a/integrations/mem0-plugin/tests/test_search.py +++ b/integrations/mem0-plugin/tests/test_search.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import urllib.error from unittest.mock import MagicMock, patch @@ -101,6 +102,39 @@ def test_search_memories_no_api_key_returns_empty(): assert results == [] +def test_search_memories_logs_rate_limit_error(capsys): + """Bug bash #22: a 429 must not look identical to a genuine empty result.""" + from _search import search_memories + + def mock_urlopen(req, timeout=None): + raise urllib.error.HTTPError("http://x", 429, "Too Many Requests", {}, None) + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + results = search_memories("key", "user", "proj", "query") + + assert results == [] + err = capsys.readouterr().err + assert "429" in err + assert "Too Many Requests" in err + + +def test_search_memories_happy_path_is_silent(capsys): + from _search import search_memories + + def mock_urlopen(req, timeout=None): + resp = MagicMock() + resp.read.return_value = json.dumps({"results": []}).encode() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + results = search_memories("key", "user", "proj", "query") + + assert results == [] + assert capsys.readouterr().err == "" + + def test_search_memories_omits_rerank_by_default(): """Regression for #5684: rerank must not be sent unless requested.""" from _search import search_memories diff --git a/integrations/mem0-plugin/tests/test_telemetry.py b/integrations/mem0-plugin/tests/test_telemetry.py index 1c15a8158..d33b33393 100644 --- a/integrations/mem0-plugin/tests/test_telemetry.py +++ b/integrations/mem0-plugin/tests/test_telemetry.py @@ -157,6 +157,22 @@ def test_platform_codex(monkeypatch): assert telemetry.detect_platform() == "codex" +def test_platform_kimi(monkeypatch): + """Kimi Code usage must be attributed to its own platform so it can be + counted distinctly. The shim also pins MEM0_PLATFORM=kimi; this covers the + KIMI_PLUGIN_ROOT auto-detection fallback.""" + import telemetry + + monkeypatch.delenv("MEM0_PLATFORM", raising=False) + monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("PLUGIN_ROOT", raising=False) + monkeypatch.setenv("KIMI_PLUGIN_ROOT", "/path") + assert telemetry.detect_platform() == "kimi" + + def test_platform_explicit_override(monkeypatch): """MEM0_PLATFORM wins over auto-detection so each editor can label itself reliably even when host env vars are ambiguous or absent.""" @@ -190,6 +206,7 @@ def test_plugin_version_is_per_editor(monkeypatch): "claude-code": os.path.join(".claude-plugin", "plugin.json"), "cursor": os.path.join(".cursor-plugin", "plugin.json"), "codex": os.path.join(".codex-plugin", "plugin.json"), + "kimi": os.path.join(".kimi-plugin", "plugin.json"), } for plat, rel in manifests.items(): monkeypatch.setenv("MEM0_PLATFORM", plat) diff --git a/integrations/n8n-nodes-mem0/.eslintrc.js b/integrations/n8n-nodes-mem0/.eslintrc.js new file mode 100644 index 000000000..1c96a24a0 --- /dev/null +++ b/integrations/n8n-nodes-mem0/.eslintrc.js @@ -0,0 +1,42 @@ +module.exports = { + root: true, + env: { browser: true, es6: true, node: true }, + parser: '@typescript-eslint/parser', + parserOptions: { sourceType: 'module', extraFileExtensions: ['.json'] }, + ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**'], + overrides: [ + { + files: ['package.json'], + plugins: ['eslint-plugin-n8n-nodes-base'], + extends: ['plugin:n8n-nodes-base/community'], + rules: { + 'n8n-nodes-base/community-package-json-name-still-default': 'off', + 'n8n-nodes-base/community-package-json-license-not-default': 'off', + }, + }, + { + files: ['./credentials/**/*.ts'], + plugins: ['eslint-plugin-n8n-nodes-base'], + extends: ['plugin:n8n-nodes-base/credentials'], + rules: { + // This rule only applies to nodes in n8n's main repository (where + // documentationUrl is an internal docs slug). Community nodes use a + // full external URL, so it is disabled here. + 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off', + }, + }, + { + files: ['./nodes/**/*.ts'], + plugins: ['eslint-plugin-n8n-nodes-base'], + extends: ['plugin:n8n-nodes-base/nodes'], + rules: { + // Superseded by the verification scanner's `@n8n/community-nodes` + // node-connection-type-literal rule, which requires + // NodeConnectionTypes.Main instead of the 'main' string literal. + // n8n's own scanner disables these two, so we match it. + 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off', + 'n8n-nodes-base/node-class-description-outputs-wrong': 'off', + }, + }, + ], +}; diff --git a/integrations/n8n-nodes-mem0/.gitignore b/integrations/n8n-nodes-mem0/.gitignore new file mode 100644 index 000000000..418172c3c --- /dev/null +++ b/integrations/n8n-nodes-mem0/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +package-lock.json +*.tsbuildinfo +.env +coverage/ +*.log +.DS_Store diff --git a/integrations/n8n-nodes-mem0/.prettierrc.js b/integrations/n8n-nodes-mem0/.prettierrc.js new file mode 100644 index 000000000..fc221e204 --- /dev/null +++ b/integrations/n8n-nodes-mem0/.prettierrc.js @@ -0,0 +1,9 @@ +module.exports = { + semi: true, + trailingComma: 'all', + bracketSpacing: true, + useTabs: true, + tabWidth: 2, + printWidth: 100, + singleQuote: true, +}; diff --git a/integrations/n8n-nodes-mem0/LICENSE b/integrations/n8n-nodes-mem0/LICENSE new file mode 100644 index 000000000..036d7eaae --- /dev/null +++ b/integrations/n8n-nodes-mem0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2026 Taranjeet Singh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/n8n-nodes-mem0/README.md b/integrations/n8n-nodes-mem0/README.md new file mode 100644 index 000000000..7677196c5 --- /dev/null +++ b/integrations/n8n-nodes-mem0/README.md @@ -0,0 +1,71 @@ +# @mem0/n8n-nodes-mem0 + +This is an n8n community node that lets you use [Mem0](https://mem0.ai) β€” the memory layer for AI agents β€” in your n8n workflows. + +Mem0 gives your agents long-term memory: add memories from conversations, then search and recall them across sessions. + +[n8n](https://n8n.io) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. + +[Installation](#installation) Β· [Operations](#operations) Β· [Credentials](#credentials) Β· [Usage](#usage) Β· [Resources](#resources) + +## Installation + +Follow the [community nodes installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) and install `@mem0/n8n-nodes-mem0`. + +## Operations + +The **Memory** resource supports: + +| Operation | Description | Endpoint | +| --- | --- | --- | +| **Add** | Extract and store memories from messages | `POST /v3/memories/add/` | +| **Search** | Semantic search over stored memories | `POST /v3/memories/search/` | +| **Get Many** | List stored memories (single page, or **Return All**) | `POST /v3/memories/` | +| **Get** | Retrieve a single memory by ID | `GET /v1/memories/{id}/` | +| **Update** | Update a memory's text or metadata | `PUT /v1/memories/{id}/` | +| **Delete** | Delete a single memory by ID | `DELETE /v1/memories/{id}/` | + +### Add & asynchronous extraction + +By default, **Add** runs LLM-based extraction asynchronously: the API returns an event ID and the node polls until extraction finishes, then returns the resulting memories. + +Two independent controls: + +- **Wait for Completion** (on by default) decides whether the node polls. Turn it off to return immediately with the event ID. +- **Infer** (on by default, under Additional Fields) decides whether the API runs LLM extraction at all. Turn it off to store the messages verbatim. + +**Custom Instructions**, **Custom Categories**, **Includes**, and **Excludes** (also under Additional Fields) steer what extraction keeps for that call. **Agent ID**, **App ID**, and **Run ID** live there too, and scope the memory alongside (or instead of) **User ID**. + +### Entity filters on Search & Get Many + +Both take **User ID**, **Agent ID**, **App ID**, and **Run ID**, and at least one is required β€” the API rejects a query with no entity scope, and the node fails with a clear message before calling it. + +Supplying several combines them with **OR**, giving the union of those scopes. Mem0 indexes each entity separately, so an `AND` across `user_id` and `agent_id` matches nothing even for a memory written with both. To narrow instead of widen, run one operation per entity id. + +## Credentials + +You need a Mem0 API key. Create one at [app.mem0.ai](https://app.mem0.ai) β†’ Settings β†’ API Keys. The key is sent as `Authorization: Token `. + +## Usage + +This node is also **usable as a tool** by n8n's AI Agent node β€” attach it so an agent can "remember" and "recall" autonomously. + +A typical loop: + +1. **Search** memory before answering, filtered by `User ID` (or `Agent ID` / `App ID` / `Run ID`). +2. **Add** durable facts after a meaningful exchange. + +Memory writes are asynchronous by default; allow a moment after an Add before searching for the same content. + +## Telemetry + +This node sends no third-party telemetry. Its API requests are tagged with `source: "N8N"` so Mem0 can see aggregate usage of the integration. No separate analytics service is contacted and nothing else is collected. + +## Resources + +- [Mem0 documentation](https://docs.mem0.ai) +- [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/) + +## License + +[Apache-2.0](./LICENSE) diff --git a/integrations/n8n-nodes-mem0/credentials/Mem0Api.credentials.ts b/integrations/n8n-nodes-mem0/credentials/Mem0Api.credentials.ts new file mode 100644 index 000000000..eb5bc5ad2 --- /dev/null +++ b/integrations/n8n-nodes-mem0/credentials/Mem0Api.credentials.ts @@ -0,0 +1,56 @@ +import { + IAuthenticateGeneric, + Icon, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class Mem0Api implements ICredentialType { + name = 'mem0Api'; + + displayName = 'Mem0 API'; + + icon: Icon = { light: 'file:mem0.svg', dark: 'file:mem0.svg' }; + + documentationUrl = 'https://docs.mem0.ai/platform/quickstart'; + + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + default: '', + required: true, + description: 'Your Mem0 API key (starts with "m0-"). Create one at app.mem0.ai β†’ Settings β†’ API Keys.', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: 'https://api.mem0.ai', + description: 'Mem0 API base URL. Override only for self-hosted or non-default deployments.', + }, + ]; + + // Injects "Authorization: Token " on every request, matching the + // scheme used by Mem0's official SDKs (Authorization: Token m0-...). + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Token {{$credentials.apiKey}}', + }, + }, + }; + + // Cheap authenticated GET; validates the key when the user clicks "Test". + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials.baseUrl}}', + url: '/v1/ping/', + method: 'GET', + }, + }; +} diff --git a/docs/logo/openmemory-icon.svg b/integrations/n8n-nodes-mem0/credentials/mem0.svg similarity index 94% rename from docs/logo/openmemory-icon.svg rename to integrations/n8n-nodes-mem0/credentials/mem0.svg index 02ca9d5b4..cf13d0d38 100644 --- a/docs/logo/openmemory-icon.svg +++ b/integrations/n8n-nodes-mem0/credentials/mem0.svg @@ -1,19 +1,19 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/integrations/n8n-nodes-mem0/gulpfile.js b/integrations/n8n-nodes-mem0/gulpfile.js new file mode 100644 index 000000000..5a559dbb3 --- /dev/null +++ b/integrations/n8n-nodes-mem0/gulpfile.js @@ -0,0 +1,16 @@ +const path = require('path'); +const { task, src, dest } = require('gulp'); + +task('build:icons', copyIcons); + +function copyIcons() { + // Copy icons and the codex (*.node.json) next to the compiled nodes; tsc emits + // only .js, so these static assets need copying for n8n to pick them up. + const nodeSource = path.resolve('nodes', '**', '*.{png,svg,json}'); + const nodeDestination = path.resolve('dist', 'nodes'); + src(nodeSource).pipe(dest(nodeDestination)); + + const credSource = path.resolve('credentials', '**', '*.{png,svg}'); + const credDestination = path.resolve('dist', 'credentials'); + return src(credSource, { allowEmpty: true }).pipe(dest(credDestination)); +} diff --git a/integrations/n8n-nodes-mem0/index.js b/integrations/n8n-nodes-mem0/index.js new file mode 100644 index 000000000..220f456a6 --- /dev/null +++ b/integrations/n8n-nodes-mem0/index.js @@ -0,0 +1,3 @@ +// n8n loads nodes and credentials via the "n8n" key in package.json. +// This entry point is intentionally empty. +module.exports = {}; diff --git a/integrations/n8n-nodes-mem0/jest.config.js b/integrations/n8n-nodes-mem0/jest.config.js new file mode 100644 index 000000000..cdbe81ec7 --- /dev/null +++ b/integrations/n8n-nodes-mem0/jest.config.js @@ -0,0 +1,10 @@ +/** Jest config lives here (not in package.json) so the published package.json + * stays minimal for the n8n verification scanner. This file is dev-only; it is + * not shipped (see the `files` field in package.json). */ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': ['ts-jest', {}], + }, +}; diff --git a/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.json b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.json new file mode 100644 index 000000000..bd8ee0289 --- /dev/null +++ b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.json @@ -0,0 +1,21 @@ +{ + "node": "@mem0/n8n-nodes-mem0.mem0", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["AI"], + "subcategories": { + "AI": ["Memory"] + }, + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.mem0.ai/integrations/n8n" + } + ], + "credentialDocumentation": [ + { + "url": "https://docs.mem0.ai/integrations/n8n" + } + ] + } +} diff --git a/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts new file mode 100644 index 000000000..b734e1db7 --- /dev/null +++ b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts @@ -0,0 +1,626 @@ +import { + IExecuteFunctions, + IDataObject, + IHttpRequestMethods, + IHttpRequestOptions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + JsonObject, + NodeApiError, + NodeConnectionTypes, + NodeOperationError, + sleep, +} from 'n8n-workflow'; + +// Poll settings for asynchronous (infer=true) memory addition. +const POLL_INTERVAL_MS = 1500; +const MAX_POLL_ATTEMPTS = 40; // ~60s ceiling + +export class Mem0 implements INodeType { + description: INodeTypeDescription = { + displayName: 'Mem0', + name: 'mem0', + icon: { light: 'file:mem0.svg', dark: 'file:mem0.svg' }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Add, search, and manage long-term memories with Mem0', + defaults: { + name: 'Mem0', + }, + // Makes the node available to the AI Agent (Tools Agent) node. + usableAsTool: true, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'mem0Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [{ name: 'Memory', value: 'memory' }], + default: 'memory', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['memory'] } }, + options: [ + { + name: 'Add', + value: 'add', + action: 'Add a memory', + description: 'Extract and store memories from messages', + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete a memory', + description: 'Delete a single memory by ID', + }, + { + name: 'Get', + value: 'get', + action: 'Get a memory', + description: 'Retrieve a single memory by ID', + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many memories', + description: 'List stored memories for an entity', + }, + { + name: 'Search', + value: 'search', + action: 'Search memories', + description: 'Semantic search over stored memories', + }, + { + name: 'Update', + value: 'update', + action: 'Update a memory', + description: 'Update the text or metadata of a memory', + }, + ], + default: 'add', + }, + + // ---- Add --------------------------------------------------------- + { + displayName: 'Messages', + name: 'messages', + placeholder: 'Add Message', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + displayOptions: { show: { resource: ['memory'], operation: ['add'] } }, + default: {}, + description: 'The conversation messages to extract memories from', + options: [ + { + name: 'message', + displayName: 'Message', + values: [ + { + displayName: 'Role', + name: 'role', + type: 'options', + options: [ + { name: 'User', value: 'user' }, + { name: 'Assistant', value: 'assistant' }, + { name: 'System', value: 'system' }, + ], + default: 'user', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + typeOptions: { rows: 2 }, + default: '', + }, + ], + }, + ], + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['add'] } }, + description: 'Associate the memories with this user', + }, + { + displayName: 'Wait for Completion', + name: 'waitForCompletion', + type: 'boolean', + default: true, + displayOptions: { show: { resource: ['memory'], operation: ['add'] } }, + description: + 'Whether to poll until memory extraction finishes and return the resulting memories. Turn off to return immediately with the event ID.', + }, + { + displayName: 'Additional Fields', + name: 'addFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { show: { resource: ['memory'], operation: ['add'] } }, + options: [ + { + displayName: 'Agent ID', + name: 'agent_id', + type: 'string', + default: '', + }, + { + displayName: 'App ID', + name: 'app_id', + type: 'string', + default: '', + }, + { + displayName: 'Custom Categories', + name: 'custom_categories', + type: 'json', + default: '', + description: + 'Optional taxonomy for categorising extracted memories, as a JSON array of {category: description} objects', + }, + { + displayName: 'Custom Instructions', + name: 'custom_instructions', + type: 'string', + typeOptions: { rows: 3 }, + default: '', + description: + 'Optional instructions that steer what the extractor keeps or ignores', + }, + { + displayName: 'Excludes', + name: 'excludes', + type: 'string', + default: '', + description: 'Optional: skip memories matching this description', + }, + { + displayName: 'Includes', + name: 'includes', + type: 'string', + default: '', + description: 'Optional: only extract memories matching this description', + }, + { + displayName: 'Infer', + name: 'infer', + type: 'boolean', + default: true, + description: + 'Whether to run LLM extraction over the messages. Turn off to store them verbatim. ' + + 'This controls extraction only β€” use "Wait for Completion" to control whether the node waits.', + }, + { + displayName: 'Metadata (JSON)', + name: 'metadata', + type: 'json', + default: '', + }, + { + displayName: 'Run ID', + name: 'run_id', + type: 'string', + default: '', + }, + ], + }, + + // ---- Search ------------------------------------------------------ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + required: true, + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'What to recall from memory', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: + 'Restrict the search to this user. Supply at least one of User ID, Agent ID, App ID, or Run ID.', + }, + { + displayName: 'Agent ID', + name: 'agentId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Restrict the search to memories scoped to this agent', + }, + { + displayName: 'App ID', + name: 'appId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Restrict the search to memories scoped to this app or project', + }, + { + displayName: 'Run ID', + name: 'runId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Restrict the search to memories scoped to this session or run', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { minValue: 1 }, + default: 50, + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Max number of results to return', + }, + + // ---- Get Many ---------------------------------------------------- + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: + 'Restrict the listing to this user. Supply at least one of User ID, Agent ID, App ID, or Run ID.', + }, + { + displayName: 'Agent ID', + name: 'agentId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Restrict the listing to memories scoped to this agent', + }, + { + displayName: 'App ID', + name: 'appId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Restrict the listing to memories scoped to this app or project', + }, + { + displayName: 'Run ID', + name: 'runId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Restrict the listing to memories scoped to this session or run', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Page', + name: 'page', + type: 'number', + typeOptions: { minValue: 1 }, + default: 1, + displayOptions: { + show: { resource: ['memory'], operation: ['getAll'], returnAll: [false] }, + }, + }, + { + displayName: 'Page Size', + name: 'pageSize', + type: 'number', + typeOptions: { minValue: 1 }, + default: 50, + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + }, + + // ---- Get / Update / Delete (by ID) ------------------------------- + { + displayName: 'Memory ID', + name: 'memoryId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { resource: ['memory'], operation: ['get', 'update', 'delete'] }, + }, + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['update'] } }, + description: 'The new memory text', + }, + { + displayName: 'Metadata (JSON)', + name: 'metadata', + type: 'json', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['update'] } }, + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + const credentials = await this.getCredentials('mem0Api'); + const baseUrl = (credentials.baseUrl as string) || 'https://api.mem0.ai'; + + const request = async ( + method: IHttpRequestMethods, + url: string, + body?: IDataObject, + qs?: IDataObject, + ): Promise => { + const options: IHttpRequestOptions = { + method, + url: `${baseUrl}${url}`, + json: true, + ...(body ? { body } : {}), + // First-party usage attribution: the backend reads `source` (same as OpenClaw). + qs: { source: 'N8N', ...(qs ?? {}) }, + }; + return (await this.helpers.httpRequestWithAuthentication.call( + this, + 'mem0Api', + options, + )) as IDataObject; + }; + + for (let i = 0; i < items.length; i++) { + try { + const operation = this.getNodeParameter('operation', i) as string; + let responseData: IDataObject | IDataObject[] = {}; + + if (operation === 'add') { + const messagesUi = this.getNodeParameter('messages.message', i, []) as IDataObject[]; + if (!messagesUi.length) { + throw new NodeOperationError(this.getNode(), 'At least one message is required', { + itemIndex: i, + }); + } + const addFields = this.getNodeParameter('addFields', i, {}) as IDataObject; + const body: IDataObject = { + messages: messagesUi.map((m) => ({ role: m.role, content: m.content })), + infer: addFields.infer !== undefined ? addFields.infer : true, + }; + const userId = this.getNodeParameter('userId', i, '') as string; + if (userId) body.user_id = userId; + if (addFields.agent_id) body.agent_id = addFields.agent_id; + if (addFields.app_id) body.app_id = addFields.app_id; + if (addFields.run_id) body.run_id = addFields.run_id; + if (addFields.metadata) { + try { + body.metadata = + typeof addFields.metadata === 'string' + ? JSON.parse(addFields.metadata as string) + : addFields.metadata; + } catch { + throw new NodeOperationError(this.getNode(), 'Invalid JSON in "Metadata" field', { + itemIndex: i, + }); + } + } + + // Custom extraction controls (optional): steer what the API extracts. + if (addFields.custom_instructions) { + body.custom_instructions = addFields.custom_instructions; + } + if (addFields.custom_categories) { + try { + body.custom_categories = + typeof addFields.custom_categories === 'string' + ? JSON.parse(addFields.custom_categories as string) + : addFields.custom_categories; + } catch { + throw new NodeOperationError( + this.getNode(), + 'Invalid JSON in "Custom Categories" field', + { itemIndex: i }, + ); + } + } + + if (addFields.includes) body.includes = addFields.includes; + if (addFields.excludes) body.excludes = addFields.excludes; + + // API requires at least one entity id β€” fail clearly instead of a raw 4xx. + if (!body.user_id && !body.agent_id && !body.run_id && !body.app_id) { + throw new NodeOperationError( + this.getNode(), + 'Add requires at least one of User ID, Agent ID, Run ID, or App ID', + { itemIndex: i }, + ); + } + + const addResp = await request('POST', '/v3/memories/add/', body); + const waitForCompletion = this.getNodeParameter('waitForCompletion', i, true) as boolean; + const addStatus = addResp.status as string | undefined; + const isTerminal = addStatus === 'SUCCEEDED' || addStatus === 'FAILED'; + + // Add returns {event_id, status:PENDING|RUNNING}; poll until terminal when asked to wait. + if (waitForCompletion && addResp.event_id && !isTerminal) { + responseData = await pollEvent(request, addResp.event_id as string, this, i); + } else if (addStatus === 'FAILED') { + throw new NodeOperationError( + this.getNode(), + `Mem0 memory add failed: ${(addResp.message as string) || 'unknown error'}`, + { itemIndex: i }, + ); + } else { + // If the response is already terminal, unwrap results; otherwise return as-is. + responseData = Array.isArray(addResp.results) + ? (addResp.results as IDataObject[]) + : addResp; + } + } else if (operation === 'search') { + const body: IDataObject = { + query: this.getNodeParameter('query', i) as string, + output_format: 'v1.1', + top_k: this.getNodeParameter('limit', i, 50) as number, + }; + body.filters = buildEntityFilters( + { + user_id: this.getNodeParameter('userId', i, '') as string, + agent_id: this.getNodeParameter('agentId', i, '') as string, + app_id: this.getNodeParameter('appId', i, '') as string, + run_id: this.getNodeParameter('runId', i, '') as string, + }, + this, + i, + ); + const resp = await request('POST', '/v3/memories/search/', body); + responseData = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : []; + } else if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i, false) as boolean; + const pageSize = this.getNodeParameter('pageSize', i, 50) as number; + const body: IDataObject = { + filters: buildEntityFilters( + { + user_id: this.getNodeParameter('userId', i, '') as string, + agent_id: this.getNodeParameter('agentId', i, '') as string, + app_id: this.getNodeParameter('appId', i, '') as string, + run_id: this.getNodeParameter('runId', i, '') as string, + }, + this, + i, + ), + }; + if (returnAll) { + // Page through until a short/empty page or no `next` (hard-capped for safety). + const all: IDataObject[] = []; + for (let page = 1; page <= 10000; page++) { + const resp = await request('POST', '/v3/memories/', body, { page, page_size: pageSize }); + const results = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : []; + all.push(...results); + if (results.length < pageSize || !resp.next) break; + } + responseData = all; + } else { + const page = this.getNodeParameter('page', i, 1) as number; + const resp = await request('POST', '/v3/memories/', body, { page, page_size: pageSize }); + responseData = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : []; + } + } else if (operation === 'get') { + const memoryId = this.getNodeParameter('memoryId', i) as string; + responseData = await request('GET', `/v1/memories/${encodeURIComponent(memoryId)}/`); + } else if (operation === 'update') { + const memoryId = this.getNodeParameter('memoryId', i) as string; + const body: IDataObject = {}; + const text = this.getNodeParameter('text', i, '') as string; + const metadata = this.getNodeParameter('metadata', i, '') as string; + if (text) body.text = text; + if (metadata) { + try { + body.metadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + } catch { + throw new NodeOperationError(this.getNode(), 'Invalid JSON in "Metadata" field', { + itemIndex: i, + }); + } + } + if (Object.keys(body).length === 0) { + throw new NodeOperationError(this.getNode(), 'Provide text or metadata to update', { + itemIndex: i, + }); + } + responseData = await request('PUT', `/v1/memories/${encodeURIComponent(memoryId)}/`, body); + } else if (operation === 'delete') { + const memoryId = this.getNodeParameter('memoryId', i) as string; + responseData = await request('DELETE', `/v1/memories/${encodeURIComponent(memoryId)}/`); + } + + const arr = Array.isArray(responseData) ? responseData : [responseData]; + for (const entry of arr) { + returnData.push({ json: entry, pairedItem: { item: i } }); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } }); + continue; + } + throw new NodeApiError(this.getNode(), error as JsonObject, { itemIndex: i }); + } + } + + return [returnData]; + } +} + +function buildEntityFilters( + ids: Record, + ctx: IExecuteFunctions, + itemIndex: number, +): IDataObject { + const clauses: IDataObject[] = Object.entries(ids) + .filter(([, value]) => value) + .map(([key, value]) => ({ [key]: value })); + + if (clauses.length === 0) { + throw new NodeOperationError( + ctx.getNode(), + 'Provide at least one of User ID, Agent ID, App ID, or Run ID', + { itemIndex }, + ); + } + + return clauses.length === 1 ? clauses[0] : { OR: clauses }; +} + +// Polls GET /v1/event/{id}/ until the memory-addition event resolves. +async function pollEvent( + request: (m: IHttpRequestMethods, u: string) => Promise, + eventId: string, + ctx: IExecuteFunctions, + itemIndex: number, +): Promise { + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const event = await request('GET', `/v1/event/${encodeURIComponent(eventId)}/`); + const status = event.status as string; + if (status === 'SUCCEEDED') { + // Match the shape of search/getAll (a clean array); fall back to the envelope. + return Array.isArray(event.results) ? (event.results as IDataObject[]) : event; + } + if (status === 'FAILED') { + const reason = (event.error as string) || (event.message as string) || 'unknown error'; + throw new NodeOperationError( + ctx.getNode(), + `Mem0 memory event ${eventId} failed: ${reason}`, + { itemIndex }, + ); + } + await sleep(POLL_INTERVAL_MS); + } + throw new NodeOperationError( + ctx.getNode(), + `Timed out waiting for memory event ${eventId} to complete`, + { itemIndex }, + ); +} diff --git a/integrations/n8n-nodes-mem0/nodes/Mem0/mem0.svg b/integrations/n8n-nodes-mem0/nodes/Mem0/mem0.svg new file mode 100644 index 000000000..cf13d0d38 --- /dev/null +++ b/integrations/n8n-nodes-mem0/nodes/Mem0/mem0.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/integrations/n8n-nodes-mem0/package.json b/integrations/n8n-nodes-mem0/package.json new file mode 100644 index 000000000..e3107b6fe --- /dev/null +++ b/integrations/n8n-nodes-mem0/package.json @@ -0,0 +1,79 @@ +{ + "name": "@mem0/n8n-nodes-mem0", + "version": "0.1.3", + "description": "n8n community node for Mem0 β€” the memory layer for AI agents. Add, search, get, update, and delete long-term memories.", + "keywords": [ + "n8n-community-node-package", + "mem0", + "memory", + "ai", + "agents", + "llm" + ], + "license": "MIT", + "homepage": "https://mem0.ai", + "author": { + "name": "Mem0", + "email": "integrations@mem0.ai" + }, + "repository": { + "type": "git", + "url": "https://github.com/mem0ai/mem0", + "directory": "integrations/n8n-nodes-mem0" + }, + "engines": { + "node": ">=20.15" + }, + "main": "index.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "npx rimraf dist && tsc && gulp build:icons", + "dev": "tsc --watch", + "format": "prettier nodes credentials --write", + "lint": "eslint nodes credentials package.json", + "lintfix": "eslint nodes credentials package.json --fix", + "test": "jest", + "prepublishOnly": "npm run build && npm run lint" + }, + "files": [ + "dist" + ], + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": [ + "dist/credentials/Mem0Api.credentials.js" + ], + "nodes": [ + "dist/nodes/Mem0/Mem0.node.js" + ] + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.57.0", + "jest": "^29.7.0", + "eslint-plugin-n8n-nodes-base": "^1.16.3", + "gulp": "^5.0.0", + "n8n-workflow": "*", + "prettier": "^3.3.0", + "rimraf": "^5.0.0", + "ts-jest": "^29.2.5", + "typescript": "^5.5.0" + }, + "peerDependencies": { + "n8n-workflow": "*" + }, + "pnpm": { + "overrides": { + "form-data@<4.0.6": ">=4.0.6", + "uuid@<11.1.1": ">=11.1.1 <12.0.0", + "lodash@<=4.17.23": ">=4.18.0", + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0", + "brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0", + "brace-expansion@>=5.0.0 <5.0.8": ">=5.0.8" + } + } +} diff --git a/integrations/n8n-nodes-mem0/pnpm-lock.yaml b/integrations/n8n-nodes-mem0/pnpm-lock.yaml new file mode 100644 index 000000000..461f32d23 --- /dev/null +++ b/integrations/n8n-nodes-mem0/pnpm-lock.yaml @@ -0,0 +1,4975 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + form-data@<4.0.6: '>=4.0.6' + uuid@<11.1.1: '>=11.1.1 <12.0.0' + lodash@<=4.17.23: '>=4.18.0' + brace-expansion@<1.1.16: '>=1.1.16 <2.0.0' + brace-expansion@>=2.0.0 <2.1.2: '>=2.1.2 <3.0.0' + brace-expansion@>=5.0.0 <5.0.8: '>=5.0.8' + +importers: + + .: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + '@typescript-eslint/parser': + specifier: ^8.0.0 + version: 8.62.1(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + eslint-plugin-n8n-nodes-base: + specifier: ^1.16.3 + version: 1.16.7(eslint@8.57.1)(typescript@5.9.3) + gulp: + specifier: ^5.0.0 + version: 5.0.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.43) + n8n-workflow: + specifier: '*' + version: 2.16.0 + prettier: + specifier: ^3.3.0 + version: 3.9.4 + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + ts-jest: + specifier: ^29.2.5 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43))(typescript@5.9.3) + typescript: + specifier: ^5.5.0 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@gulpjs/messages@1.1.0': + resolution: {integrity: sha512-Ys9sazDatyTgZVb4xPlDufLweJ/Os2uHWOv+Caxvy2O85JcnT4M3vc73bi8pdLWlv3fdWQz3pdI9tVwo8rQQSg==} + engines: {node: '>=10.13.0'} + + '@gulpjs/to-absolute-glob@4.0.0': + resolution: {integrity: sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==} + engines: {node: '>=10.13.0'} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@n8n/errors@0.6.0': + resolution: {integrity: sha512-oVJ0lgRYJY6/aPOW2h37ea5T+nX7/wULRn5FymwYeaiYlsLdqwIQEtGwZrajpzxJB0Os74u4lSH3WWQgZCkgxQ==} + + '@n8n/expression-runtime@0.8.0': + resolution: {integrity: sha512-SnBLoLsrGUCS9cVrF20UuSyKcMv+y6Clc4+EA9zLjX85S0GPwOAdApUVSsi81ejPAqMlm42TW1L6r7NpcK/ztQ==} + + '@n8n/tournament@1.0.6': + resolution: {integrity: sha512-UGSxYXXVuOX0yL6HTLBStKYwLIa0+JmRKiSZSCMcM2s2Wax984KWT6XIA1TR/27i7yYpDk1MY14KsTPnuEp27A==} + engines: {node: '>=20.15', pnpm: '>=9.5'} + + '@n8n_io/riot-tmpl@4.0.1': + resolution: {integrity: sha512-/zdRbEfTFjsm1NqnpPQHgZTkTdbp5v3VUxGeMA9098sps8jRCTraQkc3AQstJgHUm7ylBXJcIVhnVeLUMWAfwQ==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/parser@8.62.1': + resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.1': + resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.1': + resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.1': + resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.1': + resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.1': + resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.1': + resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.1': + resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.2': + resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-each@1.0.1: + resolution: {integrity: sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==} + engines: {node: '>=0.10.0'} + + array-slice@1.1.0: + resolution: {integrity: sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==} + engines: {node: '>=0.10.0'} + + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + + ast-types@0.15.2: + resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} + engines: {node: '>=4'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + async-done@2.0.0: + resolution: {integrity: sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==} + engines: {node: '>= 10.13.0'} + + async-settle@2.0.0: + resolution: {integrity: sha512-Obu/KE8FurfQRN6ODdHN9LuXqwC+JFIM9NRyZqJJ4ZfLJmIYN9Rg0/kb+wF70VV5+fJusTMQlJ1t5rF7J/ETdg==} + engines: {node: '>= 10.13.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + bach@2.0.1: + resolution: {integrity: sha512-A7bvGMGiTOxGMpNupYl9HQTf0FFDNF4VCmks4PJpFyN1AX2pdKuxuwdvUz2Hu388wcgp+OvGFNsumBfFNkR7eg==} + engines: {node: '>=10.13.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + + brace-expansion@1.1.17: + resolution: {integrity: sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==} + + brace-expansion@2.1.3: + resolution: {integrity: sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==} + + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-props@4.0.0: + resolution: {integrity: sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==} + engines: {node: '>= 10.13.0'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-file@1.0.0: + resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} + engines: {node: '>=0.10.0'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + each-props@3.0.0: + resolution: {integrity: sha512-IYf1hpuWrdzse/s/YJOrFmU15lyhSzxelNVAHTEG3DtP4QsLTWZUzcUL3HMXmKQxXpa4EIrBPpwRgj0aehdvAw==} + engines: {node: '>= 10.13.0'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-riot@1.0.0: + resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==} + + eslint-plugin-n8n-nodes-base@1.16.7: + resolution: {integrity: sha512-lKo+stn4t2jXX/ZdmJv2ClUQnRZLwSOyqfqsYgD6I/wYwy9bqA6mna4CEzdQNFVg5f3/RODpd/JQMiITARY92g==} + engines: {node: '>=20.15', pnpm: '>=9.6'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima-next@5.8.4: + resolution: {integrity: sha512-8nYVZ4ioIH4Msjb/XmhnBdz5WRRBaYqevKa1cv9nGJdCehMbzZCPNEEnqfLCZVetUVrUPEcb5IYyu1GG4hFqgg==} + engines: {node: '>=12'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expand-tilde@2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-levenshtein@3.0.0: + resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + findup-sync@5.0.0: + resolution: {integrity: sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==} + engines: {node: '>= 10.13.0'} + + fined@2.0.0: + resolution: {integrity: sha512-OFRzsL6ZMHz5s0JrsEr+TpdGNCtrVtnuG3x1yzGNiQHT0yaDnXAj8V/lWcpJVrnoDpcwXcASxAZYbuXda2Y82A==} + engines: {node: '>= 10.13.0'} + + flagged-respawn@2.0.0: + resolution: {integrity: sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==} + engines: {node: '>= 10.13.0'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + for-in@1.0.2: + resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} + engines: {node: '>=0.10.0'} + + for-own@1.0.0: + resolution: {integrity: sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==} + engines: {node: '>=0.10.0'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs-mkdirp-stream@2.0.1: + resolution: {integrity: sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==} + engines: {node: '>=10.13.0'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-stream@8.0.3: + resolution: {integrity: sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==} + engines: {node: '>=10.13.0'} + + glob-watcher@6.0.0: + resolution: {integrity: sha512-wGM28Ehmcnk2NqRORXFOTOR064L4imSw3EeOqU5bIwUf62eXGwg89WivH6VMahL8zlQHeodzvHpXplrqzrz3Nw==} + engines: {node: '>= 10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-modules@1.0.0: + resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} + engines: {node: '>=0.10.0'} + + global-prefix@1.0.2: + resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==} + engines: {node: '>=0.10.0'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + glogg@2.2.0: + resolution: {integrity: sha512-eWv1ds/zAlz+M1ioHsyKJomfY7jbDDPpwSkv14KQj89bycx1nvK5/2Cj/T9g7kzJcX5Bc7Yv22FjfBZS/jl94A==} + engines: {node: '>= 10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + gulp-cli@3.1.0: + resolution: {integrity: sha512-zZzwlmEsTfXcxRKiCHsdyjZZnFvXWM4v1NqBJSYbuApkvVKivjcmOS2qruAJ+PkEHLFavcDKH40DPc1+t12a9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + gulp@5.0.1: + resolution: {integrity: sha512-PErok3DZSA5WGMd6XXV3IRNO0mlB+wW3OzhFJLEec1jSERg2j1bxJ6e5Fh6N6fn3FH2T9AP4UYNb/pYlADB9sA==} + engines: {node: '>=10.13.0'} + hasBin: true + + gulplog@2.2.0: + resolution: {integrity: sha512-V2FaKiOhpR3DRXZuYdRLn/qiY0yI5XmqbTKrYbdemJ+xOh2d2MOweI/XFgMzd/9+1twdvMwllnZbWZNJ+BOm4A==} + engines: {node: '>= 10.13.0'} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + homedir-polyfill@1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indefinite@2.5.2: + resolution: {integrity: sha512-J3ELLIk835hmgDMUfNltTCrHz9+CteTnSuXJqvxZT18wo1U2M9/QeeJMw99QdZwPEEr1DE2aBYda101Ojjdw5g==} + engines: {node: '>=6.0.0'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + is-absolute@1.0.0: + resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} + engines: {node: '>=0.10.0'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + + is-negated-glob@1.0.0: + resolution: {integrity: sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-relative@1.0.0: + resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unc-path@1.0.0: + resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} + engines: {node: '>=0.10.0'} + + is-valid-glob@1.0.0: + resolution: {integrity: sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==} + engines: {node: '>=0.10.0'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + isolated-vm@6.1.2: + resolution: {integrity: sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==} + engines: {node: '>=22.0.0'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} + + js-base64@3.7.2: + resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonrepair@3.13.2: + resolution: {integrity: sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==} + hasBin: true + + jssha@3.3.1: + resolution: {integrity: sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + last-run@2.0.0: + resolution: {integrity: sha512-j+y6WhTLN4Itnf9j5ZQos1BGPCS8DAwmgMroR3OzfxAsBxam0hMw7J8M3KqZl0pLQJ1jNnwIexg5DYpC/ctwEQ==} + engines: {node: '>= 10.13.0'} + + lead@4.0.0: + resolution: {integrity: sha512-DpMa59o5uGUWWjruMp71e6knmwKU3jRBBn1kjuLWN9EeIOxNeSAwvHf03WIl8g/ZMR2oSQC9ej3yeLBwdDc/pg==} + engines: {node: '>=10.13.0'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + liftoff@5.0.1: + resolution: {integrity: sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==} + engines: {node: '>=10.13.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + map-cache@0.2.2: + resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} + engines: {node: '>=0.10.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stdout@2.0.0: + resolution: {integrity: sha512-32GSKM3Wyc8dg/p39lWPKYu8zci9mJFzV1Np9Of0ZEpe6Fhssn/FbI7ywAMd40uX+p3ZKh3T5EeCFv81qS3HmQ==} + engines: {node: '>= 10.13.0'} + + n8n-workflow@2.16.0: + resolution: {integrity: sha512-JoDvHLvV4QZQBCDVQl5xkrGOmPmsacLO4TkJkErCzMmiEqIej+h14J6eCUY7gbxBj8V+GIBlpqyO63akJbXRQg==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + now-and-later@3.0.0: + resolution: {integrity: sha512-pGO4pzSdaxhWTGkfSfHx3hVzJVslFPwBp2Myq9MYN/ChfJZF87ochMAXnvz6/58RJSf5ik2q9tXprBBrk2cpcg==} + engines: {node: '>= 10.13.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.defaults@1.1.0: + resolution: {integrity: sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==} + engines: {node: '>=0.10.0'} + + object.pick@1.3.0: + resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-filepath@1.0.2: + resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} + engines: {node: '>=0.8'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-passwd@1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-root-regex@0.1.2: + resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} + engines: {node: '>=0.10.0'} + + path-root@0.1.1: + resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} + engines: {node: '>=0.10.0'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recast@0.22.0: + resolution: {integrity: sha512-5AAx+mujtXijsEavc5lWXBPQqrM4+Dl5qNH96N2aNeuJFUzpiiToKPsxQD/zAIJHspz7zz0maX0PCtCTFVlixQ==} + engines: {node: '>= 4'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + remove-trailing-separator@1.1.0: + resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + + replace-ext@2.0.0: + resolution: {integrity: sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==} + engines: {node: '>= 10'} + + replace-homedir@2.0.0: + resolution: {integrity: sha512-bgEuQQ/BHW0XkkJtawzrfzHFSN70f/3cNOiHa2QsYxqrjaC30X1k74FJ6xswVBP0sr0SpGIdVFuPwfrYziVeyw==} + engines: {node: '>= 10.13.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-dir@1.0.1: + resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-options@2.0.0: + resolution: {integrity: sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==} + engines: {node: '>= 10.13.0'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver-greatest-satisfied-range@2.0.0: + resolution: {integrity: sha512-lH3f6kMbwyANB7HuOWRMlLCa2itaCrZJ+SAqqkSZrZKO/cAsk2EOyaKHUtNkVLFyFW9pct22SFesFp3Z7zpA0g==} + engines: {node: '>= 10.13.0'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sparkles@2.1.0: + resolution: {integrity: sha512-r7iW1bDw8R/cFifrD3JnQJX0K1jqT0kprL48BiBpLZLJPmAm34zsVBsK5lc7HirZYZqMW65dOXZgbAGt/I6frg==} + engines: {node: '>= 10.13.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stream-composer@1.0.2: + resolution: {integrity: sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==} + + stream-exhaust@1.0.2: + resolution: {integrity: sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + sver@1.8.4: + resolution: {integrity: sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + title-case@3.0.3: + resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-through@3.0.0: + resolution: {integrity: sha512-y8MN937s/HVhEoBU1SxfHC+wxCHkV1a9gW8eAdTadYh/bGyesZIVcbjI+mSpFbSVwQici/XjBjuUyri1dnXwBw==} + engines: {node: '>=10.13.0'} + + transliteration@2.3.5: + resolution: {integrity: sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==} + engines: {node: '>=6.0.0'} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.4.12: + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unc-path-regex@0.1.2: + resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} + engines: {node: '>=0.10.0'} + + undertaker-registry@2.0.0: + resolution: {integrity: sha512-+hhVICbnp+rlzZMgxXenpvTxpuvA67Bfgtt+O9WOE5jo7w/dyiF1VmoZVIHvP2EkUjsyKyTwYKlLhA+j47m1Ew==} + engines: {node: '>= 10.13.0'} + + undertaker@2.0.0: + resolution: {integrity: sha512-tO/bf30wBbTsJ7go80j0RzA2rcwX6o7XPBpeFcb+jzoeb4pfMM2zUeSDIkY1AWqeZabWxaQZ/h8N9t35QKDLPQ==} + engines: {node: '>=10.13.0'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + v8flags@4.0.1: + resolution: {integrity: sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==} + engines: {node: '>= 10.13.0'} + + value-or-function@4.0.0: + resolution: {integrity: sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==} + engines: {node: '>= 10.13.0'} + + vinyl-contents@2.0.0: + resolution: {integrity: sha512-cHq6NnGyi2pZ7xwdHSW1v4Jfnho4TEGtxZHw01cmnc8+i7jgR6bRnED/LbrKan/Q7CvVLbnvA5OepnhbpjBZ5Q==} + engines: {node: '>=10.13.0'} + + vinyl-fs@4.0.2: + resolution: {integrity: sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==} + engines: {node: '>=10.13.0'} + + vinyl-sourcemap@2.0.0: + resolution: {integrity: sha512-BAEvWxbBUXvlNoFQVFVHpybBbjW1r03WhohJzJDSfgrrK5xVYIDTan6xN14DlyImShgDRv2gl9qhM6irVMsV0Q==} + engines: {node: '>=10.13.0'} + + vinyl@3.0.1: + resolution: {integrity: sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==} + engines: {node: '>=10.13.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.67: + resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@gulpjs/messages@1.1.0': {} + + '@gulpjs/to-absolute-glob@4.0.0': + dependencies: + is-negated-glob: 1.0.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.0 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.19.43) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.19.43 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.43 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.43 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@n8n/errors@0.6.0': + dependencies: + callsites: 3.1.0 + + '@n8n/expression-runtime@0.8.0': + dependencies: + '@n8n/tournament': 1.0.6 + isolated-vm: 6.1.2 + js-base64: 3.7.2 + jssha: 3.3.1 + lodash: 4.18.1 + luxon: 3.7.2 + md5: 2.3.0 + title-case: 3.0.3 + transliteration: 2.3.5 + + '@n8n/tournament@1.0.6': + dependencies: + '@n8n_io/riot-tmpl': 4.0.1 + ast-types: 0.16.1 + esprima-next: 5.8.4 + recast: 0.22.0 + + '@n8n_io/riot-tmpl@4.0.1': + dependencies: + eslint-config-riot: 1.0.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@sinclair/typebox@0.27.12': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.19.43 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/parser@8.62.1(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + eslint: 8.57.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) + '@typescript-eslint/types': 8.62.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + + '@typescript-eslint/tsconfig-utils@8.62.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/types@8.62.1': {} + + '@typescript-eslint/typescript-estree@8.62.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.1(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + eslint: 8.57.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.2': {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-each@1.0.1: {} + + array-slice@1.1.0: {} + + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + + ast-types@0.15.2: + dependencies: + tslib: 2.8.1 + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + async-done@2.0.0: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + stream-exhaust: 1.0.2 + + async-settle@2.0.0: + dependencies: + async-done: 2.0.0 + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + b4a@1.8.1: {} + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + bach@2.0.1: + dependencies: + async-done: 2.0.0 + async-settle: 2.0.0 + now-and-later: 3.0.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.1: {} + + binary-extensions@2.3.0: {} + + bl@5.1.0: + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.17: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.3: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001806: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + charenc@0.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@2.1.2: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + copy-props@4.0.0: + dependencies: + each-props: 3.0.0 + is-plain-object: 5.0.0 + + create-jest@29.7.0(@types/node@20.19.43): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.19.43) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypt@0.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + detect-file@1.0.0: {} + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + each-props@3.0.0: + dependencies: + is-plain-object: 5.0.0 + object.defaults: 1.1.0 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.395: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-riot@1.0.0: {} + + eslint-plugin-n8n-nodes-base@1.16.7(eslint@8.57.1)(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.62.1(eslint@8.57.1)(typescript@5.9.3) + camel-case: 4.1.2 + indefinite: 2.5.2 + pascal-case: 3.1.2 + pluralize: 8.0.0 + sentence-case: 3.0.4 + title-case: 3.0.3 + transitivePeerDependencies: + - eslint + - supports-color + - typescript + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.2 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.3.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 3.4.3 + + esprima-next@5.8.4: {} + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expand-tilde@2.0.2: + dependencies: + homedir-polyfill: 1.0.3 + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-levenshtein@3.0.0: + dependencies: + fastest-levenshtein: 1.0.16 + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + findup-sync@5.0.0: + dependencies: + detect-file: 1.0.0 + is-glob: 4.0.3 + micromatch: 4.0.8 + resolve-dir: 1.0.1 + + fined@2.0.0: + dependencies: + expand-tilde: 2.0.2 + is-plain-object: 5.0.0 + object.defaults: 1.1.0 + object.pick: 1.3.0 + parse-filepath: 1.0.2 + + flagged-respawn@2.0.0: {} + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + for-in@1.0.2: {} + + for-own@1.0.0: + dependencies: + for-in: 1.0.2 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs-mkdirp-stream@2.0.1: + dependencies: + graceful-fs: 4.2.11 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-stream@8.0.3: + dependencies: + '@gulpjs/to-absolute-glob': 4.0.0 + anymatch: 3.1.3 + fastq: 1.20.1 + glob-parent: 6.0.2 + is-glob: 4.0.3 + is-negated-glob: 1.0.0 + normalize-path: 3.0.0 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + glob-watcher@6.0.0: + dependencies: + async-done: 2.0.0 + chokidar: 3.6.0 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-modules@1.0.0: + dependencies: + global-prefix: 1.0.2 + is-windows: 1.0.2 + resolve-dir: 1.0.1 + + global-prefix@1.0.2: + dependencies: + expand-tilde: 2.0.2 + homedir-polyfill: 1.0.3 + ini: 1.3.8 + is-windows: 1.0.2 + which: 1.3.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + glogg@2.2.0: + dependencies: + sparkles: 2.1.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gulp-cli@3.1.0: + dependencies: + '@gulpjs/messages': 1.1.0 + chalk: 4.1.2 + copy-props: 4.0.0 + gulplog: 2.2.0 + interpret: 3.1.1 + liftoff: 5.0.1 + mute-stdout: 2.0.0 + replace-homedir: 2.0.0 + semver-greatest-satisfied-range: 2.0.0 + string-width: 4.2.3 + v8flags: 4.0.1 + yargs: 16.2.2 + + gulp@5.0.1: + dependencies: + glob-watcher: 6.0.0 + gulp-cli: 3.1.0 + undertaker: 2.0.0 + vinyl-fs: 4.0.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + gulplog@2.2.0: + dependencies: + glogg: 2.2.0 + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + homedir-polyfill@1.0.3: + dependencies: + parse-passwd: 1.0.0 + + html-escaper@2.0.2: {} + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indefinite@2.5.2: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + interpret@3.1.1: {} + + is-absolute@1.0.0: + dependencies: + is-relative: 1.0.0 + is-windows: 1.0.2 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-buffer@1.1.6: {} + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + is-negated-glob@1.0.0: {} + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-object@5.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-relative@1.0.0: + dependencies: + is-unc-path: 1.0.0 + + is-stream@2.0.1: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unc-path@1.0.0: + dependencies: + unc-path-regex: 0.1.2 + + is-valid-glob@1.0.0: {} + + is-windows@1.0.2: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + isolated-vm@6.1.2: + dependencies: + node-gyp-build: 4.8.4 + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.19.43): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.43) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.43) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.19.43): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.19.43 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.19.43 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.19.43): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jmespath@0.16.0: {} + + js-base64@3.7.2: {} + + js-tokens@4.0.0: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonrepair@3.13.2: {} + + jssha@3.3.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + last-run@2.0.0: {} + + lead@4.0.0: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + liftoff@5.0.1: + dependencies: + extend: 3.0.2 + findup-sync: 5.0.0 + fined: 2.0.0 + flagged-respawn: 2.0.0 + is-plain-object: 5.0.0 + rechoir: 0.8.0 + resolve: 1.22.12 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + map-cache@0.2.2: {} + + math-intrinsics@1.1.0: {} + + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.17 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.3 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + + mute-stdout@2.0.0: {} + + n8n-workflow@2.16.0: + dependencies: + '@n8n/errors': 0.6.0 + '@n8n/expression-runtime': 0.8.0 + '@n8n/tournament': 1.0.6 + ast-types: 0.16.1 + callsites: 3.1.0 + esprima-next: 5.8.4 + form-data: 4.0.6 + jmespath: 0.16.0 + js-base64: 3.7.2 + jsonrepair: 3.13.2 + jssha: 3.3.1 + lodash: 4.18.1 + luxon: 3.7.2 + md5: 2.3.0 + recast: 0.22.0 + title-case: 3.0.3 + transliteration: 2.3.5 + uuid: 11.1.1 + xml2js: 0.6.2 + zod: 3.25.67 + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-gyp-build@4.8.4: {} + + node-int64@0.4.0: {} + + node-releases@2.0.51: {} + + normalize-path@3.0.0: {} + + now-and-later@3.0.0: + dependencies: + once: 1.4.0 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.defaults@1.1.0: + dependencies: + array-each: 1.0.1 + array-slice: 1.1.0 + for-own: 1.0.0 + isobject: 3.0.1 + + object.pick@1.3.0: + dependencies: + isobject: 3.0.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-filepath@1.0.2: + dependencies: + is-absolute: 1.0.0 + map-cache: 0.2.2 + path-root: 0.1.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-passwd@1.0.0: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-root-regex@0.1.2: {} + + path-root@0.1.1: + dependencies: + path-root-regex: 0.1.2 + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pluralize@8.0.0: {} + + possible-typed-array-names@1.1.0: {} + + prelude-ls@1.2.1: {} + + prettier@3.9.4: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + queue-microtask@1.2.3: {} + + react-is@18.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + recast@0.22.0: + dependencies: + assert: 2.1.0 + ast-types: 0.15.2 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.8.1 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + remove-trailing-separator@1.1.0: {} + + replace-ext@2.0.0: {} + + replace-homedir@2.0.0: {} + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-dir@1.0.1: + dependencies: + expand-tilde: 2.0.2 + global-modules: 1.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-options@2.0.0: + dependencies: + value-or-function: 4.0.0 + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + semver-greatest-satisfied-range@2.0.0: + dependencies: + sver: 1.8.4 + + semver@6.3.1: {} + + semver@7.8.5: {} + + sentence-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sparkles@2.1.0: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stream-composer@1.0.2: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + stream-exhaust@1.0.2: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + sver@1.8.4: + optionalDependencies: + semver: 6.3.1 + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + text-table@0.2.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + title-case@3.0.3: + dependencies: + tslib: 2.8.1 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-through@3.0.0: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + transliteration@2.3.5: + dependencies: + yargs: 17.7.3 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@20.19.43) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 29.7.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + unc-path-regex@0.1.2: {} + + undertaker-registry@2.0.0: {} + + undertaker@2.0.0: + dependencies: + bach: 2.0.1 + fast-levenshtein: 3.0.0 + last-run: 2.0.0 + undertaker-registry: 2.0.0 + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + upper-case-first@2.0.2: + dependencies: + tslib: 2.8.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.22 + + uuid@11.1.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + v8flags@4.0.1: {} + + value-or-function@4.0.0: {} + + vinyl-contents@2.0.0: + dependencies: + bl: 5.1.0 + vinyl: 3.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + vinyl-fs@4.0.2: + dependencies: + fs-mkdirp-stream: 2.0.1 + glob-stream: 8.0.3 + graceful-fs: 4.2.11 + iconv-lite: 0.6.3 + is-valid-glob: 1.0.0 + lead: 4.0.0 + normalize-path: 3.0.0 + resolve-options: 2.0.0 + stream-composer: 1.0.2 + streamx: 2.28.0 + to-through: 3.0.0 + value-or-function: 4.0.0 + vinyl: 3.0.1 + vinyl-sourcemap: 2.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + vinyl-sourcemap@2.0.0: + dependencies: + convert-source-map: 2.0.0 + graceful-fs: 4.2.11 + now-and-later: 3.0.0 + streamx: 2.28.0 + vinyl: 3.0.1 + vinyl-contents: 2.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + vinyl@3.0.1: + dependencies: + clone: 2.1.2 + remove-trailing-separator: 1.1.0 + replace-ext: 2.0.0 + teex: 1.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.67: {} diff --git a/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts b/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts new file mode 100644 index 000000000..908e41a8d --- /dev/null +++ b/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts @@ -0,0 +1,211 @@ +// Offline unit tests: they stub IExecuteFunctions and mock the HTTP layer, so +// they run with no network. They cover the review-flagged paths: the entity-id +// guard, JSON-parse errors, the poll-timeout loop, plus source attribution and +// the Return All pagination. + +// Make the poll loop instant (pollEvent sleeps between attempts). +jest.mock('n8n-workflow', () => { + const actual = jest.requireActual('n8n-workflow'); + return { ...actual, sleep: jest.fn().mockResolvedValue(undefined) }; +}); + +import { Mem0 } from '../nodes/Mem0/Mem0.node'; + +type HttpImpl = (options: any) => Promise; + +function makeCtx( + operation: string, + params: Record, + http: HttpImpl, + opts: { continueOnFail?: boolean } = {}, +): any { + const requests: any[] = []; + const node = { name: 'Mem0', type: '@mem0/n8n-nodes-mem0.mem0', typeVersion: 1 }; + const ctx: any = { + getInputData: () => [{ json: {} }], + getCredentials: async () => ({ apiKey: 'm0-test', baseUrl: 'https://api.mem0.ai' }), + getNodeParameter: (name: string, _i: number, dflt?: any) => + name === 'operation' ? operation : name in params ? params[name] : dflt, + getNode: () => node, + continueOnFail: () => opts.continueOnFail ?? false, + helpers: { + httpRequestWithAuthentication: jest.fn(async function (_cred: string, options: any) { + requests.push(options); + return http(options); + }), + }, + }; + ctx.requests = requests; + return ctx; +} + +const run = (ctx: any) => Mem0.prototype.execute.call(ctx); + +describe('Mem0 node (offline)', () => { + it('reports a clear error when Add has no entity id', async () => { + const ctx = makeCtx( + 'add', + { 'messages.message': [{ role: 'user', content: 'hi' }], addFields: {}, userId: '' }, + async () => ({}), + { continueOnFail: true }, + ); + const out: any = await run(ctx); + expect(out[0][0].json.error).toMatch(/at least one of User ID/i); + }); + + it('forwards app id, includes and excludes on Add', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: { + app_id: 'p1', + includes: 'only food preferences', + excludes: 'nothing about vehicles', + }, + userId: 'u1', + }, + async () => ({}), + ); + await run(ctx); + expect(ctx.requests[0].body).toMatchObject({ + app_id: 'p1', + includes: 'only food preferences', + excludes: 'nothing about vehicles', + }); + }); + + it('accepts an app id alone as the entity scope on Add', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: { app_id: 'p1' }, + userId: '', + }, + async () => ({}), + { continueOnFail: true }, + ); + const out: any = await run(ctx); + expect(out[0][0].json.error).toBeUndefined(); + expect(ctx.requests[0].body.app_id).toBe('p1'); + }); + + it('reports a clear error on invalid JSON in Custom Categories', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: { custom_categories: '{bad' }, + userId: 'u1', + }, + async () => ({}), + { continueOnFail: true }, + ); + const out: any = await run(ctx); + expect(out[0][0].json.error).toMatch(/Invalid JSON/i); + }); + + it('times out when the add event never resolves', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: {}, + userId: 'u1', + waitForCompletion: true, + }, + async (options) => { + if (options.url.includes('/v3/memories/add/')) return { event_id: 'e1', status: 'PENDING' }; + if (options.url.includes('/v1/event/')) return { status: 'PENDING' }; // never terminal + return {}; + }, + { continueOnFail: true }, + ); + const out: any = await run(ctx); + expect(out[0][0].json.error).toMatch(/Timed out waiting for memory event/i); + }); + + it('tags every request with source=N8N for first-party attribution', async () => { + const ctx = makeCtx('search', { query: 'x', userId: 'u1', limit: 5 }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].qs.source).toBe('N8N'); + }); + + it('sends a single entity id as a flat filter', async () => { + const ctx = makeCtx('search', { query: 'x', userId: 'u1' }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ user_id: 'u1' }); + }); + + it('combines entity ids with OR, never AND (entities are stored separately, so AND matches nothing)', async () => { + const ctx = makeCtx( + 'search', + { query: 'x', userId: 'u1', agentId: 'a1', appId: 'p1', runId: 'r1' }, + async () => ({ results: [] }), + ); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ + OR: [{ user_id: 'u1' }, { agent_id: 'a1' }, { app_id: 'p1' }, { run_id: 'r1' }], + }); + }); + + it.each(['search', 'getAll'])('filters %s by app id alone', async (op) => { + const ctx = makeCtx(op, { query: 'x', appId: 'p1' }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ app_id: 'p1' }); + }); + + it('filters Get Many by agent id alone', async () => { + const ctx = makeCtx('getAll', { agentId: 'a1' }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ agent_id: 'a1' }); + }); + + it.each(['search', 'getAll'])('reports a clear error when %s has no entity id', async (op) => { + const ctx = makeCtx(op, { query: 'x' }, async () => ({ results: [] }), { + continueOnFail: true, + }); + const out: any = await run(ctx); + expect(out[0][0].json.error).toMatch(/at least one of User ID/i); + }); + + it.each([ + ['get', 'GET'], + ['delete', 'DELETE'], + ])('escapes the memory id in the %s url', async (op, method) => { + const ctx = makeCtx(op, { memoryId: '../v1/entities' }, async () => ({})); + await run(ctx); + expect(ctx.requests[0].method).toBe(method); + expect(ctx.requests[0].url).toBe('https://api.mem0.ai/v1/memories/..%2Fv1%2Fentities/'); + }); + + it('escapes the event id when polling', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: {}, + userId: 'u1', + waitForCompletion: true, + }, + async (options) => { + if (options.url.includes('/v3/memories/add/')) return { event_id: 'a b/c' }; + return { status: 'SUCCEEDED', results: [] }; + }, + ); + await run(ctx); + expect(ctx.requests[1].url).toBe('https://api.mem0.ai/v1/event/a%20b%2Fc/'); + }); + + it('Return All pages through until a short page', async () => { + let call = 0; + const ctx = makeCtx('getAll', { userId: 'u1', returnAll: true, pageSize: 2 }, async () => { + call++; + if (call === 1) return { results: [{ id: 'a' }, { id: 'b' }], next: 'page2' }; + return { results: [{ id: 'c' }], next: null }; // short page -> stop + }); + const out: any = await run(ctx); + expect(out[0].map((d: any) => d.json.id)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/integrations/n8n-nodes-mem0/tsconfig.json b/integrations/n8n-nodes-mem0/tsconfig.json new file mode 100644 index 000000000..f6b4c8e4e --- /dev/null +++ b/integrations/n8n-nodes-mem0/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es2019", + "lib": ["es2019", "es2020", "es2022.error"], + "removeComments": true, + "useUnknownInCatchVariables": false, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "preserveConstEnums": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "incremental": false, + "declaration": false, + "sourceMap": true, + "skipLibCheck": true, + "outDir": "./dist/" + }, + "include": ["credentials/**/*", "nodes/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/integrations/openclaw/cli/commands.ts b/integrations/openclaw/cli/commands.ts index ff7bd8b5a..2d5897446 100644 --- a/integrations/openclaw/cli/commands.ts +++ b/integrations/openclaw/cli/commands.ts @@ -1449,7 +1449,7 @@ export function registerCliCommands( console.log(" openclaw mem0 config set auto_recall false"); } else { console.log(" openclaw mem0 config set vector_provider qdrant"); - console.log(" openclaw mem0 config set llm_model gpt-4o"); + console.log(" openclaw mem0 config set llm_model gpt-5-mini"); console.log(" openclaw mem0 config set embedder_provider openai"); } console.log(""); diff --git a/integrations/openclaw/openclaw.plugin.json b/integrations/openclaw/openclaw.plugin.json index 20df3b20c..34564bfe4 100644 --- a/integrations/openclaw/openclaw.plugin.json +++ b/integrations/openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "openclaw-mem0", "name": "Memory (Mem0)", "description": "Mem0 memory backend for OpenClaw β€” platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.", - "version": "1.0.14", + "version": "1.0.15", "kind": "memory", "skills": ["skills"], "commandAliases": [ diff --git a/integrations/openclaw/package.json b/integrations/openclaw/package.json index 0ed78897d..1a997e3b7 100644 --- a/integrations/openclaw/package.json +++ b/integrations/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/openclaw-mem0", - "version": "1.0.14", + "version": "1.0.15", "type": "module", "description": "Mem0 memory backend for OpenClaw β€” platform or self-hosted open-source", "license": "Apache-2.0", @@ -65,14 +65,17 @@ "pnpm": { "overrides": { "form-data@<4.0.6": ">=4.0.6", - "protobufjs@<7.5.5": "^7.5.5", + "protobufjs@<7.6.5": ">=7.6.5 <8.0.0", "vite": "^8.0.5", "langsmith@<0.6.0": "^0.6.0", "picomatch@<2.3.2": "^2.3.2", "@qdrant/js-client-rest": "^1.18.0", "uuid@<11.1.1": ">=11.1.1", "esbuild": ">=0.28.1", - "undici@<6.27.0": ">=6.27.0 <8.0.0" + "undici@<7.29.0": ">=7.29.0 <8.0.0", + "axios@<1.18.0": ">=1.18.0 <2.0.0", + "postcss@<8.5.18": ">=8.5.18 <9.0.0", + "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" } } } diff --git a/integrations/openclaw/pnpm-lock.yaml b/integrations/openclaw/pnpm-lock.yaml index 78678bb2e..f0d26cfa7 100644 --- a/integrations/openclaw/pnpm-lock.yaml +++ b/integrations/openclaw/pnpm-lock.yaml @@ -6,14 +6,17 @@ settings: overrides: form-data@<4.0.6: '>=4.0.6' - protobufjs@<7.5.5: ^7.5.5 + protobufjs@<7.6.5: '>=7.6.5 <8.0.0' vite: ^8.0.5 langsmith@<0.6.0: ^0.6.0 picomatch@<2.3.2: ^2.3.2 '@qdrant/js-client-rest': ^1.18.0 uuid@<11.1.1: '>=11.1.1' esbuild: '>=0.28.1' - undici@<6.27.0: '>=6.27.0 <8.0.0' + undici@<7.29.0: '>=7.29.0 <8.0.0' + axios@<1.18.0: '>=1.18.0 <2.0.0' + postcss@<8.5.18: '>=8.5.18 <9.0.0' + mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0' importers: @@ -24,7 +27,7 @@ importers: version: 0.34.47 mem0ai: specifier: 3.0.7 - version: 3.0.7(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.21.0)(redis@5.12.1)(ws@8.21.0) + version: 3.0.7(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.11.3)(redis@5.12.1)(ws@8.21.0) devDependencies: '@qdrant/js-client-rest': specifier: ^1.18.0 @@ -37,7 +40,7 @@ importers: version: 4.1.8(vitest@4.1.8) tsup: specifier: ^8.5.0 - version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.23)(typescript@5.9.3) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -379,9 +382,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -472,36 +472,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -563,66 +569,79 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -838,8 +857,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.17.0: - resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} base-64@0.1.0: resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} @@ -871,6 +890,10 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-writer@2.0.0: + resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} + engines: {node: '>=4'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1360,24 +1383,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1507,15 +1534,15 @@ packages: resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} engines: {node: '>=20.19.0'} - mongodb@7.2.0: - resolution: {integrity: sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==} + mongodb@7.5.0: + resolution: {integrity: sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==} engines: {node: '>=20.19.0'} peerDependencies: '@aws-sdk/credential-providers': ^3.806.0 '@mongodb-js/zstd': ^7.0.0 gcp-metadata: ^7.0.1 kerberos: ^7.0.0 - mongodb-client-encryption: '>=7.0.0 <7.1.0' + mongodb-client-encryption: ^7.2.0 snappy: ^7.3.2 socks: ^2.8.6 peerDependenciesMeta: @@ -1534,8 +1561,8 @@ packages: socks: optional: true - mongoose@9.7.0: - resolution: {integrity: sha512-pkrLZ6U41pD4Ai0ju/FYL7o5I5k+rV3RZINQTG937hbhnLGKRuqqYm1Dlt/kTQ+M4FHijzV6JawzsdHKRGt7QA==} + mongoose@9.8.0: + resolution: {integrity: sha512-PDGx3XACxrBQyWf4YT+5s1Xsx19x84UWGlRVIza4i9RG6qKjGcoG7odotT6uquR6YoaDMTZ6ZZc/jMXLrNPyyA==} engines: {node: '>=20.19.0'} mpath@0.9.0: @@ -1556,8 +1583,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1639,6 +1666,9 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + packet-reader@1.0.0: + resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1672,6 +1702,15 @@ packages: resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} engines: {node: '>=10'} + pg@8.11.3: + resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pg@8.21.0: resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} engines: {node: '>= 16.0.0'} @@ -1707,7 +1746,7 @@ packages: engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: '>=8.5.18 <9.0.0' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -1720,8 +1759,8 @@ packages: yaml: optional: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -1769,8 +1808,8 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - protobufjs@7.6.3: - resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-from-env@2.1.0: @@ -1965,7 +2004,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: ^8.4.12 + postcss: '>=8.5.18 <9.0.0' typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -1997,8 +2036,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} util-deprecate@1.0.2: @@ -2408,7 +2447,7 @@ snapshots: dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -2498,8 +2537,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -2510,7 +2547,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.9.3 - undici: 7.28.0 + undici: 7.29.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -2869,7 +2906,7 @@ snapshots: asynckit@0.4.0: {} - axios@1.17.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -2908,6 +2945,8 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-writer@2.0.0: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -3477,7 +3516,7 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 - mem0ai@3.0.7(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.21.0)(redis@5.12.1)(ws@8.21.0): + mem0ai@3.0.7(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.11.3)(redis@5.12.1)(ws@8.21.0): dependencies: '@anthropic-ai/sdk': 0.40.1 '@azure/identity': 4.13.1 @@ -3490,7 +3529,7 @@ snapshots: '@supabase/supabase-js': 2.108.1 '@types/jest': 29.5.14 '@types/pg': 8.11.0 - axios: 1.17.0 + axios: 1.18.1 better-sqlite3: 12.10.0 cloudflare: 4.5.0 compromise: 14.15.1 @@ -3498,7 +3537,7 @@ snapshots: natural: 8.1.1 ollama: 0.5.18 openai: 4.104.0(ws@8.21.0)(zod@3.25.76) - pg: 8.21.0 + pg: 8.11.3 redis: 5.12.1 uuid: 14.0.0 zod: 3.25.76 @@ -3541,16 +3580,17 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.2.0: + mongodb@7.5.0: dependencies: '@mongodb-js/saslprep': 1.4.11 bson: 7.2.0 mongodb-connection-string-url: 7.0.1 - mongoose@9.7.0: + mongoose@9.8.0: dependencies: + '@standard-schema/spec': 1.1.0 kareem: 3.3.0 - mongodb: 7.2.0 + mongodb: 7.5.0 mpath: 0.9.0 mquery: 6.0.0 ms: 2.1.3 @@ -3578,7 +3618,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.12: {} + nanoid@3.3.16: {} napi-build-utils@2.0.0: {} @@ -3589,7 +3629,7 @@ snapshots: apparatus: 0.0.10 dotenv: 17.4.2 memjs: 1.3.2 - mongoose: 9.7.0 + mongoose: 9.8.0 pg: 8.21.0 redis: 5.12.1 safe-stable-stringify: 2.5.0 @@ -3678,6 +3718,8 @@ snapshots: dependencies: p-finally: 1.0.0 + packet-reader@1.0.0: {} + pathe@2.0.3: {} pg-cloudflare@1.4.0: @@ -3689,6 +3731,10 @@ snapshots: pg-numeric@1.0.2: {} + pg-pool@3.14.0(pg@8.11.3): + dependencies: + pg: 8.11.3 + pg-pool@3.14.0(pg@8.21.0): dependencies: pg: 8.21.0 @@ -3713,6 +3759,18 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 + pg@8.11.3: + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.11.3) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + pg@8.21.0: dependencies: pg-connection-string: 2.13.0 @@ -3741,15 +3799,15 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.15): + postcss-load-config@6.0.1(postcss@8.5.23): dependencies: lilconfig: 3.1.3 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.23 - postcss@8.5.15: + postcss@8.5.23: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -3796,7 +3854,7 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - protobufjs@7.6.3: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -3804,7 +3862,6 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -4022,7 +4079,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.23)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -4033,7 +4090,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.15) + postcss-load-config: 6.0.1(postcss@8.5.23) resolve-from: 5.0.0 rollup: 4.61.1 source-map: 0.7.6 @@ -4042,7 +4099,7 @@ snapshots: tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.23 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -4064,7 +4121,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.28.0: {} + undici@7.29.0: {} util-deprecate@1.0.2: {} @@ -4076,7 +4133,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.23 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: diff --git a/integrations/openclaw/pnpm-workspace.yaml b/integrations/openclaw/pnpm-workspace.yaml index e71e343c4..0f9f833bc 100644 --- a/integrations/openclaw/pnpm-workspace.yaml +++ b/integrations/openclaw/pnpm-workspace.yaml @@ -14,11 +14,14 @@ onlyBuiltDependencies: overrides: "form-data@<4.0.6": ">=4.0.6" - "protobufjs@<7.5.5": "^7.5.5" + "protobufjs@<7.6.5": ">=7.6.5 <8.0.0" "vite": "^8.0.5" "langsmith@<0.6.0": "^0.6.0" "picomatch@<2.3.2": "^2.3.2" "@qdrant/js-client-rest": "^1.18.0" "uuid@<11.1.1": ">=11.1.1" "esbuild": ">=0.28.1" - "undici@<6.27.0": ">=6.27.0 <8.0.0" + "undici@<7.29.0": ">=7.29.0 <8.0.0" + "axios@<1.18.0": ">=1.18.0 <2.0.0" + "postcss@<8.5.18": ">=8.5.18 <9.0.0" + "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" diff --git a/integrations/pi-agent-plugin/package.json b/integrations/pi-agent-plugin/package.json index df626bef1..ce03543b5 100644 --- a/integrations/pi-agent-plugin/package.json +++ b/integrations/pi-agent-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/pi-agent-plugin", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "Mem0 memory extension for Pi Agent persistent, scoped, semantic memory across sessions and projects", "license": "Apache-2.0", @@ -73,8 +73,13 @@ "form-data@<4.0.6": ">=4.0.6", "uuid@<11.1.1": ">=11.1.1", "esbuild": ">=0.28.1", - "undici@<6.27.0": ">=6.27.0 <8.0.0", - "undici@>=8.0.0 <8.5.0": ">=8.5.0" + "undici@<7.29.0": ">=7.29.0 <8.0.0", + "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0", + "axios@<1.18.0": ">=1.18.0 <2.0.0", + "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0", + "postcss@<8.5.18": ">=8.5.18 <9.0.0", + "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0", + "protobufjs@<7.6.5": ">=7.6.5 <8.0.0" } } } diff --git a/integrations/pi-agent-plugin/pnpm-lock.yaml b/integrations/pi-agent-plugin/pnpm-lock.yaml index 248cc9739..87a5d4004 100644 --- a/integrations/pi-agent-plugin/pnpm-lock.yaml +++ b/integrations/pi-agent-plugin/pnpm-lock.yaml @@ -8,8 +8,13 @@ overrides: form-data@<4.0.6: '>=4.0.6' uuid@<11.1.1: '>=11.1.1' esbuild: '>=0.28.1' - undici@<6.27.0: '>=6.27.0 <8.0.0' - undici@>=8.0.0 <8.5.0: '>=8.5.0' + undici@<7.29.0: '>=7.29.0 <8.0.0' + undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0' + axios@<1.18.0: '>=1.18.0 <2.0.0' + brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0' + postcss@<8.5.18: '>=8.5.18 <9.0.0' + mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0' + protobufjs@<7.6.5: '>=7.6.5 <8.0.0' importers: @@ -30,7 +35,7 @@ importers: version: 25.9.2 tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.23)(typescript@6.0.3)(yaml@2.9.0) typebox: specifier: ^1.2.3 version: 1.2.8 @@ -555,9 +560,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -1064,8 +1066,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.17.0: - resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} @@ -1093,9 +1095,9 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1793,15 +1795,15 @@ packages: resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} engines: {node: '>=20.19.0'} - mongodb@7.2.0: - resolution: {integrity: sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==} + mongodb@7.5.0: + resolution: {integrity: sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==} engines: {node: '>=20.19.0'} peerDependencies: '@aws-sdk/credential-providers': ^3.806.0 '@mongodb-js/zstd': ^7.0.0 gcp-metadata: ^7.0.1 kerberos: ^7.0.0 - mongodb-client-encryption: '>=7.0.0 <7.1.0' + mongodb-client-encryption: ^7.2.0 snappy: ^7.3.2 socks: ^2.8.6 peerDependenciesMeta: @@ -1820,8 +1822,8 @@ packages: socks: optional: true - mongoose@9.7.0: - resolution: {integrity: sha512-pkrLZ6U41pD4Ai0ju/FYL7o5I5k+rV3RZINQTG937hbhnLGKRuqqYm1Dlt/kTQ+M4FHijzV6JawzsdHKRGt7QA==} + mongoose@9.8.0: + resolution: {integrity: sha512-PDGx3XACxrBQyWf4YT+5s1Xsx19x84UWGlRVIza4i9RG6qKjGcoG7odotT6uquR6YoaDMTZ6ZZc/jMXLrNPyyA==} engines: {node: '>=20.19.0'} mpath@0.9.0: @@ -1842,8 +1844,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2032,7 +2034,7 @@ packages: engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: '>=8.5.18 <9.0.0' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -2045,8 +2047,8 @@ packages: yaml: optional: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -2097,8 +2099,8 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - protobufjs@7.6.3: - resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-from-env@2.1.0: @@ -2314,7 +2316,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: ^8.4.12 + postcss: '>=8.5.18 <9.0.0' typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -2352,12 +2354,12 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} - undici@8.5.0: - resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} util-deprecate@1.0.2: @@ -2940,7 +2942,7 @@ snapshots: minimatch: 10.2.5 proper-lockfile: 4.1.2 typebox: 1.1.38 - undici: 8.5.0 + undici: 8.10.0 yaml: 2.9.0 optionalDependencies: '@mariozechner/clipboard': 0.3.9 @@ -3055,7 +3057,7 @@ snapshots: dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -3191,8 +3193,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -3203,7 +3203,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 6.0.3 - undici: 7.28.0 + undici: 7.29.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -3598,7 +3598,7 @@ snapshots: asynckit@0.4.0: {} - axios@1.17.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -3633,7 +3633,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.6: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -4253,7 +4253,7 @@ snapshots: '@supabase/supabase-js': 2.108.1 '@types/jest': 29.5.14 '@types/pg': 8.11.0 - axios: 1.17.0 + axios: 1.18.1 better-sqlite3: 12.10.0 cloudflare: 4.5.0 compromise: 14.15.1 @@ -4290,7 +4290,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.8 minimist@1.2.8: {} @@ -4310,16 +4310,17 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.2.0: + mongodb@7.5.0: dependencies: '@mongodb-js/saslprep': 1.4.11 bson: 7.2.0 mongodb-connection-string-url: 7.0.1 - mongoose@9.7.0: + mongoose@9.8.0: dependencies: + '@standard-schema/spec': 1.1.0 kareem: 3.3.0 - mongodb: 7.2.0 + mongodb: 7.5.0 mpath: 0.9.0 mquery: 6.0.0 ms: 2.1.3 @@ -4347,7 +4348,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.12: {} + nanoid@3.3.16: {} napi-build-utils@2.0.0: {} @@ -4358,7 +4359,7 @@ snapshots: apparatus: 0.0.10 dotenv: 17.4.2 memjs: 1.3.2 - mongoose: 9.7.0 + mongoose: 9.8.0 pg: 8.21.0 redis: 5.12.1 safe-stable-stringify: 2.5.0 @@ -4544,17 +4545,17 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.23)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.15 + postcss: 8.5.23 yaml: 2.9.0 - postcss@8.5.15: + postcss@8.5.23: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -4607,7 +4608,7 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - protobufjs@7.6.3: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -4615,7 +4616,6 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -4849,7 +4849,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@6.0.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.23)(typescript@6.0.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -4860,7 +4860,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.23)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.61.1 source-map: 0.7.6 @@ -4869,7 +4869,7 @@ snapshots: tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.23 typescript: 6.0.3 transitivePeerDependencies: - jiti @@ -4895,9 +4895,9 @@ snapshots: undici-types@7.24.6: {} - undici@7.28.0: {} + undici@7.29.0: {} - undici@8.5.0: {} + undici@8.10.0: {} util-deprecate@1.0.2: {} @@ -4909,7 +4909,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.23 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: diff --git a/integrations/pi-agent-plugin/pnpm-workspace.yaml b/integrations/pi-agent-plugin/pnpm-workspace.yaml index b9f73a8e9..283b117cc 100644 --- a/integrations/pi-agent-plugin/pnpm-workspace.yaml +++ b/integrations/pi-agent-plugin/pnpm-workspace.yaml @@ -5,5 +5,10 @@ overrides: "form-data@<4.0.6": ">=4.0.6" "uuid@<11.1.1": ">=11.1.1" "esbuild": ">=0.28.1" - "undici@<6.27.0": ">=6.27.0 <8.0.0" - "undici@>=8.0.0 <8.5.0": ">=8.5.0" + "undici@<7.29.0": ">=7.29.0 <8.0.0" + "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0" + "axios@<1.18.0": ">=1.18.0 <2.0.0" + "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0" + "postcss@<8.5.18": ">=8.5.18 <9.0.0" + "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" + "protobufjs@<7.6.5": ">=7.6.5 <8.0.0" diff --git a/integrations/vercel-ai-sdk/package.json b/integrations/vercel-ai-sdk/package.json index d6dee4acd..244542728 100644 --- a/integrations/vercel-ai-sdk/package.json +++ b/integrations/vercel-ai-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/vercel-ai-provider", - "version": "3.0.0", + "version": "3.0.1", "description": "Vercel AI Provider for providing memory to LLMs", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -80,13 +80,16 @@ ], "overrides": { "glob@>=10.2.0 <10.5.0": "^10.5.0", - "js-yaml@<=4.1.1": ">=4.2.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", "picomatch@<2.3.2": "^2.3.2", "rollup@>=4.0.0 <4.59.0": "^4.59.0", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0", + "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0", + "js-yaml@<3.15.0": ">=3.15.0 <4.0.0", + "js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0" } } } diff --git a/integrations/vercel-ai-sdk/pnpm-lock.yaml b/integrations/vercel-ai-sdk/pnpm-lock.yaml index 393543726..90fcb523d 100644 --- a/integrations/vercel-ai-sdk/pnpm-lock.yaml +++ b/integrations/vercel-ai-sdk/pnpm-lock.yaml @@ -6,13 +6,16 @@ settings: overrides: glob@>=10.2.0 <10.5.0: ^10.5.0 - js-yaml@<=4.1.1: '>=4.2.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 picomatch@<2.3.2: ^2.3.2 rollup@>=4.0.0 <4.59.0: ^4.59.0 esbuild: '>=0.28.1' + brace-expansion@<1.1.16: '>=1.1.16 <2.0.0' + brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0' + js-yaml@<3.15.0: '>=3.15.0 <4.0.0' + js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5.0.0' importers: @@ -794,8 +797,8 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -838,12 +841,12 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1026,6 +1029,11 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -1347,8 +1355,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true jsesc@3.1.0: @@ -1650,6 +1658,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -2219,7 +2230,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 4.2.0 + js-yaml: 3.15.0 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -2598,7 +2609,9 @@ snapshots: arg@4.1.3: {} - argparse@2.0.1: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 babel-jest@29.7.0(@babel/core@7.29.7): dependencies: @@ -2663,12 +2676,12 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -2848,6 +2861,8 @@ snapshots: escape-string-regexp@2.0.0: {} + esprima@4.0.1: {} + eventsource-parser@3.1.0: {} execa@5.1.1: @@ -3344,9 +3359,10 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@3.15.0: dependencies: - argparse: 2.0.1 + argparse: 1.0.10 + esprima: 4.0.1 jsesc@3.1.0: {} @@ -3401,11 +3417,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.8 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.16 minimist@1.2.8: {} @@ -3616,6 +3632,8 @@ snapshots: source-map@0.7.6: {} + sprintf-js@1.0.3: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 diff --git a/integrations/vercel-ai-sdk/pnpm-workspace.yaml b/integrations/vercel-ai-sdk/pnpm-workspace.yaml index 74a8c10db..656592a82 100644 --- a/integrations/vercel-ai-sdk/pnpm-workspace.yaml +++ b/integrations/vercel-ai-sdk/pnpm-workspace.yaml @@ -7,10 +7,13 @@ onlyBuiltDependencies: overrides: "glob@>=10.2.0 <10.5.0": "^10.5.0" - "js-yaml@<=4.1.1": ">=4.2.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" "picomatch@<2.3.2": "^2.3.2" "rollup@>=4.0.0 <4.59.0": "^4.59.0" "esbuild": ">=0.28.1" + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0" + "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0" + "js-yaml@<3.15.0": ">=3.15.0 <4.0.0" + "js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0" diff --git a/integrations/zapier-mem0/.gitignore b/integrations/zapier-mem0/.gitignore new file mode 100644 index 000000000..a532a2b57 --- /dev/null +++ b/integrations/zapier-mem0/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +package-lock.json +.env +.zapierapprc +build/ +dist/ +coverage/ +*.log +.DS_Store diff --git a/integrations/zapier-mem0/LICENSE b/integrations/zapier-mem0/LICENSE new file mode 100644 index 000000000..d20d5102c --- /dev/null +++ b/integrations/zapier-mem0/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2023] [Taranjeet Singh] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/integrations/zapier-mem0/README.md b/integrations/zapier-mem0/README.md new file mode 100644 index 000000000..7b3f5ed0f --- /dev/null +++ b/integrations/zapier-mem0/README.md @@ -0,0 +1,41 @@ +# Zapier integration for Mem0 + +A [Zapier](https://zapier.com) integration for [Mem0](https://mem0.ai) β€” the memory layer for AI agents. Add, search, list, and delete long-term memories from any Zap. + +Built with the [Zapier Platform CLI](https://docs.zapier.com/platform/quickstart/cli-tutorial). + +## Actions + +| Type | Name | Endpoint | +| --- | --- | --- | +| Create | **Add Memory** | `POST /v3/memories/add/` | +| Create | **Delete Memory** | `DELETE /v1/memories/{id}/` | +| Search | **Search Memories** | `POST /v3/memories/search/` | +| Search | **Get Memories** | `POST /v3/memories/` | + +**Add Memory** runs LLM extraction asynchronously and returns immediately with an event ID by default. Turn on **Wait for Completion** to have the action poll until extraction finishes and return the resulting memories β€” note that extraction can take longer than Zapier allows a single step to run, and a timeout there does **not** mean the add failed (it typically still completes server-side). Set **Infer = false** to store the message verbatim instead of extracting. + +**Get Memories** returns one page at a time; use the **Page** and **Limit** fields to page through larger result sets. + +## Authentication + +Custom (API key) auth. Provide a Mem0 API key from [app.mem0.ai](https://app.mem0.ai) β†’ Settings β†’ API Keys. It is sent as `Authorization: Token `. + +## Development + +Written in TypeScript; the app compiles to `dist/` (Zapier runs the compiled JS). + +```bash +pnpm install +pnpm build # compile src/ β†’ dist/ +pnpm test:unit # offline unit tests (mocked, no network) +MEM0_API_KEY=m0-... pnpm test # unit + live E2E against api.mem0.ai +``` + +Anonymous usage telemetry is sent to Mem0; opt out with `MEM0_TELEMETRY=false`. + +To deploy (maintainers): `pnpm build && zapier push`. + +## License + +MIT diff --git a/integrations/zapier-mem0/index.js b/integrations/zapier-mem0/index.js new file mode 100644 index 000000000..f3d35a1d6 --- /dev/null +++ b/integrations/zapier-mem0/index.js @@ -0,0 +1,3 @@ +// Zapier's Lambda wrapper requires `/index.js` and ignores package.json +// "main", so re-export the compiled app from the root. Run `npm run build` first. +module.exports = require('./dist/index.js'); diff --git a/integrations/zapier-mem0/package.json b/integrations/zapier-mem0/package.json new file mode 100644 index 000000000..b095818d3 --- /dev/null +++ b/integrations/zapier-mem0/package.json @@ -0,0 +1,77 @@ +{ + "name": "@mem0/zapier", + "version": "0.1.1", + "description": "Zapier integration for Mem0 β€” the memory layer for AI agents.", + "keywords": [ + "zapier", + "mem0", + "memory", + "ai", + "agents" + ], + "homepage": "https://mem0.ai", + "author": { + "name": "Mem0", + "email": "founders@mem0.ai" + }, + "repository": { + "type": "git", + "url": "https://github.com/mem0ai/mem0", + "directory": "integrations/zapier-mem0" + }, + "license": "Apache-2.0", + "main": "index.js", + "scripts": { + "build": "tsc", + "test": "jest --testTimeout 180000", + "test:unit": "jest test/unit.test.ts" + }, + "engines": { + "node": ">=18", + "npm": ">=5.6.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "jest": { + "testEnvironment": "node", + "testMatch": [ + "**/test/**/*.test.ts" + ], + "setupFiles": [ + "/test/setup.ts" + ], + "transform": { + "^.+\\.tsx?$": [ + "ts-jest", + {} + ] + } + }, + "dependencies": { + "zapier-platform-core": "19.0.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.9.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "^5.6.3" + }, + "pnpm": { + "overrides": { + "form-data@<4.0.6": ">=4.0.6", + "uuid@<11.1.1": ">=11.1.1", + "esbuild": ">=0.28.1", + "undici@<7.29.0": ">=7.29.0 <8.0.0", + "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0", + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0" + } + } +} diff --git a/integrations/zapier-mem0/pnpm-lock.yaml b/integrations/zapier-mem0/pnpm-lock.yaml new file mode 100644 index 000000000..796da544a --- /dev/null +++ b/integrations/zapier-mem0/pnpm-lock.yaml @@ -0,0 +1,3167 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + form-data@<4.0.6: '>=4.0.6' + uuid@<11.1.1: '>=11.1.1' + esbuild: '>=0.28.1' + undici@<7.29.0: '>=7.29.0 <8.0.0' + undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0' + brace-expansion@<1.1.16: '>=1.1.16 <2.0.0' + +importers: + + .: + dependencies: + zapier-platform-core: + specifier: 19.0.0 + version: 19.0.0 + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^22.9.0 + version: 22.20.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.20.1) + ts-jest: + specifier: ^29.2.5 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.1))(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@zapier/secret-scrubber@1.1.6': + resolution: {integrity: sha512-ETblIxh6lnkVHjVqQ8XVB2Ymeo7RudR7kIiZylXtYXAfpmGLXIzbk1h9fjF/SuO21k7jdxaMBaBdqUCBHv8+og==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.41: + resolution: {integrity: sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cipher-base@1.0.7: + resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} + engines: {node: '>= 0.10'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dotenv@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.384: + resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fernet@0.3.3: + resolution: {integrity: sha512-DvvqouVhv3VCor83wkQbSycekYUKDRQ1IKqcInaF5n5BSKgWBVfYLbSf7RRxojwQO0DZySiz5MlM2vO4MG3SUg==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-base@3.1.2: + resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} + engines: {node: '>= 0.8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-metaschema@1.3.0: + resolution: {integrity: sha512-FMDPEZQzqIVOQZ3OxzWryI28W6IZ9QKLcHObO9bS+SJrwnV3xQD0QtnhtgSpIZd8Xuy0xBqeRA74vfTnpK4eVg==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonschema@1.5.0: + resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + ripemd160@2.0.3: + resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + ts-jest@29.4.12: + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + urlsafe-base64@1.0.0: + resolution: {integrity: sha512-RtuPeMy7c1UrHwproMZN9gN6kiZ0SvJwRaEzwZY0j9MypEkFqyBaKv176jvlPtg58Zh36bOkS0NFABXMHvvGCA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zapier-platform-core@19.0.0: + resolution: {integrity: sha512-BWUq7BUQCM6oOITKgfhuStuWGGmWuYQsDi/vVspHts1AAaURSANczrO2eYaLxagMxcX0JovoOyAYm8tQltsCmA==} + engines: {node: '>=16', npm: '>=5.6.0'} + peerDependencies: + zapier-platform-legacy-scripting-runner: '>=3' + peerDependenciesMeta: + zapier-platform-legacy-scripting-runner: + optional: true + + zapier-platform-schema@19.0.0: + resolution: {integrity: sha512-mwy+1vaLBeFG5XGUbkmnwnk6EmY0yQeHKoY/wG24WtVuWacRKAmq9jbUZ1jVcor0zZBYyb7DD0GsYeSK+DR5dg==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.0 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.20.1) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.20.1 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.20.1 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.20.1 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@sinclair/typebox@0.27.10': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.20.1 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + optional: true + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@zapier/secret-scrubber@1.1.6': + dependencies: + create-hash: 1.2.0 + lodash.isplainobject: 4.0.6 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.10.41: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.41 + caniuse-lite: 1.0.30001800 + electron-to-chromium: 1.5.384 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001800: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + ci-info@3.9.0: {} + + cipher-base@1.0.7: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + cjs-module-lexer@1.4.3: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + convert-source-map@2.0.0: {} + + core-util-is@1.0.3: {} + + create-hash@1.2.0: + dependencies: + cipher-base: 1.0.7 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.3 + sha.js: 2.4.12 + + create-jest@29.7.0(@types/node@22.20.1): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.20.1) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-js@4.2.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + delayed-stream@1.0.0: {} + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + dotenv@17.2.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.384: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + esprima@4.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + fast-json-stable-stringify@2.1.0: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fernet@0.3.3: + dependencies: + crypto-js: 4.2.0 + urlsafe-base64: 1.0.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-base@3.1.2: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-escaper@2.0.2: {} + + human-signals@2.1.0: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-arrayish@0.2.1: {} + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-number@7.0.0: {} + + is-stream@2.0.1: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@22.20.1): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.20.1) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.20.1) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@22.20.1): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.20.1 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 22.20.1 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@22.20.1): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + jsesc@3.1.0: {} + + json-metaschema@1.3.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json5@2.2.3: {} + + jsonschema@1.5.0: {} + + kleur@3.0.3: {} + + leven@3.1.0: {} + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.isplainobject@4.0.6: {} + + lodash.memoize@4.1.2: {} + + lodash@4.18.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + md5.js@1.3.5: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-abort-controller@3.1.1: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-int64@0.4.0: {} + + node-releases@2.0.50: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + oauth-sign@0.9.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + possible-typed-array-names@1.1.0: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + process-nextick-args@2.0.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + pure-rand@6.1.0: {} + + react-is@18.3.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + ripemd160@2.0.3: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + tmpl@1.0.5: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + ts-algebra@2.0.0: {} + + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.1))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@22.20.1) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 29.7.0 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + urlsafe-base64@1.0.0: {} + + util-deprecate@1.0.2: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zapier-platform-core@19.0.0: + dependencies: + '@zapier/secret-scrubber': 1.1.6 + content-disposition: 0.5.4 + dotenv: 17.2.1 + fernet: 0.3.3 + form-data: 4.0.6 + json-schema-to-ts: 3.1.1 + lodash: 4.18.1 + mime-types: 3.0.1 + node-abort-controller: 3.1.1 + node-fetch: 2.7.0 + oauth-sign: 0.9.0 + semver: 7.7.2 + zapier-platform-schema: 19.0.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - encoding + + zapier-platform-schema@19.0.0: + dependencies: + json-metaschema: 1.3.0 + jsonschema: 1.5.0 + lodash: 4.18.1 diff --git a/integrations/zapier-mem0/pnpm-workspace.yaml b/integrations/zapier-mem0/pnpm-workspace.yaml new file mode 100644 index 000000000..4147ae103 --- /dev/null +++ b/integrations/zapier-mem0/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - '.' + +overrides: + "form-data@<4.0.6": ">=4.0.6" + "uuid@<11.1.1": ">=11.1.1" + "esbuild": ">=0.28.1" + "undici@<7.29.0": ">=7.29.0 <8.0.0" + "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0" + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0" diff --git a/integrations/zapier-mem0/src/authentication.ts b/integrations/zapier-mem0/src/authentication.ts new file mode 100644 index 000000000..19d325220 --- /dev/null +++ b/integrations/zapier-mem0/src/authentication.ts @@ -0,0 +1,31 @@ +import type { ZObject, Bundle } from './types'; + +// Custom (API key) authentication for Mem0. +// The key is sent as "Authorization: Token " (matches Mem0's SDKs). +const test = (z: ZObject, _bundle: Bundle) => z.request({ url: '/v1/ping/', method: 'GET' }); + +export default { + type: 'custom', + test, + fields: [ + { + key: 'apiKey', + label: 'Mem0 API Key', + // `password` so Zapier masks the key in the connection UI (it is a secret). + type: 'password', + required: true, + helpText: + 'Your Mem0 API key (starts with `m0-`). Create one at [app.mem0.ai](https://app.mem0.ai) β†’ Settings β†’ API Keys.', + }, + { + key: 'baseUrl', + label: 'Base URL', + type: 'string', + required: false, + default: 'https://api.mem0.ai', + helpText: 'Override only for self-hosted or non-default deployments.', + }, + ], + // Shown on the connection label in the Zap editor. + connectionLabel: 'Mem0', +}; diff --git a/integrations/zapier-mem0/src/creates/add_memory.ts b/integrations/zapier-mem0/src/creates/add_memory.ts new file mode 100644 index 000000000..d0e27a034 --- /dev/null +++ b/integrations/zapier-mem0/src/creates/add_memory.ts @@ -0,0 +1,168 @@ +import type { ZObject, Bundle, AddResponse, EventResponse } from '../types'; +import { captureEvent } from '../telemetry'; + +const POLL_INTERVAL_MS = 1500; +// Bounded to a 60s poll budget, under Zapier's per-step execution timeout. +const MAX_POLL_ATTEMPTS = 40; + +// Polls GET /v1/event/{id}/ until the async memory-addition event resolves. +const pollEvent = async (z: ZObject, eventId: string): Promise => { + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const res = await z.request({ url: `/v1/event/${eventId}/`, method: 'GET' }); + const data = res.data as EventResponse; + const status = data && data.status; + if (status === 'SUCCEEDED') { + return data; + } + if (status === 'FAILED') { + const reason = (data && (data.error || data.message)) || 'unknown error'; + throw new z.errors.Error(`Mem0 memory event ${eventId} failed: ${reason}`, 'Mem0EventFailed', 400); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + throw new z.errors.Error( + `Timed out waiting for memory event ${eventId}. The add was accepted and is ` + + `likely still completing on the server β€” a timeout here does not mean it failed.`, + 'Mem0Timeout', + 408, + ); +}; + +const perform = async (z: ZObject, bundle: Bundle): Promise => { + // Zapier boolean fields can arrive as the strings 'true'/'false'; coerce + // explicitly so "Infer = No" / "Wait = No" are honored. + const infer = String(bundle.inputData.infer) !== 'false'; + // Waiting is opt-in (the poll path can exceed Zapier's step timeout). + const wait = String(bundle.inputData.waitForCompletion) === 'true'; + + const body: Record = { + messages: [{ role: bundle.inputData.role || 'user', content: bundle.inputData.content }], + infer, + }; + if (bundle.inputData.user_id) body.user_id = bundle.inputData.user_id; + if (bundle.inputData.agent_id) body.agent_id = bundle.inputData.agent_id; + if (bundle.inputData.run_id) body.run_id = bundle.inputData.run_id; + if (bundle.inputData.metadata) { + try { + body.metadata = + typeof bundle.inputData.metadata === 'string' + ? JSON.parse(bundle.inputData.metadata) + : bundle.inputData.metadata; + } catch { + throw new z.errors.Error('Metadata must be valid JSON.', 'InvalidInput', 400); + } + } + // Custom extraction controls (optional): steer what the API extracts. + if (bundle.inputData.custom_instructions) { + body.custom_instructions = bundle.inputData.custom_instructions; + } + if (bundle.inputData.custom_categories) { + try { + body.custom_categories = + typeof bundle.inputData.custom_categories === 'string' + ? JSON.parse(bundle.inputData.custom_categories) + : bundle.inputData.custom_categories; + } catch { + throw new z.errors.Error('Custom Categories must be valid JSON.', 'InvalidInput', 400); + } + } + if (bundle.inputData.includes) body.includes = bundle.inputData.includes; + if (bundle.inputData.excludes) body.excludes = bundle.inputData.excludes; + + captureEvent('zapier.add_memory', bundle.authData?.apiKey, { infer, wait }); + + const response = await z.request({ + url: '/v3/memories/add/', + method: 'POST', + body, + }); + + // Default to an empty object so an empty/no-content 2xx body can't crash the + // `.status` / `.event_id` reads below. + const data = (response.data ?? {}) as AddResponse; + + // Add returns {event_id, status:PENDING|RUNNING}; poll only when opted in. + if (wait && data.event_id && data.status !== 'SUCCEEDED' && data.status !== 'FAILED') { + return pollEvent(z, data.event_id); + } + if (data.status === 'FAILED') { + const reason = data.error || data.message || 'unknown error'; + throw new z.errors.Error(`Mem0 memory add failed: ${reason}`, 'Mem0EventFailed', 400); + } + return data; +}; + +export default { + key: 'add_memory', + noun: 'Memory', + display: { + label: 'Add Memory', + description: 'Extract and store memories from a message.', + }, + operation: { + perform, + inputFields: [ + { + key: 'content', + label: 'Content', + type: 'text', + required: true, + helpText: 'The message content to extract memories from.', + }, + { + key: 'role', + label: 'Role', + choices: { user: 'User', assistant: 'Assistant', system: 'System' }, + default: 'user', + }, + { key: 'user_id', label: 'User ID', type: 'string', required: true, helpText: 'Scope this memory to a user. Mem0 requires at least one entity ID.' }, + { key: 'agent_id', label: 'Agent ID', type: 'string' }, + { key: 'run_id', label: 'Run ID', type: 'string' }, + { key: 'metadata', label: 'Metadata (JSON)', type: 'string' }, + { + key: 'custom_instructions', + label: 'Custom Instructions', + type: 'text', + helpText: 'Optional instructions that steer what the extractor keeps or ignores.', + }, + { + key: 'custom_categories', + label: 'Custom Categories (JSON)', + type: 'string', + helpText: + 'Optional taxonomy for categorising memories, as a JSON array of {category: description} objects.', + }, + { + key: 'includes', + label: 'Includes', + type: 'text', + helpText: 'Optional: only extract memories matching this description.', + }, + { + key: 'excludes', + label: 'Excludes', + type: 'text', + helpText: 'Optional: skip memories matching this description.', + }, + { + key: 'infer', + label: 'Infer', + type: 'boolean', + default: 'true', + helpText: 'Run LLM extraction over the message. Turn off to store it verbatim.', + }, + { + key: 'waitForCompletion', + label: 'Wait for Completion', + type: 'boolean', + default: 'false', + helpText: + 'Poll until extraction finishes and return the resulting memories. ' + + 'Leave off (default) to return immediately with an event ID β€” extraction can take ' + + 'longer than Zapier allows this step to run, and a timeout does not mean the add failed.', + }, + ], + // Default (no-wait) returns the accepted event; the wait path returns the resolved event. + sample: { event_id: '00000000-0000-0000-0000-000000000000', status: 'PENDING' }, + }, +}; diff --git a/integrations/zapier-mem0/src/creates/delete_memory.ts b/integrations/zapier-mem0/src/creates/delete_memory.ts new file mode 100644 index 000000000..73cac1880 --- /dev/null +++ b/integrations/zapier-mem0/src/creates/delete_memory.ts @@ -0,0 +1,24 @@ +import type { ZObject, Bundle } from '../types'; + +const perform = async (z: ZObject, bundle: Bundle) => { + // Trailing slash required (Django APPEND_SLASH); id encoded so a stray slash can't mistarget the path. + const response = await z.request({ + url: `/v1/memories/${encodeURIComponent(String(bundle.inputData.memory_id))}/`, + method: 'DELETE', + }); + return response.data || { message: 'Deleted', memory_id: bundle.inputData.memory_id }; +}; + +export default { + key: 'delete_memory', + noun: 'Memory', + display: { + label: 'Delete Memory', + description: 'Delete a single memory by its ID.', + }, + operation: { + perform, + inputFields: [{ key: 'memory_id', label: 'Memory ID', type: 'string', required: true }], + sample: { message: 'Memory deleted successfully' }, + }, +}; diff --git a/integrations/zapier-mem0/src/index.ts b/integrations/zapier-mem0/src/index.ts new file mode 100644 index 000000000..d4aee7d9d --- /dev/null +++ b/integrations/zapier-mem0/src/index.ts @@ -0,0 +1,33 @@ +import { version as platformVersion } from 'zapier-platform-core'; +import authentication from './authentication'; +import { includeApiKey, handleBadResponses } from './middleware'; +import addMemory from './creates/add_memory'; +import deleteMemory from './creates/delete_memory'; +import searchMemories from './searches/search_memories'; +import getMemories from './searches/get_memories'; +import pkg from '../package.json'; + +const app = { + version: pkg.version, + platformVersion, + + authentication, + + beforeRequest: [includeApiKey], + afterResponse: [handleBadResponses], + + creates: { + [addMemory.key]: addMemory, + [deleteMemory.key]: deleteMemory, + }, + + searches: { + [searchMemories.key]: searchMemories, + [getMemories.key]: getMemories, + }, + + resources: {}, + triggers: {}, +}; + +export = app; diff --git a/integrations/zapier-mem0/src/middleware.ts b/integrations/zapier-mem0/src/middleware.ts new file mode 100644 index 000000000..5b2be1c30 --- /dev/null +++ b/integrations/zapier-mem0/src/middleware.ts @@ -0,0 +1,50 @@ +import type { ZObject, Bundle, MutableRequest, ZResponse } from './types'; + +// Prepend the configured base URL and inject the auth header on every request. +export const includeApiKey = ( + request: MutableRequest, + _z: ZObject, + bundle: Bundle, +): MutableRequest => { + if (bundle.authData && bundle.authData.apiKey) { + request.headers = request.headers || {}; + request.headers.Authorization = `Token ${bundle.authData.apiKey}`; + } + // Resolve relative URLs against the configured base URL. + if (request.url && request.url.startsWith('/')) { + const base = (bundle.authData && bundle.authData.baseUrl) || 'https://api.mem0.ai'; + request.url = `${base.replace(/\/$/, '')}${request.url}`; + } + return request; +}; + +// Surface HTTP failures as errors. z.request does NOT throw on non-2xx by +// default, so without this a 4xx/5xx would flow downstream as a fake success +// (empty search results / error body returned as a created memory). +export const handleBadResponses = ( + response: ZResponse, + z: ZObject, + _bundle: Bundle, +): ZResponse => { + if (response.status === 401 || response.status === 403) { + throw new z.errors.Error( + 'The Mem0 API key you supplied is invalid or lacks access.', + 'AuthenticationError', + response.status, + ); + } + if (response.status >= 400) { + const data = (response.data as Record) || {}; + const raw = + data.detail || data.error || data.message || response.content || 'unknown error'; + // DRF sometimes returns detail as an object/array; stringify so the + // thrown message never renders as "[object Object]". + const detail = typeof raw === 'string' ? raw : JSON.stringify(raw); + throw new z.errors.Error( + `Mem0 API request failed (HTTP ${response.status}): ${detail}`, + 'Mem0ApiError', + response.status, + ); + } + return response; +}; diff --git a/integrations/zapier-mem0/src/searches/get_memories.ts b/integrations/zapier-mem0/src/searches/get_memories.ts new file mode 100644 index 000000000..96df8d3ee --- /dev/null +++ b/integrations/zapier-mem0/src/searches/get_memories.ts @@ -0,0 +1,52 @@ +import type { ZObject, Bundle, Memory } from '../types'; +import { captureEvent } from '../telemetry'; + +const perform = async (z: ZObject, bundle: Bundle): Promise => { + const body: Record = {}; + if (bundle.inputData.user_id) body.filters = { user_id: bundle.inputData.user_id }; + + captureEvent('zapier.get_memories', bundle.authData?.apiKey); + + const response = await z.request({ + url: '/v3/memories/', + method: 'POST', + params: { + page: Math.max(1, Math.floor(Number(bundle.inputData.page) || 1)), + page_size: Math.max(1, Math.floor(Number(bundle.inputData.limit) || 50)), + }, + body, + }); + // Guard against a null/empty body; always return an array. + const data = response.data as Memory[] | { results?: Memory[] } | null; + return Array.isArray(data) ? data : data?.results ?? []; +}; + +export default { + key: 'get_memories', + noun: 'Memory', + display: { + label: 'Get Memories', + description: 'List stored memories for a user.', + }, + operation: { + perform, + inputFields: [ + { key: 'user_id', label: 'User ID', type: 'string', required: true }, + { + key: 'limit', + label: 'Limit', + type: 'integer', + default: '50', + helpText: 'Max memories per page. Use Page to page through larger result sets.', + }, + { + key: 'page', + label: 'Page', + type: 'integer', + default: '1', + helpText: 'Which page of results to return (1-based).', + }, + ], + sample: { id: '00000000-0000-0000-0000-000000000000', memory: 'User loves hiking' }, + }, +}; diff --git a/integrations/zapier-mem0/src/searches/search_memories.ts b/integrations/zapier-mem0/src/searches/search_memories.ts new file mode 100644 index 000000000..f2f834e45 --- /dev/null +++ b/integrations/zapier-mem0/src/searches/search_memories.ts @@ -0,0 +1,40 @@ +import type { ZObject, Bundle, Memory } from '../types'; +import { captureEvent } from '../telemetry'; + +const perform = async (z: ZObject, bundle: Bundle): Promise => { + const body: Record = { + query: bundle.inputData.query, + output_format: 'v1.1', + top_k: Math.max(1, Math.floor(Number(bundle.inputData.limit) || 50)), + }; + if (bundle.inputData.user_id) body.filters = { user_id: bundle.inputData.user_id }; + + captureEvent('zapier.search_memories', bundle.authData?.apiKey); + + const response = await z.request({ + url: '/v3/memories/search/', + method: 'POST', + body, + }); + // Searches must return an array; guard against a null/empty body. + const data = response.data as Memory[] | { results?: Memory[] } | null; + return Array.isArray(data) ? data : data?.results ?? []; +}; + +export default { + key: 'search_memories', + noun: 'Memory', + display: { + label: 'Search Memories', + description: 'Semantic search over stored memories.', + }, + operation: { + perform, + inputFields: [ + { key: 'query', label: 'Query', type: 'string', required: true }, + { key: 'user_id', label: 'User ID', type: 'string', required: true }, + { key: 'limit', label: 'Limit', type: 'integer', default: '50' }, + ], + sample: { id: '00000000-0000-0000-0000-000000000000', memory: 'User loves hiking' }, + }, +}; diff --git a/integrations/zapier-mem0/src/telemetry.ts b/integrations/zapier-mem0/src/telemetry.ts new file mode 100644 index 000000000..46e2b994f --- /dev/null +++ b/integrations/zapier-mem0/src/telemetry.ts @@ -0,0 +1,39 @@ +import { createHash } from 'crypto'; + +// Anonymous usage telemetry via PostHog, mirroring the Mem0 CLI / plugins. +// Fire-and-forget: never awaited, never throws, so it can neither slow down +// nor break an action. Opt out with MEM0_TELEMETRY=false. +const POSTHOG_API_KEY = 'phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX'; +const POSTHOG_HOST = 'https://us.i.posthog.com/i/v0/e/'; + +export function captureEvent( + event: string, + apiKey: string | undefined, + properties: Record = {}, +): void { + if (process.env.MEM0_TELEMETRY === 'false') return; + try { + // Hash the API key so events are attributable to one account without + // ever transmitting the key itself. + const distinctId = apiKey ? createHash('md5').update(apiKey).digest('hex') : 'zapier-anon'; + const payload = { + api_key: POSTHOG_API_KEY, + event, + distinct_id: distinctId, + properties: { + source: 'ZAPIER', + $process_person_profile: false, + ...properties, + }, + }; + void fetch(POSTHOG_HOST, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => { + /* swallow β€” telemetry must never surface to the user */ + }); + } catch { + /* swallow β€” telemetry must never break the action */ + } +} diff --git a/integrations/zapier-mem0/src/types.ts b/integrations/zapier-mem0/src/types.ts new file mode 100644 index 000000000..fc93ca8ce --- /dev/null +++ b/integrations/zapier-mem0/src/types.ts @@ -0,0 +1,43 @@ +import type { ZObject, Bundle } from 'zapier-platform-core'; + +export type { ZObject, Bundle }; + +/** Mutable outgoing request, as seen by the beforeRequest middleware. */ +export interface MutableRequest { + url?: string; + headers?: Record; + [key: string]: unknown; +} + +/** Response as seen by the afterResponse middleware. */ +export interface ZResponse { + status: number; + data?: Record | unknown[] | null; + content?: string; + [key: string]: unknown; +} + +/** Async add response from POST /v3/memories/add/. */ +export interface AddResponse { + event_id?: string; + status?: string; + error?: string; + message?: string; + results?: unknown[]; + [key: string]: unknown; +} + +/** GET /v1/event/{id}/ response. */ +export interface EventResponse { + status?: string; + error?: string; + message?: string; + [key: string]: unknown; +} + +/** A stored memory object returned by search / list. */ +export interface Memory { + id?: string; + memory?: string; + [key: string]: unknown; +} diff --git a/integrations/zapier-mem0/test/mem0.test.ts b/integrations/zapier-mem0/test/mem0.test.ts new file mode 100644 index 000000000..614a22441 --- /dev/null +++ b/integrations/zapier-mem0/test/mem0.test.ts @@ -0,0 +1,96 @@ +import * as zapier from 'zapier-platform-core'; +import App from '../src'; + +const appTester = zapier.createAppTester(App as any); + +const authData = { + apiKey: process.env.MEM0_API_KEY, + baseUrl: process.env.MEM0_BASE_URL || 'https://api.mem0.ai', +}; + +const userId = `zapier-e2e-${Date.now()}`; + +// Retry an async op until `done` is satisfied or attempts run out. Extraction is +// async, so the default Add returns before the memory is searchable. +const until = async ( + fn: () => Promise, + done: (r: T) => boolean, + { attempts = 30, delayMs = 2000 } = {}, +): Promise => { + let last: T = undefined as unknown as T; + for (let i = 0; i < attempts; i++) { + last = await fn(); + if (done(last)) return last; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return last; +}; + +// The E2E suite hits the live Mem0 API, so it only runs when MEM0_API_KEY is +// set (locally / with a secret). In CI without a key it is skipped, not failed. +const describeE2E = authData.apiKey ? describe : describe.skip; + +describeE2E('Mem0 Zapier integration (E2E)', () => { + it('authentication.test succeeds', async () => { + const res: any = await appTester((App as any).authentication.test, { authData }); + expect(res.status).toBe(200); + }); + + it('adds, searches, lists, and deletes a memory', async () => { + // Add via the default path: returns immediately with an event id. + const added: any = await appTester((App as any).creates.add_memory.operation.perform, { + authData, + inputData: { + content: 'I love hiking in the Alps and my favorite food is sushi', + user_id: userId, + }, + }); + expect(added.event_id).toBeDefined(); + + // Extraction is async; retry search until the memory is indexed. + // Budget generously β€” live extraction can occasionally exceed a minute. + const found: any[] = await until( + () => + appTester((App as any).searches.search_memories.operation.perform, { + authData, + inputData: { query: 'outdoor activities', user_id: userId, limit: 5 }, + }), + (r: any[]) => Array.isArray(r) && r.length > 0, + { attempts: 60, delayMs: 2000 }, + ); + expect(Array.isArray(found)).toBe(true); + expect(found.length).toBeGreaterThan(0); + + // Get all + const all: any[] = await appTester((App as any).searches.get_memories.operation.perform, { + authData, + inputData: { user_id: userId }, + }); + expect(Array.isArray(all)).toBe(true); + expect(all.length).toBeGreaterThan(0); + + // Cleanup: delete every memory we created + for (const mem of all) { + await appTester((App as any).creates.delete_memory.operation.perform, { + authData, + inputData: { memory_id: mem.id }, + }); + } + + const afterDelete: any[] = await appTester( + (App as any).searches.get_memories.operation.perform, + { authData, inputData: { user_id: userId } }, + ); + expect(afterDelete.length).toBe(0); + }); + + it('surfaces API errors instead of returning an empty array (search needs a filter)', async () => { + // filters is required by the API; omitting it must throw, not return []. + await expect( + appTester((App as any).searches.search_memories.operation.perform, { + authData, + inputData: { query: 'anything' }, + }), + ).rejects.toThrow(); + }); +}); diff --git a/integrations/zapier-mem0/test/setup.ts b/integrations/zapier-mem0/test/setup.ts new file mode 100644 index 000000000..3c9be90ae --- /dev/null +++ b/integrations/zapier-mem0/test/setup.ts @@ -0,0 +1,3 @@ +// Disable telemetry during tests so runs never fire real PostHog events +// (and so the fire-and-forget fetch cannot leave an open handle after tests). +process.env.MEM0_TELEMETRY = 'false'; diff --git a/integrations/zapier-mem0/test/unit.test.ts b/integrations/zapier-mem0/test/unit.test.ts new file mode 100644 index 000000000..1b638f7f6 --- /dev/null +++ b/integrations/zapier-mem0/test/unit.test.ts @@ -0,0 +1,183 @@ +// Offline unit tests: they mock `z.request`, so they run unconditionally in CI +// (unlike the live E2E suite in mem0.test.ts, gated on MEM0_API_KEY). They cover +// what `zapier validate` can't: boolean coercion, URL join, metadata, array shapes. + +import addMemory from '../src/creates/add_memory'; +import deleteMemory from '../src/creates/delete_memory'; +import searchMemories from '../src/searches/search_memories'; +import getMemories from '../src/searches/get_memories'; +import { includeApiKey } from '../src/middleware'; + +// Minimal `z` stub: hands back queued responses and records every request. +const makeZ = (responses: any[] = []): any => { + const queue = [...responses]; + const requests: any[] = []; + return { + requests, + request: async (opts: any) => { + requests.push(opts); + const next = queue.shift(); + return next !== undefined ? next : { data: {} }; + }, + errors: { + Error: class Mem0Error extends Error { + name: string; + status?: number; + constructor(message: string, name?: string, status?: number) { + super(message); + this.name = name || 'Error'; + this.status = status; + } + }, + }, + }; +}; + +describe('add_memory (offline)', () => { + it('coerces infer="false" to a boolean and does not poll by default', async () => { + const z = makeZ([{ data: { event_id: 'e1', status: 'PENDING' } }]); + const res = await addMemory.operation.perform(z, { + inputData: { content: 'hi', user_id: 'u1', infer: 'false' }, + } as any); + // waitForCompletion defaults off -> a single request (the add), no poll. + expect(z.requests).toHaveLength(1); + expect(z.requests[0].body.infer).toBe(false); + expect((res as any).status).toBe('PENDING'); + }); + + it('polls the event only when waitForCompletion="true"', async () => { + const z = makeZ([ + { data: { event_id: 'e1', status: 'PENDING' } }, + { data: { status: 'SUCCEEDED', results: [{ id: 'm1' }] } }, + ]); + const res = await addMemory.operation.perform(z, { + inputData: { content: 'hi', user_id: 'u1', waitForCompletion: 'true' }, + } as any); + expect(z.requests).toHaveLength(2); + expect(z.requests[1].url).toBe('/v1/event/e1/'); + expect((res as any).status).toBe('SUCCEEDED'); + }); + + it('keeps polling past the old 12-attempt budget when the API is slow', async () => { + jest.useFakeTimers(); + const pendingPolls = Array.from({ length: 20 }, () => ({ data: { status: 'PENDING' } })); + const z = makeZ([ + { data: { event_id: 'e1', status: 'PENDING' } }, + ...pendingPolls, + { data: { status: 'SUCCEEDED', results: [{ id: 'm1' }] } }, + ]); + const resultPromise = addMemory.operation.perform(z, { + inputData: { content: 'hi', user_id: 'u1', waitForCompletion: 'true' }, + } as any); + for (let i = 0; i < pendingPolls.length; i++) { + await jest.advanceTimersByTimeAsync(1500); + } + const res = await resultPromise; + expect((res as any).status).toBe('SUCCEEDED'); + jest.useRealTimers(); + }); + + it('throws a clear error on invalid JSON metadata', async () => { + const z = makeZ(); + await expect( + addMemory.operation.perform(z, { + inputData: { content: 'hi', user_id: 'u1', metadata: '{not json' }, + } as any), + ).rejects.toThrow('Metadata must be valid JSON.'); + }); + + it('forwards custom_instructions and parses custom_categories JSON', async () => { + const z = makeZ([{ data: { event_id: 'e1', status: 'PENDING' } }]); + await addMemory.operation.perform(z, { + inputData: { + content: 'hi', + user_id: 'u1', + custom_instructions: 'keep durable facts', + custom_categories: '[{"work":"job related"}]', + }, + } as any); + expect(z.requests[0].body.custom_instructions).toBe('keep durable facts'); + expect(z.requests[0].body.custom_categories).toEqual([{ work: 'job related' }]); + }); + + it('throws a clear error on invalid Custom Categories JSON', async () => { + const z = makeZ(); + await expect( + addMemory.operation.perform(z, { + inputData: { content: 'hi', user_id: 'u1', custom_categories: '{bad' }, + } as any), + ).rejects.toThrow('Custom Categories must be valid JSON.'); + }); + + it('forwards includes and excludes when provided', async () => { + const z = makeZ([{ data: { event_id: 'e1', status: 'PENDING' } }]); + await addMemory.operation.perform(z, { + inputData: { content: 'hi', user_id: 'u1', includes: 'work facts', excludes: 'small talk' }, + } as any); + expect(z.requests[0].body.includes).toBe('work facts'); + expect(z.requests[0].body.excludes).toBe('small talk'); + }); +}); + +describe('search / get array-shape enforcement (offline)', () => { + it('search unwraps an object {results:[...]} into an array', async () => { + const z = makeZ([{ data: { results: [{ id: 'm1' }] } }]); + const res = await searchMemories.operation.perform(z, { + inputData: { query: 'x', user_id: 'u1' }, + } as any); + expect(Array.isArray(res)).toBe(true); + expect(res).toHaveLength(1); + }); + + it('get_memories returns [] when the API returns neither array nor results', async () => { + const z = makeZ([{ data: {} }]); + const res = await getMemories.operation.perform(z, { inputData: { user_id: 'u1' } } as any); + expect(Array.isArray(res)).toBe(true); + expect(res).toHaveLength(0); + }); + + it('get_memories forwards page and page_size as numbers', async () => { + const z = makeZ([{ data: { results: [] } }]); + await getMemories.operation.perform(z, { + inputData: { user_id: 'u1', page: '2', limit: '10' }, + } as any); + expect(z.requests[0].params).toEqual({ page: 2, page_size: 10 }); + }); + + it('search and get_memories return [] on a null/empty body (no crash)', async () => { + const zSearch = makeZ([{ data: null }]); + const found = await searchMemories.operation.perform(zSearch, { + inputData: { query: 'x', user_id: 'u1' }, + } as any); + expect(found).toEqual([]); + + const zGet = makeZ([{ data: null }]); + const all = await getMemories.operation.perform(zGet, { inputData: { user_id: 'u1' } } as any); + expect(all).toEqual([]); + }); +}); + +describe('delete_memory (offline)', () => { + it('encodes the memory id in the URL path', async () => { + const z = makeZ([{ data: {} }]); + await deleteMemory.operation.perform(z, { inputData: { memory_id: 'a/b c' } } as any); + expect(z.requests[0].url).toBe('/v1/memories/a%2Fb%20c/'); + }); +}); + +describe('includeApiKey middleware (offline)', () => { + it('prepends the base URL and injects the auth header', () => { + const req = includeApiKey({ url: '/v3/memories/' }, null as any, { + authData: { apiKey: 'k', baseUrl: 'https://api.mem0.ai/' }, + } as any); + expect(req.url).toBe('https://api.mem0.ai/v3/memories/'); + expect(req.headers!.Authorization).toBe('Token k'); + }); + + it('leaves absolute URLs untouched', () => { + const req = includeApiKey({ url: 'https://other.example/x' }, null as any, { + authData: { apiKey: 'k' }, + } as any); + expect(req.url).toBe('https://other.example/x'); + }); +}); diff --git a/integrations/zapier-mem0/tsconfig.json b/integrations/zapier-mem0/tsconfig.json new file mode 100644 index 000000000..69c0f1409 --- /dev/null +++ b/integrations/zapier-mem0/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": false, + "sourceMap": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/mem0-ts/AGENTS.md b/mem0-ts/AGENTS.md new file mode 100644 index 000000000..a225f2ab8 --- /dev/null +++ b/mem0-ts/AGENTS.md @@ -0,0 +1,56 @@ +# TypeScript SDK (`mem0-ts/`) + +The `mem0ai` package on npm. Hosted client plus self-hosted OSS memory. + +## Commands + +```bash +pnpm install +pnpm run build # tsup (CJS + ESM) +pnpm run test # jest, all tests +pnpm run test:unit # jest --coverage +pnpm run test:integration # jest, needs MEM0_API_KEY +pnpm run test:ci # jest --coverage --ci +pnpm run test:watch +pnpm run typecheck # tsc --noEmit +``` + +pnpm only. Never npm, never yarn. + +## Conventions + +- **Node 20 and 22** are the CI-tested versions. +- **Build:** tsup, dual CJS + ESM output. +- **Formatter:** Prettier. No linter is configured here; `cli/node/` uses Biome and `integrations/vercel-ai-sdk/` uses ESLint, so do not assume a shared setup. +- **Tests:** jest. `cli/node/` and `integrations/openclaw/` use vitest instead. +- **TypeScript strict mode.** +- ES module `import` syntax only. Never `require()`. +- Source files are `snake_case.ts`, tests are `.test.ts`. + +Run `pnpm run typecheck` after every change. + +## Layout + +``` +mem0-ts/src/ +β”œβ”€β”€ client/ MemoryClient (hosted platform) +└── oss/ Memory (self-hosted) + β”œβ”€β”€ llms/ + β”œβ”€β”€ embeddings/ + β”œβ”€β”€ vector_stores/ + └── graphs/ +``` + +## Public API + +| Export | Purpose | Import | +| -------------- | ---------------------- | ---------------------------------------------- | +| `MemoryClient` | Hosted platform client | `import { MemoryClient } from 'mem0ai'` | +| `Memory` | Self-hosted OSS memory | `import { Memory } from 'mem0ai/oss'` | +| Providers | OSS building blocks | `import { OpenAIEmbedding } from 'mem0ai/oss'` | + +The method surface mirrors the Python SDK: `add`, `search`, `get`, `getAll`, `update`, `delete`, `deleteAll`, `history`. Changing any public signature means updating `docs/` in the same PR. + +## Releasing + +Tag prefix `ts-v*` triggers `ts-sdk-cd.yml`, which publishes to npm over OIDC. Bump the version in `package.json` first. diff --git a/mem0-ts/CLAUDE.md b/mem0-ts/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/mem0-ts/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 48e019f73..90ebebec3 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -1,6 +1,6 @@ { "name": "mem0ai", - "version": "3.0.13", + "version": "3.1.6", "description": "The Memory Layer For Your AI Apps", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -86,6 +86,7 @@ "license": "Apache-2.0", "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/oracledb": "^7.0.1", "@types/node": "^22.7.6", "@types/uuid": "^9.0.8", "dotenv": "^16.4.5", @@ -98,22 +99,27 @@ "ts-node": "^10.9.2", "tsup": "^8.3.0", "typescript": "5.5.4", - "iovalkey": "^0.3.3" + "iovalkey": "^0.3.3", + "@mochow/mochow-sdk-node": "^2.1.5" }, "dependencies": { - "axios": "^1.16.0", + "axios": "^1.18.0", "openai": "^4.93.0", "uuid": "^11.1.1", "zod": "^3.24.1" }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", + "@aws-sdk/client-neptune-graph": ">=3.0.0 <3.968.0", "@aws-sdk/client-s3vectors": "3.967.0", + "@mochow/mochow-sdk-node": "^2.1.5", "@azure/identity": "^4.0.0", "@azure/search-documents": "^12.0.0", "@cloudflare/workers-types": "^4.20250504.0", + "@databricks/sql": "^1.16.0", "@google-cloud/aiplatform": "^6.8.0", "@google/genai": "^1.40.0", + "@huggingface/transformers": "^3.0.0 || ^4.0.0", "@langchain/core": "^1.1.47", "@mistralai/mistralai": "^1.5.2", "@opensearch-project/opensearch": "^3.5.1", @@ -128,26 +134,129 @@ "cassandra-driver": "4.8.0", "chromadb": "^3.5.0", "cloudflare": "^4.2.0", + "cohere-ai": "^7.17.0 || ^8.0.0", "fastembed": "^2.1.0", "groq-sdk": "0.3.0", "mongodb": "^7.0.0", "weaviate-client": "^3.0.0", "ollama": "^0.5.14", + "oracledb": "^6.5.0 || ^7.0.0", "pg": "8.11.3", "redis": "^4.6.13", "@elastic/elasticsearch": "^9.0.0", "iovalkey": "^0.3.3", "compromise": "^14.0.0", "natural": "^8.0.1", + "zeroentropy": "^0.1.0-alpha.10", "mysql2": "^3.0.0", - "@zilliz/milvus2-sdk-node": "^2.4.0 || ^3.0.0" + "@zilliz/milvus2-sdk-node": "^2.4.0 || ^3.0.0", + "@aws-sdk/client-bedrock-runtime": ">=3.0.0 <3.968.0" }, "peerDependenciesMeta": { + "@qdrant/js-client-rest": { + "optional": true + }, + "redis": { + "optional": true + }, + "@supabase/supabase-js": { + "optional": true + }, + "cloudflare": { + "optional": true + }, + "@azure/search-documents": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@langchain/core": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, + "@google/genai": { + "optional": true + }, + "groq-sdk": { + "optional": true + }, + "@mistralai/mistralai": { + "optional": true + }, + "ollama": { + "optional": true + }, "mysql2": { "optional": true }, "@zilliz/milvus2-sdk-node": { "optional": true + }, + "@mochow/mochow-sdk-node": { + "optional": true + }, + "@aws-sdk/client-bedrock-runtime": { + "optional": true + }, + "@databricks/sql": { + "optional": true + }, + "@aws-sdk/client-neptune-graph": { + "optional": true + }, + "chromadb": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "weaviate-client": { + "optional": true + }, + "cassandra-driver": { + "optional": true + }, + "@pinecone-database/pinecone": { + "optional": true + }, + "@aws-sdk/client-s3vectors": { + "optional": true + }, + "@turbopuffer/turbopuffer": { + "optional": true + }, + "@upstash/vector": { + "optional": true + }, + "@elastic/elasticsearch": { + "optional": true + }, + "@opensearch-project/opensearch": { + "optional": true + }, + "cohere-ai": { + "optional": true + }, + "zeroentropy": { + "optional": true + }, + "fastembed": { + "optional": true + }, + "@google-cloud/aiplatform": { + "optional": true + }, + "@huggingface/transformers": { + "optional": true + }, + "iovalkey": { + "optional": true + }, + "oracledb": { + "optional": true } }, "engines": { @@ -167,24 +276,35 @@ "picomatch@<2.3.2": "^2.3.2", "picomatch@>=4.0.0 <4.0.4": "^4.0.4", "jws@4.0.0": "4.0.1", - "js-yaml@<3.14.2": ">=3.14.2", + "js-yaml@<3.15.0": ">=3.15.0 <4.0.0", + "js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0", "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", - "postcss@<8.5.10": ">=8.5.10", + "postcss@<8.5.18": ">=8.5.18 <9.0.0", "uuid@<11.1.1": ">=11.1.1", "weaviate-client>uuid": "^11.1.1", "ws@>=8.0.0 <8.20.1": ">=8.20.1", "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", - "fast-xml-parser@>=5.0.0 <5.7.0": "^5.9.3", - "tar@<=7.5.15": "^7.5.19", "@modelcontextprotocol/sdk": "^1.25.4", "esbuild": ">=0.28.1", - "undici@<6.27.0": ">=6.27.0 <8.0.0" + "undici@<7.29.0": ">=7.29.0 <8.0.0", + "@aws-sdk/client-bedrock-runtime": "3.967.0", + "@aws-sdk/client-neptune-graph": "3.966.0", + "axios@<1.18.0": ">=1.18.0 <2.0.0", + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0", + "brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3.0.0", + "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0", + "fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0", + "tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0", + "thrift@<0.23.0": "^0.23.0", + "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0", + "protobufjs@<7.6.5": ">=7.6.5 <8.0.0", + "ip-address@<10.3.1": ">=10.3.1 <11.0.0" } } } diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index 4a8b9311c..d0ed3d2bb 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -9,24 +9,35 @@ overrides: picomatch@<2.3.2: ^2.3.2 picomatch@>=4.0.0 <4.0.4: ^4.0.4 jws@4.0.0: 4.0.1 - js-yaml@<3.14.2: '>=3.14.2' + js-yaml@<3.15.0: '>=3.15.0 <4.0.0' + js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5.0.0' 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 - postcss@<8.5.10: '>=8.5.10' + postcss@<8.5.18: '>=8.5.18 <9.0.0' uuid@<11.1.1: '>=11.1.1' weaviate-client>uuid: ^11.1.1 ws@>=8.0.0 <8.20.1: '>=8.20.1' 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 - fast-xml-parser@>=5.0.0 <5.7.0: ^5.9.3 - tar@<=7.5.15: ^7.5.19 '@modelcontextprotocol/sdk': ^1.25.4 esbuild: '>=0.28.1' - undici@<6.27.0: '>=6.27.0 <8.0.0' + undici@<7.29.0: '>=7.29.0 <8.0.0' + '@aws-sdk/client-bedrock-runtime': 3.967.0 + '@aws-sdk/client-neptune-graph': 3.966.0 + axios@<1.18.0: '>=1.18.0 <2.0.0' + brace-expansion@<1.1.16: '>=1.1.16 <2.0.0' + brace-expansion@>=2.0.0 <2.1.4: '>=2.1.4 <3.0.0' + brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0' + fast-xml-parser@>=5.0.0 <5.10.1: '>=5.10.1 <6.0.0' + tar@>=7.0.0 <7.5.21: '>=7.5.21 <8.0.0' + thrift@<0.23.0: ^0.23.0 + mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0' + protobufjs@<7.6.5: '>=7.6.5 <8.0.0' + ip-address@<10.3.1: '>=10.3.1 <11.0.0' importers: @@ -35,6 +46,12 @@ importers: '@anthropic-ai/sdk': specifier: ^0.40.1 version: 0.40.1 + '@aws-sdk/client-bedrock-runtime': + specifier: 3.967.0 + version: 3.967.0 + '@aws-sdk/client-neptune-graph': + specifier: 3.966.0 + version: 3.966.0 '@aws-sdk/client-s3vectors': specifier: 3.967.0 version: 3.967.0 @@ -47,6 +64,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20250504.0 version: 4.20260611.1 + '@databricks/sql': + specifier: ^1.16.0 + version: 1.16.0 '@elastic/elasticsearch': specifier: ^9.0.0 version: 9.4.2 @@ -56,6 +76,9 @@ importers: '@google/genai': specifier: ^1.40.0 version: 1.52.0 + '@huggingface/transformers': + specifier: ^3.0.0 || ^4.0.0 + version: 4.2.0 '@langchain/core': specifier: ^1.1.47 version: 1.1.48(@opentelemetry/api@1.9.1)(openai@4.104.0(ws@5.2.5)(zod@3.25.76))(ws@5.2.5) @@ -90,8 +113,8 @@ importers: specifier: ^2.4.0 || ^3.0.0 version: 3.0.3 axios: - specifier: ^1.16.0 - version: 1.17.0 + specifier: ^1.18.0 + version: 1.18.1 better-sqlite3: specifier: ^12.6.2 version: 12.10.0 @@ -104,6 +127,9 @@ importers: cloudflare: specifier: ^4.2.0 version: 4.5.0 + cohere-ai: + specifier: ^7.17.0 || ^8.0.0 + version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.3) compromise: specifier: ^14.0.0 version: 14.15.1 @@ -115,19 +141,22 @@ importers: version: 0.3.0 mongodb: specifier: ^7.0.0 - version: 7.2.0 + version: 7.2.0(socks@2.8.9) mysql2: specifier: ^3.0.0 version: 3.22.5(@types/node@22.19.21) natural: specifier: ^8.0.1 - version: 8.1.1(@opentelemetry/api@1.9.1) + version: 8.1.1(@opentelemetry/api@1.9.1)(socks@2.8.9) ollama: specifier: ^0.5.14 version: 0.5.18 openai: specifier: ^4.93.0 version: 4.104.0(ws@5.2.5)(zod@3.25.76) + oracledb: + specifier: ^6.5.0 || ^7.0.0 + version: 7.0.1 pg: specifier: 8.11.3 version: 8.11.3 @@ -140,16 +169,25 @@ importers: weaviate-client: specifier: ^3.0.0 version: 3.13.1 + zeroentropy: + specifier: ^0.1.0-alpha.10 + version: 0.1.0-alpha.10 zod: specifier: ^3.24.1 version: 3.25.76 devDependencies: + '@mochow/mochow-sdk-node': + specifier: ^2.1.5 + version: 2.1.5 '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 '@types/node': specifier: ^22.7.6 version: 22.19.21 + '@types/oracledb': + specifier: ^7.0.1 + version: 7.0.1 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -182,13 +220,22 @@ importers: version: 10.9.2(@types/node@22.19.21)(typescript@5.5.4) tsup: specifier: ^8.3.0 - version: 8.5.1(typescript@5.5.4) + version: 8.5.1(tsx@4.23.0)(typescript@5.5.4) typescript: specifier: 5.5.4 version: 5.5.4 packages: + '@75lb/deep-merge@1.1.4': + resolution: {integrity: sha512-Fjmi8VSxoGF80wn8HSHTPfUKLSJ+aiQE7rXyrFV6apP8Xyj8v4bqdC7BBoklxjt671gUqP0yREwpDlUP5R/VyQ==} + engines: {node: '>=12.17'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + '@anthropic-ai/sdk@0.40.1': resolution: {integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==} @@ -230,94 +277,150 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.13': - resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==} + '@aws-sdk/checksums@3.1000.16': + resolution: {integrity: sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1080.0': - resolution: {integrity: sha512-erKuxbwhYLKs0ZviBeTDvXxC0E4AtugHXLbt5kFk8u9/rQMglh/QJNK5f9KuLjlMrbFSJgkBmjNSY5O03YoK4A==} + '@aws-sdk/client-bedrock-runtime@3.967.0': + resolution: {integrity: sha512-pFID1Lb/54u413HTpnqQYLJjX+voEKLZyqlpdVHDbxcw8MfalmmSg5PR/uJWGO3kku0LkB+H/Ca5ftL11PTL9w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-neptune-graph@3.966.0': + resolution: {integrity: sha512-O10O8/hKrZiSGxAQUG0T1UcdIRfE9szG7toipoUoYsbI6BOlov8U6zWz43ao+TfUCeI0luNcjevGhiqt04lB/w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-s3@3.1083.0': + resolution: {integrity: sha512-3YOicHy6qjexsy4rHk5jPvtLgg6Ho1u+ycOWtJcVTAIMlMq32MRtnhllAyFSezhAXeBnnLLtKtAGXim7vV8oog==} engines: {node: '>=20.0.0'} '@aws-sdk/client-s3vectors@3.967.0': resolution: {integrity: sha512-DDmq8Jm/4IwliVEYVUbTub0RhcYJXVexRklhsIOdIWM42zf4ZMWPeeNvHJBSI+DMNbpqRN57TLCfH5tgiPdyVQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.966.0': + resolution: {integrity: sha512-hQZDQgqRJclALDo9wK+bb5O+VpO8JcjImp52w9KPSz9XveNRgE9AYfklRJd8qT2Bwhxe6IbnqYEino2wqUMA1w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.967.0': resolution: {integrity: sha512-7RgUwHcRMJtWme6kCHGUVT+Rn9GmNH+FHm34N9UgMXzUqQlzFMweE7T5E9O8nv3wIp7xFNB20ADaCw9Xdnox1Q==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.966.0': + resolution: {integrity: sha512-QaRVBHD1prdrFXIeFAY/1w4b4S0EFyo/ytzU+rCklEjMRT7DKGXGoHXTWLGz+HD7ovlS5u+9cf8a/LeSOEMzww==} + engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.967.0': resolution: {integrity: sha512-sJmuP7GrVmlbO6DpXkuf9Mbn6jGNNvy6PLawvaxVF150c8bpNk3w39rerRls6q1dot1dBFV2D29hBXMY1agNMg==} engines: {node: '>=18.0.0'} - '@aws-sdk/core@3.974.28': - resolution: {integrity: sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw==} + '@aws-sdk/core@3.975.1': + resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.966.0': + resolution: {integrity: sha512-sxVKc9PY0SH7jgN/8WxhbKQ7MWDIgaJv1AoAKJkhJ+GM5r09G5Vb2Vl8ALYpsy+r8b+iYpq5dGJj8k2VqxoQMg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.967.0': resolution: {integrity: sha512-+XWw0+f/txeMbEVRtTFZhgSw1ymH1ffaVKkdMBSnw48rfSohJElKmitCqdihagRTZpzh7m8qI6tIQ5t3OUqugw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-env@3.972.54': - resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==} + '@aws-sdk/credential-provider-env@3.972.57': + resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.966.0': + resolution: {integrity: sha512-VTJDP1jOibVtc5pn5TNE12rhqOO/n10IjkoJi8fFp9BMfmh3iqo70Ppvphz/Pe/R9LcK5Z3h0Z4EB9IXDR6kag==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.967.0': resolution: {integrity: sha512-0/GIAEv5pY5htg6IBMuYccBgzz3oS2DqHjHi396ziTrwlhbrCNX96AbNhQhzAx3LBZUk13sPfeapjyQ7G57Ekg==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-http@3.972.56': - resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==} + '@aws-sdk/credential-provider-http@3.972.59': + resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.966.0': + resolution: {integrity: sha512-4oQKkYMCUx0mffKuH8LQag1M4Fo5daKVmsLAnjrIqKh91xmCrcWlAFNMgeEYvI1Yy125XeNSaFMfir6oNc2ODA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.967.0': resolution: {integrity: sha512-U8dMpaM6Qf6+2Qvp1uG6OcWv1RlrZW7tQkpmzEVWH8HZTGrVHIXXju64NMtIOr7yOnNwd0CKcytuD1QG+phCwQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-ini@3.972.61': - resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==} + '@aws-sdk/credential-provider-ini@3.973.1': + resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.966.0': + resolution: {integrity: sha512-wD1KlqLyh23Xfns/ZAPxebwXixoJJCuDbeJHFrLDpP4D4h3vA2S8nSFgBSFR15q9FhgRfHleClycf6g5K4Ww6w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-login@3.967.0': resolution: {integrity: sha512-kbvZsZL6CBlfnb71zuJdJmBUFZN5utNrcziZr/DZ2olEOkA9vlmizE8i9BUIbmS7ptjgvRnmcY1A966yfhiblw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-login@3.972.60': - resolution: {integrity: sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw==} + '@aws-sdk/credential-provider-login@3.972.63': + resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.966.0': + resolution: {integrity: sha512-7QCOERGddMw7QbjE+LSAFgwOBpPv4px2ty0GCK7ZiPJGsni2EYmM4TtYnQb9u1WNHmHqIPWMbZR0pKDbyRyHlQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.967.0': resolution: {integrity: sha512-WuNbHs9rfKKSVok4+OBrZf0AHfzDgFYYMxN2G/q6ZfUmY4QmiPyxV5HkNFh1rqDxS9VV6kAZPo0EBmry10idSg==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-node@3.972.63': - resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==} + '@aws-sdk/credential-provider-node@3.972.66': + resolution: {integrity: sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.966.0': + resolution: {integrity: sha512-q5kCo+xHXisNbbPAh/DiCd+LZX4wdby77t7GLk0b2U0/mrel4lgy6o79CApe+0emakpOS1nPZS7voXA7vGPz4w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.967.0': resolution: {integrity: sha512-sNCY5JDV0whsfsZ6c2+6eUwH33H7UhKbqvCPbEYlIIa8wkGjCtCyFI3zZIJHVcMKJJ3117vSUFHEkNA7g+8rtw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-process@3.972.54': - resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==} + '@aws-sdk/credential-provider-process@3.972.57': + resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.966.0': + resolution: {integrity: sha512-Rv5aEfbpqsQZzxpX2x+FbSyVFOE3Dngome+exNA8jGzc00rrMZEUnm3J3yAsLp/I2l7wnTfI0r2zMe+T9/nZAQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.967.0': resolution: {integrity: sha512-0K6kITKNytFjk1UYabYUsTThgU6TQkyW6Wmt8S5zd1A/up7NSQGpp58Rpg9GIf4amQDQwb+p9FGG7emmV8FEeA==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-sso@3.972.60': - resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==} + '@aws-sdk/credential-provider-sso@3.973.1': + resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.966.0': + resolution: {integrity: sha512-Yv1lc9iic9xg3ywMmIAeXN1YwuvfcClLVdiF2y71LqUgIOupW8B8my84XJr6pmOQuKzZa++c2znNhC9lGsbKyw==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.967.0': resolution: {integrity: sha512-Vkr7S2ec7q/v8i/MzkHcBEdqqfWz3lyb8FDjb+NjslEwdxC3f6XwADRZzWwV1pChfx6SbsvJXKfkcF/pKAelhA==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.60': - resolution: {integrity: sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA==} + '@aws-sdk/credential-provider-web-identity@3.972.63': + resolution: {integrity: sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.965.0': + resolution: {integrity: sha512-QriACiXP+/x2xXw8u849BxID+zSUbh/7Gt0Zfaxeye0mIKVeSTid5776rXfrM8wcYhbVXWWZhKd1Du7oPuFwsg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-eventstream@3.965.0': + resolution: {integrity: sha512-YVNOPbc3r+gETUY6ufnJYsgIRMaBfoGRM9GzPb+gwtidCPd0BEpLjmZNIVGYawMrGc2kAdlV1kjBzAvmYaMINw==} + engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-host-header@3.965.0': resolution: {integrity: sha512-SfpSYqoPOAmdb3DBsnNsZ0vix+1VAtkUkzXM79JL3R5IfacpyKE2zytOgVAQx/FjhhlpSTwuXd+LRhUEVb3MaA==} engines: {node: '>=18.0.0'} @@ -330,34 +433,51 @@ packages: resolution: {integrity: sha512-6dvD+18Ni14KCRu+tfEoNxq1sIGVp9tvoZDZ7aMvpnA7mDXuRLrOjRQ/TAZqXwr9ENKVGyxcPl0cRK8jk1YWjA==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.59': - resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==} + '@aws-sdk/middleware-sdk-s3@3.972.62': + resolution: {integrity: sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.966.0': + resolution: {integrity: sha512-MvGoy0vhMluVpSB5GaGJbYLqwbZfZjwEZhneDHdPhgCgQqmCtugnYIIjpUw7kKqWGsmaMQmNEgSFf1zYYmwOyg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.967.0': resolution: {integrity: sha512-2qzJzZj5u+cZiG7kz3XJPaTH4ssUY/aet1kwJsUTFKrWeHUf7mZZkDFfkXP5cOffgiOyR5ZkrmJoLKAde9hshg==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-websocket@3.965.0': + resolution: {integrity: sha512-BGU92StrWF0EJj8jX5EFvRkX9z4/CVIZfON0nWow8gb5ouKwz47o1rO9CP/k2b3F6g134/0XqwXvrUgIWfjJeA==} + engines: {node: '>= 14.0.0'} + deprecated: Please update your @aws-sdk client to a more recent version, such as https://github.com/aws/aws-sdk-js-v3/releases/tag/v3.982.0, if using browser-based WebSocket bidirectional streaming. + + '@aws-sdk/nested-clients@3.966.0': + resolution: {integrity: sha512-FRzAWwLNoKiaEWbYhnpnfartIdOgiaBLnPcd3uG1Io+vvxQUeRPhQIy4EfKnT3AuA+g7gzSCjMG2JKoJOplDtQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.967.0': resolution: {integrity: sha512-PYa7V8w0gaNux6Sz/Z7zrHmPloEE+EKpRxQIOG/D0askTr5Yd4oO2KGgcInf65uHK3f0Z9U4CTUGHZvQvABypA==} engines: {node: '>=18.0.0'} - '@aws-sdk/nested-clients@3.997.28': - resolution: {integrity: sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q==} + '@aws-sdk/nested-clients@3.997.31': + resolution: {integrity: sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==} engines: {node: '>=20.0.0'} '@aws-sdk/region-config-resolver@3.965.0': resolution: {integrity: sha512-RoMhu9ly2B0coxn8ctXosPP2WmDD0MkQlZGLjoYHQUOCBmty5qmCxOqBmBDa6wbWbB8xKtMQ/4VXloQOgzjHXg==} engines: {node: '>=18.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.38': - resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} + '@aws-sdk/signature-v4-multi-region@3.996.39': + resolution: {integrity: sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1080.0': - resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==} + '@aws-sdk/token-providers@3.1083.0': + resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.966.0': + resolution: {integrity: sha512-8k5cBTicTGYJHhKaweO4gL4fud1KDnLS5fByT6/Xbiu59AxYM4E/h3ds+3jxDMnniCE3gIWpEnyfM9khtmw2lA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.967.0': resolution: {integrity: sha512-Qnd/nJ0CgeUa7zQczgmdQm0vYUF7pD1G0C+dR1T7huHQHRIsgCWIsCV9wNKzOFluqtcr6YAeuTwvY0+l8XWxnA==} engines: {node: '>=18.0.0'} @@ -370,14 +490,18 @@ packages: resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.15': - resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} + '@aws-sdk/types@3.974.0': + resolution: {integrity: sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==} engines: {node: '>=20.0.0'} '@aws-sdk/util-endpoints@3.965.0': resolution: {integrity: sha512-WqSCB0XIsGUwZWvrYkuoofi2vzoVHqyeJ2kN+WyoOsxPLTiQSBIoqm/01R/qJvoxwK/gOOF7su9i84Vw2NQQpQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-format-url@3.965.0': + resolution: {integrity: sha512-KiplV4xYGXdNCcz5eRP8WfAejT5EkE2gQxC4IY6WsuxYprzQKsnGaAzEQ+giR5GgQLIRBkPaWT0xHEYkMiCQ1Q==} + engines: {node: '>=18.0.0'} + '@aws-sdk/util-locate-window@3.965.8': resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} engines: {node: '>=20.0.0'} @@ -385,6 +509,15 @@ packages: '@aws-sdk/util-user-agent-browser@3.965.0': resolution: {integrity: sha512-Xiza/zMntQGpkd2dETQeAK8So1pg5+STTzpcdGWxj5q0jGO5ayjqT/q1Q7BrsX5KIr6PvRkl9/V7lLCv04wGjQ==} + '@aws-sdk/util-user-agent-node@3.966.0': + resolution: {integrity: sha512-vPPe8V0GLj+jVS5EqFz2NUBgWH35favqxliUOvhp8xBdNRkEjiZm5TqitVtFlxS4RrLY3HOndrWbrP5ejbwl1Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/util-user-agent-node@3.967.0': resolution: {integrity: sha512-yUz6pCGxyG4+QaDg0dkdIBphjQp8A9rrbZa/+U3RJgRrW47hy64clFQUROzj5Poy1Ur8ICVXEUpBsSqRuYEU2g==} engines: {node: '>=18.0.0'} @@ -398,8 +531,8 @@ packages: resolution: {integrity: sha512-Tcod25/BTupraQwtb+Q+GX8bmEZfxIFjjJ/AvkhUZsZlkPeVluzq1uu3Oeqf145DCdMjzLIN6vab5MrykbDP+g==} engines: {node: '>=18.0.0'} - '@aws-sdk/xml-builder@3.972.33': - resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} + '@aws-sdk/xml-builder@3.972.34': + resolution: {integrity: sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': @@ -651,6 +784,58 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@databricks/databricks-sql-kernel-darwin-arm64@0.2.0': + resolution: {integrity: sha512-dSZJD1uileOqRDfs5KsW6m43PAl3GqzQMcETbRT1Zjw2FRLQDtLKTA3+VWmlUUHhIz583WeTps91Sjee79cXbA==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [darwin] + + '@databricks/databricks-sql-kernel-darwin-x64@0.2.0': + resolution: {integrity: sha512-JEe7TqLrXrAoN3SWsi7NgjAo6V+7jEvBwLswwhl0qUp1MoPrZT+y5Kf49vuHPlh61vFgi2F6NlCNfdB/8wpihg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [darwin] + + '@databricks/databricks-sql-kernel-linux-arm64-gnu@0.2.0': + resolution: {integrity: sha512-meJ2JF5w4qEiaiI9TNwg2iMgasadqeKSDAw3r3hWTEdzKwUY7saMTFNcjumwR8R3cQMCFJfp3YBOwKS5/hvN9A==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-arm64-musl@0.2.0': + resolution: {integrity: sha512-YTCPs11w6purXCQwJBCh99oHBwTEW4q46OWxO9Rnddo1wJd8EPBa3Misw08URoUVuVuEfYGy5YtSvuPfkkj8Vw==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-x64-gnu@0.2.0': + resolution: {integrity: sha512-wIEJX2mtoCc/KGOzzbxXwOR5aUB5dBSUG1Ypp+JLI28XsZB2eCLcP4jyAQ+Uklxn+SU1hICuok+30t/7wSvKUg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-x64-musl@0.2.0': + resolution: {integrity: sha512-0wtWdOxYh7BfeklDpI0tpTk/ljdjJmCQsNFPLy2C7EnK60J3sroYzTaFqgn8Qqc7T03VHZl/6GG1pXC3zPuU1g==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@databricks/databricks-sql-kernel-win32-arm64-msvc@0.2.0': + resolution: {integrity: sha512-gS4y1hIIDitr1d9bW6yWEkn4wMW7abinDwFFVUJtYsWkFS+rMq/8CwAYioqNprXQP+XEQS7fLiLYq/BYsQB3Aw==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [win32] + + '@databricks/databricks-sql-kernel-win32-x64-msvc@0.2.0': + resolution: {integrity: sha512-zl6KtfB02eVsBNZEQ/kf/dhmesUqbVGwhSFb5oKlmtyj/1kah9R1FRKBVKTLt3v5n9LnjBnomV2s+DRxRSh4rA==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [win32] + + '@databricks/sql@1.16.0': + resolution: {integrity: sha512-kaSBjYUT0rDYsrouvPakwRjs030KKvImOTHRG95EsYOLkNN+/YvJ8JgQ0vQjvgT3NUREoGys+SQ4ZzqwHuwoCA==} + engines: {node: '>=14.0.0'} + '@datastructures-js/deque@1.0.8': resolution: {integrity: sha512-PSBhJ2/SmeRPRHuBv7i/fHWIdSC3JTyq56qb+Rq0wjOagi0/fdV5/B/3Md5zFZus/W6OkSPMaxMKKMNMrSmubg==} @@ -667,6 +852,9 @@ packages: resolution: {integrity: sha512-L38Ax21uF2OPUmCRWycZ/dZdMYf7gMrtClcxvVrqJVFmn8ET2M++GYmFGJpLqOHS1beATxOXLWe7y2ijSQz/ng==} engines: {node: '>=20'} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -858,12 +1046,159 @@ packages: engines: {node: '>=18'} hasBin: true + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} + engines: {node: '>=18'} + '@huggingface/tasks@0.21.21': resolution: {integrity: sha512-6cHtkeMvgbudI6p4+gPP/+xvcAG2uumF+ntojCSNnHy5xcSzuHXCMJHzh+rlX0TSZ/QdDZ4uWkPLQikbphNyEw==} + '@huggingface/tokenizers@0.1.3': + resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} + + '@huggingface/transformers@4.2.0': + resolution: {integrity: sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==} + '@huggingface/xetchunk-wasm@0.1.0': resolution: {integrity: sha512-wWpp2qwPgf9kv1KLJjcDUk/OrpDOsFoQ3Qpz0U5LGn20csoymBf8eneOv6wm/GzPBzlWac1OYiR0aa1vT6aM2Q==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@iovalkey/commands@0.1.0': resolution: {integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==} @@ -978,11 +1313,14 @@ packages: '@mistralai/mistralai@1.15.1': resolution: {integrity: sha512-fb995eiz3r0KsBGtRjFV+/iLbX+UpfalxpF+YitT3R6ukrPD4PN+FGwwmYcRFhNAzVzDUtTVxQYnjQWEnwV5nw==} + '@mochow/mochow-sdk-node@2.1.5': + resolution: {integrity: sha512-IYluqAf50wH51uMGDqRni1Mmuc8GCNDHibJf0x3YlJFQkTJkU+l/PvLsmrer9qV1gMj6wHA+LE0OkNevW7KsCw==} + '@mongodb-js/saslprep@1.4.11': resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} - '@nodable/entities@2.2.0': - resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1043,9 +1381,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -1280,12 +1615,32 @@ packages: resolution: {integrity: sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.2': + resolution: {integrity: sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.2': resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.4.6': - resolution: {integrity: sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==} + '@smithy/credential-provider-imds@4.4.7': + resolution: {integrity: sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.4.6': + resolution: {integrity: sha512-4N/HbPptAjynK/FDrTiSAEXmpEDcQw54SeY7qnKbNMOcbHK0B0nEq+OKDYXrnqyp5YhZGzXFDUJANGkdzz6H5Q==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.4.6': + resolution: {integrity: sha512-++N4gziozNj71MLr9HN/pzt8nnoTdsX9ccjShQthl2TcoZtHObjmbT3d3kBZ8ih1uRTepGGVUVc06qkFtRUAKg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.5.6': + resolution: {integrity: sha512-Xn7gFGQg6fxaE0bQSnoAN6miP4M4t7juhv/0ch7wBba+8r+XHCNaF7ktpLkOSrzyCrVQ3ixV8SaMZKINWB80hA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.4.6': + resolution: {integrity: sha512-kt2PZjaLJK+kSfLetRFKdHo6rfUeVuk65TreMlGBwVi+EhGspaDw3GTQMKKCrHPVe/LVYrkNf518pMZ4hrj/EQ==} engines: {node: '>=18.0.0'} '@smithy/fetch-http-handler@5.5.2': @@ -1296,6 +1651,10 @@ packages: resolution: {integrity: sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.4': + resolution: {integrity: sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.4.2': resolution: {integrity: sha512-M/8PUgwzekn0GcMBjcrTXV4PGFIe1AKuMuHjkHNBfofCPQ9+9Nr35lCvRfQK9BwYHQM+LKiKj8GY0pXSwYm6qQ==} engines: {node: '>=18.0.0'} @@ -1340,6 +1699,10 @@ packages: resolution: {integrity: sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.4': + resolution: {integrity: sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.4.2': resolution: {integrity: sha512-V74/UQ+fPPIqZHzeXIUd0H+k/MF+WpeO3I+5wrMCMgsllLt6uqSNbWnLhJs6pMeXZGu6ry+g1bpIdcHNeL+Oww==} engines: {node: '>=18.0.0'} @@ -1348,6 +1711,10 @@ packages: resolution: {integrity: sha512-ZCQGk/UNS+YF5D+Jmhr8+PQX2PJKe4QzR6zuOR5sUinxO/ZiKNA7nyrbeO/Ds2hXLts3fZAtgLPKLJgvjCX9uQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.4.6': + resolution: {integrity: sha512-JT0MimWrP+6EMhbrhxHTpbNfdMmhX77EYoddZgarmc28pA22oseu4NErGMUlUVJaqsn550A4Vz9lx60q0zXK8w==} + engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.6.2': resolution: {integrity: sha512-9Xti1yhj7Em9t4ZQ1E8YRA6wbhAqx1nfLu0XSAqv82HED43TMUvn3nOO2xBISxZc0hcOg7wvuHEypUy4L4XF2w==} engines: {node: '>=18.0.0'} @@ -1360,6 +1727,10 @@ packages: resolution: {integrity: sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.3': + resolution: {integrity: sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.14.2': resolution: {integrity: sha512-H3fp0F/IhG8a6qnoWHeGTH1qDPRR9VVZjcxM9NMXPB6nqzaqcoYIX8D4FFI1Ap2siGr+yvYgKqHC76+afBgF1A==} engines: {node: '>=18.0.0'} @@ -1372,6 +1743,10 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@smithy/types@4.16.0': + resolution: {integrity: sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.4.2': resolution: {integrity: sha512-vx7FUYJMRfQqdqPRLYwpisOuYU6e1PaabcPUTEc7xG6X0VEr4B6PnjZloHz67qva0pQ9/eNs3xPxvQ6w5gRGAw==} engines: {node: '>=18.0.0'} @@ -1404,6 +1779,10 @@ packages: resolution: {integrity: sha512-K50/sbl8HRnBJt5+UYvIG8HCdEDNA9rhCmkWTDftzGMlJ4XlPgtqI1V6jTSi9tmK7MLfgw7CfGvzSKFSWOteOQ==} engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.4.6': + resolution: {integrity: sha512-hdLoL1t/JqmsSbhodgbs7/fYwXIGEq+3MiJcSBsVoHsHyaBFv/Y1qQfZm0i5FR3NakhgYQsD5d1Z4Aoc5JdDlg==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.4.2': resolution: {integrity: sha512-Xk039bCOxEZfasaHSHPj0QS8EY2npd0z3nxLkDvczg9ueF7eboiKw0HEa5pILLwjb+sjSlgzSjoCOVAM715TzA==} engines: {node: '>=18.0.0'} @@ -1424,6 +1803,10 @@ packages: resolution: {integrity: sha512-N5CpfaL+/LPQU9PFdOT55ayUo5T0QypG4Almzd1/efJvoDypuT1shkgJk1+hhg/02scYluW6Q2JGnSHIPwCEGQ==} engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.5.7': + resolution: {integrity: sha512-9NFs7MyxFBEQenFHrveJK/rirLSnYAgCMfAqKvvA+UlyzJCKFc51TYKbMUv5rGsM4bQ419Fw6KKgHc53QyJ30w==} + engines: {node: '>=18.0.0'} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -1457,6 +1840,9 @@ packages: resolution: {integrity: sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==} engines: {node: '>=20.0.0'} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1487,6 +1873,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/command-line-args@5.2.0': + resolution: {integrity: sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==} + + '@types/command-line-usage@5.0.2': + resolution: {integrity: sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1514,15 +1906,24 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.3.0': + resolution: {integrity: sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==} + '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} - '@types/node@25.9.4': - resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/oracledb@7.0.1': + resolution: {integrity: sha512-0A6m9YE4yu73KXehr5D6cbyALUzZoANGY4bG5cAPQIpJoJG4eMVPbR1Vau8ycnC25V/+F4Au1pdFSy8ODOAD0w==} + + '@types/pad-left@2.1.1': + resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==} + '@types/pg@8.11.0': resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} @@ -1646,6 +2047,10 @@ packages: anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + apache-arrow@13.0.0: + resolution: {integrity: sha512-3gvCX0GDawWz6KFNC28p65U+zGh/LZ6ZNKWNu74N6CQlKzxeoWHpi4CgEQsgRSEMuyrIIXi1Ea2syja7dwcHvw==} + hasBin: true + apparatus@0.0.10: resolution: {integrity: sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg==} engines: {node: '>=0.2.6'} @@ -1653,8 +2058,20 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + + array-back@6.2.3: + resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} + engines: {node: '>=12.17'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} @@ -1672,8 +2089,8 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - axios@1.17.0: - resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -1718,6 +2135,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + better-sqlite3@12.10.0: resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} @@ -1742,15 +2163,15 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1836,6 +2257,10 @@ packages: resolution: {integrity: sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1858,6 +2283,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1923,6 +2352,24 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + cohere-ai@8.0.0: + resolution: {integrity: sha512-j6gHcQfdK/r9i3e7FM6NtWx8OR270E/PyEyDEVQeeJT212bSJbWvWFfIg7WAPOUMHatDbxOFYP2aKKWnj8zSYA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@aws-crypto/sha256-js': ^5.2.0 + '@aws-sdk/credential-providers': ^3.583.0 + '@smithy/protocol-http': ^5.1.2 + '@smithy/signature-v4': ^5.1.2 + peerDependenciesMeta: + '@aws-crypto/sha256-js': + optional: true + '@aws-sdk/credential-providers': + optional: true + '@smithy/protocol-http': + optional: true + '@smithy/signature-v4': + optional: true + collect-v8-coverage@1.0.3: resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} @@ -1953,10 +2400,22 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + + command-line-usage@7.0.1: + resolution: {integrity: sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==} + engines: {node: '>=12.20.0'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + compromise@14.15.1: resolution: {integrity: sha512-9F3UkUaEU1PPz2fgStkE/TI4tk++0wHxS8xfWq9PQWL/v28dy8bEcPVVSLh3dISIRD7PEhJ8YTzHRKF8y9tnLA==} engines: {node: '>=12.0.0'} @@ -1974,6 +2433,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convict@6.2.5: + resolution: {integrity: sha512-JtXpxqDqJ8P0UwEHwhxLzCIXQy97vlYBZR222Sbzb1q1Erex9ASrztJ29SyhWFQjod1AeFBaPzEEC8YvtZMIYg==} + engines: {node: '>=6'} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1992,10 +2455,17 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + cuint@0.2.2: + resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -2040,6 +2510,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -2048,6 +2522,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2161,6 +2639,24 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -2204,8 +2700,8 @@ packages: fast-xml-builder@1.2.1: resolution: {integrity: sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==} - fast-xml-parser@5.9.3: - resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} hasBin: true fastembed@2.1.0: @@ -2240,6 +2736,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2251,6 +2751,12 @@ packages: resolution: {integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==} hasBin: true + flatbuffers@23.5.26: + resolution: {integrity: sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==} + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -2270,6 +2776,10 @@ packages: form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data-encoder@4.1.0: + resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} + engines: {node: '>= 18'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -2278,6 +2788,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formdata-node@6.0.3: + resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==} + engines: {node: '>= 18'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -2285,6 +2799,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2338,6 +2856,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -2405,6 +2927,9 @@ packages: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} @@ -2503,6 +3028,10 @@ packages: resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==} engines: {node: '>=18.12.0'} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + engines: {node: '>= 12'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2517,6 +3046,11 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2554,8 +3088,12 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-unsafe@1.0.1: - resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} @@ -2725,6 +3263,9 @@ packages: node-notifier: optional: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2735,8 +3276,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true jsesc@3.1.0: @@ -2747,6 +3288,10 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2839,6 +3384,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2869,6 +3417,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -2885,6 +3436,14 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lru-cache@9.1.2: resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==} engines: {node: 14 || >=16.14} @@ -2893,6 +3452,10 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lz4@0.6.5: + resolution: {integrity: sha512-KSZcJU49QZOlJSItaeIU3p8WoAvkTmD9fJqeahQXNu1iQ/kR0/mQLdbrK8JY9MY8f6AhJoMrihp1nu1xDbscSQ==} + engines: {node: '>= 0.10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2968,10 +3531,22 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -2979,6 +3554,11 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -3013,8 +3593,35 @@ packages: socks: optional: true - mongoose@9.7.0: - resolution: {integrity: sha512-pkrLZ6U41pD4Ai0ju/FYL7o5I5k+rV3RZINQTG937hbhnLGKRuqqYm1Dlt/kTQ+M4FHijzV6JawzsdHKRGt7QA==} + mongodb@7.5.0: + resolution: {integrity: sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose@9.8.0: + resolution: {integrity: sha512-PDGx3XACxrBQyWf4YT+5s1Xsx19x84UWGlRVIza4i9RG6qKjGcoG7odotT6uquR6YoaDMTZ6ZZc/jMXLrNPyyA==} engines: {node: '>=20.19.0'} mpath@0.9.0: @@ -3045,6 +3652,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -3058,6 +3668,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + nice-grpc-client-middleware-retry@3.1.15: resolution: {integrity: sha512-fXfNNdtjCQzc3O/w3WsK1AOU+xdE1V7FCm4FEQWS/UUVsV1616S2oryvWqsZAF8aOvuMir8lFtNmgH3DeP+PBg==} @@ -3117,6 +3731,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -3128,6 +3746,10 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + ollama@0.5.18: resolution: {integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==} @@ -3144,14 +3766,31 @@ packages: onnxruntime-common@1.21.0: resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} + onnxruntime-common@1.24.0-dev.20251116-b39e144322: + resolution: {integrity: sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==} + + onnxruntime-common@1.24.3: + resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} + onnxruntime-node@1.21.0: resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} os: [win32, darwin, linux] + onnxruntime-node@1.24.3: + resolution: {integrity: sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==} + os: [win32, darwin, linux] + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + openai@4.104.0: resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} hasBin: true @@ -3164,6 +3803,13 @@ packages: zod: optional: true + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + + oracledb@7.0.1: + resolution: {integrity: sha512-xlM0Ceh6A5stQLAdEfKf3pgCSkbOjQLo2ZPEi3+kXklz+KbZD3fLi/nsTSbQeZZNFBSFDNxdn1Ek3+bxG40M8w==} + engines: {node: '>=14.17'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -3196,12 +3842,24 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} packet-reader@1.0.0: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} + pad-left@2.1.0: + resolution: {integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==} + engines: {node: '>=0.10.0'} + pako@2.2.0: resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} @@ -3312,12 +3970,15 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.5.10' + postcss: '>=8.5.18 <9.0.0' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -3396,10 +4057,17 @@ packages: resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} engines: {node: '>=18'} - protobufjs@7.6.3: - resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -3470,6 +4138,10 @@ packages: resolution: {integrity: sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==} engines: {node: '>= 18.19.0'} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3555,6 +4227,10 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3590,9 +4266,21 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + snappyjs@0.7.0: resolution: {integrity: sha512-u5iEEXkMe2EInQio6Wv9LWHOQYRDbD2O9hzS27GpT/lwfIQhTCnHCTqedqHIHe9ZcvQo+9au6vngQayipz1NYw==} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -3623,6 +4311,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -3647,6 +4338,10 @@ packages: stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + stream-read-all@3.0.1: + resolution: {integrity: sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==} + engines: {node: '>=10'} + stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} @@ -3723,6 +4418,11 @@ packages: resolution: {integrity: sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA==} engines: {node: '>=0.2.6'} + table-layout@3.0.2: + resolution: {integrity: sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==} + engines: {node: '>=12.17'} + hasBin: true + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -3730,8 +4430,13 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.5.19: - resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} teeny-request@10.1.3: @@ -3846,7 +4551,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: '>=8.5.10' + postcss: '>=8.5.18 <9.0.0' typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -3858,6 +4563,11 @@ packages: typescript: optional: true + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -3886,6 +4596,14 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -3909,8 +4627,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} update-browserslist-db@1.2.3: @@ -4000,6 +4718,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4046,6 +4768,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4053,6 +4779,9 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + xxhashjs@0.2.2: + resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4067,6 +4796,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4083,6 +4816,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zeroentropy@0.1.0-alpha.10: + resolution: {integrity: sha512-4OwBUNuAQZzigzU75Nk+vbPGmL449U4XJtZTsZWu7cpztVHOx9gc9v0GPoyymmqtf0AnyUPPs8IWinNTUZLHhA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4093,6 +4829,11 @@ packages: snapshots: + '@75lb/deep-merge@1.1.4': + dependencies: + lodash: 4.18.1 + typical: 7.3.0 + '@anthropic-ai/sdk@0.40.1': dependencies: '@types/node': 18.19.130 @@ -4131,7 +4872,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@aws-sdk/util-locate-window': 3.965.8 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -4139,7 +4880,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -4152,26 +4893,124 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.13': + '@aws-sdk/checksums@3.1000.16': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1080.0': + '@aws-sdk/client-bedrock-runtime@3.967.0': dependencies: - '@aws-sdk/checksums': 3.1000.13 - '@aws-sdk/core': 3.974.28 - '@aws-sdk/credential-provider-node': 3.972.63 - '@aws-sdk/middleware-sdk-s3': 3.972.59 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.967.0 + '@aws-sdk/credential-provider-node': 3.967.0 + '@aws-sdk/eventstream-handler-node': 3.965.0 + '@aws-sdk/middleware-eventstream': 3.965.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.967.0 + '@aws-sdk/middleware-websocket': 3.965.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/token-providers': 3.967.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.967.0 + '@smithy/config-resolver': 4.6.2 '@smithy/core': 3.29.1 + '@smithy/eventstream-serde-browser': 4.4.6 + '@smithy/eventstream-serde-config-resolver': 4.5.6 + '@smithy/eventstream-serde-node': 4.4.6 '@smithy/fetch-http-handler': 5.6.3 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 '@smithy/node-http-handler': 4.9.3 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 '@smithy/types': 4.15.1 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-stream': 4.7.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-neptune-graph@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/credential-provider-node': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-stream': 4.7.2 + '@smithy/util-utf8': 4.4.2 + '@smithy/util-waiter': 4.5.7 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-s3@3.1083.0': + dependencies: + '@aws-sdk/checksums': 3.1000.16 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-node': 3.972.66 + '@aws-sdk/middleware-sdk-s3': 3.972.62 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/client-s3vectors@3.967.0': @@ -4218,6 +5057,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sso@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4233,8 +5115,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.965.0 '@aws-sdk/util-user-agent-node': 3.967.0 '@smithy/config-resolver': 4.6.2 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 '@smithy/hash-node': 4.4.2 '@smithy/invalid-dependency': 4.4.2 '@smithy/middleware-content-length': 4.4.2 @@ -4243,10 +5125,10 @@ snapshots: '@smithy/middleware-serde': 4.4.2 '@smithy/middleware-stack': 4.4.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/node-http-handler': 4.8.2 + '@smithy/node-http-handler': 4.9.3 '@smithy/protocol-http': 5.5.2 '@smithy/smithy-client': 4.14.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 '@smithy/url-parser': 4.4.2 '@smithy/util-base64': 4.5.2 '@smithy/util-body-length-browser': 4.4.2 @@ -4261,6 +5143,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/core@3.966.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@aws-sdk/xml-builder': 3.965.0 + '@smithy/core': 3.29.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.6.3 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/util-base64': 4.5.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + '@aws-sdk/core@3.967.0': dependencies: '@aws-sdk/types': 3.965.0 @@ -4277,56 +5175,96 @@ snapshots: '@smithy/util-utf8': 4.4.2 tslib: 2.8.1 - '@aws-sdk/core@3.974.28': + '@aws-sdk/core@3.975.1': dependencies: - '@aws-sdk/types': 3.973.15 - '@aws-sdk/xml-builder': 3.972.33 + '@aws-sdk/types': 3.974.0 + '@aws-sdk/xml-builder': 3.972.34 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.29.1 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@smithy/core': 3.29.2 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.54': + '@aws-sdk/credential-provider-env@3.972.57': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/util-stream': 4.7.2 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 '@aws-sdk/types': 3.965.0 - '@smithy/fetch-http-handler': 5.5.2 - '@smithy/node-http-handler': 4.8.2 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/node-http-handler': 4.9.3 '@smithy/property-provider': 4.4.2 '@smithy/protocol-http': 5.5.2 '@smithy/smithy-client': 4.14.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 '@smithy/util-stream': 4.7.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.56': + '@aws-sdk/credential-provider-http@3.972.59': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/credential-provider-env': 3.966.0 + '@aws-sdk/credential-provider-http': 3.966.0 + '@aws-sdk/credential-provider-login': 3.966.0 + '@aws-sdk/credential-provider-process': 3.966.0 + '@aws-sdk/credential-provider-sso': 3.966.0 + '@aws-sdk/credential-provider-web-identity': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-ini@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4341,27 +5279,40 @@ snapshots: '@smithy/credential-provider-imds': 4.4.2 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-ini@3.972.61': + '@aws-sdk/credential-provider-ini@3.973.1': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/credential-provider-env': 3.972.54 - '@aws-sdk/credential-provider-http': 3.972.56 - '@aws-sdk/credential-provider-login': 3.972.60 - '@aws-sdk/credential-provider-process': 3.972.54 - '@aws-sdk/credential-provider-sso': 3.972.60 - '@aws-sdk/credential-provider-web-identity': 3.972.60 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/credential-provider-imds': 4.4.6 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-login': 3.972.63 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4370,20 +5321,37 @@ snapshots: '@smithy/property-provider': 4.4.2 '@smithy/protocol-http': 5.5.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.60': + '@aws-sdk/credential-provider-login@3.972.63': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.966.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.966.0 + '@aws-sdk/credential-provider-http': 3.966.0 + '@aws-sdk/credential-provider-ini': 3.966.0 + '@aws-sdk/credential-provider-process': 3.966.0 + '@aws-sdk/credential-provider-sso': 3.966.0 + '@aws-sdk/credential-provider-web-identity': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.967.0': dependencies: '@aws-sdk/credential-provider-env': 3.967.0 @@ -4401,18 +5369,27 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.63': + '@aws-sdk/credential-provider-node@3.972.66': dependencies: - '@aws-sdk/credential-provider-env': 3.972.54 - '@aws-sdk/credential-provider-http': 3.972.56 - '@aws-sdk/credential-provider-ini': 3.972.61 - '@aws-sdk/credential-provider-process': 3.972.54 - '@aws-sdk/credential-provider-sso': 3.972.60 - '@aws-sdk/credential-provider-web-identity': 3.972.60 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/credential-provider-imds': 4.4.6 - '@smithy/types': 4.15.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-ini': 3.973.1 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.967.0': @@ -4421,17 +5398,30 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.972.54': - dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.57': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.966.0': + dependencies: + '@aws-sdk/client-sso': 3.966.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/token-providers': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-sso@3.967.0': dependencies: '@aws-sdk/client-sso': 3.967.0 @@ -4440,21 +5430,33 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-sso@3.972.60': + '@aws-sdk/credential-provider-sso@3.973.1': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/token-providers': 3.1080.0 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/token-providers': 3.1083.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4462,17 +5464,31 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.60': + '@aws-sdk/credential-provider-web-identity@3.972.63': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@smithy/eventstream-codec': 4.4.6 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@smithy/protocol-http': 5.5.2 '@smithy/types': 4.15.1 tslib: 2.8.1 @@ -4480,13 +5496,13 @@ snapshots: dependencies: '@aws-sdk/types': 3.965.0 '@smithy/protocol-http': 5.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.965.0': @@ -4494,16 +5510,26 @@ snapshots: '@aws-sdk/types': 3.965.0 '@aws/lambda-invoke-store': 0.2.4 '@smithy/protocol-http': 5.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.59': + '@aws-sdk/middleware-sdk-s3@3.972.62': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@smithy/core': 3.29.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.967.0': @@ -4516,6 +5542,62 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-format-url': 3.965.0 + '@smithy/eventstream-codec': 4.4.6 + '@smithy/eventstream-serde-browser': 4.4.6 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.6.2 + '@smithy/types': 4.15.1 + '@smithy/util-hex-encoding': 4.4.6 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/nested-clients@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4531,8 +5613,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.965.0 '@aws-sdk/util-user-agent-node': 3.967.0 '@smithy/config-resolver': 4.6.2 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 '@smithy/hash-node': 4.4.2 '@smithy/invalid-dependency': 4.4.2 '@smithy/middleware-content-length': 4.4.2 @@ -4541,10 +5623,10 @@ snapshots: '@smithy/middleware-serde': 4.4.2 '@smithy/middleware-stack': 4.4.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/node-http-handler': 4.8.2 + '@smithy/node-http-handler': 4.9.3 '@smithy/protocol-http': 5.5.2 '@smithy/smithy-client': 4.14.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 '@smithy/url-parser': 4.4.2 '@smithy/util-base64': 4.5.2 '@smithy/util-body-length-browser': 4.4.2 @@ -4559,15 +5641,15 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/nested-clients@3.997.28': + '@aws-sdk/nested-clients@3.997.31': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/region-config-resolver@3.965.0': @@ -4575,25 +5657,37 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/config-resolver': 4.6.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.38': + '@aws-sdk/signature-v4-multi-region@3.996.39': dependencies: - '@aws-sdk/types': 3.973.15 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.0 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1080.0': + '@aws-sdk/token-providers@3.1083.0': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/token-providers@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4601,14 +5695,14 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt '@aws-sdk/types@3.965.0': dependencies: - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/types@3.973.13': @@ -4616,19 +5710,26 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.15': + '@aws-sdk/types@3.974.0': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/util-endpoints@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 '@smithy/url-parser': 4.4.2 '@smithy/util-endpoints': 3.6.2 tslib: 2.8.1 + '@aws-sdk/util-format-url@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@smithy/querystring-builder': 4.4.6 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.8': dependencies: tslib: 2.8.1 @@ -4636,10 +5737,18 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.966.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/node-config-provider': 4.5.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.967.0': dependencies: '@aws-sdk/middleware-user-agent': 3.967.0 @@ -4651,12 +5760,12 @@ snapshots: '@aws-sdk/xml-builder@3.965.0': dependencies: '@smithy/types': 4.15.0 - fast-xml-parser: 5.9.3 + fast-xml-parser: 5.10.1 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.33': + '@aws-sdk/xml-builder@3.972.34': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} @@ -4975,6 +6084,60 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@databricks/databricks-sql-kernel-darwin-arm64@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-darwin-x64@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-arm64-gnu@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-arm64-musl@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-x64-gnu@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-x64-musl@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-win32-arm64-msvc@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-win32-x64-msvc@0.2.0': + optional: true + + '@databricks/sql@1.16.0': + dependencies: + apache-arrow: 13.0.0 + commander: 9.5.0 + flatbuffers: 23.5.26 + node-fetch: 2.7.0 + node-int64: 0.4.0 + open: 8.4.2 + openid-client: 5.7.1 + proxy-agent: 6.5.0 + thrift: 0.23.0 + uuid: 11.1.1 + winston: 3.19.0 + optionalDependencies: + '@databricks/databricks-sql-kernel-darwin-arm64': 0.2.0 + '@databricks/databricks-sql-kernel-darwin-x64': 0.2.0 + '@databricks/databricks-sql-kernel-linux-arm64-gnu': 0.2.0 + '@databricks/databricks-sql-kernel-linux-arm64-musl': 0.2.0 + '@databricks/databricks-sql-kernel-linux-x64-gnu': 0.2.0 + '@databricks/databricks-sql-kernel-linux-x64-musl': 0.2.0 + '@databricks/databricks-sql-kernel-win32-arm64-msvc': 0.2.0 + '@databricks/databricks-sql-kernel-win32-x64-msvc': 0.2.0 + lz4: 0.6.5 + transitivePeerDependencies: + - '@75lb/nature' + - bufferutil + - encoding + - supports-color + - utf-8-validate + '@datastructures-js/deque@1.0.8': {} '@elastic/elasticsearch@9.4.2': @@ -4993,10 +6156,15 @@ snapshots: ms: 2.1.3 secure-json-parse: 4.1.0 tslib: 2.8.1 - undici: 7.28.0 + undici: 7.29.0 transitivePeerDependencies: - supports-color + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -5085,7 +6253,7 @@ snapshots: dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -5105,7 +6273,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 yargs: 17.7.2 '@huggingface/blake3-jit@0.0.2': {} @@ -5117,13 +6285,121 @@ snapshots: optionalDependencies: cli-progress: 3.12.0 + '@huggingface/jinja@0.5.9': {} + '@huggingface/tasks@0.21.21': {} + '@huggingface/tokenizers@0.1.3': {} + + '@huggingface/transformers@4.2.0': + dependencies: + '@huggingface/jinja': 0.5.9 + '@huggingface/tokenizers': 0.1.3 + onnxruntime-node: 1.24.3 + onnxruntime-web: 1.26.0-dev.20260416-b7804b056c + sharp: 0.34.5 + '@huggingface/xetchunk-wasm@0.1.0': dependencies: '@huggingface/blake3-jit': 0.0.2 gearhash-jit: 1.0.2 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@iovalkey/commands@0.1.0': {} '@isaacs/cliui@8.0.2': @@ -5144,7 +6420,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 4.2.0 + js-yaml: 3.15.0 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -5362,11 +6638,16 @@ snapshots: - bufferutil - utf-8-validate + '@mochow/mochow-sdk-node@2.1.5': + dependencies: + tsx: 4.23.0 + winston: 3.19.0 + '@mongodb-js/saslprep@1.4.11': dependencies: sparse-bitfield: 3.0.3 - '@nodable/entities@2.2.0': {} + '@nodable/entities@3.0.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -5421,8 +6702,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -5433,7 +6712,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.5.4 - undici: 7.28.0 + undici: 7.29.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -5562,7 +6841,7 @@ snapshots: '@shanghaikid/parquetjs@1.8.8': dependencies: - '@aws-sdk/client-s3': 3.1080.0 + '@aws-sdk/client-s3': 3.1083.0 '@types/node-int64': 0.4.32 '@types/thrift': 0.10.17 '@zenfs/core': 2.5.7 @@ -5605,18 +6884,43 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.4.2': + '@smithy/core@3.29.2': dependencies: - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.4.6': + '@smithy/credential-provider-imds@4.4.2': dependencies: '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.7': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.5.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.5.2': dependencies: '@smithy/core': 3.26.0 @@ -5629,14 +6933,20 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.4': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@smithy/hash-node@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/invalid-dependency@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -5650,7 +6960,7 @@ snapshots: '@smithy/middleware-endpoint@4.6.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/middleware-retry@4.7.2': @@ -5660,7 +6970,7 @@ snapshots: '@smithy/middleware-serde@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/middleware-stack@4.4.2': @@ -5685,6 +6995,12 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.9.4': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@smithy/property-provider@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -5695,9 +7011,14 @@ snapshots: '@smithy/core': 3.26.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.6.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.1 tslib: 2.8.1 '@smithy/signature-v4@5.5.2': @@ -5712,6 +7033,12 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/signature-v4@5.6.3': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@smithy/smithy-client@4.14.2': dependencies: '@smithy/core': 3.26.0 @@ -5726,6 +7053,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.16.0': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -5733,7 +7064,7 @@ snapshots: '@smithy/util-base64@4.5.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-body-length-browser@4.4.2': @@ -5743,7 +7074,7 @@ snapshots: '@smithy/util-body-length-node@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': @@ -5766,9 +7097,14 @@ snapshots: '@smithy/core': 3.26.0 tslib: 2.8.1 + '@smithy/util-hex-encoding@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + '@smithy/util-middleware@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-retry@4.5.2': @@ -5778,7 +7114,7 @@ snapshots: '@smithy/util-stream@4.7.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.1 tslib: 2.8.1 '@smithy/util-utf8@2.3.0': @@ -5788,7 +7124,12 @@ snapshots: '@smithy/util-utf8@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.5.7': + dependencies: + '@smithy/core': 3.29.2 tslib: 2.8.1 '@so-ric/colorspace@1.1.6': @@ -5830,6 +7171,8 @@ snapshots: '@supabase/realtime-js': 2.108.1 '@supabase/storage-js': 2.108.1 + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -5841,7 +7184,7 @@ snapshots: '@turbopuffer/turbopuffer@2.5.0': dependencies: pako: 2.2.0 - undici: 7.28.0 + undici: 7.29.0 '@types/babel__core@7.20.5': dependencies: @@ -5868,6 +7211,10 @@ snapshots: dependencies: '@types/node': 22.19.21 + '@types/command-line-args@5.2.0': {} + + '@types/command-line-usage@5.0.2': {} + '@types/estree@1.0.9': {} '@types/graceful-fs@4.1.9': @@ -5902,16 +7249,24 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.3.0': {} + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 - '@types/node@25.9.4': + '@types/node@25.9.5': dependencies: undici-types: 7.24.6 '@types/normalize-package-data@2.4.4': {} + '@types/oracledb@7.0.1': + dependencies: + '@types/node': 22.19.21 + + '@types/pad-left@2.1.1': {} + '@types/pg@8.11.0': dependencies: '@types/node': 22.19.21 @@ -5961,7 +7316,7 @@ snapshots: '@zenfs/core@2.5.7': dependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 buffer: 6.0.3 eventemitter3: 5.0.4 kerium: 1.3.9 @@ -5979,7 +7334,7 @@ snapshots: dayjs: 1.11.21 generic-pool: 3.9.0 lru-cache: 9.1.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 winston: 3.19.0 transitivePeerDependencies: - bufferutil @@ -6040,13 +7395,38 @@ snapshots: anynum@1.0.1: {} + apache-arrow@13.0.0: + dependencies: + '@types/command-line-args': 5.2.0 + '@types/command-line-usage': 5.0.2 + '@types/node': 20.3.0 + '@types/pad-left': 2.1.1 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + flatbuffers: 23.5.26 + json-bignum: 0.0.3 + pad-left: 2.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + apparatus@0.0.10: dependencies: sylvester: 0.0.21 arg@4.1.3: {} - argparse@2.0.1: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + array-back@3.1.0: {} + + array-back@6.2.3: {} + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 async-limiter@1.0.1: {} @@ -6058,7 +7438,7 @@ snapshots: aws4@1.13.2: {} - axios@1.17.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -6133,6 +7513,8 @@ snapshots: baseline-browser-mapping@2.10.35: {} + basic-ftp@5.3.1: {} + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 @@ -6156,16 +7538,16 @@ snapshots: bowser@2.14.1: {} - brace-expansion@1.1.15: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.1: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -6243,6 +7625,10 @@ snapshots: adm-zip: 0.5.17 long: 5.2.5 + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6270,6 +7656,8 @@ snapshots: chownr@1.1.4: {} + chownr@2.0.0: {} + chownr@3.0.0: {} chromadb-js-bindings-darwin-arm64@1.3.4: @@ -6328,6 +7716,18 @@ snapshots: co@4.6.0: {} + cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.3): + dependencies: + convict: 6.2.5 + form-data: 4.0.6 + form-data-encoder: 4.1.0 + formdata-node: 6.0.3 + readable-stream: 4.7.0 + optionalDependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.6.3 + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: @@ -6355,8 +7755,26 @@ snapshots: dependencies: delayed-stream: 1.0.0 + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + + command-line-usage@7.0.1: + dependencies: + array-back: 6.2.3 + chalk-template: 0.4.0 + table-layout: 3.0.2 + typical: 7.3.0 + transitivePeerDependencies: + - '@75lb/nature' + commander@4.1.1: {} + commander@9.5.0: {} + compromise@14.15.1: dependencies: efrt: 2.7.0 @@ -6371,6 +7789,11 @@ snapshots: convert-source-map@2.0.0: {} + convict@6.2.5: + dependencies: + lodash.clonedeep: 4.5.0 + yargs-parser: 20.2.9 + create-jest@29.7.0(@types/node@22.19.21)(ts-node@10.9.2(@types/node@22.19.21)(typescript@5.5.4)): dependencies: '@jest/types': 29.6.3 @@ -6402,8 +7825,13 @@ snapshots: crypt@0.0.2: {} + cuint@0.2.2: + optional: true + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} + dayjs@1.11.21: {} debug@4.4.3(supports-color@5.5.0): @@ -6435,6 +7863,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} define-properties@1.2.1: @@ -6443,6 +7873,12 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -6557,6 +7993,20 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -6606,14 +8056,14 @@ snapshots: path-expression-matcher: 1.6.2 xml-naming: 0.1.0 - fast-xml-parser@5.9.3: + fast-xml-parser@5.10.1: dependencies: - '@nodable/entities': 2.2.0 + '@nodable/entities': 3.0.0 fast-xml-builder: 1.2.1 - is-unsafe: 1.0.1 + is-unsafe: 2.0.0 path-expression-matcher: 1.6.2 strnum: 2.4.1 - xml-naming: 0.1.0 + xml-naming: 0.3.0 fastembed@2.1.0: dependencies: @@ -6621,7 +8071,7 @@ snapshots: '@huggingface/hub': 2.13.2 onnxruntime-node: 1.21.0 progress: 2.0.3 - tar: 7.5.19 + tar: 6.2.1 fastq@1.20.1: dependencies: @@ -6648,6 +8098,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6666,6 +8120,10 @@ snapshots: kolorist: 1.8.0 read-pkg: 8.1.0 + flatbuffers@23.5.26: {} + + flatbuffers@25.9.23: {} + fn.name@1.1.0: {} follow-redirects@1.16.0: {} @@ -6677,6 +8135,8 @@ snapshots: form-data-encoder@1.7.2: {} + form-data-encoder@4.1.0: {} + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -6690,12 +8150,18 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formdata-node@6.0.3: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 fs-constants@1.0.0: {} + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -6753,6 +8219,14 @@ snapshots: get-stream@6.0.1: {} + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -6824,7 +8298,7 @@ snapshots: node-fetch: 3.3.2 object-hash: 3.0.0 proto3-json-serializer: 3.0.4 - protobufjs: 7.6.3 + protobufjs: 7.6.5 retry-request: 8.0.3 rimraf: 5.0.10 transitivePeerDependencies: @@ -6869,6 +8343,8 @@ snapshots: transitivePeerDependencies: - supports-color + guid-typescript@1.0.9: {} + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -6973,6 +8449,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.4.0: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -6985,6 +8463,8 @@ snapshots: dependencies: hasown: 2.0.4 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -7007,7 +8487,11 @@ snapshots: is-stream@2.0.1: {} - is-unsafe@1.0.1: {} + is-unsafe@2.0.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 is-wsl@3.1.1: dependencies: @@ -7375,6 +8859,8 @@ snapshots: - supports-color - ts-node + jose@4.15.9: {} + joycon@3.1.1: {} js-tiktoken@1.0.21: @@ -7383,9 +8869,10 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@3.15.0: dependencies: - argparse: 2.0.1 + argparse: 1.0.10 + esprima: 4.0.1 jsesc@3.1.0: {} @@ -7393,6 +8880,8 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-bignum@0.0.3: {} + json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@3.0.2: {} @@ -7463,6 +8952,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} + lodash.defaults@4.2.0: {} lodash.includes@4.3.0: {} @@ -7483,6 +8974,8 @@ snapshots: lodash.once@4.1.1: {} + lodash@4.18.1: {} + logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -7502,10 +8995,24 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + lru-cache@9.1.2: {} lru.min@1.1.4: {} + lz4@0.6.5: + dependencies: + buffer: 5.7.1 + cuint: 0.2.2 + nan: 2.28.0 + xxhashjs: 0.2.2 + optional: true + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7562,26 +9069,39 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.8 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.16 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.1 + brace-expansion: 2.1.4 minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + minizlib@3.1.0: dependencies: minipass: 7.1.3 mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -7594,16 +9114,27 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.2.0: + mongodb@7.2.0(socks@2.8.9): dependencies: '@mongodb-js/saslprep': 1.4.11 bson: 7.2.0 mongodb-connection-string-url: 7.0.1 + optionalDependencies: + socks: 2.8.9 - mongoose@9.7.0: + mongodb@7.5.0(socks@2.8.9): dependencies: + '@mongodb-js/saslprep': 1.4.11 + bson: 7.2.0 + mongodb-connection-string-url: 7.0.1 + optionalDependencies: + socks: 2.8.9 + + mongoose@9.8.0(socks@2.8.9): + dependencies: + '@standard-schema/spec': 1.1.0 kareem: 3.3.0 - mongodb: 7.2.0 + mongodb: 7.5.0(socks@2.8.9) mpath: 0.9.0 mquery: 6.0.0 ms: 2.1.3 @@ -7647,18 +9178,21 @@ snapshots: dependencies: lru.min: 1.1.4 + nan@2.28.0: + optional: true + napi-build-utils@2.0.0: {} natural-compare@1.4.0: {} - natural@8.1.1(@opentelemetry/api@1.9.1): + natural@8.1.1(@opentelemetry/api@1.9.1)(socks@2.8.9): dependencies: afinn-165: 2.0.2 afinn-165-financialmarketnews: 3.0.0 apparatus: 0.0.10 dotenv: 17.4.2 memjs: 1.3.2 - mongoose: 9.7.0 + mongoose: 9.8.0(socks@2.8.9) pg: 8.21.0 redis: 5.12.1(@opentelemetry/api@1.9.1) safe-stable-stringify: 2.5.0 @@ -7681,6 +9215,8 @@ snapshots: neo-async@2.6.2: {} + netmask@2.1.1: {} + nice-grpc-client-middleware-retry@3.1.15: dependencies: abort-controller-x: 0.5.0 @@ -7743,12 +9279,16 @@ snapshots: object-assign@4.1.1: {} + object-hash@2.2.0: {} + object-hash@3.0.0: {} object-keys@1.1.1: {} obuf@1.1.2: {} + oidc-token-hash@5.2.0: {} + ollama@0.5.18: dependencies: whatwg-fetch: 3.6.20 @@ -7767,11 +9307,30 @@ snapshots: onnxruntime-common@1.21.0: {} + onnxruntime-common@1.24.0-dev.20251116-b39e144322: {} + + onnxruntime-common@1.24.3: {} + onnxruntime-node@1.21.0: dependencies: global-agent: 3.0.0 onnxruntime-common: 1.21.0 - tar: 7.5.19 + tar: 7.5.22 + + onnxruntime-node@1.24.3: + dependencies: + adm-zip: 0.5.17 + global-agent: 3.0.0 + onnxruntime-common: 1.24.3 + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.3.2 + onnxruntime-common: 1.24.0-dev.20251116-b39e144322 + platform: 1.3.6 + protobufjs: 7.6.5 open@10.2.0: dependencies: @@ -7780,6 +9339,12 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openai@4.104.0(ws@5.2.5)(zod@3.25.76): dependencies: '@types/node': 18.19.130 @@ -7795,6 +9360,15 @@ snapshots: transitivePeerDependencies: - encoding + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + + oracledb@7.0.1: {} + p-finally@1.0.0: {} p-limit@2.3.0: @@ -7825,10 +9399,32 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-json-from-dist@1.0.1: {} packet-reader@1.0.0: {} + pad-left@2.1.0: + dependencies: + repeat-string: 1.6.1 + pako@2.2.0: {} parse-json@5.2.0: @@ -7944,9 +9540,13 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1: + platform@1.3.6: {} + + postcss-load-config@6.0.1(tsx@4.23.0): dependencies: lilconfig: 3.1.3 + optionalDependencies: + tsx: 4.23.0 postgres-array@2.0.0: {} @@ -8004,9 +9604,9 @@ snapshots: proto3-json-serializer@3.0.4: dependencies: - protobufjs: 7.6.3 + protobufjs: 7.6.5 - protobufjs@7.6.3: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -8014,13 +9614,27 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 '@types/node': 22.19.21 long: 5.3.2 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} pstree.remy@1.1.8: {} @@ -8100,6 +9714,8 @@ snapshots: - '@node-rs/xxhash' - '@opentelemetry/api' + repeat-string@1.6.1: {} + require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -8198,6 +9814,37 @@ snapshots: dependencies: type-fest: 0.13.1 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8226,8 +9873,23 @@ snapshots: slash@3.0.0: {} + smart-buffer@4.2.0: {} + snappyjs@0.7.0: {} + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.4.0 + smart-buffer: 4.2.0 + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -8257,6 +9919,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} sql-escaper@1.3.3: {} @@ -8275,6 +9939,8 @@ snapshots: dependencies: stubs: 3.0.0 + stream-read-all@3.0.1: {} + stream-shift@1.0.3: {} string-length@4.0.2: @@ -8348,6 +10014,18 @@ snapshots: sylvester@0.0.21: {} + table-layout@3.0.2: + dependencies: + '@75lb/deep-merge': 1.1.4 + array-back: 6.2.3 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + stream-read-all: 3.0.1 + typical: 7.3.0 + wordwrapjs: 5.1.1 + transitivePeerDependencies: + - '@75lb/nature' + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -8363,7 +10041,16 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@7.5.19: + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -8478,7 +10165,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(typescript@5.5.4): + tsup@8.5.1(tsx@4.23.0)(typescript@5.5.4): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -8489,7 +10176,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1 + postcss-load-config: 6.0.1(tsx@4.23.0) resolve-from: 5.0.0 rollup: 4.61.1 source-map: 0.7.6 @@ -8505,6 +10192,12 @@ snapshots: - tsx - yaml + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -8521,6 +10214,10 @@ snapshots: typescript@5.5.4: {} + typical@4.0.0: {} + + typical@7.3.0: {} + ufo@1.6.4: {} uglify-js@3.19.3: @@ -8536,7 +10233,7 @@ snapshots: undici-types@7.24.6: {} - undici@7.28.0: {} + undici@7.29.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -8637,6 +10334,8 @@ snapshots: wordwrap@1.0.0: {} + wordwrapjs@5.1.1: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -8668,10 +10367,17 @@ snapshots: xml-naming@0.1.0: {} + xml-naming@0.3.0: {} + xtend@4.0.2: {} xxhash-wasm@1.1.0: {} + xxhashjs@0.2.2: + dependencies: + cuint: 0.2.2 + optional: true + y18n@5.0.8: {} yallist@3.1.1: {} @@ -8680,6 +10386,8 @@ snapshots: yallist@5.0.0: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -8696,6 +10404,18 @@ snapshots: yocto-queue@0.1.0: {} + zeroentropy@0.1.0-alpha.10: + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/mem0-ts/pnpm-workspace.yaml b/mem0-ts/pnpm-workspace.yaml index df3b89724..ca0da76cc 100644 --- a/mem0-ts/pnpm-workspace.yaml +++ b/mem0-ts/pnpm-workspace.yaml @@ -10,13 +10,14 @@ overrides: "picomatch@<2.3.2": "^2.3.2" "picomatch@>=4.0.0 <4.0.4": "^4.0.4" "jws@4.0.0": "4.0.1" - "js-yaml@<3.14.2": ">=3.14.2" + "js-yaml@<3.15.0": ">=3.15.0 <4.0.0" + "js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0" "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" - "postcss@<8.5.10": ">=8.5.10" + "postcss@<8.5.18": ">=8.5.18 <9.0.0" "uuid@<11.1.1": ">=11.1.1" "ws@>=8.0.0 <8.20.1": ">=8.20.1" "rollup@>=4.0.0 <4.59.0": "^4.59.0" @@ -24,4 +25,14 @@ overrides: "glob@>=10.2.0 <10.5.0": "^10.5.0" "@modelcontextprotocol/sdk": "^1.25.4" "esbuild": ">=0.28.1" - "undici@<6.27.0": ">=6.27.0 <8.0.0" + "undici@<7.29.0": ">=7.29.0 <8.0.0" + "axios@<1.18.0": ">=1.18.0 <2.0.0" + "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0" + "brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3.0.0" + "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0" + "fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0" + "tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0" + "thrift@<0.23.0": "^0.23.0" + "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" + "protobufjs@<7.6.5": ">=7.6.5 <8.0.0" + "ip-address@<10.3.1": ">=10.3.1 <11.0.0" diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 3fd19eb39..ff259f2fe 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -81,8 +81,20 @@ class APIError extends Error { interface ClientOptions { apiKey: string; host?: string; + /** Max cached identities per process. Defaults to 50. */ + identityCacheMax?: number; } +interface ClientIdentity { + telemetryId: string; + organizationId: string | number | null; + projectId: string | number | null; +} + +// Shares one ping per (host, api key) across clients; FIFO-capped. +const IDENTITY_CACHE_MAX_DEFAULT = 50; +const identityByCredentials = new Map>(); + export default class MemoryClient { apiKey: string; host: string; @@ -91,6 +103,8 @@ export default class MemoryClient { headers: Record; client: any; telemetryId: string; + private initialized: Promise; + private identityCacheMax: number; _validateApiKey(): any { if (!this.apiKey) { @@ -109,6 +123,8 @@ export default class MemoryClient { this.host = options.host || "https://api.mem0.ai"; this.organizationId = null; this.projectId = null; + this.identityCacheMax = + options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT; this.headers = { Authorization: `Token ${this.apiKey}`, @@ -122,11 +138,44 @@ export default class MemoryClient { }); this._validateApiKey(); + this.telemetryId = ""; - this._initializeClient(); + + // Memory requests never wait on this; telemetry and _awaitIdentity do. + this.initialized = this._resolveIdentity(); } - private async _initializeClient() { + // One ping per credential pair per process, shared via identityByCredentials. + private _resolveIdentity(): Promise { + const credentials = `${this.host}\u0000${this.apiKey}`; + let shared = identityByCredentials.get(credentials); + if (!shared) { + shared = this._initializeClient(); + if (identityByCredentials.size >= this.identityCacheMax) { + identityByCredentials.delete( + identityByCredentials.keys().next().value!, + ); + } + identityByCredentials.set(credentials, shared); + // A failed ping must not be cached, or the process never recovers. + shared.then((identity) => { + if (!identity.telemetryId) identityByCredentials.delete(credentials); + }); + } + return shared.then((identity) => { + this.telemetryId = identity.telemetryId; + if (identity.organizationId != null) + this.organizationId = identity.organizationId; + if (identity.projectId != null) this.projectId = identity.projectId; + }); + } + + // Blocks until the ping has populated organizationId/projectId. + private async _awaitIdentity(): Promise { + await this.initialized; + } + + private async _initializeClient(): Promise { try { await this.ping(); @@ -148,6 +197,12 @@ export default class MemoryClient { stack: error?.stack || "No stack trace", }); } + + return { + telemetryId: this.telemetryId, + organizationId: this.organizationId, + projectId: this.projectId, + }; } private async _maybeAliasAnonToEmail(): Promise { @@ -175,13 +230,18 @@ export default class MemoryClient { } private _captureEvent(methodName: string, args: any[]) { - captureClientEvent(methodName, this, { - success: true, - args_count: args.length, - keys: args.length > 0 ? args[0] : [], - }).catch((error: any) => { - console.error("Failed to capture event:", error); - }); + // Deferred until ping() has resolved telemetryId, off the request path. + this.initialized + .then(() => + captureClientEvent(methodName, this, { + success: true, + args_count: args.length, + keys: args.length > 0 ? args[0] : [], + }), + ) + .catch((error: any) => { + console.error("Failed to capture event:", error); + }); } async _fetchWithErrorHandling(url: string, options: any): Promise { @@ -262,8 +322,6 @@ export default class MemoryClient { throw new Error("Cannot process an empty messages payload."); } - if (this.telemetryId === "") await this.ping(); - const payload = this._preparePayload(messages, options); const payloadKeys = Object.keys(payload); this._captureEvent("add", [payloadKeys]); @@ -304,7 +362,6 @@ export default class MemoryClient { ); } - if (this.telemetryId === "") await this.ping(); const payload: Record = {}; if (text !== undefined) payload.text = text; if (metadata !== undefined) payload.metadata = metadata; @@ -326,7 +383,6 @@ export default class MemoryClient { } async get(memoryId: string): Promise { - if (this.telemetryId === "") await this.ping(); this._captureEvent("get", []); return this._fetchWithErrorHandling( `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, @@ -340,7 +396,6 @@ export default class MemoryClient { // Reject top-level entity params - must use filters instead rejectTopLevelEntityParams(options as Record, "getAll"); - if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(options || {}); this._captureEvent("get_all", [payloadKeys]); const { page, pageSize, filters, ...rest } = options ?? {}; @@ -349,10 +404,10 @@ export default class MemoryClient { ...(filters && { filters }), }; - let url = `${this.host}/v3/memories/`; - if (page && pageSize) { - url += `?page=${page}&page_size=${pageSize}`; - } + const queryParams: string[] = []; + if (page !== undefined) queryParams.push(`page=${page}`); + if (pageSize !== undefined) queryParams.push(`page_size=${pageSize}`); + const url = `${this.host}/v3/memories/${queryParams.length ? `?${queryParams.join("&")}` : ""}`; const response = await this._fetchWithErrorHandling(url, { method: "POST", @@ -369,7 +424,6 @@ export default class MemoryClient { // Reject top-level entity params - must use filters instead rejectTopLevelEntityParams(options as Record, "search"); - if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(options || {}); this._captureEvent("search", [payloadKeys]); const { filters, ...rest } = options ?? {}; @@ -395,7 +449,6 @@ export default class MemoryClient { memoryId: string, options: DeleteMemoryOptions = {}, ): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("delete", [Object.keys(options || {})]); const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); // @ts-ignore @@ -412,7 +465,6 @@ export default class MemoryClient { async deleteAll( options: DeleteAllMemoryOptions = {}, ): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(options || {}); this._captureEvent("delete_all", [payloadKeys]); const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); @@ -429,7 +481,6 @@ export default class MemoryClient { } async history(memoryId: string): Promise> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("history", []); const response = await this._fetchWithErrorHandling( `${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`, @@ -444,7 +495,6 @@ export default class MemoryClient { page?: number; pageSize?: number; }): Promise { - if (this.telemetryId === "") await this.ping(); this._captureEvent("users", []); let url = `${this.host}/v1/entities/`; const params: string[] = []; @@ -464,7 +514,6 @@ export default class MemoryClient { entity_id: number; entity_type: string; }): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("delete_user", []); if (!data.entity_type) { data.entity_type = "user"; @@ -487,8 +536,6 @@ export default class MemoryClient { runId?: string; } = {}, ): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); - let to_delete: Array<{ type: string; name: string }> = []; const { userId, agentId, appId, runId } = params; @@ -514,8 +561,14 @@ export default class MemoryClient { for (const entity of to_delete) { try { - await this.client.delete( - `/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`, + // fetch() reuses the pooled connection; axios here defaulted to + // keepAlive: false, one handshake per entity. + await this._fetchWithErrorHandling( + `${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`, + { + method: "DELETE", + headers: this.headers, + }, ); } catch (error: any) { throw new APIError( @@ -537,7 +590,6 @@ export default class MemoryClient { } async batchUpdate(memories: Array): Promise { - if (this.telemetryId === "") await this.ping(); this._captureEvent("batch_update", []); const memoriesBody = memories.map((memory) => ({ memory_id: memory.memoryId, @@ -555,7 +607,6 @@ export default class MemoryClient { } async batchDelete(memories: Array): Promise { - if (this.telemetryId === "") await this.ping(); this._captureEvent("batch_delete", []); const memoriesBody = memories.map((memory) => ({ memory_id: memory, @@ -572,10 +623,10 @@ export default class MemoryClient { } async getProject(options: ProjectOptions): Promise { - if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(options || {}); this._captureEvent("get_project", [payloadKeys]); const { fields } = options; + await this._awaitIdentity(); if (!(this.organizationId && this.projectId)) { throw new Error( @@ -598,8 +649,8 @@ export default class MemoryClient { async updateProject( prompts: PromptUpdatePayload, ): Promise> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("update_project", []); + await this._awaitIdentity(); if (!(this.organizationId && this.projectId)) { throw new Error( "organizationId and projectId must be set to update instructions or categories", @@ -619,9 +670,12 @@ export default class MemoryClient { // WebHooks async getWebhooks(data?: { projectId?: string }): Promise> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("get_webhooks", []); + if (!data?.projectId) await this._awaitIdentity(); const project_id = data?.projectId || this.projectId; + if (!project_id) { + throw new Error("projectId must be set to access webhooks"); + } const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/webhooks/projects/${project_id}/`, { @@ -632,8 +686,11 @@ export default class MemoryClient { } async createWebhook(webhook: WebhookCreatePayload): Promise { - if (this.telemetryId === "") await this.ping(); this._captureEvent("create_webhook", []); + await this._awaitIdentity(); + if (!this.projectId) { + throw new Error("projectId must be set to create a webhook"); + } const body = { name: webhook.name, url: webhook.url, @@ -653,7 +710,6 @@ export default class MemoryClient { async updateWebhook( webhook: WebhookUpdatePayload, ): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("update_webhook", []); const body: Record = {}; if (webhook.name != null) body.name = webhook.name; @@ -673,7 +729,6 @@ export default class MemoryClient { async deleteWebhook(data: { webhookId: string; }): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("delete_webhook", []); const webhook_id = data.webhookId || data; const response = await this._fetchWithErrorHandling( @@ -687,7 +742,6 @@ export default class MemoryClient { } async feedback(data: FeedbackPayload): Promise<{ message: string }> { - if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(data || {}); this._captureEvent("feedback", [payloadKeys]); const response = await this._fetchWithErrorHandling( @@ -704,7 +758,6 @@ export default class MemoryClient { async createMemoryExport( data: CreateMemoryExportPayload, ): Promise<{ message: string; id: string }> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("create_memory_export", []); if (!data.filters || !data.schema) { @@ -734,7 +787,6 @@ export default class MemoryClient { async getMemoryExport( data: GetMemoryExportPayload, ): Promise<{ message: string; id: string }> { - if (this.telemetryId === "") await this.ping(); this._captureEvent("get_memory_export", []); if (!data.memoryExportId && !data.filters) { diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index 8cf862205..c230441e0 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -12,6 +12,7 @@ export interface AddMemoryOptions extends EntityOptions { infer?: boolean; customCategories?: custom_categories[]; customInstructions?: string; + agentCustomInstructions?: string; timestamp?: number; expirationDate?: string; structuredDataSchema?: Record; @@ -27,6 +28,8 @@ export interface SearchMemoryOptions { fields?: string[]; categories?: string[]; showExpired?: boolean; + referenceDate?: string | number; + keywordSearch?: boolean; } export interface GetAllMemoryOptions { @@ -58,8 +61,8 @@ export interface ProjectOptions { export interface PromptUpdatePayload { customInstructions?: string; + agentCustomInstructions?: string; customCategories?: custom_categories[]; - retrievalCriteria?: any[]; version?: string; memoryDepth?: string | null; usecaseSetting?: string | number; @@ -173,6 +176,7 @@ export interface PaginatedMemories { export interface ProjectResponse { customInstructions?: string; + agentCustomInstructions?: string; // The API returns category objects (`[{ "": "" }]`), // not bare strings (see issue #5738). customCategories?: custom_categories[]; diff --git a/mem0-ts/src/client/tests/memoryClient.crud.test.ts b/mem0-ts/src/client/tests/memoryClient.crud.test.ts index f116955f1..d1645ab40 100644 --- a/mem0-ts/src/client/tests/memoryClient.crud.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.crud.test.ts @@ -74,6 +74,35 @@ describe("MemoryClient - add()", () => { expect(getFetchBody(call!).expiration_date).toBe("2030-01-31"); }); + test("serializes agentCustomInstructions as agent_custom_instructions", async () => { + const extra = new Map(); + extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add([{ role: "user", content: "test" }], { + agentId: "a1", + agentCustomInstructions: "Remember tool failures", + }); + + const call = findFetchCall(mock, "/v3/memories/add/", "POST"); + expect(getFetchBody(call!).agent_custom_instructions).toBe( + "Remember tool failures", + ); + }); + + test("omits agent_custom_instructions when it is not passed", async () => { + const extra = new Map(); + extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add([{ role: "user", content: "test" }], { userId: "u1" }); + + const call = findFetchCall(mock, "/v3/memories/add/", "POST"); + expect(getFetchBody(call!)).not.toHaveProperty("agent_custom_instructions"); + }); + test("throws an error when given an empty messages array", async () => { setupMockFetch(); diff --git a/mem0-ts/src/client/tests/memoryClient.identity.test.ts b/mem0-ts/src/client/tests/memoryClient.identity.test.ts new file mode 100644 index 000000000..ac77e2356 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.identity.test.ts @@ -0,0 +1,228 @@ +/** + * MemoryClient unit tests β€” org/project identity resolution ordering. + * + * Each test calls the method under test as the first awaited operation on a + * fresh client. Any earlier await on the instance resolves identity and voids + * the test. + */ +import { MemoryClient } from "../mem0"; +import { TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers"; +import { setupMockFetch, installConsoleSuppression } from "./setup"; + +installConsoleSuppression(); + +// Distinct key per client keeps each test off the module-scope identity cache. +let keySeq = 0; +const freshClient = () => + new MemoryClient({ apiKey: `test-api-key-identity-${keySeq++}` }); + +// Selects by path; the ping and PostHog calls also land in the mock. +const findUrlOrNone = (mock: jest.Mock, needle: string): string | undefined => + mock.mock.calls + .map((c: [string, RequestInit]) => c[0]) + .find((u: string) => u.includes(needle)); + +const findUrl = (mock: jest.Mock, needle: string): string => { + const url = findUrlOrNone(mock, needle); + expect(url).toBeDefined(); + return url as string; +}; + +describe("MemoryClient - project-scoped URLs on a fresh client", () => { + test("getProject uses the resolved org and project ids", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { custom_instructions: "Be helpful" }, + }); + const mock = setupMockFetch(extra); + + await freshClient().getProject({ fields: ["custom_instructions"] }); + + const url = findUrl(mock, "/api/v1/orgs/organizations/"); + expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`); + expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`); + }); + + test("updateProject uses the resolved org and project ids", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { custom_instructions: "Updated" }, + }); + const mock = setupMockFetch(extra); + + await freshClient().updateProject({ customInstructions: "Updated" }); + + const url = findUrl(mock, "/api/v1/orgs/organizations/"); + expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`); + expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`); + }); + + test("getWebhooks targets the resolved project, not null", async () => { + const extra = new Map(); + extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + await freshClient().getWebhooks(); + + const url = findUrl(mock, "/api/v1/webhooks/projects/"); + expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`); + expect(url).not.toContain("/projects/null/"); + expect(url).not.toContain("/projects/undefined/"); + }); + + test("createWebhook targets the resolved project, not null", async () => { + const extra = new Map(); + extra.set("/api/v1/webhooks/projects/", { + status: 200, + body: { webhook_id: "wh_1" }, + }); + const mock = setupMockFetch(extra); + + await freshClient().createWebhook({ + name: "hook", + url: "https://example.com/hook", + eventTypes: ["memory_add"], + }); + + const url = findUrl(mock, "/api/v1/webhooks/projects/"); + expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`); + expect(url).not.toContain("/projects/null/"); + expect(url).not.toContain("/projects/undefined/"); + }); + + test("an explicit projectId is honored without waiting on identity", async () => { + const extra = new Map(); + extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] }); + const mock = setupMockFetch(extra); + + await freshClient().getWebhooks({ projectId: "proj_explicit_789" }); + + expect(findUrl(mock, "/api/v1/webhooks/projects/")).toContain( + "/api/v1/webhooks/projects/proj_explicit_789/", + ); + }); +}); + +describe("MemoryClient - memory endpoints do not wait on identity", () => { + test("search completes while the ping is still pending", async () => { + // The ping never settles. + const mock = jest.fn((url: string) => { + if (url.includes("/v1/ping/")) return new Promise(() => {}); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ results: [] }), + text: () => Promise.resolve(""), + }); + }); + global.fetch = mock as unknown as typeof global.fetch; + + await expect( + freshClient().search("query", { filters: { user_id: "alice" } }), + ).resolves.toBeDefined(); + + expect(findUrl(mock, "/v3/memories/search/")).toBeDefined(); + }); +}); + +describe("MemoryClient - identity cache overflow", () => { + const pingCount = (mock: jest.Mock) => + mock.mock.calls.filter((c: [string, RequestInit]) => + c[0].includes("/v1/ping/"), + ).length; + + // The cache is module scope, so each test needs its own module registry. + const isolatedClient = (): typeof MemoryClient => { + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require("../mem0").MemoryClient; + }; + + const projectOk = () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { status: 200, body: {} }); + return setupMockFetch(extra); + }; + + test("a reused credential pair pings once", async () => { + const mock = projectOk(); + const Client = isolatedClient(); + + const opts = { apiKey: "cache-warm-key", identityCacheMax: 1 }; + await new Client(opts).getProject({ fields: [] }); + await new Client(opts).getProject({ fields: [] }); + + expect(pingCount(mock)).toBe(1); + }); + + test("an evicted credential pair re-pings and still resolves identity", async () => { + const mock = projectOk(); + const Client = isolatedClient(); + const opts = (apiKey: string) => ({ apiKey, identityCacheMax: 1 }); + + await new Client(opts("key-a")).getProject({ fields: [] }); + // Evicts key-a. + await new Client(opts("key-b")).getProject({ fields: [] }); + expect(pingCount(mock)).toBe(2); + + await new Client(opts("key-a")).getProject({ fields: [] }); + + // Eviction costs a ping; it never yields an unresolved identity. + expect(pingCount(mock)).toBe(3); + expect(findUrl(mock, "/api/v1/orgs/organizations/")).toContain( + `/api/v1/orgs/organizations/${TEST_ORG_ID}/`, + ); + }); +}); + +describe("MemoryClient - unresolved identity", () => { + const pingFails = () => { + const mock = jest.fn((url: string) => { + if (url.includes("/v1/ping/")) { + return Promise.resolve({ + ok: false, + status: 500, + text: () => Promise.resolve("boom"), + json: () => Promise.resolve({}), + }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve([]), + text: () => Promise.resolve(""), + }); + }); + global.fetch = mock as unknown as typeof global.fetch; + return mock; + }; + + test("getProject reports the unset ids", async () => { + pingFails(); + await expect(freshClient().getProject({ fields: [] })).rejects.toThrow( + "organizationId and projectId must be set", + ); + }); + + test("getWebhooks reports the unset project instead of requesting null", async () => { + const mock = pingFails(); + await expect(freshClient().getWebhooks()).rejects.toThrow( + "projectId must be set", + ); + expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined(); + }); + + test("createWebhook reports the unset project instead of requesting null", async () => { + const mock = pingFails(); + await expect( + freshClient().createWebhook({ + name: "hook", + url: "https://example.com/hook", + eventTypes: ["memory_add"], + }), + ).rejects.toThrow("projectId must be set"); + expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined(); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.project.test.ts b/mem0-ts/src/client/tests/memoryClient.project.test.ts index ed5dd6696..2fcfb1f0c 100644 --- a/mem0-ts/src/client/tests/memoryClient.project.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.project.test.ts @@ -96,6 +96,84 @@ describe("MemoryClient - updateProject()", () => { "Updated instructions", ); }); + + test("camelCases agentCustomInstructions into agent_custom_instructions", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { message: "Updated" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + await client.updateProject({ + agentCustomInstructions: "Remember tool failures", + }); + + const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH"); + expect(getFetchBody(call!).agent_custom_instructions).toBe( + "Remember tool failures", + ); + }); + + test("sends both instruction sets in one PATCH", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { message: "Updated" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + await client.updateProject({ + customInstructions: "Remember user preferences", + agentCustomInstructions: "Remember tool failures", + }); + + const body = getFetchBody( + findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!, + ); + expect(body.custom_instructions).toBe("Remember user preferences"); + expect(body.agent_custom_instructions).toBe("Remember tool failures"); + }); + + test("an empty string round-trips, so the field can be cleared", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { message: "Updated" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + await client.updateProject({ agentCustomInstructions: "" }); + + const body = getFetchBody( + findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!, + ); + expect(body).toHaveProperty("agent_custom_instructions", ""); + }); + + test("omits agent_custom_instructions when it is not passed", async () => { + const extra = new Map(); + extra.set("/api/v1/orgs/organizations/", { + status: 200, + body: { message: "Updated" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.ping(); + await client.updateProject({ customInstructions: "Be helpful" }); + + const body = getFetchBody( + findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!, + ); + expect(body).not.toHaveProperty("agent_custom_instructions"); + }); }); // ─── feedback() ───────────────────────────────────────── diff --git a/mem0-ts/src/client/tests/memoryClient.search.test.ts b/mem0-ts/src/client/tests/memoryClient.search.test.ts index 9b36babe3..be85df9f5 100644 --- a/mem0-ts/src/client/tests/memoryClient.search.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.search.test.ts @@ -99,6 +99,42 @@ describe("MemoryClient - search()", () => { expect(getFetchBody(call!).show_expired).toBe(true); }); + test("serializes referenceDate as reference_date (MEM-5893)", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { + filters: { user_id: "u1" }, + referenceDate: "2024-01-01", + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).reference_date).toBe("2024-01-01"); + }); + + test("serializes keywordSearch as keyword_search (MEM-5893)", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { + filters: { user_id: "u1" }, + keywordSearch: true, + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).keyword_search).toBe(true); + }); + test("passes complex OR filters through to the API body", async () => { const extra = new Map(); extra.set("/v3/memories/search/", { @@ -337,3 +373,56 @@ describe("MemoryClient - getAll() entity param rejection", () => { expect(getFetchBody(call!).show_expired).toBe(true); }); }); + +describe("MemoryClient - getAll() page/pageSize query params", () => { + test("sends no query params when neither page nor pageSize is provided", async () => { + const extra = new Map(); + extra.set("/v3/memories/", { status: 200, body: { results: [] } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ filters: { user_id: "u1" } }); + + const call = findFetchCall(mock, "/v3/memories/", "POST"); + expect(call![0]).not.toContain("?"); + }); + + test("sends page alone as a query param", async () => { + const extra = new Map(); + extra.set("/v3/memories/", { status: 200, body: { results: [] } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ filters: { user_id: "u1" }, page: 2 }); + + const call = findFetchCall(mock, "/v3/memories/", "POST"); + expect(call![0]).toContain("?page=2"); + expect(call![0]).not.toContain("page_size"); + }); + + test("sends pageSize alone as a query param", async () => { + const extra = new Map(); + extra.set("/v3/memories/", { status: 200, body: { results: [] } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ filters: { user_id: "u1" }, pageSize: 50 }); + + const call = findFetchCall(mock, "/v3/memories/", "POST"); + expect(call![0]).toContain("?page_size=50"); + expect(call![0]).not.toContain("?page=50"); + }); + + test("sends both page and pageSize when provided, including falsy 0 values", async () => { + const extra = new Map(); + extra.set("/v3/memories/", { status: 200, body: { results: [] } }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ filters: { user_id: "u1" }, page: 0, pageSize: 0 }); + + const call = findFetchCall(mock, "/v3/memories/", "POST"); + expect(call![0]).toContain("page=0"); + expect(call![0]).toContain("page_size=0"); + }); +}); diff --git a/mem0-ts/src/client/tests/memoryClient.users.test.ts b/mem0-ts/src/client/tests/memoryClient.users.test.ts index 5cdb30294..052325d71 100644 --- a/mem0-ts/src/client/tests/memoryClient.users.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.users.test.ts @@ -98,16 +98,23 @@ describe("MemoryClient - deleteUser() (deprecated)", () => { describe("MemoryClient - deleteUsers()", () => { test("URL-encodes entity path segments", async () => { - setupMockFetch(); + const extra = new Map(); + extra.set("/v2/entities/user/org%2Fteam%3Factive%23frag/", { + status: 200, + body: { message: "Entity deleted successfully!" }, + }); + const mock = setupMockFetch(extra); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); - client.telemetryId = "test@example.com"; - const deleteMock = jest.fn().mockResolvedValue({}); - client.client = { delete: deleteMock }; await client.deleteUsers({ userId: "org/team?active#frag" }); - expect(deleteMock).toHaveBeenCalledWith( - "/v2/entities/user/org%2Fteam%3Factive%23frag/", - ); + expect( + findFetchCall( + mock, + "/v2/entities/user/org%2Fteam%3Factive%23frag/", + "DELETE", + ), + ).toBeDefined(); }); }); diff --git a/mem0-ts/src/community/src/integrations/langchain/mem0.ts b/mem0-ts/src/community/src/integrations/langchain/mem0.ts index 1aac51358..8fe9a46d3 100644 --- a/mem0-ts/src/community/src/integrations/langchain/mem0.ts +++ b/mem0-ts/src/community/src/integrations/langchain/mem0.ts @@ -141,7 +141,7 @@ export interface Mem0MemoryInput extends BaseChatMemoryInput { * * // Use with a chat model * const model = new ChatOpenAI({ - * modelName: "gpt-3.5-turbo", + * modelName: "gpt-5-mini", * temperature: 0, * }); * diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index c72692583..b2803d1b2 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -40,6 +40,10 @@ export class ConfigManager { | undefined); return { + // Spread first so provider-specific keys (e.g. the Vertex AI + // project/location/credentials) survive the merge, while the + // normalized values below still win. + ...userConf, apiKey: userConf?.apiKey !== undefined ? userConf.apiKey @@ -56,9 +60,14 @@ export class ConfigManager { })(), }, vectorStore: { - provider: + // Every factory already matches the provider case-insensitively, so a capitalized + // name constructs the right store -- but the `provider === "memory"` comparisons that + // pick per-provider entity-store settings do not. Normalize once, here, so those + // comparisons cannot silently miss. + provider: ( userConfig.vectorStore?.provider || - DEFAULT_MEMORY_CONFIG.vectorStore.provider, + DEFAULT_MEMORY_CONFIG.vectorStore.provider + ).toLowerCase(), config: (() => { const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config; const userConf = userConfig.vectorStore?.config; @@ -133,6 +142,11 @@ export class ConfigManager { userConf?.maxTokens ?? (llmRaw?.max_tokens as number | undefined); return { + // Spread user-provided config first so any additional fields + // (e.g. future aws_bedrock options) pass through without a + // manager.ts edit, matching the vectorStore.config pattern above + // and making the schema's .passthrough() on llm.config meaningful. + ...userConf, baseURL: llmBaseURL, url: userConf?.url, apiKey: @@ -147,6 +161,20 @@ export class ConfigManager { temperature, topP, maxTokens, + // Pass through AWS Bedrock fields so the aws_bedrock provider works + // through the standard Memory config path (snake_case tolerated). + awsRegion: + userConf?.awsRegion ?? (llmRaw?.aws_region as string | undefined), + awsAccessKeyId: + userConf?.awsAccessKeyId ?? + (llmRaw?.aws_access_key_id as string | undefined), + awsSecretAccessKey: + userConf?.awsSecretAccessKey ?? + (llmRaw?.aws_secret_access_key as string | undefined), + awsSessionToken: + userConf?.awsSessionToken ?? + (llmRaw?.aws_session_token as string | undefined), + client: userConf?.client, }; })(), }, @@ -177,6 +205,7 @@ export class ConfigManager { })(), disableHistory: userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory, + reranker: userConfig.reranker, }; // Validate the merged config diff --git a/mem0-ts/src/oss/src/embeddings/aws_bedrock.ts b/mem0-ts/src/oss/src/embeddings/aws_bedrock.ts new file mode 100644 index 000000000..caf80c860 --- /dev/null +++ b/mem0-ts/src/oss/src/embeddings/aws_bedrock.ts @@ -0,0 +1,292 @@ +import { Embedder } from "./base"; +import { EmbeddingConfig } from "../types"; + +const DEFAULT_MODEL = "amazon.titan-embed-text-v1"; +const DEFAULT_REGION = "us-west-2"; + +// Cohere's Bedrock embed API rejects an InvokeModel call carrying more than 96 +// texts, so `embedBatch` chunks at that boundary. +const COHERE_MAX_BATCH = 96; + +// Titan has no server-side batch endpoint -- one InvokeModel call per text -- +// so without a cap a large embedBatch() would fan out one request per text. +// Bounds concurrency the same way COHERE_MAX_BATCH bounds the Cohere path. +const TITAN_MAX_CONCURRENCY = 4; + +// Cohere wants to know whether a text is being embedded for storage or for a +// retrieval query; embedding a search query in document mode silently +// degrades retrieval. Titan ignores this and has no equivalent parameter. +const COHERE_INPUT_TYPES: Record<"add" | "update" | "search", string> = { + add: "search_document", + update: "search_document", + search: "search_query", +}; + +type BedrockRuntimeModule = typeof import("@aws-sdk/client-bedrock-runtime"); + +interface BedrockCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + +interface BedrockEmbeddingResponse { + // Titan returns a single vector. Cohere v3 returns a flat array of vectors; + // Cohere v4, when `embedding_types` is requested, nests it as `{ float }`. + embedding?: number[]; + embeddings?: number[][] | { float?: number[][] }; +} + +/** + * Runs `fn` over `items` with at most `limit` calls in flight at once, + * returning results in input order regardless of completion order. + */ +async function mapWithConcurrencyLimit( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + + async function worker(): Promise { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index]); + } + } + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + return results; +} + +/** + * AWS Bedrock embedder, mirroring `mem0/embeddings/aws_bedrock.py`. + * + * Supports the Amazon Titan and Cohere embedding model families. The + * `@aws-sdk/client-bedrock-runtime` dependency is lazily imported so the + * package stays optional: importing this module never forces the SDK to be + * installed until a Bedrock embedder actually embeds something. + */ +export class AWSBedrockEmbedder implements Embedder { + private readonly model: string; + private readonly region: string; + private readonly embeddingDims?: number; + private readonly credentials?: BedrockCredentials; + private clientPromise?: Promise<{ + sdk: BedrockRuntimeModule; + client: { send: (command: any) => Promise<{ body?: Uint8Array }> }; + }>; + + constructor(config: EmbeddingConfig) { + this.model = config.model || DEFAULT_MODEL; + this.region = config.awsRegion || process.env.AWS_REGION || DEFAULT_REGION; + this.embeddingDims = config.embeddingDims; + + const hasKeyPair = Boolean( + config.awsAccessKeyId && config.awsSecretAccessKey, + ); + const hasAnyCredential = Boolean( + config.awsAccessKeyId || + config.awsSecretAccessKey || + config.awsSessionToken, + ); + + // Partially configured credentials would silently fall back to the default + // chain, embedding under an identity the caller never chose. + if (hasAnyCredential && !hasKeyPair) { + throw new Error( + "AWS Bedrock requires both awsAccessKeyId and awsSecretAccessKey when any explicit credential is configured. " + + "Omit all credential fields to use the AWS default credential chain.", + ); + } + + // Leaving `credentials` unset lets the AWS SDK resolve them from its + // default chain: environment, shared config, SSO, or the instance role. + if (hasKeyPair) { + this.credentials = { + accessKeyId: config.awsAccessKeyId!, + secretAccessKey: config.awsSecretAccessKey!, + ...(config.awsSessionToken && { sessionToken: config.awsSessionToken }), + }; + } + } + + private async loadSdk(): Promise { + try { + return await import("@aws-sdk/client-bedrock-runtime"); + } catch (error) { + // Only a genuine module-resolution failure gets the friendly install + // hint. Node's native ESM loader raises ERR_MODULE_NOT_FOUND; Jest's + // and bundlers' CJS-style resolvers raise MODULE_NOT_FOUND. Anything + // else (e.g. the package is installed but throws while loading, such + // as on a Node version older than the SDK's own engines requirement) + // rethrows unchanged instead of being misreported as "not installed". + const code = (error as { code?: string } | undefined)?.code; + if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") { + throw Object.assign( + new Error( + "The '@aws-sdk/client-bedrock-runtime' package is required to use the AWS Bedrock embedder. " + + "Install it with: npm install @aws-sdk/client-bedrock-runtime", + ), + { cause: error }, + ); + } + throw error; + } + } + + private async createClient(): Promise<{ + sdk: BedrockRuntimeModule; + client: { send: (command: any) => Promise<{ body?: Uint8Array }> }; + }> { + const sdk = await this.loadSdk(); + return { + sdk, + client: new sdk.BedrockRuntimeClient({ + region: this.region, + ...(this.credentials && { credentials: this.credentials }), + }), + }; + } + + private getClient() { + // Memoized so concurrent embed() calls share one client instead of each + // racing to build their own. Cleared on rejection so a transient failure + // (e.g. a network blip while resolving credentials) doesn't permanently + // disable Bedrock for the rest of this embedder's lifetime. + if (!this.clientPromise) { + this.clientPromise = this.createClient().catch((err) => { + this.clientPromise = undefined; + throw err; + }); + } + return this.clientPromise; + } + + private isCohereModel(): boolean { + return this.model.startsWith("cohere."); + } + + private isCohereV4Model(): boolean { + return this.model.includes("embed-v4"); + } + + private buildRequestBody( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Record { + if (this.isCohereModel()) { + const body: Record = { + texts, + input_type: memoryAction + ? COHERE_INPUT_TYPES[memoryAction] + : "search_document", + }; + + // Only Embed v4 understands embedding_types / output_dimension; v3 + // rejects unknown fields, so they're guarded to the v4 model family. + if (this.isCohereV4Model()) { + body.embedding_types = ["float"]; + if (this.embeddingDims !== undefined) { + body.output_dimension = this.embeddingDims; + } + } + return body; + } + + // Titan accepts one text per call. Only Titan Text Embeddings V2 supports + // a caller-chosen output size (256/512/1024), so the field is guarded the + // same way the Python provider guards it. + return { + inputText: texts[0], + ...(this.embeddingDims !== undefined && + this.model.includes("titan-embed-text-v2") && { + dimensions: this.embeddingDims, + }), + }; + } + + private async invoke( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Promise { + const { sdk, client } = await this.getClient(); + + let payload: BedrockEmbeddingResponse; + try { + const response = await client.send( + new sdk.InvokeModelCommand({ + modelId: this.model, + contentType: "application/json", + accept: "application/json", + body: new TextEncoder().encode( + JSON.stringify(this.buildRequestBody(texts, memoryAction)), + ), + }), + ); + payload = JSON.parse(new TextDecoder().decode(response.body)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Error getting embedding from AWS Bedrock model ${this.model}: ${message}`, + ); + } + + // Validated outside the try so this message is not re-wrapped by the catch. + // Cohere v3 replies with a flat `embeddings` array; v4 (when + // embedding_types is requested) nests it under `.float`. + const embeddings = this.isCohereModel() + ? Array.isArray(payload.embeddings) + ? payload.embeddings + : payload.embeddings?.float + : payload.embedding && [payload.embedding]; + + // `[]` is truthy, so a lone zero-length vector must be checked for + // explicitly -- otherwise it passes the length check and hands the + // caller an empty embedding instead of an error. + if ( + !embeddings || + embeddings.length !== texts.length || + embeddings.some((embedding) => embedding.length === 0) + ) { + throw new Error( + `AWS Bedrock model ${this.model} returned no embedding for one or more inputs`, + ); + } + return embeddings; + } + + async embed( + text: string, + memoryAction?: "add" | "update" | "search", + ): Promise { + return (await this.invoke([text], memoryAction))[0]; + } + + async embedBatch( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Promise { + if (texts.length === 0) return []; + + if (!this.isCohereModel()) { + return mapWithConcurrencyLimit(texts, TITAN_MAX_CONCURRENCY, (text) => + this.embed(text, memoryAction), + ); + } + + const embeddings: number[][] = []; + for (let i = 0; i < texts.length; i += COHERE_MAX_BATCH) { + embeddings.push( + ...(await this.invoke( + texts.slice(i, i + COHERE_MAX_BATCH), + memoryAction, + )), + ); + } + return embeddings; + } +} diff --git a/mem0-ts/src/oss/src/embeddings/base.ts b/mem0-ts/src/oss/src/embeddings/base.ts index 30d12ed36..31cab70de 100644 --- a/mem0-ts/src/oss/src/embeddings/base.ts +++ b/mem0-ts/src/oss/src/embeddings/base.ts @@ -1,4 +1,10 @@ export interface Embedder { - embed(text: string): Promise; - embedBatch(texts: string[]): Promise; + embed( + text: string, + memoryAction?: "add" | "update" | "search", + ): Promise; + embedBatch( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Promise; } diff --git a/mem0-ts/src/oss/src/embeddings/fastembed.ts b/mem0-ts/src/oss/src/embeddings/fastembed.ts index 6c298ef50..c0983ab9f 100644 --- a/mem0-ts/src/oss/src/embeddings/fastembed.ts +++ b/mem0-ts/src/oss/src/embeddings/fastembed.ts @@ -1,19 +1,28 @@ -import { EmbeddingModel, FlagEmbedding } from "fastembed"; +import type { FlagEmbedding } from "fastembed"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; +import { loadPeer } from "../utils/load_peer"; -const DEFAULT_MODEL = EmbeddingModel.BGESmallENV15; -type FastEmbedModel = Exclude; - -// FastEmbed only ships a fixed set of ONNX models. Keep the list handy so we can -// reject unknown model names up front with a clear message instead of letting -// FlagEmbedding.init fail later with an opaque download error. -const SUPPORTED_MODELS = Object.values(EmbeddingModel).filter( - (model) => model !== EmbeddingModel.CUSTOM, -) as FastEmbedModel[]; +// FastEmbed only ships a fixed set of ONNX models (fastembed's `EmbeddingModel` +// enum, minus CUSTOM). Mirrored here as literals so an invalid model name can +// be rejected synchronously in the constructor β€” with a clear message instead +// of a `FlagEmbedding.init()` download error β€” without eagerly importing the +// optional 'fastembed' package just to read its enum. Keep in sync if +// fastembed adds a model. +const SUPPORTED_MODELS = [ + "fast-all-MiniLM-L6-v2", + "fast-bge-base-en", + "fast-bge-base-en-v1.5", + "fast-bge-small-en", + "fast-bge-small-en-v1.5", + "fast-bge-small-zh-v1.5", + "fast-multilingual-e5-large", +] as const; +type FastEmbedModel = (typeof SUPPORTED_MODELS)[number]; +const DEFAULT_MODEL: FastEmbedModel = "fast-bge-small-en-v1.5"; export class FastEmbedEmbedder implements Embedder { - private modelName: FastEmbedModel; + private readonly modelName: FastEmbedModel; private embeddingModel?: Promise; constructor(config: EmbeddingConfig) { @@ -32,9 +41,7 @@ export class FastEmbedEmbedder implements Embedder { private getEmbeddingModel(): Promise { if (!this.embeddingModel) { - this.embeddingModel = FlagEmbedding.init({ - model: this.modelName, - }).catch((error) => { + this.embeddingModel = this.initEmbeddingModel().catch((error) => { this.embeddingModel = undefined; throw error; }); @@ -43,6 +50,20 @@ export class FastEmbedEmbedder implements Embedder { return this.embeddingModel; } + /** + * Lazily import the optional `fastembed` peer and initialize the model, so + * consumers that never touch FastEmbed don't need it installed. + */ + private async initEmbeddingModel(): Promise { + const sdk = await loadPeer( + "fastembed", + "FastEmbed embedder", + () => import("fastembed"), + ); + + return sdk.FlagEmbedding.init({ model: this.modelName }); + } + private normalizeInput(text: string): string { return text.replace(/\n/g, " "); } diff --git a/mem0-ts/src/oss/src/embeddings/google.ts b/mem0-ts/src/oss/src/embeddings/google.ts index 1e342b070..db23ffdee 100644 --- a/mem0-ts/src/oss/src/embeddings/google.ts +++ b/mem0-ts/src/oss/src/embeddings/google.ts @@ -1,21 +1,32 @@ -import { GoogleGenAI } from "@google/genai"; +import type { GoogleGenAI } from "@google/genai"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class GoogleEmbedder implements Embedder { - private google: GoogleGenAI; + private google!: GoogleGenAI; private model: string; private embeddingDims: number | undefined; + private readonly apiKey: string | undefined; constructor(config: EmbeddingConfig) { - this.google = new GoogleGenAI({ - apiKey: config.apiKey || process.env.GOOGLE_API_KEY, - }); + this.apiKey = config.apiKey || process.env.GOOGLE_API_KEY; this.model = config.model || "gemini-embedding-001"; this.embeddingDims = config.embeddingDims; } + private async ensureClient(): Promise { + if (this.google) return; + const sdk = await loadPeer( + "@google/genai", + "Google embedder", + () => import("@google/genai"), + ); + this.google = new sdk.GoogleGenAI({ apiKey: this.apiKey }); + } + async embed(text: string): Promise { + await this.ensureClient(); const response = await this.google.models.embedContent({ model: this.model, contents: text, @@ -27,6 +38,7 @@ export class GoogleEmbedder implements Embedder { } async embedBatch(texts: string[]): Promise { + await this.ensureClient(); const response = await this.google.models.embedContent({ model: this.model, contents: texts, diff --git a/mem0-ts/src/oss/src/embeddings/langchain.ts b/mem0-ts/src/oss/src/embeddings/langchain.ts index 7ffb85065..615cd0f8f 100644 --- a/mem0-ts/src/oss/src/embeddings/langchain.ts +++ b/mem0-ts/src/oss/src/embeddings/langchain.ts @@ -1,4 +1,4 @@ -import { Embeddings } from "@langchain/core/embeddings"; +import type { Embeddings } from "@langchain/core/embeddings"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; diff --git a/mem0-ts/src/oss/src/embeddings/ollama.ts b/mem0-ts/src/oss/src/embeddings/ollama.ts index 51d339ce2..be5835366 100644 --- a/mem0-ts/src/oss/src/embeddings/ollama.ts +++ b/mem0-ts/src/oss/src/embeddings/ollama.ts @@ -1,19 +1,19 @@ -import { Ollama } from "ollama"; +import type { Ollama } from "ollama"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; import { logger } from "../utils/logger"; +import { loadPeer } from "../utils/load_peer"; export class OllamaEmbedder implements Embedder { - private ollama: Ollama; + private ollama!: Ollama; private model: string; private embeddingDims?: number; + private readonly host: string; // Using this variable to avoid calling the Ollama server multiple times private initialized: boolean = false; constructor(config: EmbeddingConfig) { - this.ollama = new Ollama({ - host: config.url || config.baseURL || "http://localhost:11434", - }); + this.host = config.url || config.baseURL || "http://localhost:11434"; this.model = config.model || "nomic-embed-text:latest"; this.embeddingDims = config.embeddingDims || 768; this.ensureModelExists().catch((err) => { @@ -21,7 +21,18 @@ export class OllamaEmbedder implements Embedder { }); } + private async ensureClient(): Promise { + if (this.ollama) return; + const sdk = await loadPeer( + "ollama", + "Ollama embedder", + () => import("ollama"), + ); + this.ollama = new sdk.Ollama({ host: this.host }); + } + async embed(text: string): Promise { + await this.ensureClient(); try { await this.ensureModelExists(); } catch (err) { @@ -54,6 +65,7 @@ export class OllamaEmbedder implements Embedder { if (this.initialized) { return true; } + await this.ensureClient(); const local_models = await this.ollama.list(); const target = OllamaEmbedder.normalizeModelName(this.model); if ( diff --git a/mem0-ts/src/oss/src/embeddings/together.ts b/mem0-ts/src/oss/src/embeddings/together.ts index acd1d17e3..27ff9b6a4 100644 --- a/mem0-ts/src/oss/src/embeddings/together.ts +++ b/mem0-ts/src/oss/src/embeddings/together.ts @@ -17,7 +17,14 @@ export class TogetherEmbedder extends OpenAIEmbedder { super({ ...openAICompatibleConfig, apiKey, - baseURL: config.baseURL || config.url || DEFAULT_BASE_URL, + // Honor TOGETHER_API_BASE like the Together LLM does, so a user behind a + // gateway who sets it gets it for embeddings too instead of silently + // hitting api.together.ai. + baseURL: + config.baseURL || + config.url || + process.env.TOGETHER_API_BASE || + DEFAULT_BASE_URL, model: config.model || DEFAULT_MODEL, }); } diff --git a/mem0-ts/src/oss/src/embeddings/vertexai.ts b/mem0-ts/src/oss/src/embeddings/vertexai.ts new file mode 100644 index 000000000..5af0493ab --- /dev/null +++ b/mem0-ts/src/oss/src/embeddings/vertexai.ts @@ -0,0 +1,251 @@ +import type { PredictionServiceClient } from "@google-cloud/aiplatform"; +import { Embedder } from "./base"; +import { VertexAIConfig } from "../types"; +import { loadPeer } from "../utils/load_peer"; + +type AIPlatform = typeof import("@google-cloud/aiplatform"); +type ClientOptions = NonNullable< + ConstructorParameters[0] +>; + +interface EmbeddingResponse { + embeddings: { + values: number[]; + }; +} + +/** + * Vertex AI caps how many input texts one `predict()` call may carry, and the + * cap depends on the model family. `gemini-embedding-*` accepts exactly one + * text per request; the older `text-embedding-*` / `text-multilingual-*` + * models accept up to 250. + * https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-text-embeddings + */ +function maxInstancesPerRequest(model: string): number { + return model.startsWith("gemini-embedding") ? 1 : 250; +} + +function isValidEmbedding(value: unknown): value is EmbeddingResponse { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + if (typeof obj.embeddings !== "object" || obj.embeddings === null) + return false; + const embeddings = obj.embeddings as Record; + const values = embeddings.values; + return ( + Array.isArray(values) && + values.every((v) => typeof v === "number" && Number.isFinite(v)) + ); +} + +export class VertexAIEmbedder implements Embedder { + private client: PredictionServiceClient | undefined; + private helpers: AIPlatform["helpers"] | undefined; + private initPromise: Promise | undefined; + private clientOptions: ClientOptions; + private model: string; + private embeddingDims: number; + private location: string; + private projectId: string; + private embeddingTypes: { + add: string; + update: string; + search: string; + }; + + constructor(config: VertexAIConfig) { + this.model = config.model || "gemini-embedding-001"; + this.embeddingDims = config.embeddingDims || 256; + this.location = + config.location || process.env.GCP_LOCATION || "us-central1"; + + // Left empty when unset: initClient() resolves it from Application Default + // Credentials or the service account key file, the way the Python SDK does. + this.projectId = + config.googleProjectId || + process.env.GCP_PROJECT_ID || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + ""; + + this.embeddingTypes = { + add: config.memoryAddEmbeddingType || "RETRIEVAL_DOCUMENT", + update: config.memoryUpdateEmbeddingType || "RETRIEVAL_DOCUMENT", + search: config.memorySearchEmbeddingType || "RETRIEVAL_QUERY", + }; + + const endpoint = `${this.location}-aiplatform.googleapis.com`; + this.clientOptions = { apiEndpoint: endpoint }; + + if (config.vertexCredentialsJson) { + this.clientOptions.keyFilename = config.vertexCredentialsJson; + } else if (config.googleServiceAccountJson) { + try { + this.clientOptions.credentials = + typeof config.googleServiceAccountJson === "string" + ? JSON.parse(config.googleServiceAccountJson) + : config.googleServiceAccountJson; + } catch (err) { + throw new Error( + "Failed to parse googleServiceAccountJson: " + (err as Error).message, + ); + } + } + } + + private async initClient(): Promise { + // Memoized so concurrent embed() calls share one client instead of each + // racing to build (and leak) their own gRPC channel. + if (!this.initPromise) { + this.initPromise = this.createClient().catch((err) => { + this.initPromise = undefined; + throw err; + }); + } + await this.initPromise; + } + + private async createClient(): Promise { + const aiplatform: AIPlatform = await loadPeer( + "@google-cloud/aiplatform", + "Vertex AI embedding provider", + () => import("@google-cloud/aiplatform"), + ); + + const client = new aiplatform.PredictionServiceClient(this.clientOptions); + + if (!this.projectId) { + try { + this.projectId = await client.getProjectId(); + } catch (err) { + throw new Error( + "Vertex AI could not determine a Google Cloud project ID. Set googleProjectId in config, " + + "one of the GCP_PROJECT_ID / GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env vars, or configure " + + "Application Default Credentials: " + + (err as Error).message, + ); + } + } + + this.client = client; + this.helpers = aiplatform.helpers; + } + + private endpoint(): string { + return `projects/${this.projectId}/locations/${this.location}/publishers/google/models/${this.model}`; + } + + private formatInstance(text: string, taskType: string) { + // task_type must live on the instance (snake_case), not in `parameters`. + // Vertex silently ignores an unknown `parameters.taskType`, which would + // fall back to the model's default task type. This mirrors the Python SDK's + // TextEmbeddingInput(text=..., task_type=...). + return { + content: text, + task_type: taskType, + }; + } + + async embed( + text: string, + memoryAction?: "add" | "update" | "search", + ): Promise { + await this.initClient(); + if (!this.client || !this.helpers) { + throw new Error("Client not initialized"); + } + + let embeddingType = "SEMANTIC_SIMILARITY"; + if (memoryAction !== undefined) { + if (!(memoryAction in this.embeddingTypes)) { + throw new Error(`Invalid memory action: ${memoryAction}`); + } + embeddingType = this.embeddingTypes[memoryAction]; + } + + const instance = this.formatInstance(text, embeddingType); + const parameters = { + outputDimensionality: this.embeddingDims, + }; + + const [response] = await this.client.predict({ + endpoint: this.endpoint(), + instances: [this.helpers.toValue(instance) as any], + parameters: this.helpers.toValue(parameters) as any, + }); + + if (!response.predictions || response.predictions.length === 0) { + throw new Error("No predictions returned from Vertex AI"); + } + + const decoded = this.helpers.fromValue(response.predictions[0] as any); + if (!isValidEmbedding(decoded)) { + throw new Error("Failed to extract embedding values from response"); + } + + return decoded.embeddings.values; + } + + async embedBatch( + texts: string[], + memoryAction: "add" | "update" | "search" = "add", + ): Promise { + if (!texts || texts.length === 0) { + return []; + } + + await this.initClient(); + if (!this.client || !this.helpers) { + throw new Error("Client not initialized"); + } + + if (!(memoryAction in this.embeddingTypes)) { + throw new Error(`Invalid memory action: ${memoryAction}`); + } + const embeddingType = this.embeddingTypes[memoryAction]; + + const allEmbeddings: number[][] = []; + const batchSize = maxInstancesPerRequest(this.model); + + for (let i = 0; i < texts.length; i += batchSize) { + const chunk = texts.slice(i, i + batchSize); + const instances = chunk.map( + (text) => + this.helpers!.toValue( + this.formatInstance(text, embeddingType), + ) as any, + ); + const parameters = { + outputDimensionality: this.embeddingDims, + }; + + const [response] = await this.client.predict({ + endpoint: this.endpoint(), + instances, + parameters: this.helpers.toValue(parameters) as any, + }); + + if (!response.predictions || response.predictions.length === 0) { + throw new Error("No predictions returned from Vertex AI batch request"); + } + + for (const prediction of response.predictions) { + const decoded = this.helpers.fromValue(prediction as any); + if (!isValidEmbedding(decoded)) { + throw new Error( + "Failed to extract embedding values from batch response", + ); + } + allEmbeddings.push(decoded.embeddings.values); + } + } + + if (allEmbeddings.length !== texts.length) { + throw new Error( + `Vertex AI embedBatch() returned ${allEmbeddings.length} embeddings for ${texts.length} texts using model '${this.model}'`, + ); + } + + return allEmbeddings; + } +} diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 8b772fbe1..c780a2edf 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -2,6 +2,7 @@ export * from "./memory"; export * from "./memory/memory.types"; export * from "./types"; export * from "./embeddings/base"; +export * from "./embeddings/aws_bedrock"; export * from "./embeddings/huggingface"; export * from "./embeddings/openai"; export * from "./embeddings/ollama"; @@ -10,6 +11,7 @@ export * from "./embeddings/together"; export * from "./embeddings/google"; export * from "./embeddings/azure"; export * from "./embeddings/langchain"; +export * from "./embeddings/vertexai"; export * from "./embeddings/fastembed"; export * from "./llms/base"; export * from "./llms/openai"; @@ -23,8 +25,10 @@ export * from "./llms/mistral"; export * from "./llms/langchain"; export * from "./llms/litellm"; export * from "./llms/vllm"; +export * from "./llms/aws_bedrock"; export * from "./vector_stores/base"; export * from "./vector_stores/memory"; +export * from "./vector_stores/baidu"; export * from "./vector_stores/qdrant"; export * from "./vector_stores/redis"; export * from "./vector_stores/valkey"; @@ -33,6 +37,8 @@ export * from "./vector_stores/langchain"; export * from "./vector_stores/vectorize"; export * from "./vector_stores/azure_ai_search"; export * from "./vector_stores/pgvector"; +export * from "./vector_stores/databricks"; +export * from "./vector_stores/neptune_analytics"; export * from "./vector_stores/elasticsearch"; export * from "./vector_stores/upstash_vector"; export * from "./vector_stores/azure_mysql"; @@ -45,4 +51,10 @@ export * from "./vector_stores/milvus"; export * from "./vector_stores/mongodb"; export * from "./vector_stores/opensearch"; export * from "./vector_stores/weaviate"; +export * from "./vector_stores/oracledb"; +export * from "./rerankers/base"; +export * from "./rerankers/cohere"; +export * from "./rerankers/llm"; +export * from "./rerankers/zeroentropy"; +export * from "./rerankers/cross_encoder"; export * from "./utils/factory"; diff --git a/mem0-ts/src/oss/src/llms/anthropic.ts b/mem0-ts/src/oss/src/llms/anthropic.ts index ea91f8ca4..0fcf43284 100644 --- a/mem0-ts/src/oss/src/llms/anthropic.ts +++ b/mem0-ts/src/oss/src/llms/anthropic.ts @@ -1,9 +1,11 @@ -import Anthropic from "@anthropic-ai/sdk"; +import type Anthropic from "@anthropic-ai/sdk"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class AnthropicLLM implements LLM { - private client: Anthropic; + private client!: Anthropic; + private readonly clientArgs: { apiKey: string; baseURL?: string }; private model: string; private maxTokens: number; private temperature?: number; @@ -20,7 +22,7 @@ export class AnthropicLLM implements LLM { if (config.baseURL) { clientArgs.baseURL = config.baseURL; } - this.client = new Anthropic(clientArgs); + this.clientArgs = clientArgs; this.model = config.model || "claude-sonnet-4-6"; // Defaults mirror the Python provider's AnthropicConfig // (max_tokens=2000, temperature=0.1, top_p omitted). @@ -29,11 +31,22 @@ export class AnthropicLLM implements LLM { this.topP = config.topP; } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "@anthropic-ai/sdk", + "Anthropic LLM", + () => import("@anthropic-ai/sdk"), + ); + this.client = new sdk.default(this.clientArgs); + } + async generateResponse( messages: Message[], responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); // Extract system message if present const systemMessage = messages.find((msg) => msg.role === "system"); const otherMessages = messages.filter((msg) => msg.role !== "system"); @@ -87,12 +100,16 @@ export class AnthropicLLM implements LLM { return { content, role: "assistant", toolCalls }; } - const firstBlock = response.content[0]; - if (firstBlock.type === "text") { - return firstBlock.text; - } else { - throw new Error("Unexpected response type from Anthropic API"); + // Thinking-enabled responses put a thinking block before the text block, + // and a response can carry no text block at all, so find the text block + // like the tools branch above instead of indexing content[0]. Mirrors the + // Python provider (#6481). + for (const block of response.content) { + if (block.type === "text") { + return block.text; + } } + return ""; } async generateChat(messages: Message[]): Promise { diff --git a/mem0-ts/src/oss/src/llms/aws_bedrock.ts b/mem0-ts/src/oss/src/llms/aws_bedrock.ts new file mode 100644 index 000000000..39c8b2b56 --- /dev/null +++ b/mem0-ts/src/oss/src/llms/aws_bedrock.ts @@ -0,0 +1,305 @@ +import { LLM, LLMResponse } from "./base"; +import { LLMConfig, Message } from "../types"; + +/** + * Providers recognised in Bedrock model identifiers, mirroring the Python + * provider's `PROVIDERS` list (`mem0/llms/aws_bedrock.py`). + */ +const PROVIDERS = [ + "ai21", + "amazon", + "anthropic", + "cohere", + "meta", + "mistral", + "stability", + "writer", + "deepseek", + "gpt-oss", + "perplexity", + "snowflake", + "titan", + "command", + "j2", + "llama", + "minimax", +]; + +/** + * Extract the model-family provider from a Bedrock model id + * (e.g. `anthropic.claude-3-sonnet-...` -> `anthropic`). + */ +export function extractProvider( + model: string, + providerOverride?: string, +): string { + if (providerOverride) { + if (!PROVIDERS.includes(providerOverride)) { + throw new Error( + `Unknown providerOverride '${providerOverride}'. Valid providers: ${PROVIDERS.join(", ")}`, + ); + } + return providerOverride; + } + for (const provider of PROVIDERS) { + const re = new RegExp( + `\\b${provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, + ); + if (re.test(model)) return provider; + } + throw new Error(`Unknown provider in model: ${model}`); +} + +/** + * AWS Bedrock fields (awsRegion / awsAccessKeyId / awsSecretAccessKey / + * awsSessionToken / client) now live on the shared `LLMConfig`, so the + * provider is configurable through the standard typed `Memory` config path. + */ +type AWSBedrockConfig = LLMConfig; + +/** + * AWS Bedrock LLM provider for the TypeScript OSS SDK. + * + * Mirrors `mem0/llms/aws_bedrock.py`. Uses the Bedrock **Converse API** + * (`ConverseCommand`), which provides a uniform message/tool interface across + * the Anthropic / Amazon (Nova) / Meta / Mistral / Cohere model families, so a + * single code path serves them all (the Python provider keeps per-family + * `invoke_model` branches for legacy reasons; Converse supersedes them). + * + * The `@aws-sdk/client-bedrock-runtime` dependency is loaded on first use via + * dynamic `import()` so the package stays optional. Credentials resolve via the + * standard AWS chain unless provided explicitly in config. + */ +interface BedrockSDK { + BedrockRuntimeClient: new (config: Record) => any; + ConverseCommand: new (input: Record) => any; +} + +export class AWSBedrockLLM implements LLM { + private model: string; + private provider: string; + private temperature: number; + private maxTokens: number; + private topP?: number; + private clientConfig: Record; + private clientOverride?: any; + private sdkPromise?: Promise; + private clientPromise?: Promise; + + constructor(config: AWSBedrockConfig = {}) { + this.model = + (typeof config.model === "string" && config.model) || + "anthropic.claude-3-5-sonnet-20240620-v1:0"; + this.provider = extractProvider(this.model, config.providerOverride); + this.temperature = config.temperature ?? 0.1; + this.maxTokens = config.maxTokens ?? 2000; + this.topP = config.topP; + + const region = + config.awsRegion || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION; + const clientConfig: Record = {}; + if (region) clientConfig.region = region; + if (config.awsAccessKeyId && config.awsSecretAccessKey) { + clientConfig.credentials = { + accessKeyId: config.awsAccessKeyId, + secretAccessKey: config.awsSecretAccessKey, + ...(config.awsSessionToken && { + sessionToken: config.awsSessionToken, + }), + }; + } + + this.clientConfig = clientConfig; + this.clientOverride = config.client; + } + + /** + * Load the optional AWS SDK on first use. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite + * `require()` in the published ESM bundle (`dist/oss/index.mjs`) into a + * `__require` shim that throws `Dynamic require of "..." is not supported`, + * so every ESM consumer would hit a dead provider even with the SDK installed. + */ + private async getSDK(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-bedrock-runtime").then( + (sdk) => sdk as unknown as BedrockSDK, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sdkPromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@aws-sdk/client-bedrock-runtime' package is required to use the AWS Bedrock LLM provider. " + + `Install it with: npm install @aws-sdk/client-bedrock-runtime (original error: ${detail})`, + ); + }, + ); + } + return this.sdkPromise; + } + + /** Memoized Bedrock client; an injected `config.client` short-circuits the SDK. */ + private async getClient(): Promise { + if (this.clientOverride) return this.clientOverride; + if (!this.clientPromise) { + this.clientPromise = this.getSDK().then( + ({ BedrockRuntimeClient }) => + new BedrockRuntimeClient(this.clientConfig), + ); + } + return this.clientPromise; + } + + /** + * Split messages into a top-level `system` block (Converse passes system + * prompts separately) and role-tagged content blocks for everything else. + */ + private formatMessages(messages: Message[]): { + system?: { text: string }[]; + converseMessages: { role: string; content: { text: string }[] }[]; + } { + const systemParts: string[] = []; + const converseMessages: { role: string; content: { text: string }[] }[] = + []; + + for (const msg of messages) { + const role = msg.role; + const content = + typeof msg.content === "string" + ? msg.content + : JSON.stringify(msg.content); + if (role === "system") { + systemParts.push(content); + } else { + converseMessages.push({ + role: role === "assistant" ? "assistant" : "user", + content: [{ text: content }], + }); + } + } + + if (converseMessages.length === 0) { + converseMessages.push({ role: "user", content: [{ text: "" }] }); + } + + return { + system: systemParts.length + ? [{ text: systemParts.join("\n") }] + : undefined, + converseMessages, + }; + } + + /** + * Build the Converse `inferenceConfig`. Anthropic and MiniMax reasoning + * models reject requests carrying both `temperature` and `topP`, so `topP` + * is omitted for those families (mirrors the Python `_build_inference_config`). + */ + private buildInferenceConfig(): Record { + const inferenceConfig: Record = { + maxTokens: this.maxTokens, + temperature: this.temperature, + }; + if ( + this.topP != null && + !["anthropic", "minimax"].includes(this.provider) + ) { + inferenceConfig.topP = this.topP; + } + return inferenceConfig; + } + + /** Convert OpenAI-style tools to the Converse `toolConfig` shape. */ + private convertToolsToConverse(tools: any[]): any | undefined { + if (!tools || tools.length === 0) return undefined; + const converseTools = tools + .filter((t) => t?.type === "function" && t.function) + .map((t) => ({ + toolSpec: { + name: t.function.name, + description: t.function.description || "", + inputSchema: { json: t.function.parameters || {} }, + }, + })); + return converseTools.length ? { tools: converseTools } : undefined; + } + + private async converse(messages: Message[], tools?: any[]): Promise { + const { system, converseMessages } = this.formatMessages(messages); + const input: Record = { + modelId: this.model, + messages: converseMessages, + inferenceConfig: this.buildInferenceConfig(), + }; + if (system) input.system = system; + const toolConfig = tools ? this.convertToolsToConverse(tools) : undefined; + if (toolConfig) input.toolConfig = toolConfig; + + const [{ ConverseCommand }, client] = await Promise.all([ + this.getSDK(), + this.getClient(), + ]); + return client.send(new ConverseCommand(input)); + } + + /** Pull the first text block out of a Converse response. */ + private parseText(response: any): string { + const content = response?.output?.message?.content || []; + for (const block of content) { + if (block && typeof block.text === "string") return block.text; + } + return ""; + } + + /** Collect any toolUse blocks out of a Converse response. */ + private parseToolCalls(response: any): { name: string; arguments: string }[] { + const content = response?.output?.message?.content || []; + const calls: { name: string; arguments: string }[] = []; + for (const block of content) { + if (block?.toolUse) { + calls.push({ + name: block.toolUse.name, + arguments: JSON.stringify(block.toolUse.input ?? {}), + }); + } + } + return calls; + } + + async generateResponse( + messages: Message[], + _responseFormat?: { type: string }, + tools?: any[], + ): Promise { + try { + const response = await this.converse(messages, tools); + if (tools && tools.length) { + const toolCalls = this.parseToolCalls(response); + if (toolCalls.length) { + return { + content: this.parseText(response), + role: "assistant", + toolCalls, + }; + } + } + return this.parseText(response); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`AWS Bedrock LLM failed: ${message}`); + } + } + + async generateChat(messages: Message[]): Promise { + try { + const response = await this.converse(messages); + return { content: this.parseText(response), role: "assistant" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`AWS Bedrock LLM failed: ${message}`); + } + } +} diff --git a/mem0-ts/src/oss/src/llms/google.ts b/mem0-ts/src/oss/src/llms/google.ts index d9a461b98..ecab15ef1 100644 --- a/mem0-ts/src/oss/src/llms/google.ts +++ b/mem0-ts/src/oss/src/llms/google.ts @@ -1,16 +1,28 @@ -import { GoogleGenAI } from "@google/genai"; +import type { GoogleGenAI } from "@google/genai"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class GoogleLLM implements LLM { - private google: GoogleGenAI; + private google!: GoogleGenAI; private model: string; + private readonly apiKey: string | undefined; constructor(config: LLMConfig) { - this.google = new GoogleGenAI({ apiKey: config.apiKey }); + this.apiKey = config.apiKey; this.model = config.model || "gemini-2.0-flash"; } + private async ensureClient(): Promise { + if (this.google) return; + const sdk = await loadPeer( + "@google/genai", + "Google LLM", + () => import("@google/genai"), + ); + this.google = new sdk.GoogleGenAI({ apiKey: this.apiKey }); + } + private formatContents(messages: Message[]) { return messages.map((msg) => ({ parts: [ @@ -30,6 +42,7 @@ export class GoogleLLM implements LLM { responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); const contents = this.formatContents(messages); // Build config with tools if provided @@ -46,6 +59,15 @@ export class GoogleLLM implements LLM { ]; } + // Honor a requested JSON response format (parity with the Python SDK's + // mem0/llms/gemini.py). Gemini's structured output is opt-in via + // responseMimeType β€” without it the model is never told to emit JSON, so + // callers passing json_object silently get free-form text and depend on a + // fragile markdown-fence strip downstream. + if (responseFormat?.type === "json_object") { + config.responseMimeType = "application/json"; + } + const completion = await this.google.models.generateContent({ contents, model: this.model, @@ -72,6 +94,7 @@ export class GoogleLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); const completion = await this.google.models.generateContent({ contents: this.formatContents(messages), model: this.model, diff --git a/mem0-ts/src/oss/src/llms/groq.ts b/mem0-ts/src/oss/src/llms/groq.ts index 616262101..94d20cd46 100644 --- a/mem0-ts/src/oss/src/llms/groq.ts +++ b/mem0-ts/src/oss/src/llms/groq.ts @@ -1,24 +1,37 @@ -import { Groq } from "groq-sdk"; +import type { Groq } from "groq-sdk"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class GroqLLM implements LLM { - private client: Groq; + private client!: Groq; private model: string; + private readonly apiKey: string; constructor(config: LLMConfig) { const apiKey = config.apiKey || process.env.GROQ_API_KEY; if (!apiKey) { throw new Error("Groq API key is required"); } - this.client = new Groq({ apiKey }); + this.apiKey = apiKey; this.model = config.model || "llama3-70b-8192"; } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "groq-sdk", + "Groq LLM", + () => import("groq-sdk"), + ); + this.client = new sdk.Groq({ apiKey: this.apiKey }); + } + async generateResponse( messages: Message[], responseFormat?: { type: string }, ): Promise { + await this.ensureClient(); const response = await this.client.chat.completions.create({ model: this.model, messages: messages.map((msg) => ({ @@ -35,6 +48,7 @@ export class GroqLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); const response = await this.client.chat.completions.create({ model: this.model, messages: messages.map((msg) => ({ diff --git a/mem0-ts/src/oss/src/llms/langchain.ts b/mem0-ts/src/oss/src/llms/langchain.ts index 522ebd83f..3c21d6f26 100644 --- a/mem0-ts/src/oss/src/llms/langchain.ts +++ b/mem0-ts/src/oss/src/llms/langchain.ts @@ -1,17 +1,16 @@ -import { BaseLanguageModel } from "@langchain/core/language_models/base"; -import { - AIMessage, - HumanMessage, - SystemMessage, - BaseMessage, -} from "@langchain/core/messages"; +import type { BaseLanguageModel } from "@langchain/core/language_models/base"; +import type { BaseMessage } from "@langchain/core/messages"; import { z } from "zod"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types/index"; // Import the schemas directly into LangchainLLM import { FactRetrievalSchema, MemoryUpdateSchema } from "../prompts"; -const convertToLangchainMessages = (messages: Message[]): BaseMessage[] => { +const convertToLangchainMessages = async ( + messages: Message[], +): Promise => { + const { AIMessage, HumanMessage, SystemMessage } = + await import("@langchain/core/messages"); return messages.map((msg) => { const content = typeof msg.content === "string" @@ -62,7 +61,7 @@ export class LangchainLLM implements LLM { response_format?: { type: string }, tools?: any[], ): Promise { - const langchainMessages = convertToLangchainMessages(messages); + const langchainMessages = await convertToLangchainMessages(messages); let runnable: any = this.llmInstance; const invokeOptions: Record = {}; let isStructuredOutput = false; @@ -170,7 +169,7 @@ export class LangchainLLM implements LLM { } async generateChat(messages: Message[]): Promise { - const langchainMessages = convertToLangchainMessages(messages); + const langchainMessages = await convertToLangchainMessages(messages); try { const response = await this.llmInstance.invoke(langchainMessages); if (response && typeof response.content === "string") { diff --git a/mem0-ts/src/oss/src/llms/mistral.ts b/mem0-ts/src/oss/src/llms/mistral.ts index a80972b6d..ebbf3cbf3 100644 --- a/mem0-ts/src/oss/src/llms/mistral.ts +++ b/mem0-ts/src/oss/src/llms/mistral.ts @@ -1,21 +1,31 @@ -import { Mistral } from "@mistralai/mistralai"; +import type { Mistral } from "@mistralai/mistralai"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class MistralLLM implements LLM { - private client: Mistral; + private client!: Mistral; private model: string; + private readonly apiKey: string; constructor(config: LLMConfig) { if (!config.apiKey) { throw new Error("Mistral API key is required"); } - this.client = new Mistral({ - apiKey: config.apiKey, - }); + this.apiKey = config.apiKey; this.model = config.model || "mistral-tiny-latest"; } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "@mistralai/mistralai", + "Mistral LLM", + () => import("@mistralai/mistralai"), + ); + this.client = new sdk.Mistral({ apiKey: this.apiKey }); + } + // Helper function to convert content to string private contentToString(content: any): string { if (typeof content === "string") { @@ -41,6 +51,7 @@ export class MistralLLM implements LLM { responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); const response = await this.client.chat.complete({ model: this.model, messages: messages.map((msg) => ({ @@ -82,6 +93,7 @@ export class MistralLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); const formattedMessages = messages.map((msg) => ({ role: msg.role as "system" | "user" | "assistant", content: diff --git a/mem0-ts/src/oss/src/llms/ollama.ts b/mem0-ts/src/oss/src/llms/ollama.ts index 987262731..2a57e0526 100644 --- a/mem0-ts/src/oss/src/llms/ollama.ts +++ b/mem0-ts/src/oss/src/llms/ollama.ts @@ -1,29 +1,36 @@ -import { Ollama } from "ollama"; +import type { Ollama } from "ollama"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; import { logger } from "../utils/logger"; +import { loadPeer } from "../utils/load_peer"; export class OllamaLLM implements LLM { - private ollama: Ollama; + private ollama!: Ollama; private model: string; + private readonly host: string; // Using this variable to avoid calling the Ollama server multiple times private initialized: boolean = false; constructor(config: LLMConfig) { - this.ollama = new Ollama({ - host: config.url || config.baseURL || "http://localhost:11434", - }); + this.host = config.url || config.baseURL || "http://localhost:11434"; this.model = config.model || "llama3.1:8b"; this.ensureModelExists().catch((err) => { logger.error(`Error ensuring model exists: ${err}`); }); } + private async ensureClient(): Promise { + if (this.ollama) return; + const sdk = await loadPeer("ollama", "Ollama LLM", () => import("ollama")); + this.ollama = new sdk.Ollama({ host: this.host }); + } + async generateResponse( messages: Message[], responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); try { await this.ensureModelExists(); } catch (err) { @@ -63,6 +70,7 @@ export class OllamaLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); try { await this.ensureModelExists(); } catch (err) { @@ -93,6 +101,7 @@ export class OllamaLLM implements LLM { if (this.initialized) { return true; } + await this.ensureClient(); const local_models = await this.ollama.list(); if (!local_models.models.find((m: any) => m.name === this.model)) { logger.info(`Pulling model ${this.model}...`); diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 5d8aea211..1efbc3e45 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -7,12 +7,14 @@ import { Message, SearchFilters, SearchResult, + VectorStoreConfig, } from "../types"; import { EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory, + RerankerFactory, } from "../utils/factory"; import { FactRetrievalSchema, @@ -28,6 +30,7 @@ import { import { DummyHistoryManager } from "../storage/DummyHistoryManager"; import { Embedder } from "../embeddings/base"; import { LLM } from "../llms/base"; +import { Reranker } from "../rerankers/base"; import { VectorStore } from "../vector_stores/base"; import { ConfigManager } from "../config/manager"; @@ -77,7 +80,18 @@ import { logger } from "../utils/logger"; import { normalizeExpirationDate, payloadIsExpired } from "../utils/expiration"; import { getOrCreateMem0UserId } from "../../../client/config"; -// Entity params that must be passed via filters - check both snake_case and camelCase +export class LLMError extends Error { + readonly cause?: unknown; + + constructor(message: string, options: { cause?: unknown } = {}) { + super(message); + this.name = "LLMError"; + this.cause = options.cause; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// Entity params that must be passed via filters check both snake_case and camelCase const ENTITY_PARAMS = [ "user_id", "agent_id", @@ -86,6 +100,35 @@ const ENTITY_PARAMS = [ "agentId", "runId", ]; +// Identity keys stripped from caller metadata in add() and update(): ENTITY_PARAMS covers +// user_id/agent_id/run_id in both casings (the default store promotes camelCase on read); +// actor_id has no camelCase alias. +const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"]; + +const PAYLOAD_METADATA_EXCLUDED_KEYS = new Set([ + "user_id", + "agent_id", + "run_id", + "hash", + "data", + "createdAt", + "updatedAt", + "textLemmatized", + "attributedTo", +]); + +// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367 / #6371). +function stripIdentityKeys( + metadata: Record = {}, +): Record { + return Object.fromEntries( + Object.entries(metadata).filter(([key]) => !IDENTITY_KEYS.includes(key)), + ); +} + +// Batch size for deleteAll pagination. Larger than most vector store default +// page limits (~100) to minimize roundtrips while bounded to avoid memory pressure. +const DELETE_ALL_BATCH_SIZE = 1000; /** * Validates that no top-level entity parameters are passed in config. @@ -108,18 +151,19 @@ function rejectTopLevelEntityParams( /** * Validates and normalizes an entity ID. + * - Coerces non-string ids (e.g. numeric database keys) to string * - Trims leading/trailing whitespace * - Rejects empty or whitespace-only strings * - Rejects strings containing internal whitespace - * @returns The trimmed entity ID, or undefined if input is undefined + * @returns The trimmed entity ID, or undefined if input is undefined/null * @throws Error if entity ID is invalid */ function validateAndTrimEntityId( - value: string | undefined, + value: string | number | undefined | null, name: string, ): string | undefined { - if (value === undefined) return undefined; - const trimmed = value.trim(); + if (value == null) return undefined; + const trimmed = String(value).trim(); if (trimmed === "") { throw new Error( `Invalid ${name}: cannot be empty or whitespace-only. Provide a valid identifier.`, @@ -164,6 +208,7 @@ export class Memory { private embedder: Embedder; private vectorStore!: VectorStore; private llm: LLM; + private reranker: Reranker | null = null; private db: HistoryManager; private collectionName: string | undefined; private apiVersion: string; @@ -188,6 +233,12 @@ export class Memory { this.config.llm.provider, this.config.llm.config, ); + if (this.config.reranker) { + this.reranker = RerankerFactory.create( + this.config.reranker.provider, + this.config.reranker.config, + ); + } if (this.config.disableHistory) { this.db = new DummyHistoryManager(); } else { @@ -266,18 +317,24 @@ export class Memory { private async getEntityStore(): Promise { if (!this._entityStore) { + const entityProvider = this.config.vectorStore.provider; const entityCollectionName = `${this.collectionName}_entities`; - const entityConfig = { + const entityConfig: VectorStoreConfig = { ...this.config.vectorStore.config, collectionName: entityCollectionName, }; // For file-based stores (memory/SQLite), always use a separate DB for entities - if (this.config.vectorStore.provider === "memory") { + if (entityProvider === "memory") { const basePath = entityConfig.dbPath || getDefaultVectorStoreDbPath(); entityConfig.dbPath = basePath.replace(/\.db$/, "_entities.db"); } + if (entityProvider === "databricks") { + entityConfig.tableName = entityConfig.tableName + ? `${entityConfig.tableName}_entities` + : entityCollectionName; + } this._entityStore = VectorStoreFactory.create( - this.config.vectorStore.provider, + entityProvider, entityConfig, ); await this._entityStore.initialize(); @@ -399,7 +456,7 @@ export class Memory { } let vec: number[]; try { - vec = await this.embedder.embed(entityText); + vec = await this.embedder.embed(entityText, "update"); } catch (e) { console.debug(`Entity re-embed failed for '${entityText}': ${e}`); continue; @@ -443,7 +500,7 @@ export class Memory { try { let entityVec: number[]; try { - entityVec = await this.embedder.embed(entity.text); + entityVec = await this.embedder.embed(entity.text, "add"); } catch (e) { console.debug(`Entity embed failed for '${entity.text}': ${e}`); continue; @@ -511,11 +568,18 @@ export class Memory { } } + private escapeScopeValue(val: unknown): string { + return String(val) + .replace(/%/g, "%25") + .replace(/&/g, "%26") + .replace(/=/g, "%3D"); + } + private buildSessionScope(filters: SearchFilters): string { const parts: string[] = []; for (const key of ["agent_id", "run_id", "user_id"].sort()) { const val = (filters as any)[key]; - if (val) parts.push(`${key}=${val}`); + if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`); } return parts.join("&"); } @@ -704,7 +768,8 @@ export class Memory { has_filters: !!config.filters, infer: config.infer, }); - const { metadata = {}, filters = {}, infer = true } = config; + const { filters = {}, infer = true } = config; + const metadata = stripIdentityKeys(config.metadata); // Validate and trim entity IDs const userId = validateAndTrimEntityId(config.userId, "userId"); @@ -715,6 +780,9 @@ export class Memory { if (userId) filters.user_id = metadata.user_id = userId; if (agentId) filters.agent_id = metadata.agent_id = agentId; if (runId) filters.run_id = metadata.run_id = runId; + if (filters.user_id) metadata.user_id = filters.user_id; + if (filters.agent_id) metadata.agent_id = filters.agent_id; + if (filters.run_id) metadata.run_id = filters.run_id; // Normalize expiration date into the stored metadata (round-trips via get()). if (config.expirationDate != null) { @@ -818,7 +886,7 @@ export class Memory { .join("\n"); // Phase 1: Existing memory retrieval - const queryEmbedding = await this.embedder.embed(parsedMessages); + const queryEmbedding = await this.embedder.embed(parsedMessages, "search"); const existingResults = await this.vectorStore.search( queryEmbedding, 10, @@ -862,7 +930,7 @@ export class Memory { )) as string; } catch (e) { console.error("LLM extraction failed:", e); - return []; + throw new LLMError(`LLM extraction failed: ${e}`, { cause: e }); } // Parse response @@ -912,7 +980,7 @@ export class Memory { .filter((t) => t.length > 0); let embedMap: Record = {}; try { - const memEmbeddingsList = await this.embedder.embedBatch(memTexts); + const memEmbeddingsList = await this.embedder.embedBatch(memTexts, "add"); for (let i = 0; i < memTexts.length; i++) { embedMap[memTexts[i]] = memEmbeddingsList[i]; } @@ -920,7 +988,7 @@ export class Memory { // Fallback: embed individually for (const text of memTexts) { try { - embedMap[text] = await this.embedder.embed(text); + embedMap[text] = await this.embedder.embed(text, "add"); } catch (e) { console.warn(`Failed to embed memory text: ${e}`); } @@ -944,7 +1012,8 @@ export class Memory { for (const mem of extractedMemories) { const text = mem.text; - if (!text || !(text in embedMap)) continue; + if (!text || !Object.prototype.hasOwnProperty.call(embedMap, text)) + continue; const memHash = createHash("md5").update(text).digest("hex"); if (existingHashes.has(memHash) || seenHashes.has(memHash)) { @@ -1098,13 +1167,13 @@ export class Memory { // 7b: Single batch embed for all unique entities let entityEmbeddings: (number[] | null)[]; try { - entityEmbeddings = await this.embedder.embedBatch(entityTexts); + entityEmbeddings = await this.embedder.embedBatch(entityTexts, "add"); } catch { // Fallback: embed individually entityEmbeddings = []; for (const t of entityTexts) { try { - entityEmbeddings.push(await this.embedder.embed(t)); + entityEmbeddings.push(await this.embedder.embed(t, "add")); } catch { entityEmbeddings.push(null); } @@ -1241,20 +1310,8 @@ export class Memory { metadata: {}, }; - // Add additional metadata - const excludedKeys = new Set([ - "userId", - "agentId", - "runId", - "hash", - "data", - "createdAt", - "updatedAt", - "textLemmatized", - "attributedTo", - ]); for (const [key, value] of Object.entries(memory.payload)) { - if (!excludedKeys.has(key)) { + if (!PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) { memoryItem.metadata![key] = value; } } @@ -1368,7 +1425,7 @@ export class Memory { const queryEntities = extractEntities(query); // Step 2: Embed query - const queryEmbedding = await this.embedder.embed(query); + const queryEmbedding = await this.embedder.embed(query, "search"); // Step 3: Semantic search (over-fetch for scoring pool) const internalLimit = Math.max(topK * 4, 60); @@ -1433,7 +1490,10 @@ export class Memory { entitySearchFilters[k] = effectiveFilters[k]; } const entityTexts = deduped.map((e) => e.text); - const embeddings = await this.embedder.embedBatch(entityTexts); + const embeddings = await this.embedder.embedBatch( + entityTexts, + "search", + ); if (embeddings.length !== entityTexts.length) { console.warn( @@ -1507,18 +1567,6 @@ export class Memory { ); // Step 9: Format results - const excludedKeys = new Set([ - "user_id", - "agent_id", - "run_id", - "hash", - "data", - "createdAt", - "updatedAt", - "textLemmatized", - "attributedTo", - ]); - const results = scoredResults .filter((scored) => scored.payload?.data) .map((scored) => { @@ -1531,7 +1579,7 @@ export class Memory { updatedAt: payload.updatedAt, score: scored.score, metadata: Object.entries(payload) - .filter(([key]) => !excludedKeys.has(key)) + .filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), ...(payload.user_id && { user_id: payload.user_id }), ...(payload.agent_id && { agent_id: payload.agent_id }), @@ -1541,8 +1589,30 @@ export class Memory { }; }); + // Step 10: Optionally re-rank with the configured reranker. Opt-in per + // search via `rerank: true`; a no-op when no reranker is configured. + const invokeReranker = Boolean( + config.rerank && this.reranker && results.length > 0, + ); + let finalResults = results; + if (invokeReranker) { + try { + const ranked = await this.reranker!.rerank( + query, + results.map((r) => r.memory), + topK, + ); + finalResults = ranked.map((r) => ({ + ...results[r.index], + rerankScore: r.rerankScore, + })); + } catch (e) { + console.warn(`Reranking failed, using original results: ${e}`); + } + } + const result = { - results, + results: finalResults, }; const searchElapsedMs = Date.now() - searchStartMs; if (temporalUsageNotice) { @@ -1617,7 +1687,7 @@ export class Memory { const existingEmbeddings: Record = {}; if (text != null) { - existingEmbeddings[text] = await this.embedder.embed(text); + existingEmbeddings[text] = await this.embedder.embed(text, "update"); } await this.updateMemory(memoryId, text, existingEmbeddings, updateMetadata); @@ -1670,18 +1740,39 @@ export class Memory { ); } - const [memories] = await this.vectorStore.list(filters); - for (const memory of memories) { - await this.deleteMemory(memory.id); + let deletedCount = 0; + const seenBatches = new Set(); + + while (true) { + const [batch] = await this.vectorStore.list( + filters, + DELETE_ALL_BATCH_SIZE, + ); + if (!batch.length) { + break; + } + const batchKey = batch + .map((memory) => String(memory.id)) + .sort() + .join(","); + if (seenBatches.has(batchKey)) { + logger.warn("Stopping deleteAll after a repeated memory batch"); + break; + } + seenBatches.add(batchKey); + for (const memory of batch) { + await this.deleteMemory(memory.id); + } + deletedCount += batch.length; } const result = { message: "Memories deleted successfully!" }; - if (memories.length > 0) { + if (deletedCount > 0) { await this._displayDecayUsageNotice({ triggerFunction: "delete_all", triggerSource: "delete_all", triggerReason: "bulk_delete", - deletedCount: memories.length, + deletedCount, }); } else { await this._displayFirstRunNotice("delete_all"); @@ -1702,7 +1793,7 @@ export class Memory { await this.db.reset(); // Check provider before attempting deleteCol - if (this.config.vectorStore.provider.toLowerCase() !== "langchain") { + if (this.config.vectorStore.provider !== "langchain") { try { await this.vectorStore.deleteCol(); } catch (e) { @@ -1794,17 +1885,6 @@ export class Memory { ? memories : memories.filter((mem) => !payloadIsExpired(mem.payload)); - const excludedKeys = new Set([ - "user_id", - "agent_id", - "run_id", - "hash", - "data", - "createdAt", - "updatedAt", - "textLemmatized", - "attributedTo", - ]); const results = visibleMemories.slice(0, topK).map((mem) => ({ id: mem.id, memory: mem.payload.data, @@ -1812,7 +1892,7 @@ export class Memory { createdAt: mem.payload.createdAt, updatedAt: mem.payload.updatedAt, metadata: Object.entries(mem.payload) - .filter(([key]) => !excludedKeys.has(key)) + .filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), ...(mem.payload.user_id && { user_id: mem.payload.user_id }), ...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }), @@ -1841,8 +1921,12 @@ export class Memory { metadata: Record, ): Promise { const memoryId = uuidv4(); - const embedding = - existingEmbeddings[data] || (await this.embedder.embed(data)); + const embedding = Object.prototype.hasOwnProperty.call( + existingEmbeddings, + data, + ) + ? existingEmbeddings[data] + : await this.embedder.embed(data, "add"); const memoryMetadata = { ...metadata, @@ -1885,12 +1969,18 @@ export class Memory { } const textChanged = newData !== prevValue; - const embedding = - existingEmbeddings[newData] || (await this.embedder.embed(newData)); + const embedding = Object.prototype.hasOwnProperty.call( + existingEmbeddings, + newData, + ) + ? existingEmbeddings[newData] + : await this.embedder.embed(newData, "update"); + + const sanitizedMetadata = stripIdentityKeys(metadata); const newMetadata = { ...existingMemory.payload, - ...metadata, + ...sanitizedMetadata, data: newData, hash: createHash("md5").update(newData).digest("hex"), textLemmatized: lemmatizeForBm25(newData), diff --git a/mem0-ts/src/oss/src/memory/memory.types.ts b/mem0-ts/src/oss/src/memory/memory.types.ts index 861db25f7..33ca2df14 100644 --- a/mem0-ts/src/oss/src/memory/memory.types.ts +++ b/mem0-ts/src/oss/src/memory/memory.types.ts @@ -36,6 +36,11 @@ export interface SearchMemoryOptions { threshold?: number; explain?: boolean; referenceDate?: number | string | Date | null; + /** + * Re-rank the results with the configured reranker before returning. No-op + * when no `reranker` is configured on the Memory. + */ + rerank?: boolean; /** Include expired memories in the results. Defaults to false. */ showExpired?: boolean; } diff --git a/mem0-ts/src/oss/src/rerankers/base.ts b/mem0-ts/src/oss/src/rerankers/base.ts new file mode 100644 index 000000000..a785558bb --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/base.ts @@ -0,0 +1,21 @@ +export interface RerankResult { + /** Index into the input `documents` array. */ + index: number; + /** Relevance of the document to the query, 0..1, higher = more relevant. */ + rerankScore: number; +} + +export interface Reranker { + /** + * Rank `documents` by relevance to `query`. + * + * Returns results sorted by descending relevance. When `topK` is given, at + * most that many results are returned. Each result's `index` points back into + * the input `documents` array so callers can recover the original item. + */ + rerank( + query: string, + documents: string[], + topK?: number, + ): Promise; +} diff --git a/mem0-ts/src/oss/src/rerankers/cohere.test.ts b/mem0-ts/src/oss/src/rerankers/cohere.test.ts new file mode 100644 index 000000000..33389a1b9 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cohere.test.ts @@ -0,0 +1,140 @@ +const mockRerank = jest.fn(); + +jest.mock("cohere-ai", () => ({ + CohereClient: jest.fn().mockImplementation(() => ({ + rerank: mockRerank, + })), +})); + +import { CohereClient } from "cohere-ai"; +import { CohereReranker } from "./cohere"; + +describe("CohereReranker", () => { + beforeEach(() => { + mockRerank.mockReset(); + (CohereClient as unknown as jest.Mock).mockClear(); + }); + + it("throws when no API key is provided or configured", () => { + const originalEnv = process.env.COHERE_API_KEY; + delete process.env.COHERE_API_KEY; + + expect(() => new CohereReranker({})).toThrow(/Cohere API key is required/); + + if (originalEnv !== undefined) process.env.COHERE_API_KEY = originalEnv; + }); + + it("sends the query, documents, topN, and default model to Cohere", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ apiKey: "key" }); + + await reranker.rerank("capital of US?", ["a", "b", "c"], 2); + + expect(mockRerank).toHaveBeenCalledWith({ + model: "rerank-v3.5", + query: "capital of US?", + documents: ["a", "b", "c"], + topN: 2, + returnDocuments: false, + maxChunksPerDoc: undefined, + }); + }); + + it("defaults topN to documents.length when neither the call nor config sets a top_k", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ apiKey: "key" }); + + await reranker.rerank("q", ["a", "b", "c"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ topN: 3 }), + ); + }); + + it("forwards returnDocuments and maxChunksPerDoc from config", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ + apiKey: "key", + returnDocuments: true, + maxChunksPerDoc: 5, + }); + + await reranker.rerank("q", ["a"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ returnDocuments: true, maxChunksPerDoc: 5 }), + ); + }); + + it("returns Cohere's ranked results as {index, rerankScore}", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 2, relevanceScore: 0.9 }, + { index: 0, relevanceScore: 0.31 }, + ], + }); + const reranker = new CohereReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["x", "y", "z"]); + + expect(results).toEqual([ + { index: 2, rerankScore: 0.9 }, + { index: 0, rerankScore: 0.31 }, + ]); + }); + + it("uses a custom model when provided", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ + apiKey: "key", + model: "rerank-v4.0-pro", + }); + + await reranker.rerank("q", ["a"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ model: "rerank-v4.0-pro" }), + ); + }); + + it("returns an empty array without calling Cohere when there are no documents", async () => { + const reranker = new CohereReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", []); + + expect(results).toEqual([]); + expect(mockRerank).not.toHaveBeenCalled(); + }); + + it("falls back to the original order with rerankScore 0.0 when the Cohere API call fails", async () => { + mockRerank.mockRejectedValue(new Error("cohere is down")); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CohereReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + { index: 2, rerankScore: 0.0 }, + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("slices the fallback results by topK when the Cohere API call fails", async () => { + mockRerank.mockRejectedValue(new Error("cohere is down")); + jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CohereReranker({ apiKey: "key", topK: 2 }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + ]); + + (console.warn as jest.Mock).mockRestore(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/cohere.ts b/mem0-ts/src/oss/src/rerankers/cohere.ts new file mode 100644 index 000000000..e58a4cae0 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cohere.ts @@ -0,0 +1,87 @@ +import { RerankerConfig } from "../types"; +import { Reranker, RerankResult } from "./base"; +import { loadPeer } from "../utils/load_peer"; + +const DEFAULT_MODEL = "rerank-v3.5"; + +export class CohereReranker implements Reranker { + private clientInstance?: any; + private clientPromise?: Promise; + private readonly apiKey: string; + private model: string; + private topK?: number; + private returnDocuments: boolean; + private maxChunksPerDoc?: number; + + constructor(config: RerankerConfig) { + const apiKey = config.apiKey || process.env.COHERE_API_KEY; + if (!apiKey) { + throw new Error( + "Cohere API key is required. Set COHERE_API_KEY environment variable or pass apiKey in config.", + ); + } + this.apiKey = apiKey; + this.model = config.model || DEFAULT_MODEL; + this.topK = config.topK; + this.returnDocuments = config.returnDocuments ?? false; + this.maxChunksPerDoc = config.maxChunksPerDoc; + } + + /** + * Lazily construct (or reuse) the Cohere client, importing the optional + * `cohere-ai` peer only when the reranker is first used so consumers that + * never touch Cohere don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + const sdk = await loadPeer( + "cohere-ai", + "Cohere reranker", + () => import("cohere-ai"), + ); + return new sdk.CohereClient({ token: this.apiKey }); + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + try { + const client = await this.getClient(); + const response = await client.rerank({ + model: this.model, + query, + documents, + topN: topK || this.topK || documents.length, + returnDocuments: this.returnDocuments, + maxChunksPerDoc: this.maxChunksPerDoc, + }); + + return response.results.map((result: any) => ({ + index: result.index, + rerankScore: result.relevanceScore, + })); + } catch (e) { + console.warn( + `Cohere reranking failed, falling back to original order: ${e}`, + ); + const scored = documents.map((_, index) => ({ + index, + rerankScore: 0.0, + })); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + } +} diff --git a/mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts b/mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts new file mode 100644 index 000000000..552607c15 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts @@ -0,0 +1,189 @@ +const mockModelFromPretrained = jest.fn(); +const mockTokenizerFromPretrained = jest.fn(); + +jest.mock("@huggingface/transformers", () => ({ + AutoModelForSequenceClassification: { + from_pretrained: mockModelFromPretrained, + }, + AutoTokenizer: { from_pretrained: mockTokenizerFromPretrained }, +})); + +import { CrossEncoderReranker } from "./cross_encoder"; + +const sigmoid = (x: number) => 1 / (1 + Math.exp(-x)); + +/** Wire the mocked tokenizer + model so the model returns `logits` for a call. */ +function setupModel(logits: number[][]) { + const tokenizer = jest.fn().mockReturnValue({ input_ids: [] }); + mockTokenizerFromPretrained.mockResolvedValue(tokenizer); + const model = jest + .fn() + .mockResolvedValue({ logits: { tolist: () => logits } }); + mockModelFromPretrained.mockResolvedValue(model); + return { tokenizer, model }; +} + +describe("CrossEncoderReranker", () => { + beforeEach(() => { + mockModelFromPretrained.mockReset(); + mockTokenizerFromPretrained.mockReset(); + }); + + it("scores each document and returns them sorted by relevance, sigmoid-normalized to [0,1]", async () => { + setupModel([[0.0], [2.0], [-1.0]]); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + // sigmoid: b(2.0)=0.88 > a(0.0)=0.5 > c(-1.0)=0.27 + expect(results.map((r) => r.index)).toEqual([1, 0, 2]); + expect(results[0].rerankScore).toBeCloseTo(sigmoid(2.0), 5); + expect(results[1].rerankScore).toBeCloseTo(sigmoid(0.0), 5); + expect(results[2].rerankScore).toBeCloseTo(sigmoid(-1.0), 5); + }); + + it("pairs the query with each document via text_pair when tokenizing", async () => { + const { tokenizer } = setupModel([[0.1], [0.2]]); + const reranker = new CrossEncoderReranker( + { maxLength: 128 }, + "default-model", + ); + + await reranker.rerank("what is x", ["doc one", "doc two"]); + + expect(tokenizer).toHaveBeenCalledWith( + ["what is x", "what is x"], + expect.objectContaining({ + text_pair: ["doc one", "doc two"], + padding: true, + truncation: true, + max_length: 128, + }), + ); + }); + + it("applies the topK limit", async () => { + setupModel([[0.0], [2.0], [-1.0]]); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"], 2); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.index)).toEqual([1, 0]); + }); + + it("falls back to config.topK when the rerank() call omits one", async () => { + setupModel([[0.0], [2.0], [-1.0]]); + const reranker = new CrossEncoderReranker({ topK: 1 }, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toHaveLength(1); + expect(results.map((r) => r.index)).toEqual([1]); + }); + + it("returns [] without loading the model when there are no documents", async () => { + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", []); + + expect(results).toEqual([]); + expect(mockModelFromPretrained).not.toHaveBeenCalled(); + expect(mockTokenizerFromPretrained).not.toHaveBeenCalled(); + }); + + it("returns raw logits as scores when normalize is false", async () => { + setupModel([[2.0], [0.0]]); + const reranker = new CrossEncoderReranker( + { normalize: false }, + "default-model", + ); + + const results = await reranker.rerank("q", ["a", "b"]); + + expect(results.map((r) => r.index)).toEqual([0, 1]); + expect(results[0].rerankScore).toBe(2.0); + expect(results[1].rerankScore).toBe(0.0); + }); + + it("loads the model and tokenizer only once across multiple rerank calls", async () => { + setupModel([[0.5]]); + const reranker = new CrossEncoderReranker({}, "default-model"); + + await reranker.rerank("q", ["a"]); + await reranker.rerank("q2", ["b"]); + + expect(mockModelFromPretrained).toHaveBeenCalledTimes(1); + expect(mockTokenizerFromPretrained).toHaveBeenCalledTimes(1); + }); + + it("loads the default model, or the configured model when provided", async () => { + setupModel([[0.5]]); + + await new CrossEncoderReranker({}, "the-default").rerank("q", ["a"]); + expect(mockModelFromPretrained).toHaveBeenCalledWith( + "the-default", + expect.any(Object), + ); + + mockModelFromPretrained.mockClear(); + setupModel([[0.5]]); + await new CrossEncoderReranker( + { model: "custom/model" }, + "the-default", + ).rerank("q", ["a"]); + expect(mockModelFromPretrained).toHaveBeenCalledWith( + "custom/model", + expect.any(Object), + ); + }); + + it("applies a default maxLength (as the huggingface provider passes 512) when config omits one", async () => { + const { tokenizer } = setupModel([[0.5]]); + const reranker = new CrossEncoderReranker({}, "default-model", 512); + + await reranker.rerank("q", ["a"]); + + expect(tokenizer).toHaveBeenCalledWith( + ["q"], + expect.objectContaining({ max_length: 512 }), + ); + }); + + it("falls back to the original order with rerankScore 0.0 when the model fails to load", async () => { + mockModelFromPretrained.mockResolvedValue(jest.fn()); + mockTokenizerFromPretrained.mockRejectedValue( + new Error("model download failed"), + ); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + { index: 2, rerankScore: 0.0 }, + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("falls back to the original order with rerankScore 0.0, sliced by topK, when scoring fails", async () => { + const tokenizer = jest.fn().mockReturnValue({ input_ids: [] }); + mockTokenizerFromPretrained.mockResolvedValue(tokenizer); + mockModelFromPretrained.mockResolvedValue( + jest.fn().mockRejectedValue(new Error("forward pass failed")), + ); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b"], 1); + + expect(results).toEqual([{ index: 0, rerankScore: 0.0 }]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/cross_encoder.ts b/mem0-ts/src/oss/src/rerankers/cross_encoder.ts new file mode 100644 index 000000000..095455f11 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cross_encoder.ts @@ -0,0 +1,98 @@ +import { RerankerConfig } from "../types"; +import { Reranker, RerankResult } from "./base"; + +const sigmoid = (x: number) => 1 / (1 + Math.exp(-x)); + +export class CrossEncoderReranker implements Reranker { + private modelId: string; + private device?: string; + private maxLength?: number; + private normalize: boolean; + private topK?: number; + // ponytail: batchSize/showProgressBar are accepted for config parity with the + // Python SDK but are no-ops here β€” a memory search reranks a small candidate + // set in a single forward pass. Chunk by batchSize if that ever grows. + private loaded?: Promise<{ model: any; tokenizer: any }>; + + constructor( + config: RerankerConfig, + defaultModel: string, + defaultMaxLength?: number, + ) { + this.modelId = config.model || defaultModel; + this.device = config.device; + this.maxLength = config.maxLength ?? defaultMaxLength; + this.normalize = config.normalize ?? true; + this.topK = config.topK; + } + + private load() { + if (!this.loaded) { + this.loaded = (async () => { + // Lazy-load Transformers.js (and its onnxruntime native binding) only + // when a rerank actually runs. A static import would pull onnxruntime + // into every `new Memory()`, colliding on Linux with fastembed's + // separate onnxruntime version β€” see the merge with the FastEmbed + // embedder. Deferring it keeps memory construction free of ONNX. + const { AutoModelForSequenceClassification, AutoTokenizer } = + await import("@huggingface/transformers"); + const options: any = {}; + if (this.device) options.device = this.device; + const model = await AutoModelForSequenceClassification.from_pretrained( + this.modelId, + options, + ); + const tokenizer = await AutoTokenizer.from_pretrained(this.modelId); + return { model, tokenizer }; + })(); + } + return this.loaded; + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + try { + const { model, tokenizer } = await this.load(); + + const inputs = tokenizer( + documents.map(() => query), + { + text_pair: documents, + padding: true, + truncation: true, + ...(this.maxLength ? { max_length: this.maxLength } : {}), + }, + ); + + const { logits } = await model(inputs); + const rows: unknown[] = logits.tolist(); + + const scored = rows.map((row, index) => { + const logit = Array.isArray(row) ? (row[0] as number) : (row as number); + return { + index, + rerankScore: this.normalize ? sigmoid(logit) : logit, + }; + }); + + scored.sort((a, b) => b.rerankScore - a.rerankScore); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } catch (e) { + console.warn( + `Cross-encoder reranking failed, falling back to original order: ${e}`, + ); + const scored = documents.map((_, index) => ({ + index, + rerankScore: 0.0, + })); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + } +} diff --git a/mem0-ts/src/oss/src/rerankers/llm.test.ts b/mem0-ts/src/oss/src/rerankers/llm.test.ts new file mode 100644 index 000000000..0b953d260 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/llm.test.ts @@ -0,0 +1,174 @@ +import { LLM } from "../llms/base"; +import { LLMReranker } from "./llm"; + +// Duplicated rather than imported from ./llm.ts so this test catches drift. +const EXPECTED_SYSTEM_PROMPT = `You are a relevance scoring assistant. Given a query and a document, score how relevant the document is to the query. + +Score the relevance on a scale from 0.0 to 1.0, where: +- 1.0 = Perfectly relevant and directly answers the query +- 0.8-0.9 = Highly relevant with good information +- 0.6-0.7 = Moderately relevant with some useful information +- 0.4-0.5 = Slightly relevant with limited useful information +- 0.0-0.3 = Not relevant or no useful information + +Respond with only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text.`; + +/** + * Fake LLM that scores a document by looking up the document text inside the + * prompt. Test document tokens must be distinct and must not be substrings of + * the prompt boilerplate (e.g. avoid "a"/"b"), or the lookup resolves the + * wrong doc. + */ +function makeLLM(scoreByDoc: Record): LLM { + return { + generateResponse: async ( + messages: Array<{ role: string; content: string }>, + ) => { + const prompt = messages.map((m) => m.content).join("\n"); + const doc = Object.keys(scoreByDoc).find((d) => prompt.includes(d)); + return doc ? scoreByDoc[doc] : "no number here"; + }, + generateChat: async () => ({ content: "", role: "assistant" }), + }; +} + +describe("LLMReranker", () => { + it("sorts documents by descending relevance score", async () => { + const llm = makeLLM({ cats: "0.2", dogs: "0.9", fish: "0.5" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("pets", ["cats", "dogs", "fish"]); + + expect(results.map((r) => r.index)).toEqual([1, 2, 0]); + expect(results.map((r) => r.rerankScore)).toEqual([0.9, 0.5, 0.2]); + }); + + it("clamps scores to the [0, 1] range", async () => { + const llm = makeLLM({ zebra: "1.5", walrus: "-0.3" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["zebra", "walrus"]); + + const byIndex = new Map(results.map((r) => [r.index, r.rerankScore])); + expect(byIndex.get(0)).toBe(1); // "zebra" 1.5 -> clamped to 1 + expect(byIndex.get(1)).toBe(0); // "walrus" -0.3 -> clamped to 0 + }); + + it("truncates results to topK", async () => { + const llm = makeLLM({ alpha: "0.1", bravo: "0.8", charlie: "0.5" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank( + "q", + ["alpha", "bravo", "charlie"], + 2, + ); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.index)).toEqual([1, 2]); // bravo(0.8), charlie(0.5) + }); + + it("falls back to config.topK when the rerank() call omits one", async () => { + const llm = makeLLM({ alpha: "0.1", bravo: "0.8", charlie: "0.5" }); + const reranker = new LLMReranker({ topK: 1 }, llm); + + const results = await reranker.rerank("q", ["alpha", "bravo", "charlie"]); + + expect(results).toHaveLength(1); + expect(results[0].index).toBe(1); // bravo(0.8) + }); + + it("falls back to a neutral score of 0.5 (not 0) when the LLM output has no number", async () => { + const llm = makeLLM({ junk: "I cannot rate this" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["junk"]); + + expect(results[0].rerankScore).toBe(0.5); + }); + + it("prefers a decimal match over an integer match when extracting the score", async () => { + const llm = makeLLM({ item: "The score is 0.73 out of 1" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["item"]); + + expect(results[0].rerankScore).toBe(0.73); + }); + + it("falls back to an integer match when no decimal is present", async () => { + const llm = makeLLM({ item: "I'd say this is a solid 1" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["item"]); + + expect(results[0].rerankScore).toBe(1); + }); + + it("assigns a neutral 0.5 score (not 0.0) when a per-document LLM call fails, and still returns that document", async () => { + const llm: LLM = { + generateResponse: jest + .fn() + .mockResolvedValueOnce("0.9") // scores "good" + .mockRejectedValueOnce(new Error("rate limited")), // scores "bad" + generateChat: async () => ({ content: "", role: "assistant" }), + }; + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["good", "bad"]); + + expect(results).toHaveLength(2); + const byIndex = new Map(results.map((r) => [r.index, r.rerankScore])); + expect(byIndex.get(0)).toBe(0.9); + expect(byIndex.get(1)).toBe(0.5); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("sends the exact system prompt and a separate user message with the query and document", async () => { + const generateResponse = jest.fn().mockResolvedValue("0.5"); + const llm: LLM = { + generateResponse, + generateChat: async () => ({ content: "", role: "assistant" }), + }; + const reranker = new LLMReranker({}, llm); + + await reranker.rerank("what is the capital?", [ + "Paris is the capital of France.", + ]); + + expect(generateResponse).toHaveBeenCalledWith([ + { role: "system", content: EXPECTED_SYSTEM_PROMPT }, + { + role: "user", + content: + "Query: what is the capital?\n\nDocument: Paris is the capital of France.", + }, + ]); + }); + + it("truncates the query and document to 4000 characters before sending", async () => { + const generateResponse = jest.fn().mockResolvedValue("0.5"); + const llm: LLM = { + generateResponse, + generateChat: async () => ({ content: "", role: "assistant" }), + }; + const reranker = new LLMReranker({}, llm); + const longQuery = "q".repeat(5000); + const longDoc = "d".repeat(5000); + + await reranker.rerank(longQuery, [longDoc]); + + const userMessage = generateResponse.mock.calls[0][0][1]; + const sentQuery = userMessage.content.match(/^Query: (q+)/)[1]; + const sentDoc = userMessage.content.match(/Document: (d+)/)[1]; + expect(sentQuery).toHaveLength(4000); + expect(sentDoc).toHaveLength(4000); + }); + + it("throws when no LLM is provided", () => { + expect(() => new LLMReranker({}, undefined as unknown as LLM)).toThrow(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/llm.ts b/mem0-ts/src/oss/src/rerankers/llm.ts new file mode 100644 index 000000000..e2285705b --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/llm.ts @@ -0,0 +1,87 @@ +import { RerankerConfig } from "../types"; +import { LLM, LLMResponse } from "../llms/base"; +import { Reranker, RerankResult } from "./base"; + +const SYSTEM_PROMPT = `You are a relevance scoring assistant. Given a query and a document, score how relevant the document is to the query. + +Score the relevance on a scale from 0.0 to 1.0, where: +- 1.0 = Perfectly relevant and directly answers the query +- 0.8-0.9 = Highly relevant with good information +- 0.6-0.7 = Moderately relevant with some useful information +- 0.4-0.5 = Slightly relevant with limited useful information +- 0.0-0.3 = Not relevant or no useful information + +Respond with only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text.`; + +const MAX_INPUT_LEN = 4000; + +export class LLMReranker implements Reranker { + private llm: LLM; + private topK?: number; + + constructor(config: RerankerConfig, llm: LLM) { + if (!llm) { + throw new Error( + "LLMReranker requires an LLM instance; RerankerFactory should always provide one for the llm_reranker provider.", + ); + } + this.llm = llm; + this.topK = config.topK; + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + const scored = await Promise.all( + documents.map(async (document, index) => { + try { + const rerankScore = await this.score(query, document); + return { index, rerankScore }; + } catch (e) { + console.warn( + `LLM reranking failed for a document, assigning neutral score: ${e}`, + ); + return { index, rerankScore: 0.5 }; + } + }), + ); + + scored.sort((a, b) => b.rerankScore - a.rerankScore); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + + private async score(query: string, document: string): Promise { + const safeQuery = query.slice(0, MAX_INPUT_LEN); + const safeDoc = document.slice(0, MAX_INPUT_LEN); + const userMessage = `Query: ${safeQuery}\n\nDocument: ${safeDoc}`; + + const response = await this.llm.generateResponse([ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ]); + + const text = + typeof response === "string" + ? response + : ((response as LLMResponse)?.content ?? ""); + + return this.extractScore(text); + } + + private extractScore(responseText: string): number { + const matches = + responseText.match(/-?\d+\.\d+/g) || responseText.match(/-?\d+/g); + + if (matches && matches.length > 0) { + const score = parseFloat(matches[0]); + return Math.min(Math.max(score, 0.0), 1.0); + } + + return 0.5; + } +} diff --git a/mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts b/mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts new file mode 100644 index 000000000..a18202373 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts @@ -0,0 +1,133 @@ +const mockRerank = jest.fn(); + +jest.mock("zeroentropy", () => ({ + ZeroEntropy: jest.fn().mockImplementation(() => ({ + models: { rerank: mockRerank }, + })), +})); + +import { ZeroEntropy } from "zeroentropy"; +import { ZeroEntropyReranker } from "./zeroentropy"; + +describe("ZeroEntropyReranker", () => { + beforeEach(() => { + mockRerank.mockReset(); + (ZeroEntropy as unknown as jest.Mock).mockClear(); + }); + + it("throws when no API key is provided or configured", () => { + const originalEnv = process.env.ZERO_ENTROPY_API_KEY; + delete process.env.ZERO_ENTROPY_API_KEY; + + expect(() => new ZeroEntropyReranker({})).toThrow( + /Zero Entropy API key is required/, + ); + + if (originalEnv !== undefined) + process.env.ZERO_ENTROPY_API_KEY = originalEnv; + }); + + it("sends the query, documents, and default model to ZeroEntropy without a top_n parameter", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + await reranker.rerank("capital of US?", ["a", "b", "c"], 2); + + expect(mockRerank).toHaveBeenCalledWith({ + model: "zerank-1", + query: "capital of US?", + documents: ["a", "b", "c"], + }); + }); + + it("maps ZeroEntropy's results (relevance_score) to {index, rerankScore}", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 2, relevance_score: 0.9 }, + { index: 0, relevance_score: 0.31 }, + ], + }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["x", "y", "z"]); + + expect(results).toEqual([ + { index: 2, rerankScore: 0.9 }, + { index: 0, rerankScore: 0.31 }, + ]); + }); + + it("sorts unsorted API results by descending relevance score client-side", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 0, relevance_score: 0.2 }, + { index: 1, relevance_score: 0.9 }, + { index: 2, relevance_score: 0.5 }, + ], + }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results.map((r) => r.index)).toEqual([1, 2, 0]); + expect(results.map((r) => r.rerankScore)).toEqual([0.9, 0.5, 0.2]); + }); + + it("slices to topK client-side after sorting", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 0, relevance_score: 0.2 }, + { index: 1, relevance_score: 0.9 }, + { index: 2, relevance_score: 0.5 }, + ], + }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"], 2); + + expect(results).toEqual([ + { index: 1, rerankScore: 0.9 }, + { index: 2, rerankScore: 0.5 }, + ]); + }); + + it("uses a custom model when provided", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new ZeroEntropyReranker({ + apiKey: "key", + model: "zerank-1-small", + }); + + await reranker.rerank("q", ["a"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ model: "zerank-1-small" }), + ); + }); + + it("returns an empty array without calling ZeroEntropy when there are no documents", async () => { + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", []); + + expect(results).toEqual([]); + expect(mockRerank).not.toHaveBeenCalled(); + }); + + it("falls back to the original order with rerankScore 0.0 when the API call fails", async () => { + mockRerank.mockRejectedValue(new Error("zero entropy is down")); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + { index: 2, rerankScore: 0.0 }, + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/zeroentropy.ts b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts new file mode 100644 index 000000000..8f6ccee59 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts @@ -0,0 +1,84 @@ +import { RerankerConfig } from "../types"; +import { Reranker, RerankResult } from "./base"; +import { loadPeer } from "../utils/load_peer"; + +const DEFAULT_MODEL = "zerank-1"; + +export class ZeroEntropyReranker implements Reranker { + private clientInstance?: any; + private clientPromise?: Promise; + private readonly apiKey: string; + private model: string; + private topK?: number; + + constructor(config: RerankerConfig) { + const apiKey = config.apiKey || process.env.ZERO_ENTROPY_API_KEY; + if (!apiKey) { + throw new Error( + "Zero Entropy API key is required. Set ZERO_ENTROPY_API_KEY environment variable or pass apiKey in config.", + ); + } + this.apiKey = apiKey; + this.model = config.model || DEFAULT_MODEL; + this.topK = config.topK; + } + + /** + * Lazily construct (or reuse) the ZeroEntropy client, importing the + * optional `zeroentropy` peer only when the reranker is first used so + * consumers that never touch ZeroEntropy don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + const sdk = await loadPeer( + "zeroentropy", + "ZeroEntropy reranker", + () => import("zeroentropy"), + ); + return new sdk.ZeroEntropy({ apiKey: this.apiKey }); + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + try { + const client = await this.getClient(); + const response = await client.models.rerank({ + model: this.model, + query, + documents, + }); + + const scored: RerankResult[] = response.results.map((result: any) => ({ + index: result.index, + rerankScore: result.relevance_score, + })); + scored.sort((a, b) => b.rerankScore - a.rerankScore); + + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } catch (e) { + console.warn( + `Zero Entropy reranking failed, falling back to original order: ${e}`, + ); + const scored = documents.map((_, index) => ({ + index, + rerankScore: 0.0, + })); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + } +} diff --git a/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts b/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts index d8cf0e4c8..2b09a1e5f 100644 --- a/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts +++ b/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts @@ -1,6 +1,7 @@ -import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { v4 as uuidv4 } from "uuid"; import { HistoryManager } from "./base"; +import { loadPeer } from "../utils/load_peer"; interface HistoryEntry { id: string; @@ -20,16 +21,32 @@ interface SupabaseHistoryConfig { } export class SupabaseHistoryManager implements HistoryManager { - private supabase: SupabaseClient; + // ponytail: benign double-construct race β€” two concurrent first-calls may each + // build a client; createClient opens no connection, so last-write-wins is fine. + private supabase!: SupabaseClient; + private readonly supabaseUrl: string; + private readonly supabaseKey: string; private readonly tableName: string; constructor(config: SupabaseHistoryConfig) { this.tableName = config.tableName || "memory_history"; - this.supabase = createClient(config.supabaseUrl, config.supabaseKey); + this.supabaseUrl = config.supabaseUrl; + this.supabaseKey = config.supabaseKey; this.initializeSupabase().catch(console.error); } + private async ensureClient(): Promise { + if (this.supabase) return; + const sdk = await loadPeer( + "@supabase/supabase-js", + "Supabase history manager", + () => import("@supabase/supabase-js"), + ); + this.supabase = sdk.createClient(this.supabaseUrl, this.supabaseKey); + } + private async initializeSupabase(): Promise { + await this.ensureClient(); // Check if table exists const { error } = await this.supabase .from(this.tableName) @@ -65,6 +82,7 @@ create table ${this.tableName} ( updatedAt?: string, isDeleted: number = 0, ): Promise { + await this.ensureClient(); const historyEntry: HistoryEntry = { id: uuidv4(), memory_id: memoryId, @@ -87,6 +105,7 @@ create table ${this.tableName} ( } async getHistory(memoryId: string): Promise { + await this.ensureClient(); const { data, error } = await this.supabase .from(this.tableName) .select("*") @@ -103,6 +122,7 @@ create table ${this.tableName} ( } async reset(): Promise { + await this.ensureClient(); const { error } = await this.supabase .from(this.tableName) .delete() diff --git a/mem0-ts/src/oss/src/tests/aws_bedrock.test.ts b/mem0-ts/src/oss/src/tests/aws_bedrock.test.ts new file mode 100644 index 000000000..7ddb48caf --- /dev/null +++ b/mem0-ts/src/oss/src/tests/aws_bedrock.test.ts @@ -0,0 +1,259 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { AWSBedrockLLM, extractProvider } from "../llms/aws_bedrock"; + +/** + * Fake BedrockRuntimeClient capturing the Converse command input and returning + * a scripted response β€” exercises request shaping + response parsing without + * the AWS SDK or live credentials. + */ +class FakeBedrockClient { + public lastInput: any = null; + public response: any; + constructor(response: any) { + this.response = response; + } + async send(command: any) { + // ConverseCommand stores its input on `.input` (mirrored by our fake command). + this.lastInput = command?.input ?? command; + return this.response; + } +} + +// Counts real client constructions so we can prove the SDK stays untouched +// until the first request. (jest only lets mock factories close over `mock*`.) +let mockClientConstructions = 0; + +// The provider loads the AWS SDK on first use via dynamic import(). Mock the +// module so it resolves and ConverseCommand simply wraps its input. +jest.mock( + "@aws-sdk/client-bedrock-runtime", + () => ({ + BedrockRuntimeClient: class { + constructor(public config: any) { + mockClientConstructions++; + } + }, + ConverseCommand: class { + input: any; + constructor(input: any) { + this.input = input; + } + }, + }), + { virtual: true }, +); + +function makeLLM(client: FakeBedrockClient, overrides: any = {}) { + return new AWSBedrockLLM({ + model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + client, + ...overrides, + }); +} + +describe("extractProvider", () => { + it("detects the model family from the Bedrock model id", () => { + expect(extractProvider("anthropic.claude-3-5-sonnet-20240620-v1:0")).toBe( + "anthropic", + ); + expect(extractProvider("amazon.nova-pro-v1:0")).toBe("amazon"); + expect(extractProvider("meta.llama3-70b-instruct-v1:0")).toBe("meta"); + expect(extractProvider("mistral.mistral-large-2407-v1:0")).toBe("mistral"); + }); + + it("throws on an unknown provider", () => { + expect(() => extractProvider("totally-unknown-model")).toThrow( + /Unknown provider/, + ); + }); + + const ARN = + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"; + + it("throws for an application inference profile ARN without providerOverride", () => { + expect(() => extractProvider(ARN)).toThrow(/Unknown provider in model/); + }); + + it("resolves an application inference profile ARN via providerOverride", () => { + expect(extractProvider(ARN, "anthropic")).toBe("anthropic"); + }); + + it("lets providerOverride take precedence over regex detection", () => { + expect( + extractProvider("anthropic.claude-3-5-sonnet-20240620-v1:0", "amazon"), + ).toBe("amazon"); + }); + + it("throws on a misspelled providerOverride", () => { + expect(() => extractProvider(ARN, "anthorpic")).toThrow( + /Unknown providerOverride 'anthorpic'/, + ); + }); +}); + +describe("AWSBedrockLLM", () => { + const textResponse = { + output: { message: { content: [{ text: "hello from bedrock" }] } }, + }; + + it("returns assistant text from a Converse response", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + const out = await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(out).toBe("hello from bedrock"); + }); + + it("lifts system messages into the top-level system block", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + await llm.generateResponse([ + { role: "system", content: "be terse" }, + { role: "user", content: "hi" }, + ]); + expect(client.lastInput.system).toEqual([{ text: "be terse" }]); + expect(client.lastInput.messages).toEqual([ + { role: "user", content: [{ text: "hi" }] }, + ]); + }); + + it("omits topP for anthropic models even when configured", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client, { topP: 0.9, temperature: 0.2 }); + await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(client.lastInput.inferenceConfig.topP).toBeUndefined(); + expect(client.lastInput.inferenceConfig.temperature).toBe(0.2); + }); + + it("includes topP for non-anthropic models", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client, { + model: "meta.llama3-70b-instruct-v1:0", + topP: 0.9, + }); + await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(client.lastInput.inferenceConfig.topP).toBe(0.9); + }); + + it("converts OpenAI-style tools to Converse toolConfig and parses toolUse", async () => { + const toolResponse = { + output: { + message: { + content: [{ toolUse: { name: "add_memory", input: { text: "x" } } }], + }, + }, + }; + const client = new FakeBedrockClient(toolResponse); + const llm = makeLLM(client); + const tools = [ + { + type: "function", + function: { + name: "add_memory", + description: "store a memory", + parameters: { type: "object", properties: {} }, + }, + }, + ]; + const out = await llm.generateResponse( + [{ role: "user", content: "remember x" }], + undefined, + tools, + ); + expect(client.lastInput.toolConfig.tools[0].toolSpec.name).toBe( + "add_memory", + ); + expect(typeof out).toBe("object"); + expect((out as any).toolCalls[0]).toEqual({ + name: "add_memory", + arguments: JSON.stringify({ text: "x" }), + }); + }); + + it("never sends an empty messages array", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + await llm.generateResponse([{ role: "system", content: "only system" }]); + expect(client.lastInput.messages).toEqual([ + { role: "user", content: [{ text: "" }] }, + ]); + }); + + it("wraps SDK errors with a provider-tagged message", async () => { + const client = { + send: async () => { + throw new Error("AccessDeniedException"); + }, + } as any; + const llm = makeLLM(client); + await expect( + llm.generateResponse([{ role: "user", content: "hi" }]), + ).rejects.toThrow(/AWS Bedrock LLM failed: AccessDeniedException/); + }); + + it("generateChat returns text content with assistant role", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + const res = await llm.generateChat([{ role: "user", content: "hi" }]); + expect(res).toEqual({ content: "hello from bedrock", role: "assistant" }); + }); + + it("uses providerOverride to resolve inference profile ARNs (topP omitted like anthropic)", async () => { + const arn = + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"; + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client, { + model: arn, + providerOverride: "anthropic", + topP: 0.9, + }); + await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(client.lastInput.modelId).toBe(arn); + expect(client.lastInput.inferenceConfig.topP).toBeUndefined(); + }); + + it("throws when constructed with an inference profile ARN and no providerOverride", () => { + const arn = + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"; + expect(() => new AWSBedrockLLM({ model: arn })).toThrow( + /Unknown provider in model/, + ); + }); + + it("does not construct the Bedrock client until the first request", () => { + const before = mockClientConstructions; + // No injected client: the old constructor eagerly built a real one. + new AWSBedrockLLM({ + model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + awsRegion: "us-west-2", + }); + expect(mockClientConstructions).toBe(before); + }); +}); + +/** + * ts-jest downlevels this suite to CommonJS, where `require()` works fine β€” so + * no runtime test here can observe the ESM failure. Guard the source invariant + * that causes it instead: esbuild rewrites `require()` in the published ESM + * bundle (dist/oss/index.mjs) into a `__require` shim that throws + * `Dynamic require of "..." is not supported`, stranding every ESM consumer. + */ +describe("aws_bedrock.ts ESM safety", () => { + const source = readFileSync( + join(__dirname, "..", "llms", "aws_bedrock.ts"), + "utf8", + ); + // Comments legitimately mention require(); only real code should be checked. + const code = source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + + it("never calls require() β€” the ESM bundle turns it into a throwing shim", () => { + expect(code).not.toMatch(/\brequire\s*\(/); + }); + + it("loads the optional AWS SDK through a dynamic import()", () => { + expect(code).toMatch(/import\("@aws-sdk\/client-bedrock-runtime"\)/); + }); +}); diff --git a/mem0-ts/src/oss/src/tests/chroma.test.ts b/mem0-ts/src/oss/src/tests/chroma.test.ts index 65874d402..3b51e7eef 100644 --- a/mem0-ts/src/oss/src/tests/chroma.test.ts +++ b/mem0-ts/src/oss/src/tests/chroma.test.ts @@ -1,60 +1,42 @@ -// jest.mock is hoisted above variable declarations, so shared mock functions -// are attached to the module-level `__mocks__` object populated inside the -// factory. The hoisted mock reaches them through this stable reference. +// The provider imports `chromadb` lazily (await import) only on first use, so +// the jest.mock factory no longer runs at module-eval time. Create the shared +// mock handles at module top-level (not inside the factory) so `beforeEach` can +// reach them before the lazy import has fired; the factory, which runs on first +// use, just returns references to them. +const add = jest.fn().mockResolvedValue(undefined); +const query = jest + .fn() + .mockResolvedValue({ ids: [[]], distances: [[]], metadatas: [[]] }); +const get = jest.fn().mockResolvedValue({ ids: [], metadatas: [] }); +const update = jest.fn().mockResolvedValue(undefined); +const upsert = jest.fn().mockResolvedValue(undefined); +const deleteFn = jest.fn().mockResolvedValue(undefined); -const __mocks__: { - add: jest.Mock; - query: jest.Mock; - get: jest.Mock; - update: jest.Mock; - upsert: jest.Mock; - deleteFn: jest.Mock; - getOrCreateCollection: jest.Mock; - deleteCollection: jest.Mock; - ChromaClient: jest.Mock; - CloudClient: jest.Mock; -} = {} as any; +const collectionHandle = { add, query, get, update, upsert, delete: deleteFn }; +const getOrCreateCollection = jest.fn().mockResolvedValue(collectionHandle); +const deleteCollection = jest.fn().mockResolvedValue(undefined); -jest.mock("chromadb", () => { - const add = jest.fn().mockResolvedValue(undefined); - const query = jest - .fn() - .mockResolvedValue({ ids: [[]], distances: [[]], metadatas: [[]] }); - const get = jest.fn().mockResolvedValue({ ids: [], metadatas: [] }); - const update = jest.fn().mockResolvedValue(undefined); - const upsert = jest.fn().mockResolvedValue(undefined); - const deleteFn = jest.fn().mockResolvedValue(undefined); +const clientImpl = () => ({ getOrCreateCollection, deleteCollection }); +const ChromaClient = jest.fn().mockImplementation(clientImpl); +const CloudClient = jest.fn().mockImplementation(clientImpl); - const collectionHandle = { - add, - query, - get, - update, - upsert, - delete: deleteFn, - }; - const getOrCreateCollection = jest.fn().mockResolvedValue(collectionHandle); - const deleteCollection = jest.fn().mockResolvedValue(undefined); +const __mocks__ = { + add, + query, + get, + update, + upsert, + deleteFn, + getOrCreateCollection, + deleteCollection, + ChromaClient, + CloudClient, +}; - const clientImpl = () => ({ getOrCreateCollection, deleteCollection }); - const ChromaClient = jest.fn().mockImplementation(clientImpl); - const CloudClient = jest.fn().mockImplementation(clientImpl); - - Object.assign(__mocks__, { - add, - query, - get, - update, - upsert, - deleteFn, - getOrCreateCollection, - deleteCollection, - ChromaClient, - CloudClient, - }); - - return { ChromaClient, CloudClient }; -}); +jest.mock("chromadb", () => ({ + ChromaClient: __mocks__.ChromaClient, + CloudClient: __mocks__.CloudClient, +})); import { ChromaDB } from "../vector_stores/chroma"; import { VectorStoreFactory } from "../utils/factory"; @@ -127,8 +109,10 @@ describe("VectorStoreFactory", () => { }); describe("Constructor", () => { - it("builds a local ChromaClient with host and port", () => { - makeDb({ host: "localhost", port: 8000 }); + // The client is built lazily on first use, so trigger initialize() before + // asserting how it was constructed. + it("builds a local ChromaClient with host and port", async () => { + await initDb({ host: "localhost", port: 8000 }); expect(__mocks__.ChromaClient).toHaveBeenCalledWith({ host: "localhost", port: 8000, @@ -136,8 +120,8 @@ describe("Constructor", () => { expect(__mocks__.CloudClient).not.toHaveBeenCalled(); }); - it("passes ssl and path through to ChromaClient when provided", () => { - makeDb({ host: "example.com", port: 443, ssl: true, path: "/db" }); + it("passes ssl and path through to ChromaClient when provided", async () => { + await initDb({ host: "example.com", port: 443, ssl: true, path: "/db" }); expect(__mocks__.ChromaClient).toHaveBeenCalledWith({ host: "example.com", port: 443, @@ -146,12 +130,13 @@ describe("Constructor", () => { }); }); - it("builds a CloudClient when apiKey and tenant are set", () => { - new ChromaDB({ + it("builds a CloudClient when apiKey and tenant are set", async () => { + const db = new ChromaDB({ collectionName: "test-collection", apiKey: "key-123", tenant: "tenant-abc", } as any); + await db.initialize(); expect(__mocks__.CloudClient).toHaveBeenCalledWith({ apiKey: "key-123", tenant: "tenant-abc", @@ -160,13 +145,14 @@ describe("Constructor", () => { expect(__mocks__.ChromaClient).not.toHaveBeenCalled(); }); - it("honors an explicit cloud database name", () => { - new ChromaDB({ + it("honors an explicit cloud database name", async () => { + const db = new ChromaDB({ collectionName: "test-collection", apiKey: "key-123", tenant: "tenant-abc", database: "custom-db", } as any); + await db.initialize(); expect(__mocks__.CloudClient).toHaveBeenCalledWith( expect.objectContaining({ database: "custom-db" }), ); @@ -454,4 +440,52 @@ describe("generateWhereClause", () => { ChromaDB.generateWhereClause({ $not: [{ age: { gt: 18 } }] }), ).toEqual({ age: { $lte: 18 } }); }); + + // Regression tests for the three where-clause translation bugs fixed in + // Python by #6452 and tracked for the TS SDK in #6513. ChromaDB allows + // exactly one operator or field per dict level. + it("keeps both bounds of a same-field range as $and-combined clauses", () => { + expect(ChromaDB.generateWhereClause({ age: { gte: 18, lte: 65 } })).toEqual( + { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }, + ); + }); + + it("keeps same-field ranges inside $or branches", () => { + expect( + ChromaDB.generateWhereClause({ + $or: [{ age: { gte: 18, lte: 65 } }, { vip: true }], + }), + ).toEqual({ + $or: [ + { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }, + { vip: { $eq: true } }, + ], + }); + }); + + it("wraps multi-field conditions inside $or in $and", () => { + expect( + ChromaDB.generateWhereClause({ + $or: [{ age: { gte: 18 }, vip: true }, { city: "sh" }], + }), + ).toEqual({ + $or: [ + { $and: [{ age: { $gte: 18 } }, { vip: { $eq: true } }] }, + { city: { $eq: "sh" } }, + ], + }); + }); + + it("negates $not contains/icontains instead of dropping the clause", () => { + expect( + ChromaDB.generateWhereClause({ + $not: [{ title: { contains: "draft" } }], + }), + ).toEqual({ title: { $ne: "draft" } }); + expect( + ChromaDB.generateWhereClause({ + $not: [{ title: { icontains: "draft" } }], + }), + ).toEqual({ title: { $ne: "draft" } }); + }); }); diff --git a/mem0-ts/src/oss/src/tests/milvus.test.ts b/mem0-ts/src/oss/src/tests/milvus.test.ts index 9f1a92625..a2c0184e4 100644 --- a/mem0-ts/src/oss/src/tests/milvus.test.ts +++ b/mem0-ts/src/oss/src/tests/milvus.test.ts @@ -267,6 +267,36 @@ describe("Milvus vector store (TS OSS SDK)", () => { }); }); + it("skips a wildcard '*' filter value and keeps the rest", async () => { + const client = new FakeMilvusClient({ existing: ["mem0"] }); + client.searchResponse = { results: [] }; + const store = makeStore(client, { metricType: "COSINE" }); + await store.initialize(); + + // "*" means match-any: it must be dropped, not emitted as `== "*"` (which + // matches nothing), leaving only the real agent_id clause. + await store.search([0.1, 0.2, 0.3], 5, { + user_id: "*", + agent_id: "a1", + }); + + const searchCall = client.calls.find((c) => c.method === "search")!; + expect(searchCall.args.filter).toBe('(metadata["agent_id"] == "a1")'); + }); + + it("omits the filter entirely when every value is a wildcard", async () => { + const client = new FakeMilvusClient({ existing: ["mem0"] }); + const store = makeStore(client); + await store.initialize(); + + await store.list({ user_id: "*" }); + + // All clauses dropped, so list() falls back to its match-all "" filter + // rather than a literal `(metadata["user_id"] == "*")` that matches nothing. + const queryCall = client.calls.filter((c) => c.method === "query").pop()!; + expect(queryCall.args.filter).toBe(""); + }); + it("normalises L2 distances into a 0..1 similarity score", async () => { const client = new FakeMilvusClient({ existing: ["mem0"] }); client.searchResponse = { @@ -432,12 +462,18 @@ describe("Milvus vector store (TS OSS SDK)", () => { ["a"], [{ data: "hello world", text_lemmatized: "hello world lemma" }], ); + await store.insert( + [[0.2, 0.3, 0.4]], + ["c"], + [{ data: "hello world", textLemmatized: "hello world camel" }], + ); // Falls back to raw data when there is no lemmatized text. await store.insert([[0.4, 0.5, 0.6]], ["b"], [{ data: "just data" }]); const insertCalls = client.calls.filter((c) => c.method === "insert"); expect(insertCalls[0].args.data[0].text).toBe("hello world lemma"); - expect(insertCalls[1].args.data[0].text).toBe("just data"); + expect(insertCalls[1].args.data[0].text).toBe("hello world camel"); + expect(insertCalls[2].args.data[0].text).toBe("just data"); }); it("writes the BM25 text field on update for a BM25 collection", async () => { diff --git a/mem0-ts/src/oss/src/tests/mongodb.test.ts b/mem0-ts/src/oss/src/tests/mongodb.test.ts index d339de582..fe847b56c 100644 --- a/mem0-ts/src/oss/src/tests/mongodb.test.ts +++ b/mem0-ts/src/oss/src/tests/mongodb.test.ts @@ -5,6 +5,7 @@ const mockFindOne = jest.fn(); const mockUpdateOne = jest.fn(); const mockListSearchIndexes = jest.fn(); const mockCreateSearchIndex = jest.fn(); +const mockDropSearchIndex = jest.fn(); const mockDrop = jest.fn(); const mockToArray = jest.fn(); const mockLimit = jest.fn().mockReturnThis(); @@ -26,6 +27,7 @@ const mockCollection = { updateOne: mockUpdateOne, listSearchIndexes: mockListSearchIndexes, createSearchIndex: mockCreateSearchIndex, + dropSearchIndex: mockDropSearchIndex, drop: mockDrop, find: mockFind, aggregate: mockAggregate, @@ -74,6 +76,25 @@ describe("MongoDB Vector Store", () => { await store.close(); }); + const expectedTextSearchIndexDefinition = { + name: "test_col_text_search_index", + definition: { + mappings: { + dynamic: false, + fields: { + payload: { + type: "document", + fields: { + data: { type: "string" }, + textLemmatized: { type: "string" }, + text_lemmatized: { type: "string" }, + }, + }, + }, + }, + }, + }; + it("should initialize client and check/create collection and indexes", async () => { await store.initialize(); @@ -84,6 +105,83 @@ describe("MongoDB Vector Store", () => { }); expect(mockCollection.deleteOne).toHaveBeenCalledWith({ _id: 0 }); expect(mockCreateSearchIndex).toHaveBeenCalledTimes(2); + expect(mockCreateSearchIndex).toHaveBeenCalledWith( + expectedTextSearchIndexDefinition, + ); + }); + + it("should drop and recreate a stale text search index on upgrade", async () => { + mockListCollections.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ name: "test_col" }]), + }); + mockListSearchIndexes.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { name: "test_col_vector_index" }, + { + name: "test_col_text_search_index", + definition: { + mappings: { + dynamic: false, + fields: { + payload: { + type: "document", + fields: { + data: { type: "string" }, + text_lemmatized: { type: "string" }, + }, + }, + }, + }, + }, + }, + ]), + }); + + await store.initialize(); + + expect(mockDropSearchIndex).toHaveBeenCalledWith( + "test_col_text_search_index", + ); + expect(mockCreateSearchIndex).toHaveBeenCalledTimes(1); + expect(mockCreateSearchIndex).toHaveBeenCalledWith( + expectedTextSearchIndexDefinition, + ); + }); + + it("should not recreate a text search index that already has textLemmatized", async () => { + mockListCollections.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ name: "test_col" }]), + }); + mockListSearchIndexes.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { name: "test_col_vector_index" }, + { + name: "test_col_text_search_index", + latestDefinition: expectedTextSearchIndexDefinition.definition, + }, + ]), + }); + + await store.initialize(); + + expect(mockDropSearchIndex).not.toHaveBeenCalled(); + expect(mockCreateSearchIndex).not.toHaveBeenCalled(); + }); + + it("should map payload.textLemmatized in the text search index", async () => { + await store.initialize(); + + const textIndexCall = mockCreateSearchIndex.mock.calls.find( + ([arg]: any[]) => arg.name === "test_col_text_search_index", + ); + expect(textIndexCall).toBeDefined(); + expect(textIndexCall![0].definition.mappings.fields.payload.fields).toEqual( + { + data: { type: "string" }, + text_lemmatized: { type: "string" }, + textLemmatized: { type: "string" }, + }, + ); }); it("should insert documents correctly", async () => { @@ -197,7 +295,11 @@ describe("MongoDB Vector Store", () => { index: "test_col_text_search_index", text: { query: "test", - path: ["payload.data", "payload.text_lemmatized"], + path: [ + "payload.data", + "payload.text_lemmatized", + "payload.textLemmatized", + ], }, }, }, diff --git a/mem0-ts/src/oss/src/tests/pinecone.test.ts b/mem0-ts/src/oss/src/tests/pinecone.test.ts index 0a266978d..3c008538e 100644 --- a/mem0-ts/src/oss/src/tests/pinecone.test.ts +++ b/mem0-ts/src/oss/src/tests/pinecone.test.ts @@ -3,73 +3,64 @@ // the module-level `__mocks__` object that is populated inside the factory so // that the hoisted mock can reach them via a stable reference. -const __mocks__: { - upsert: jest.Mock; - query: jest.Mock; - fetch: jest.Mock; - deleteOne: jest.Mock; - namespace: jest.Mock; - describeIndexStats: jest.Mock; - index: jest.Mock; - listIndexes: jest.Mock; - createIndex: jest.Mock; - deleteIndex: jest.Mock; - Pinecone: jest.Mock; -} = {} as any; +// The provider imports `@pinecone-database/pinecone` lazily (await import) only +// on first use, so the jest.mock factory no longer runs at module-eval time. +// Create the shared mock handles at module top-level (not inside the factory) +// so `beforeEach` can reach them before the lazy import has fired; the factory, +// which runs on first use, just returns a reference to the Pinecone class mock. +const upsert = jest.fn().mockResolvedValue(undefined); +const query = jest.fn().mockResolvedValue({ matches: [] }); +const fetch = jest.fn().mockResolvedValue({ records: {} }); +const deleteOne = jest.fn().mockResolvedValue(undefined); +const deleteAll = jest.fn().mockResolvedValue(undefined); -jest.mock("@pinecone-database/pinecone", () => { - // These are created fresh inside the factory so hoisting is safe. - const upsert = jest.fn().mockResolvedValue(undefined); - const query = jest.fn().mockResolvedValue({ matches: [] }); - const fetch = jest.fn().mockResolvedValue({ records: {} }); - const deleteOne = jest.fn().mockResolvedValue(undefined); +const nsHandle = { upsert, query, fetch, deleteOne, deleteAll }; +const namespace = jest.fn().mockReturnValue(nsHandle); - const nsHandle = { upsert, query, fetch, deleteOne }; - const namespace = jest.fn().mockReturnValue(nsHandle); +const describeIndexStats = jest + .fn() + .mockResolvedValue({ totalRecordCount: 0, namespaces: {} }); - const describeIndexStats = jest - .fn() - .mockResolvedValue({ totalRecordCount: 0, namespaces: {} }); +const indexHandle = { + namespace, + describeIndexStats, + // expose ops directly for the no-namespace path + upsert, + query, + fetch, + deleteOne, +}; +const index = jest.fn().mockReturnValue(indexHandle); - const indexHandle = { - namespace, - describeIndexStats, - // expose ops directly for the no-namespace path - upsert, - query, - fetch, - deleteOne, - }; - const index = jest.fn().mockReturnValue(indexHandle); +const listIndexes = jest.fn().mockResolvedValue({ indexes: [] }); +const createIndex = jest.fn().mockResolvedValue(undefined); +const deleteIndex = jest.fn().mockResolvedValue(undefined); - const listIndexes = jest.fn().mockResolvedValue({ indexes: [] }); - const createIndex = jest.fn().mockResolvedValue(undefined); - const deleteIndex = jest.fn().mockResolvedValue(undefined); +const Pinecone = jest.fn().mockImplementation(() => ({ + listIndexes, + createIndex, + deleteIndex, + index, +})); - const Pinecone = jest.fn().mockImplementation(() => ({ - listIndexes, - createIndex, - deleteIndex, - index, - })); +const __mocks__ = { + upsert, + query, + fetch, + deleteOne, + deleteAll, + namespace, + describeIndexStats, + index, + listIndexes, + createIndex, + deleteIndex, + Pinecone, +}; - // Populate the shared reference so tests can reach the mocks. - Object.assign(__mocks__, { - upsert, - query, - fetch, - deleteOne, - namespace, - describeIndexStats, - index, - listIndexes, - createIndex, - deleteIndex, - Pinecone, - }); - - return { Pinecone }; -}); +jest.mock("@pinecone-database/pinecone", () => ({ + Pinecone: __mocks__.Pinecone, +})); import { PineconeDB } from "../vector_stores/pinecone"; import { VectorStoreFactory } from "../utils/factory"; @@ -105,6 +96,7 @@ beforeEach(() => { __mocks__.query.mockResolvedValue({ matches: [] }); __mocks__.fetch.mockResolvedValue({ records: {} }); __mocks__.deleteOne.mockResolvedValue(undefined); + __mocks__.deleteAll.mockResolvedValue(undefined); __mocks__.describeIndexStats.mockResolvedValue({ totalRecordCount: 0, namespaces: {}, @@ -115,6 +107,7 @@ beforeEach(() => { query: __mocks__.query, fetch: __mocks__.fetch, deleteOne: __mocks__.deleteOne, + deleteAll: __mocks__.deleteAll, }; __mocks__.namespace.mockReturnValue(nsHandle); __mocks__.index.mockReturnValue({ @@ -435,6 +428,14 @@ describe("deleteCol", () => { await db.initialize(); expect(__mocks__.createIndex).toHaveBeenCalledTimes(2); }); + + it("clears only the namespace and never drops the shared index", async () => { + const db = await initDb({ namespace: "tenant-a" }); + await db.deleteCol(); + expect(__mocks__.namespace).toHaveBeenCalledWith("tenant-a"); + expect(__mocks__.deleteAll).toHaveBeenCalled(); + expect(__mocks__.deleteIndex).not.toHaveBeenCalled(); + }); }); describe("list", () => { diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index 82f7bcc17..fb3e5b391 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -21,8 +21,22 @@ export interface EmbeddingConfig { modelProperties?: Record; // HuggingFace TEI / OpenAI-compatible inference endpoint base URL. huggingfaceBaseUrl?: string; + // AWS Bedrock. Omit the credential fields to use the AWS default chain. + awsRegion?: string; + awsAccessKeyId?: string; + awsSecretAccessKey?: string; + awsSessionToken?: string; } +export interface VertexAIConfig extends EmbeddingConfig { + vertexCredentialsJson?: string; + googleServiceAccountJson?: string | Record; + googleProjectId?: string; + location?: string; + memoryAddEmbeddingType?: string; + memoryUpdateEmbeddingType?: string; + memorySearchEmbeddingType?: string; +} export type { ValkeyConfig } from "./valkey"; export interface VectorStoreConfig { @@ -58,6 +72,62 @@ export interface LLMConfig { temperature?: number; topP?: number; maxTokens?: number; + // AWS Bedrock provider config (used when provider === "aws_bedrock"). + // Credentials otherwise resolve via the standard AWS credential chain. + awsRegion?: string; + awsAccessKeyId?: string; + awsSecretAccessKey?: string; + awsSessionToken?: string; + providerOverride?: string; + // Optional pre-constructed client (e.g. BedrockRuntimeClient) for DI/testing. + client?: any; +} + +export interface RerankerConfig { + apiKey?: string; + /** The reranker model to use. Default varies by provider. */ + model?: string; + /** Maximum number of documents to return after reranking. Default: unset (return all). */ + topK?: number; + /** `cohere` only. Return document texts in the response. Default: `false`. */ + returnDocuments?: boolean; + /** `cohere` only. Maximum number of chunks per document. Default: unset. */ + maxChunksPerDoc?: number; + /** + * `sentence_transformer` / `huggingface` only. Transformers.js device, e.g. + * `"cpu"`, `"wasm"`, `"webgpu"`. Default: unset (auto-detect). + */ + device?: string; + /** `huggingface` only. Max token length per query-document pair. Default: `512`. */ + maxLength?: number; + /** + * `sentence_transformer` / `huggingface` only. Sigmoid-normalize raw logits + * to `[0, 1]`. Default: `true`; set `false` to surface raw logits. + */ + normalize?: boolean; + /** No-op: a search reranks a small candidate set in one forward pass. */ + batchSize?: number; + /** No-op in this runtime. */ + showProgressBar?: boolean; + /** + * `llm_reranker` only. LLM provider used to build the scoring LLM when + * `llm` is not set. Default: `"openai"`. + */ + provider?: string; + /** `llm_reranker` only. Temperature for LLM generation. Default: `0.0`. */ + temperature?: number; + /** `llm_reranker` only. Maximum tokens for the LLM response. Default: `100`. */ + maxTokens?: number; + /** + * `llm_reranker` only. Nested LLM configuration. When set, it overrides the + * top-level `provider`/`model`/`temperature`/`maxTokens`/`apiKey`, which + * then only act as defaults for fields missing from `llm.config`. + */ + llm?: { + provider: string; + config: LLMConfig; + }; + [key: string]: any; } export interface MemoryConfig { @@ -74,6 +144,10 @@ export interface MemoryConfig { provider: string; config: LLMConfig; }; + reranker?: { + provider: string; + config: RerankerConfig; + }; historyStore?: HistoryStoreConfig; disableHistory?: boolean; historyDbPath?: string; @@ -87,6 +161,8 @@ export interface MemoryItem { createdAt?: string; updatedAt?: string; score?: number; + /** Relevance score added by the reranker, alongside (not replacing) `score`. */ + rerankScore?: number; metadata?: Record; attributedTo?: string; } @@ -119,6 +195,19 @@ export const MemoryConfigSchema = z.object({ baseURL: z.string().optional(), embeddingDims: z.number().optional(), url: z.string().optional(), + vertexCredentialsJson: z.string().optional(), + googleServiceAccountJson: z + .union([z.string(), z.record(z.string(), z.any())]) + .optional(), + googleProjectId: z.string().optional(), + location: z.string().optional(), + memoryAddEmbeddingType: z.string().optional(), + memoryUpdateEmbeddingType: z.string().optional(), + memorySearchEmbeddingType: z.string().optional(), + awsRegion: z.string().optional(), + awsAccessKeyId: z.string().optional(), + awsSecretAccessKey: z.string().optional(), + awsSessionToken: z.string().optional(), }), }), vectorStore: z.object({ @@ -132,21 +221,29 @@ export const MemoryConfigSchema = z.object({ }) .passthrough(), }), + llm: z.object({ provider: z.string(), - config: z.object({ - apiKey: z.string().optional(), - model: z.union([z.string(), z.any()]).optional(), - modelProperties: z.record(z.string(), z.any()).optional(), - baseURL: z.string().optional(), - vllmBaseURL: z.string().optional(), - vllm_base_url: z.string().optional(), - url: z.string().optional(), - timeout: z.number().optional(), - temperature: z.number().optional(), - topP: z.number().optional(), - maxTokens: z.number().optional(), - }), + config: z + .object({ + apiKey: z.string().optional(), + model: z.union([z.string(), z.any()]).optional(), + modelProperties: z.record(z.string(), z.any()).optional(), + baseURL: z.string().optional(), + vllmBaseURL: z.string().optional(), + vllm_base_url: z.string().optional(), + url: z.string().optional(), + timeout: z.number().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + maxTokens: z.number().optional(), + awsRegion: z.string().optional(), + awsAccessKeyId: z.string().optional(), + awsSecretAccessKey: z.string().optional(), + awsSessionToken: z.string().optional(), + client: z.any().optional(), + }) + .passthrough(), }), historyDbPath: z.string().optional(), customInstructions: z.string().optional(), @@ -156,5 +253,11 @@ export const MemoryConfigSchema = z.object({ config: z.record(z.string(), z.any()), }) .optional(), + reranker: z + .object({ + provider: z.string(), + config: z.record(z.string(), z.any()), + }) + .optional(), disableHistory: z.boolean().optional(), }); diff --git a/mem0-ts/src/oss/src/utils/casing.ts b/mem0-ts/src/oss/src/utils/casing.ts new file mode 100644 index 000000000..c9a5b0ad3 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/casing.ts @@ -0,0 +1,22 @@ +function toCamelCase(obj: Record): Record { + if (typeof obj !== "object" || obj === null) return obj; + + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), + value, + ]), + ); +} + +export function toCamelCasePreservingIds( + payload: Record, +): Record { + const { agent_id, run_id, user_id, ...rest } = payload; + return { + ...toCamelCase(rest), + ...(agent_id !== undefined && { agent_id }), + ...(run_id !== undefined && { run_id }), + ...(user_id !== undefined && { user_id }), + }; +} diff --git a/mem0-ts/src/oss/src/utils/factory.test.ts b/mem0-ts/src/oss/src/utils/factory.test.ts new file mode 100644 index 000000000..0a358ca44 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/factory.test.ts @@ -0,0 +1,86 @@ +jest.mock("zeroentropy", () => ({ ZeroEntropy: jest.fn() })); +jest.mock("@huggingface/transformers", () => ({ + AutoModelForSequenceClassification: { from_pretrained: jest.fn() }, + AutoTokenizer: { from_pretrained: jest.fn() }, +})); + +import { RerankerFactory } from "./factory"; +import { CohereReranker } from "../rerankers/cohere"; +import { LLMReranker } from "../rerankers/llm"; +import { ZeroEntropyReranker } from "../rerankers/zeroentropy"; +import { CrossEncoderReranker } from "../rerankers/cross_encoder"; + +describe("RerankerFactory", () => { + const originalOpenAiKey = process.env.OPENAI_API_KEY; + + afterEach(() => { + if (originalOpenAiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalOpenAiKey; + } + }); + + it("creates a CohereReranker for provider 'cohere'", () => { + const reranker = RerankerFactory.create("cohere", { apiKey: "key" }); + expect(reranker).toBeInstanceOf(CohereReranker); + }); + + it("matches the provider name case-insensitively", () => { + const reranker = RerankerFactory.create("Cohere", { apiKey: "key" }); + expect(reranker).toBeInstanceOf(CohereReranker); + }); + + it("creates a ZeroEntropyReranker for provider 'zero_entropy'", () => { + const reranker = RerankerFactory.create("zero_entropy", { + apiKey: "key", + }); + expect(reranker).toBeInstanceOf(ZeroEntropyReranker); + }); + + it("creates a CrossEncoderReranker for provider 'sentence_transformer'", () => { + const reranker = RerankerFactory.create("sentence_transformer", {}); + expect(reranker).toBeInstanceOf(CrossEncoderReranker); + }); + + it("creates a CrossEncoderReranker for provider 'huggingface'", () => { + const reranker = RerankerFactory.create("huggingface", {}); + expect(reranker).toBeInstanceOf(CrossEncoderReranker); + }); + + it("creates an LLMReranker for provider 'llm_reranker', building a default openai LLM from top-level config", () => { + const reranker = RerankerFactory.create("llm_reranker", { + apiKey: "key", + }); + expect(reranker).toBeInstanceOf(LLMReranker); + }); + + it("creates an LLMReranker that builds its own LLM from a nested config.llm", () => { + const reranker = RerankerFactory.create("llm_reranker", { + llm: { provider: "openai", config: { apiKey: "x" } }, + }); + expect(reranker).toBeInstanceOf(LLMReranker); + }); + + it("prefers the nested llm.provider over the top-level provider when building the llm_reranker's LLM", () => { + // If the top-level `provider` were used instead of the nested one, this + // would throw ("Unsupported LLM provider: not-a-real-provider"). + const reranker = RerankerFactory.create("llm_reranker", { + provider: "not-a-real-provider", + llm: { provider: "openai", config: { apiKey: "key" } }, + }); + expect(reranker).toBeInstanceOf(LLMReranker); + }); + + it("throws for the 'llm_reranker' provider when the default LLM has no API key available", () => { + delete process.env.OPENAI_API_KEY; + + expect(() => RerankerFactory.create("llm_reranker", {})).toThrow(); + }); + + it("throws for an unsupported provider", () => { + expect(() => RerankerFactory.create("banana", {})).toThrow( + /unsupported reranker provider/i, + ); + }); +}); diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 50f487e23..16de0301f 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -1,4 +1,5 @@ import { OpenAIEmbedder } from "../embeddings/openai"; +import { AWSBedrockEmbedder } from "../embeddings/aws_bedrock"; import { OllamaEmbedder } from "../embeddings/ollama"; import { LMStudioEmbedder } from "../embeddings/lmstudio"; import { TogetherEmbedder } from "../embeddings/together"; @@ -12,11 +13,18 @@ import { EmbeddingConfig, HistoryStoreConfig, LLMConfig, + RerankerConfig, VectorStoreConfig, } from "../types"; +import { Reranker } from "../rerankers/base"; +import { CohereReranker } from "../rerankers/cohere"; +import { LLMReranker } from "../rerankers/llm"; +import { ZeroEntropyReranker } from "../rerankers/zeroentropy"; +import { CrossEncoderReranker } from "../rerankers/cross_encoder"; import { Embedder } from "../embeddings/base"; import { LLM } from "../llms/base"; import { VectorStore } from "../vector_stores/base"; +import { BaiduDB } from "../vector_stores/baidu"; import { Qdrant } from "../vector_stores/qdrant"; import { ChromaDB } from "../vector_stores/chroma"; import { VectorizeDB } from "../vector_stores/vectorize"; @@ -27,6 +35,7 @@ import { LMStudioLLM } from "../llms/lmstudio"; import { DeepSeekLLM } from "../llms/deepseek"; import { XAILLM } from "../llms/xai"; import { SarvamLLM } from "../llms/sarvam"; +import { AWSBedrockLLM } from "../llms/aws_bedrock"; import { LiteLLM } from "../llms/litellm"; import { MiniMaxLLM } from "../llms/minimax"; import { TogetherLLM } from "../llms/together"; @@ -47,6 +56,9 @@ import { HuggingFaceEmbedder } from "../embeddings/huggingface"; import { LangchainVectorStore } from "../vector_stores/langchain"; import { AzureAISearch } from "../vector_stores/azure_ai_search"; import { PGVector } from "../vector_stores/pgvector"; +import { DatabricksVectorStore } from "../vector_stores/databricks"; +import { NeptuneAnalyticsVectorStore } from "../vector_stores/neptune_analytics"; +import { VertexAIEmbedder } from "../embeddings/vertexai"; import { ElasticsearchDB } from "../vector_stores/elasticsearch"; import { OpenSearchDB } from "../vector_stores/opensearch"; import { UpstashVector } from "../vector_stores/upstash_vector"; @@ -59,12 +71,15 @@ import { TurbopufferDB } from "../vector_stores/turbopuffer"; import { Milvus } from "../vector_stores/milvus"; import { MongoDB } from "../vector_stores/mongodb"; import { WeaviateDB } from "../vector_stores/weaviate"; +import { OracleAIVectorSearch } from "../vector_stores/oracledb"; export class EmbedderFactory { static create(provider: string, config: EmbeddingConfig): Embedder { switch (provider.toLowerCase()) { case "openai": return new OpenAIEmbedder(config); + case "aws_bedrock": + return new AWSBedrockEmbedder(config); case "ollama": return new OllamaEmbedder(config); case "lmstudio": @@ -80,6 +95,8 @@ export class EmbedderFactory { return new FastEmbedEmbedder(config); case "langchain": return new LangchainEmbedder(config); + case "vertexai": + return new VertexAIEmbedder(config); case "huggingface": return new HuggingFaceEmbedder(config); default: @@ -118,6 +135,8 @@ export class LLMFactory { return new XAILLM(config); case "sarvam": return new SarvamLLM(config); + case "aws_bedrock": + return new AWSBedrockLLM(config); case "litellm": return new LiteLLM(config); case "minimax": @@ -137,6 +156,8 @@ export class VectorStoreFactory { switch (provider.toLowerCase()) { case "memory": return new MemoryVectorStore(config); + case "baidu": + return new BaiduDB(config as any); case "qdrant": return new Qdrant(config as any); case "chroma": @@ -157,6 +178,11 @@ export class VectorStoreFactory { return new VertexAIVectorSearch(config as any); case "pgvector": return new PGVector(config as any); + case "databricks": + return new DatabricksVectorStore(config as any); + case "neptune": + case "neptune-analytics": + return new NeptuneAnalyticsVectorStore(config as any); case "elasticsearch": return new ElasticsearchDB(config as any); case "opensearch": @@ -180,12 +206,77 @@ export class VectorStoreFactory { return new MongoDB(config as any); case "weaviate": return new WeaviateDB(config as any); + case "oracledb": + return new OracleAIVectorSearch(config as any); default: throw new Error(`Unsupported vector store provider: ${provider}`); } } } +export class RerankerFactory { + static create(provider: string, config: RerankerConfig): Reranker { + switch (provider.toLowerCase()) { + case "cohere": + return new CohereReranker(config); + case "zero_entropy": + return new ZeroEntropyReranker(config); + case "sentence_transformer": + return new CrossEncoderReranker( + config, + "Xenova/ms-marco-MiniLM-L-6-v2", + ); + case "huggingface": + return new CrossEncoderReranker( + config, + "Xenova/bge-reranker-base", + 512, + ); + case "llm_reranker": { + const llm = RerankerFactory.buildLLMRerankerLLM(config); + return new LLMReranker(config, llm); + } + default: + throw new Error(`Unsupported reranker provider: ${provider}`); + } + } + + private static buildLLMRerankerLLM(config: RerankerConfig): LLM { + const nested = config.llm; + let llmProvider: string; + let llmConfig: LLMConfig; + + if (nested) { + llmProvider = nested.provider || config.provider || "openai"; + llmConfig = { ...(nested.config || {}) }; + if (llmConfig.model === undefined) { + llmConfig.model = config.model ?? "gpt-5-mini"; + } + if (llmConfig.temperature === undefined) { + llmConfig.temperature = config.temperature ?? 0.0; + } + if (llmConfig.maxTokens === undefined) { + llmConfig.maxTokens = config.maxTokens ?? 100; + } + if (config.apiKey && llmConfig.apiKey === undefined) { + llmConfig.apiKey = config.apiKey; + } + } else { + llmProvider = config.provider || "openai"; + llmConfig = { + model: config.model ?? "gpt-5-mini", + temperature: config.temperature ?? 0.0, + maxTokens: config.maxTokens ?? 100, + }; + if (config.apiKey) { + llmConfig.apiKey = config.apiKey; + } + } + + return LLMFactory.create(llmProvider, llmConfig); + } +} + export class HistoryManagerFactory { static create(provider: string, config: HistoryStoreConfig): HistoryManager { switch (provider.toLowerCase()) { diff --git a/mem0-ts/src/oss/src/utils/load_peer.ts b/mem0-ts/src/oss/src/utils/load_peer.ts new file mode 100644 index 000000000..e6ba92af9 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/load_peer.ts @@ -0,0 +1,13 @@ +export async function loadPeer( + pkg: string, + label: string, + load: () => Promise, +): Promise { + try { + return await load(); + } catch { + throw new Error( + `The '${pkg}' package is required to use the ${label}. Install it with: npm install ${pkg}`, + ); + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts b/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts index a693626d3..52576aca0 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts @@ -1,7 +1,6 @@ -import { +import type { SearchClient, SearchIndexClient, - AzureKeyCredential, SearchIndex, SearchField, SearchFieldDataType, @@ -13,9 +12,9 @@ import { BinaryQuantizationCompression, VectorizedQuery, } from "@azure/search-documents"; -import { DefaultAzureCredential } from "@azure/identity"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; /** * Configuration interface for Azure AI Search vector store @@ -71,8 +70,8 @@ interface AzureAISearchConfig extends VectorStoreConfig { * Supports vector search with hybrid search, compression, and filtering */ export class AzureAISearch implements VectorStore { - private searchClient: SearchClient; - private indexClient: SearchIndexClient; + private searchClient!: SearchClient; + private indexClient!: SearchIndexClient; private readonly serviceName: string; private readonly indexName: string; private readonly embeddingModelDims: number; @@ -93,25 +92,42 @@ export class AzureAISearch implements VectorStore { this.vectorFilterMode = config.vectorFilterMode || "preFilter"; this.apiKey = config.apiKey; + // Initialize the index + this.initialize().catch(console.error); + } + + private async ensureClient(): Promise { + if (this.searchClient) return; + const searchSdk = await loadPeer( + "@azure/search-documents", + "Azure AI Search vector store", + () => import("@azure/search-documents"), + ); + const serviceEndpoint = `https://${this.serviceName}.search.windows.net`; // Determine authentication: API key or DefaultAzureCredential - const credential = - this.apiKey && this.apiKey !== "" && this.apiKey !== "your-api-key" - ? new AzureKeyCredential(this.apiKey) - : new DefaultAzureCredential(); + let credential: any; + if (this.apiKey && this.apiKey !== "" && this.apiKey !== "your-api-key") { + credential = new searchSdk.AzureKeyCredential(this.apiKey); + } else { + const identitySdk = await loadPeer( + "@azure/identity", + "Azure AI Search without an apiKey", + () => import("@azure/identity"), + ); + credential = new identitySdk.DefaultAzureCredential(); + } - // Initialize clients - this.searchClient = new SearchClient( + this.searchClient = new searchSdk.SearchClient( serviceEndpoint, this.indexName, credential, ); - - this.indexClient = new SearchIndexClient(serviceEndpoint, credential); - - // Initialize the index - this.initialize().catch(console.error); + this.indexClient = new searchSdk.SearchIndexClient( + serviceEndpoint, + credential, + ); } /** @@ -125,6 +141,7 @@ export class AzureAISearch implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { const collections = await this.listCols(); if (!collections.includes(this.indexName)) { @@ -262,6 +279,7 @@ export class AzureAISearch implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); console.log( `Inserting ${vectors.length} vectors into index ${this.indexName}`, ); @@ -334,6 +352,7 @@ export class AzureAISearch implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const filterExpression = filters ? this.buildFilterExpression(filters) @@ -373,6 +392,7 @@ export class AzureAISearch implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const filterExpression = filters ? this.buildFilterExpression(filters) : undefined; @@ -429,6 +449,7 @@ export class AzureAISearch implements VectorStore { * Delete a vector by ID */ async delete(vectorId: string): Promise { + await this.initialize(); const response = await this.searchClient.deleteDocuments([ { id: vectorId }, ]); @@ -454,6 +475,7 @@ export class AzureAISearch implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const document: Record = { id: vectorId }; if (vector) { @@ -486,6 +508,7 @@ export class AzureAISearch implements VectorStore { * Retrieve a vector by ID */ async get(vectorId: string): Promise { + await this.initialize(); try { const result = await this.searchClient.getDocument(vectorId); const payloadStr = result.payload as string; @@ -521,6 +544,7 @@ export class AzureAISearch implements VectorStore { * Delete the index */ async deleteCol(): Promise { + await this.initialize(); await this.indexClient.deleteIndex(this.indexName); } @@ -542,6 +566,7 @@ export class AzureAISearch implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const filterExpression = filters ? this.buildFilterExpression(filters) : undefined; @@ -586,6 +611,7 @@ export class AzureAISearch implements VectorStore { * Required by VectorStore interface */ async getUserId(): Promise { + await this.initialize(); try { // Check if memory_migrations index exists const collections = await this.listCols(); @@ -648,6 +674,7 @@ export class AzureAISearch implements VectorStore { * Required by VectorStore interface */ async setUserId(userId: string): Promise { + await this.initialize(); try { // Get existing point ID or generate new one const searchResults = await this.searchClient.search("*", { @@ -677,6 +704,7 @@ export class AzureAISearch implements VectorStore { * Reset the index by deleting and recreating it */ async reset(): Promise { + await this.initialize(); console.log(`Resetting index ${this.indexName}...`); try { diff --git a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts index 0b5bdf210..123767484 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts @@ -1,7 +1,7 @@ -import { createPool } from "mysql2/promise"; import type { Pool, RowDataPacket } from "mysql2/promise"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,127}$/; @@ -93,6 +93,14 @@ export class AzureMySQLDB implements VectorStore { ...(this.config.sslCa ? { ca: this.config.sslCa } : {}), }; + // Loaded dynamically: mysql2 is an optional peer dependency, so a static value import + // would break `import { Memory } from "mem0ai/oss"` for everyone else. + const { createPool }: typeof import("mysql2/promise") = await loadPeer( + "mysql2", + "Azure MySQL vector store", + () => import("mysql2/promise"), + ); + this.pool = createPool({ host: this.config.host, port: this.config.port ?? 3306, diff --git a/mem0-ts/src/oss/src/vector_stores/baidu.ts b/mem0-ts/src/oss/src/vector_stores/baidu.ts new file mode 100644 index 000000000..34e54c5fe --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/baidu.ts @@ -0,0 +1,617 @@ +import type { + AutoBuildIncrementPolicy, + CommonResponse, + DescTableResponse, + FieldType, + IndexSchema, + MochowClient, + QueryResponse, + SearchResponse, + SelectResponse, + TableSchema, +} from "@mochow/mochow-sdk-node"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; + +type MochowSdk = typeof import("@mochow/mochow-sdk-node"); + +export interface BaiduConfig extends VectorStoreConfig { + endpoint: string; + account: string; + apiKey: string; + databaseName: string; + tableName: string; + embeddingModelDims: number; + metricType?: "L2" | "IP" | "COSINE"; + client?: MochowClient; +} + +const VECTOR_INDEX = "vector_idx"; +const FILTERING_INDEX = "metadata_filtering_idx"; +// Named after the column it actually indexes, and deliberately not Python's "data_bm25_idx". +// This index holds Porter-stemmed text, but mem0/vector_stores/baidu.py's keyword_search() +// sends a raw, unstemmed query to that name. Sharing it would let Python find an index whose +// contents it cannot match properly, silently returning degraded hits instead of None. +const BM25_INDEX = "text_lemmatized_bm25_idx"; +const PROJECTIONS = ["id", "data", "metadata"]; +const TABLE_POLL_INTERVAL_MS = 2000; +const TABLE_POLL_ATTEMPTS = 60; + +// Mochow's server accepts JSON columns, but the Node SDK's FieldType enum predates them +// (pymochow 2.4.1 ships FieldType.JSON == "JSON"). The wire value is the bare string. +const JSON_FIELD_TYPE = "JSON" as unknown as FieldType; + +// Querying a primary key that isn't there answers with this code, not an empty row. The Node +// SDK's ServerErrCode stops at 100, but its siblings against the same server name it: +// pymochow ROW_KEY_NOT_FOUND = 101, mochow-sdk-go RowKeyNotFound = 101. +const ROW_KEY_NOT_FOUND = 101; + +const SAFE_FILTER_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function escapeFilterString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Mochow resolves with a {code, msg} envelope instead of rejecting, so every call site +// has to inspect the code. `tolerated` lets callers accept the idempotent outcomes +// (database/table already exists, table already dropped). +function check( + response: CommonResponse, + action: string, + ...tolerated: number[] +): number { + if (response.code !== 0 && !tolerated.includes(response.code)) { + throw new Error( + `Baidu Mochow ${action} failed (code ${response.code}): ${response.msg}`, + ); + } + return response.code; +} + +function lemmatizedText(payload: Record): string { + const data = typeof payload.data === "string" ? payload.data : ""; + return typeof payload.textLemmatized === "string" && + payload.textLemmatized.length > 0 + ? payload.textLemmatized + : data; +} + +function memoryData(payload: Record): string { + return typeof payload.data === "string" ? payload.data : ""; +} + +function metadataPayload(payload: Record): Record { + const { data: _data, textLemmatized: _textLemmatized, ...metadata } = payload; + return metadata; +} + +function resultPayload(row: Record): Record { + return { + ...(row.metadata || {}), + ...(typeof row.data === "string" ? { data: row.data } : {}), + }; +} + +export class BaiduDB implements VectorStore { + private client: MochowClient | null = null; + private sdk: MochowSdk | null = null; + private readonly endpoint: string; + private readonly account: string; + private readonly apiKey: string; + private readonly databaseName: string; + private readonly tableName: string; + private readonly embeddingModelDims: number; + private readonly metricType: "L2" | "IP" | "COSINE"; + // Fails closed: keyword search stays off until an inverted index is observed. + private supportsKeywordSearch = false; + private storeUserId = "anonymous-baidu-user"; + private _initPromise?: Promise; + + constructor(config: BaiduConfig) { + this.endpoint = config.endpoint; + this.account = config.account; + this.apiKey = config.apiKey; + this.databaseName = config.databaseName; + this.tableName = config.tableName; + this.embeddingModelDims = config.embeddingModelDims; + this.metricType = config.metricType || "L2"; + this.client = config.client || null; + + const requiredFields: Array< + readonly [string, string | number | undefined] + > = [ + ["databaseName", this.databaseName], + ["tableName", this.tableName], + ["embeddingModelDims", this.embeddingModelDims], + ]; + + if (!this.client) { + requiredFields.unshift( + ["endpoint", this.endpoint], + ["account", this.account], + ["apiKey", this.apiKey], + ); + } + + for (const [name, value] of requiredFields) { + if (value === undefined || value === null || value === "") { + throw new Error( + `Baidu vector store requires a non-empty '${name}' config value.`, + ); + } + } + + this.initialize().catch(console.error); + } + + private get ns(): { database: string; table: string } { + return { database: this.databaseName, table: this.tableName }; + } + + // Loaded dynamically: @mochow/mochow-sdk-node is an optional peer dependency, so a static + // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. + private async loadSdk(): Promise { + if (!this.sdk) { + const module: MochowSdk & { default?: MochowSdk } = await loadPeer( + "@mochow/mochow-sdk-node", + "Baidu vector store", + () => import("@mochow/mochow-sdk-node"), + ); + this.sdk = module.default ?? module; + } + return this.sdk; + } + + private async ensureClient(): Promise { + if (!this.client) { + const sdk = await this.loadSdk(); + this.client = new sdk.MochowClient({ + endpoint: this.endpoint, + credential: { account: this.account, apiKey: this.apiKey }, + }); + } + return this.client; + } + + private async ready(): Promise<{ client: MochowClient; sdk: MochowSdk }> { + await this.initialize(); + return { client: await this.ensureClient(), sdk: await this.loadSdk() }; + } + + private buildSchema(sdk: MochowSdk): TableSchema { + const { + AutoBuildPolicyType, + FieldType, + IndexType, + InvertedIndexAnalyzer, + InvertedIndexFieldAttribute, + InvertedIndexParseMode, + MetricType, + } = sdk; + + // sdk.AutoBuildIncrement() stamps policyType "TIMING" (bug in 2.1.5), so build the + // increment policy by hand. + const autoBuildPolicy: AutoBuildIncrementPolicy = { + policyType: AutoBuildPolicyType.Increment, + rowCountIncrement: 10000, + }; + + const vectorIndex: IndexSchema = { + indexName: VECTOR_INDEX, + indexType: IndexType.HNSW, + field: "vector", + metricType: MetricType[this.metricType], + params: { M: 16, efConstruction: 200 }, + autoBuild: true, + autoBuildPolicy, + }; + + return { + fields: [ + { + fieldName: "id", + fieldType: FieldType.String, + primaryKey: true, + partitionKey: true, + autoIncrement: false, + notNull: true, + }, + { + fieldName: "data", + fieldType: FieldType.Text, + }, + { + fieldName: "vector", + fieldType: FieldType.FloatVector, + notNull: true, + dimension: this.embeddingModelDims, + }, + // Stored outside `metadata` because Mochow cannot build an inverted index on a + // field inside a JSON column. Memory.search() passes an already-lemmatized query, + // so only the lemmatized form is worth indexing. + { fieldName: "textLemmatized", fieldType: FieldType.Text }, + { fieldName: "metadata", fieldType: JSON_FIELD_TYPE }, + ], + indexes: [ + vectorIndex, + { + indexName: FILTERING_INDEX, + indexType: IndexType.FilteringIndex, + fields: ["metadata"], + }, + { + indexName: BM25_INDEX, + indexType: IndexType.InvertedIndex, + fields: ["textLemmatized"], + fieldAttributes: [InvertedIndexFieldAttribute.Analyzed], + params: { + analyzer: InvertedIndexAnalyzer.EnglishAnalyzer, + parseMode: InvertedIndexParseMode.FineMode, + }, + }, + ], + }; + } + + private buildFilter(filters: SearchFilters): string { + const conditions: string[] = []; + + for (const [key, value] of Object.entries(filters)) { + if (!SAFE_FILTER_KEY.test(key)) { + throw new Error(`Invalid filter key: ${key}`); + } + + if (typeof value === "string") { + conditions.push(`metadata["${key}"] = "${escapeFilterString(value)}"`); + continue; + } + + if (typeof value === "number" || typeof value === "boolean") { + conditions.push(`metadata["${key}"] = ${value}`); + continue; + } + + throw new Error( + `Filter value for ${key} must be str, int, float, or bool, got ${Array.isArray(value) ? "array" : typeof value}`, + ); + } + + return conditions.join(" AND "); + } + + private filterOf(filters?: SearchFilters): string | undefined { + return filters && Object.keys(filters).length > 0 + ? this.buildFilter(filters) + : undefined; + } + + private async pollTable( + client: MochowClient, + settled: (response: DescTableResponse) => boolean, + what: string, + ): Promise { + for (let attempt = 0; attempt < TABLE_POLL_ATTEMPTS; attempt++) { + if (settled(await client.descTable(this.databaseName, this.tableName))) { + return; + } + await sleep(TABLE_POLL_INTERVAL_MS); + } + + throw new Error( + `Baidu Mochow table '${this.tableName}' was not ${what} after ${(TABLE_POLL_ATTEMPTS * TABLE_POLL_INTERVAL_MS) / 1000}s.`, + ); + } + + private async ensureTable(): Promise { + const sdk = await this.loadSdk(); + const client = await this.ensureClient(); + const { ServerErrCode, TableState } = sdk; + + check( + await client.createDatabase(this.databaseName), + `createDatabase '${this.databaseName}'`, + ServerErrCode.DBAlreadyExist, + ); + + const created = check( + await client.createTable({ + ...this.ns, + description: "mem0 memories", + replication: 3, + partition: { partitionType: sdk.PartitionType.HASH, partitionNum: 1 }, + enableDynamicField: false, + schema: this.buildSchema(sdk), + }), + `createTable '${this.tableName}'`, + ServerErrCode.TableAlreadyExist, + ); + + // A table is CREATING until its indexes are built; writing to it before then fails. + let description: DescTableResponse | undefined; + await this.pollTable( + client, + (response) => { + check(response, `descTable '${this.tableName}'`); + description = response; + return response.table.state === TableState.Normal; + }, + "ready", + ); + + this.applySchema( + created === ServerErrCode.TableAlreadyExist, + description!.table.schema, + ); + } + + private applySchema(preexisting: boolean, schema: TableSchema): void { + if (!preexisting) { + this.supportsKeywordSearch = true; + return; + } + + const fields = schema?.fields ?? []; + const indexes = schema?.indexes ?? []; + const field = (name: string) => fields.find((f) => f.fieldName === name); + const typeOf = (name: string) => String(field(name)?.fieldType ?? ""); + const label = `${this.databaseName}.${this.tableName}`; + + if ( + typeOf("id") !== "STRING" || + !typeOf("data").startsWith("TEXT") || + typeOf("vector") !== "FLOAT_VECTOR" || + typeOf("metadata") !== "JSON" + ) { + throw new Error( + `Baidu Mochow table '${label}' exists but is missing the id/data/vector/metadata schema mem0 requires. Drop it, or point 'tableName' at an unused table.`, + ); + } + + const dimension = field("vector")?.dimension; + if (dimension !== undefined && dimension !== this.embeddingModelDims) { + throw new Error( + `Baidu Mochow table '${label}' stores ${dimension}-dimensional vectors, but 'embeddingModelDims' is ${this.embeddingModelDims}.`, + ); + } + + this.supportsKeywordSearch = + typeOf("textLemmatized").startsWith("TEXT") && + indexes.some((index) => index.indexName === BM25_INDEX); + + if (!this.supportsKeywordSearch) { + console.warn( + `Baidu Mochow table '${label}' has no '${BM25_INDEX}' inverted index. keywordSearch() will return null until the table is recreated.`, + ); + } + } + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this.ensureTable().catch((error) => { + this._initPromise = undefined; + throw error; + }); + } + + return this._initPromise; + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + const { client } = await this.ready(); + + if (vectors.length !== ids.length || vectors.length !== payloads.length) { + throw new Error( + `Baidu insert requires vectors, ids, and payloads of equal length (got ${vectors.length}/${ids.length}/${payloads.length}).`, + ); + } + + const rows = vectors.map((vector, index) => ({ + id: ids[index], + data: memoryData(payloads[index] || {}), + vector, + textLemmatized: lemmatizedText(payloads[index] || {}), + metadata: metadataPayload(payloads[index] || {}), + })); + + check(await client.upsert({ ...this.ns, rows }), "upsert"); + } + + async search( + query: number[], + topK = 5, + filters?: SearchFilters, + ): Promise { + const { client, sdk } = await this.ready(); + const filter = this.filterOf(filters); + + const request = new sdk.VectorTopkSearchRequest( + "vector", + new sdk.Vector(query), + topK, + ) + .Projections(PROJECTIONS) + .Config(new sdk.VectorSearchConfig().Ef(200)); + if (filter) { + request.Filter(filter); + } + + const response = (await client.vectorSearch({ + ...this.ns, + request, + })) as SearchResponse; + check(response, "vectorSearch"); + + return (response.rows ?? []).map((result) => ({ + id: String(result.row.id), + payload: resultPayload(result.row), + // L2 is a distance (lower = closer); the VectorStore contract wants higher = better. + score: + this.metricType === "L2" + ? result.score != null + ? 1 / (1 + result.score) + : undefined + : result.score, + })); + } + + async keywordSearch( + query: string, + topK = 5, + filters?: SearchFilters, + ): Promise { + const { client, sdk } = await this.ready(); + + if (!this.supportsKeywordSearch) { + return null; + } + + const filter = this.filterOf(filters); + const request = new sdk.BM25SearchRequest(BM25_INDEX, query) + .Projections(PROJECTIONS) + .Limit(topK); + if (filter) { + request.Filter(filter); + } + + const response = (await client.bm25Search({ + ...this.ns, + request, + })) as SearchResponse; + check(response, "bm25Search"); + + return (response.rows ?? []).map((result) => ({ + id: String(result.row.id), + payload: resultPayload(result.row), + score: result.score, + })); + } + + async get(vectorId: string): Promise { + const { client } = await this.ready(); + + const response: QueryResponse = await client.query({ + ...this.ns, + primaryKey: { id: vectorId }, + projections: PROJECTIONS, + }); + check(response, `query '${vectorId}'`, ROW_KEY_NOT_FOUND); + + if (!response.row || response.row.id === undefined) { + return null; + } + + return { + id: String(response.row.id), + payload: resultPayload(response.row), + }; + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + const { client } = await this.ready(); + + check( + await client.upsert({ + ...this.ns, + rows: [ + { + id: vectorId, + data: memoryData(payload), + vector, + textLemmatized: lemmatizedText(payload), + metadata: metadataPayload(payload), + }, + ], + }), + `upsert '${vectorId}'`, + ); + } + + async delete(vectorId: string): Promise { + const { client } = await this.ready(); + + check( + await client.delete({ ...this.ns, primaryKey: { id: vectorId } }), + `delete '${vectorId}'`, + ); + } + + async deleteCol(): Promise { + // The constructor starts initialize() without awaiting it. Let any in-flight run land + // first, otherwise it recreates the table after dropTable() and reset() is a no-op. + await this._initPromise?.catch(() => undefined); + this._initPromise = undefined; + this.supportsKeywordSearch = false; + + const sdk = await this.loadSdk(); + const client = await this.ensureClient(); + const { ServerErrCode } = sdk; + + const dropped = check( + await client.dropTable(this.databaseName, this.tableName), + `dropTable '${this.tableName}'`, + ServerErrCode.TableNotExist, + ); + if (dropped === ServerErrCode.TableNotExist) { + return; + } + + // Drops are asynchronous; recreating the table before it is gone fails. + await this.pollTable( + client, + (response) => + check( + response, + `descTable '${this.tableName}'`, + ServerErrCode.TableNotExist, + ) === ServerErrCode.TableNotExist, + "dropped", + ); + } + + async reset(): Promise { + await this.deleteCol(); + await this.initialize(); + } + + async list( + filters?: SearchFilters, + topK = 100, + ): Promise<[VectorStoreResult[], number]> { + const { client } = await this.ready(); + + const response: SelectResponse = await client.select({ + ...this.ns, + filter: this.filterOf(filters), + projections: PROJECTIONS, + limit: topK, + }); + check(response, "select"); + + const memories = (response.rows ?? []).map((row) => ({ + id: String(row.id), + payload: resultPayload(row), + })); + return [memories, memories.length]; + } + + async getUserId(): Promise { + return this.storeUserId; + } + + async setUserId(userId: string): Promise { + this.storeUserId = userId; + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/cassandra.ts b/mem0-ts/src/oss/src/vector_stores/cassandra.ts index fb314e507..6a28fe2e9 100644 --- a/mem0-ts/src/oss/src/vector_stores/cassandra.ts +++ b/mem0-ts/src/oss/src/vector_stores/cassandra.ts @@ -1,6 +1,6 @@ -import cassandra from "cassandra-driver"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; const MIGRATION_ROW_ID = "mem0-user"; const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; @@ -18,7 +18,9 @@ interface CassandraConfig extends VectorStoreConfig { protocolVersion?: number; loadBalancingPolicy?: any; client?: CassandraClientLike; - driver?: typeof cassandra; + /** Pre-configured Cassandra driver module (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + driver?: any; } interface CassandraClientLike { @@ -38,7 +40,7 @@ interface CassandraVector { export class CassandraDB implements VectorStore { private static readonly PAGE_SIZE = 500; - private readonly driver: typeof cassandra; + private readonly driver?: any; private readonly contactPoints?: string[]; private readonly port: number; private readonly username?: string; @@ -54,7 +56,7 @@ export class CassandraDB implements VectorStore { private _initPromise?: Promise; constructor(config: CassandraConfig) { - this.driver = config.driver || cassandra; + this.driver = config.driver; this.contactPoints = config.contactPoints; this.port = config.port || 9042; this.username = config.username; @@ -85,7 +87,7 @@ export class CassandraDB implements VectorStore { private async _doInitialize(): Promise { if (!this.client) { - this.client = this.createClient(); + this.client = await this.createClient(); } if (typeof this.client.connect === "function") { await this.client.connect(); @@ -326,7 +328,8 @@ export class CassandraDB implements VectorStore { ); } - private createClient(): CassandraClientLike { + private async createClient(): Promise { + const driver = this.driver ?? (await this.loadDriver()); const clientConfig: Record = {}; if (this.secureConnectBundle) { @@ -363,13 +366,24 @@ export class CassandraDB implements VectorStore { }; } if (this.username && this.password) { - clientConfig.authProvider = new this.driver.auth.PlainTextAuthProvider( + clientConfig.authProvider = new driver.auth.PlainTextAuthProvider( this.username, this.password, ); } - return new this.driver.Client(clientConfig); + return new driver.Client(clientConfig); + } + + // Loaded dynamically: cassandra-driver is an optional peer dependency, so a static + // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. + private async loadDriver(): Promise { + const sdk = await loadPeer( + "cassandra-driver", + "Cassandra vector store", + () => import("cassandra-driver"), + ); + return sdk.default ?? sdk; } private validateIdentifier(name: string, label: string): string { @@ -441,44 +455,64 @@ export class CassandraDB implements VectorStore { return value.includes(payloadValue); } + // Every operator present in a compound condition must hold (AND), so check + // them all instead of returning on the first match. Returning early meant a + // range like { gte: 10, lte: 20 } only applied `gte`. Mirrors the databricks + // store's matcher. + let sawOperator = false; + if ("eq" in value) { - return payloadValue === value.eq; + sawOperator = true; + if (payloadValue !== value.eq) return false; } if ("ne" in value) { - return payloadValue !== value.ne; + sawOperator = true; + if (payloadValue === value.ne) return false; } if ("gt" in value) { - return payloadValue > value.gt; + sawOperator = true; + if (!(payloadValue > value.gt)) return false; } if ("gte" in value) { - return payloadValue >= value.gte; + sawOperator = true; + if (!(payloadValue >= value.gte)) return false; } if ("lt" in value) { - return payloadValue < value.lt; + sawOperator = true; + if (!(payloadValue < value.lt)) return false; } if ("lte" in value) { - return payloadValue <= value.lte; + sawOperator = true; + if (!(payloadValue <= value.lte)) return false; } if ("in" in value) { - return Array.isArray(value.in) && value.in.includes(payloadValue); + sawOperator = true; + if (!Array.isArray(value.in) || !value.in.includes(payloadValue)) + return false; } if ("nin" in value) { - return !Array.isArray(value.nin) || !value.nin.includes(payloadValue); + sawOperator = true; + if (Array.isArray(value.nin) && value.nin.includes(payloadValue)) + return false; } if ("contains" in value) { - return ( - typeof payloadValue === "string" && - payloadValue.includes(value.contains) - ); + sawOperator = true; + if ( + typeof payloadValue !== "string" || + !payloadValue.includes(value.contains) + ) + return false; } if ("icontains" in value) { - return ( - typeof payloadValue === "string" && - payloadValue.toLowerCase().includes(value.icontains.toLowerCase()) - ); + sawOperator = true; + if ( + typeof payloadValue !== "string" || + !payloadValue.toLowerCase().includes(value.icontains.toLowerCase()) + ) + return false; } - return payloadValue === value; + return sawOperator ? true : payloadValue === value; } private filterVector( diff --git a/mem0-ts/src/oss/src/vector_stores/chroma.ts b/mem0-ts/src/oss/src/vector_stores/chroma.ts index 076e8fda9..bec0e961a 100644 --- a/mem0-ts/src/oss/src/vector_stores/chroma.ts +++ b/mem0-ts/src/oss/src/vector_stores/chroma.ts @@ -1,6 +1,7 @@ -import { ChromaClient, CloudClient } from "chromadb"; +import type { ChromaClient, CloudClient } from "chromadb"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface ChromaConfig extends VectorStoreConfig { /** Pre-configured ChromaDB client instance. */ @@ -32,36 +33,65 @@ const MIGRATIONS_COLLECTION = "memory_migrations"; * so no embedding function is required on the collection. */ export class ChromaDB implements VectorStore { - private client: any; + private clientInstance?: any; + private clientPromise?: Promise; + private readonly config: ChromaConfig; private readonly collectionName: string; private collectionPromise?: Promise; private migrationsPromise?: Promise; constructor(config: ChromaConfig) { - if (config.client) { - this.client = config.client; - } else if (config.apiKey && config.tenant) { - this.client = new CloudClient({ - apiKey: config.apiKey, - tenant: config.tenant, - database: config.database || "mem0", - } as any); - } else { - const params: Record = {}; - if (config.host) params.host = config.host; - if (config.port) params.port = config.port; - if (config.ssl !== undefined) params.ssl = config.ssl; - if (config.path) params.path = config.path; - this.client = new ChromaClient(params as any); - } - + this.config = config; this.collectionName = config.collectionName; this.initialize().catch(console.error); } + /** + * Lazily construct (or reuse) the ChromaDB client, importing the optional + * `chromadb` peer only when the store is first used so consumers that never + * touch Chroma don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + const config = this.config; + if (config.client) { + return config.client; + } + + const sdk = await loadPeer( + "chromadb", + "Chroma vector store", + () => import("chromadb"), + ); + + if (config.apiKey && config.tenant) { + return new sdk.CloudClient({ + apiKey: config.apiKey, + tenant: config.tenant, + database: config.database || "mem0", + } as any); + } + + const params: Record = {}; + if (config.host) params.host = config.host; + if (config.port) params.port = config.port; + if (config.ssl !== undefined) params.ssl = config.ssl; + if (config.path) params.path = config.path; + return new sdk.ChromaClient(params as any); + } + private async getCollection(): Promise { if (!this.collectionPromise) { - this.collectionPromise = this.client.getOrCreateCollection({ + const client = await this.getClient(); + this.collectionPromise = client.getOrCreateCollection({ name: this.collectionName, embeddingFunction: null, }); @@ -71,7 +101,8 @@ export class ChromaDB implements VectorStore { private async getMigrationsCollection(): Promise { if (!this.migrationsPromise) { - this.migrationsPromise = this.client.getOrCreateCollection({ + const client = await this.getClient(); + this.migrationsPromise = client.getOrCreateCollection({ name: MIGRATIONS_COLLECTION, embeddingFunction: null, }); @@ -170,7 +201,8 @@ export class ChromaDB implements VectorStore { } async deleteCol(): Promise { - await this.client.deleteCollection({ name: this.collectionName }); + const client = await this.getClient(); + await client.deleteCollection({ name: this.collectionName }); this.collectionPromise = undefined; } @@ -241,14 +273,14 @@ export class ChromaDB implements VectorStore { private static convertCondition( key: string, value: any, - ): Record | null { + ): Array> { // Wildcard - ChromaDB has no direct wildcard, so skip this filter. if (value === "*") { - return null; + return []; } if (Array.isArray(value)) { - return { [key]: { $in: value } }; + return [{ [key]: { $in: value } }]; } if (value !== null && typeof value === "object") { @@ -262,19 +294,31 @@ export class ChromaDB implements VectorStore { in: "$in", nin: "$nin", }; - const condition: Record = {}; - for (const [op, val] of Object.entries(value)) { - if (op in opMap) { - condition[key] = { [opMap[op]]: val }; - } else { - // contains/icontains and unknown operators fall back to equality. - condition[key] = { $eq: val }; - } - } - return condition; + // ChromaDB allows exactly one operator per field expression, so each + // operator becomes its own clause (combined with $and by the caller). + // Previously each operator overwrote the last, silently dropping range + // bounds. contains/icontains and unknown operators fall back to + // equality. + return Object.entries(value).map(([op, val]) => ({ + [key]: { [opMap[op] ?? "$eq"]: val }, + })); } - return { [key]: { $eq: value } }; + return [{ [key]: { $eq: value } }]; + } + + /** Combine clauses under a logical operator, unwrapping singletons. */ + private static combineClauses( + clauses: Array>, + operator: "$and" | "$or", + ): Record | null { + if (clauses.length === 0) { + return null; + } + if (clauses.length === 1) { + return clauses[0]; + } + return { [operator]: clauses }; } /** @@ -305,66 +349,55 @@ export class ChromaDB implements VectorStore { if (key === "$or" || key === "OR") { const orConditions: any[] = []; for (const condition of value as any[]) { - const built: Record = {}; + const subClauses: Array> = []; for (const [subKey, subValue] of Object.entries(condition)) { - const converted = ChromaDB.convertCondition(subKey, subValue); - if (converted) Object.assign(built, converted); + subClauses.push(...ChromaDB.convertCondition(subKey, subValue)); } - if (Object.keys(built).length > 0) orConditions.push(built); - } - if (orConditions.length > 1) { - processed.push({ $or: orConditions }); - } else if (orConditions.length === 1) { - processed.push(orConditions[0]); + // Multi-field conditions must be wrapped in $and β€” ChromaDB rejects + // flat objects with more than one field per level. + const combined = ChromaDB.combineClauses(subClauses, "$and"); + if (combined) orConditions.push(combined); } + const combinedOr = ChromaDB.combineClauses(orConditions, "$or"); + if (combinedOr) processed.push(combinedOr); } else if (key === "$not" || key === "NOT") { // De Morgan: NOT(a AND b) is (NOT a) OR (NOT b), so the negated fields // within one condition are combined with $or, and separate conditions // are combined with $and. This mirrors the Python SDK's ChromaDB port. const negatedPerGroup: any[] = []; for (const condition of value as any[]) { - const negatedFields: any[] = []; + const negatedFields: Array> = []; for (const [subKey, subValue] of Object.entries(condition)) { if (subValue !== null && typeof subValue === "object") { for (const [op, val] of Object.entries(subValue as any)) { - const neg = negateOp[op]; - if (neg) { - const converted = ChromaDB.convertCondition(subKey, { - [neg]: val, - }); - if (converted) negatedFields.push(converted); - } + // Unknown operators mirror the positive-path equality + // fallback as inequality (previously they were silently + // dropped, which could erase the entire NOT clause). + const neg = negateOp[op] ?? "ne"; + negatedFields.push( + ...ChromaDB.convertCondition(subKey, { [neg]: val }), + ); } } else { - const converted = ChromaDB.convertCondition(subKey, { - ne: subValue, - }); - if (converted) negatedFields.push(converted); + negatedFields.push( + ...ChromaDB.convertCondition(subKey, { ne: subValue }), + ); } } - if (negatedFields.length > 1) { - negatedPerGroup.push({ $or: negatedFields }); - } else if (negatedFields.length === 1) { - negatedPerGroup.push(negatedFields[0]); - } - } - if (negatedPerGroup.length > 1) { - processed.push({ $and: negatedPerGroup }); - } else if (negatedPerGroup.length === 1) { - processed.push(negatedPerGroup[0]); + const combined = ChromaDB.combineClauses(negatedFields, "$or"); + if (combined) negatedPerGroup.push(combined); } + const combinedNot = ChromaDB.combineClauses(negatedPerGroup, "$and"); + if (combinedNot) processed.push(combinedNot); } else { - const converted = ChromaDB.convertCondition(key, value); - if (converted) processed.push(converted); + const combined = ChromaDB.combineClauses( + ChromaDB.convertCondition(key, value), + "$and", + ); + if (combined) processed.push(combined); } } - if (processed.length === 0) { - return undefined; - } - if (processed.length === 1) { - return processed[0]; - } - return { $and: processed }; + return ChromaDB.combineClauses(processed, "$and") ?? undefined; } } diff --git a/mem0-ts/src/oss/src/vector_stores/databricks.ts b/mem0-ts/src/oss/src/vector_stores/databricks.ts new file mode 100644 index 000000000..627e522ee --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/databricks.ts @@ -0,0 +1,1627 @@ +import axios, { AxiosInstance } from "axios"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const DEFAULT_PAGE_SIZE = 100; +const MAX_QUERY_RESULTS = 10_000; +const MAX_FULL_TEXT_RESULTS = 200; +const DEFAULT_SYNC_POLL_INTERVAL_MS = 1000; +const DEFAULT_SYNC_TIMEOUT_MS = 5 * 60 * 1000; +const DATABRICKS_SERVER_FILTER_KEYS = new Set([ + "memory_id", + "user_id", + "agent_id", + "run_id", +]); + +interface DatabricksConfig extends VectorStoreConfig { + workspaceUrl?: string; + host?: string; + httpPath: string; + accessToken?: string; + clientId?: string; + clientSecret?: string; + endpointName?: string; + endpointType?: "STANDARD" | "STORAGE_OPTIMIZED"; + pipelineType?: "TRIGGERED" | "CONTINUOUS"; + queryType?: "ANN" | "HYBRID"; + catalog?: string; + schema?: string; + tableName?: string; + embeddingModelDims?: number; + syncPollIntervalMs?: number; + syncTimeoutMs?: number; + sqlClient?: DatabricksSqlClientLike; + httpClient?: DatabricksHttpClientLike; +} + +interface DatabricksSqlClientLike { + connect(options: Record): Promise; + openSession(): Promise; + close?(): Promise; +} + +interface DatabricksSqlSessionLike { + executeStatement(statement: string): Promise; + close?(): Promise; +} + +interface DatabricksSqlOperationLike { + fetchAll(): Promise>>; + close?(): Promise; +} + +interface DatabricksHttpClientLike { + get(url: string, config?: Record): Promise<{ data: any }>; + post( + url: string, + data?: any, + config?: Record, + ): Promise<{ data: any }>; + delete(url: string, config?: Record): Promise<{ data: any }>; +} + +interface DatabricksVector { + id: string; + payload: Record; +} + +function buildDatabricksAuthorizationDetails( + indexName: string, + operation: "ReadVectorIndex" | "WriteVectorIndex", +): string { + return JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: indexName, + operation, + }, + ]); +} + +function validateIdentifier( + name: string, + label: string = "identifier", +): string { + if (!SAFE_IDENTIFIER_RE.test(name)) { + throw new Error( + `Invalid ${label} '${name}': only letters, digits, and underscores are allowed, ` + + `must start with a letter or underscore, and be at most 128 characters.`, + ); + } + return name; +} + +function extractHostAndWorkspaceUrl(config: DatabricksConfig): { + host: string; + workspaceUrl: string; +} { + if (config.workspaceUrl) { + const url = new URL(config.workspaceUrl); + return { + host: url.host, + workspaceUrl: `${url.protocol}//${url.host}`, + }; + } + + if (config.host) { + return { + host: config.host, + workspaceUrl: `https://${config.host}`, + }; + } + + throw new Error( + "Databricks vector store requires either workspaceUrl or host.", + ); +} + +// `Memory.search()` hands keywordSearch() an already-lemmatized query, so BM25 has to score +// against lemmatized text. Same contract as baidu.ts / pgvector.ts. +function lemmatizedText(payload: Record): string { + const data = typeof payload.data === "string" ? payload.data : ""; + return typeof payload.textLemmatized === "string" && + payload.textLemmatized.length > 0 + ? payload.textLemmatized + : data; +} + +function formatSqlValue(value: any): string { + if (value === null || value === undefined) { + return "NULL"; + } + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Databricks vector store only accepts finite numbers."); + } + return String(value); + } + if (Array.isArray(value)) { + return `array(${value.map((entry) => formatSqlValue(entry)).join(", ")})`; + } + const json = + typeof value === "string" ? value : JSON.stringify(value ?? {}) || "{}"; + // Databricks/Spark SQL treats backslash as an escape char in string literals, + // so a trailing "\" would consume the closing quote (breaking out of the + // literal) and any backslash would be dropped on write. Escape backslashes + // before doubling quotes so the literal is injection-safe and round-trips. + const escaped = json.replace(/\\/g, "\\\\").replace(/'/g, "''"); + return `'${escaped}'`; +} + +function extractRowValue(row: Record, keys: string[]): any { + for (const key of keys) { + if (key in row) { + return row[key]; + } + const upper = key.toUpperCase(); + if (upper in row) { + return row[upper]; + } + } + return undefined; +} + +function isPlainObject(value: any): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeFilterValue(value: any): any { + if (Array.isArray(value)) { + return value.map((entry) => normalizeFilterValue(entry)); + } + return value; +} + +function isDatabricksServerFilterKey(key: string): boolean { + return DATABRICKS_SERVER_FILTER_KEYS.has(key); +} + +function mergeDatabricksFilters( + target: Record, + source: Record, +): boolean { + for (const [key, value] of Object.entries(source)) { + if ( + key in target && + JSON.stringify(target[key]) !== JSON.stringify(value) + ) { + return false; + } + target[key] = value; + } + return true; +} + +function buildSimpleDatabricksFilter( + key: string, + value: any, +): Record | null { + if (!isDatabricksServerFilterKey(key)) { + return null; + } + + const safeKey = validateIdentifier(key, "filter key"); + + if (!isPlainObject(value)) { + return { + [safeKey]: normalizeFilterValue(value), + }; + } + + const entries = Object.entries(value); + if (entries.length !== 1) { + return null; + } + + const [operator, operand] = entries[0]; + switch (operator) { + case "eq": + return { [safeKey]: normalizeFilterValue(operand) }; + case "ne": + return { [`${safeKey} NOT`]: normalizeFilterValue(operand) }; + case "gt": + return { [`${safeKey} >`]: normalizeFilterValue(operand) }; + case "gte": + return { [`${safeKey} >=`]: normalizeFilterValue(operand) }; + case "lt": + return { [`${safeKey} <`]: normalizeFilterValue(operand) }; + case "lte": + return { [`${safeKey} <=`]: normalizeFilterValue(operand) }; + case "in": + return Array.isArray(operand) + ? { [safeKey]: normalizeFilterValue(operand) } + : null; + case "nin": + return Array.isArray(operand) && operand.length === 1 + ? { [`${safeKey} NOT`]: normalizeFilterValue(operand[0]) } + : null; + default: + return null; + } +} + +function buildStandardDatabricksFiltersFromClauses( + clauses: Array<[string, any]>, +): Record | undefined { + const result: Record = {}; + + for (const [key, value] of clauses) { + const translated = buildSimpleDatabricksFilter(key, value); + if (!translated) { + continue; + } + if (!mergeDatabricksFilters(result, translated)) { + return undefined; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function buildStorageOptimizedDatabricksFilterClause( + key: string, + value: any, +): string | null { + if (!isDatabricksServerFilterKey(key)) { + return null; + } + + const safeKey = validateIdentifier(key, "filter key"); + + if (!isPlainObject(value)) { + if (Array.isArray(value)) { + return value.length > 0 + ? `${safeKey} IN (${value + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")})` + : null; + } + return `${safeKey} = ${formatSqlValue(normalizeFilterValue(value))}`; + } + + const entries = Object.entries(value); + if (entries.length !== 1) { + return null; + } + + const [operator, operand] = entries[0]; + switch (operator) { + case "eq": + return `${safeKey} = ${formatSqlValue(normalizeFilterValue(operand))}`; + case "ne": + // SQL three-valued logic: `col != x` is NULL (excluded) when col IS NULL, + // but the local matcher keeps rows with a missing field for `ne`. Match it. + return `(${safeKey} IS NULL OR ${safeKey} != ${formatSqlValue(normalizeFilterValue(operand))})`; + case "gt": + return `${safeKey} > ${formatSqlValue(normalizeFilterValue(operand))}`; + case "gte": + return `${safeKey} >= ${formatSqlValue(normalizeFilterValue(operand))}`; + case "lt": + return `${safeKey} < ${formatSqlValue(normalizeFilterValue(operand))}`; + case "lte": + return `${safeKey} <= ${formatSqlValue(normalizeFilterValue(operand))}`; + case "in": + return Array.isArray(operand) && operand.length > 0 + ? `${safeKey} IN (${operand + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")})` + : null; + case "nin": + // Same NULL-handling as `ne` above. + return Array.isArray(operand) && operand.length > 0 + ? `(${safeKey} IS NULL OR ${safeKey} NOT IN (${operand + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")}))` + : null; + default: + return null; + } +} + +function collectConjunctiveDatabricksFilters( + filters?: SearchFilters, +): Array<[string, any]> { + if (!filters || Object.keys(filters).length === 0) { + return []; + } + + const clauses: Array<[string, any]> = []; + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + if (!isPlainObject(entry)) { + continue; + } + clauses.push(...collectConjunctiveDatabricksFilters(entry)); + } + continue; + } + + if (key === "$or" || key === "$not") { + continue; + } + + clauses.push([key, value]); + } + + return clauses; +} + +// True iff `filters` has no `$or`/`$not` at any nesting depth (recursing through +// `$and`), i.e. every entry is something collectConjunctiveDatabricksFilters can see. +function isFullyConjunctiveDatabricksFilter(filters?: SearchFilters): boolean { + if (!filters || Object.keys(filters).length === 0) { + return true; + } + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + return false; + } + if ( + value.some( + (entry) => + !isPlainObject(entry) || + !isFullyConjunctiveDatabricksFilter(entry as SearchFilters), + ) + ) { + return false; + } + continue; + } + + if (key === "$or" || key === "$not") { + return false; + } + } + + return true; +} + +function buildDatabricksServerFilters( + endpointType: "STANDARD" | "STORAGE_OPTIMIZED", + filters?: SearchFilters, +): { filters?: string; filters_json?: string } { + const clauses = collectConjunctiveDatabricksFilters(filters); + + if (clauses.length === 0) { + return {}; + } + + if (endpointType === "STORAGE_OPTIMIZED") { + const translatedClauses = clauses + .map(([key, value]) => + buildStorageOptimizedDatabricksFilterClause(key, value), + ) + .filter((clause): clause is string => Boolean(clause)); + + return translatedClauses.length > 0 + ? { filters: translatedClauses.join(" AND ") } + : {}; + } + + const translatedFilters = buildStandardDatabricksFiltersFromClauses(clauses); + + return translatedFilters + ? { filters_json: JSON.stringify(translatedFilters) } + : {}; +} + +export class DatabricksVectorStore implements VectorStore { + private readonly host: string; + private readonly workspaceUrl: string; + private readonly httpPath: string; + private readonly accessToken?: string; + private readonly clientId?: string; + private readonly clientSecret?: string; + private readonly endpointName: string; + private readonly endpointType: "STANDARD" | "STORAGE_OPTIMIZED"; + private readonly pipelineType: "TRIGGERED" | "CONTINUOUS"; + private readonly queryType: "ANN" | "HYBRID"; + private readonly dimension: number; + private readonly catalog: string; + private readonly schema: string; + private readonly tableName: string; + private readonly indexName: string; + private readonly fullTableName: string; + private readonly fullIndexName: string; + private readonly syncPollIntervalMs: number; + private readonly syncTimeoutMs: number; + private sqlClient: DatabricksSqlClientLike | null; + private readonly httpClient: DatabricksHttpClientLike; + private session?: DatabricksSqlSessionLike; + private _sessionPromise?: Promise; + private _initPromise?: Promise; + private sqlModulePromise?: Promise<{ + DBSQLClient: new () => DatabricksSqlClientLike; + }>; + private indexSyncWait: Promise | null = null; + private indexSyncRunning = false; + private indexSyncQueued = false; + private indexSyncError: unknown = null; + private readonly oauthTokens = new Map< + string, + { accessToken: string; expiresAt: number } + >(); + + constructor(config: DatabricksConfig) { + const { host, workspaceUrl } = extractHostAndWorkspaceUrl(config); + + this.host = host; + this.workspaceUrl = workspaceUrl; + this.httpPath = config.httpPath; + this.accessToken = config.accessToken; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.endpointName = config.endpointName || "mem0_vector_search"; + this.endpointType = config.endpointType || "STANDARD"; + this.pipelineType = config.pipelineType || "TRIGGERED"; + this.queryType = config.queryType || "ANN"; + this.dimension = config.embeddingModelDims || config.dimension || 1536; + this.catalog = validateIdentifier(config.catalog || "main", "catalog"); + this.schema = validateIdentifier(config.schema || "default", "schema"); + this.indexName = validateIdentifier( + config.collectionName || "mem0", + "collectionName", + ); + this.tableName = validateIdentifier( + config.tableName || this.indexName, + "tableName", + ); + this.fullTableName = `${this.catalog}.${this.schema}.${this.tableName}`; + this.fullIndexName = `${this.catalog}.${this.schema}.${this.indexName}`; + this.syncPollIntervalMs = + config.syncPollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS; + this.syncTimeoutMs = config.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS; + this.sqlClient = config.sqlClient ?? null; + this.httpClient = config.httpClient || this.createHttpClient(); + + if ( + this.endpointType === "STORAGE_OPTIMIZED" && + this.pipelineType !== "TRIGGERED" + ) { + throw new Error( + "Databricks storage-optimized endpoints only support TRIGGERED pipelineType.", + ); + } + + if ( + this.endpointType === "STORAGE_OPTIMIZED" && + this.dimension % 16 !== 0 + ) { + throw new Error( + "Databricks storage-optimized endpoints require dimensions divisible by 16.", + ); + } + + this.initialize().catch(console.error); + } + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize().catch((error) => { + // A failed init (e.g. a cold/auto-suspended warehouse at startup) must not be cached + // forever -- clear it so the next public call retries instead of replaying the + // rejection. Every step is idempotent (CREATE ... IF NOT EXISTS / ensure*), so a + // retry is safe. + this._initPromise = undefined; + throw error; + }); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + await this.executeSql( + `CREATE SCHEMA IF NOT EXISTS ${this.catalog}.${this.schema}`, + ); + await this.executeSql(` + CREATE TABLE IF NOT EXISTS ${this.fullTableName} ( + memory_id STRING, + embedding ARRAY, + payload STRING, + text_lemmatized STRING, + user_id STRING, + agent_id STRING, + run_id STRING + ) USING DELTA + TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true') + `); + await this.ensureChangeDataFeedEnabled(); + await this.executeSql(` + CREATE TABLE IF NOT EXISTS ${this.catalog}.${this.schema}.memory_migrations ( + user_id STRING + ) USING DELTA + `); + + await this.ensureEndpointExists(); + await this.waitForEndpointReadiness(); + await this.ensureIndexExists(); + await this.waitForIndexReadiness(); + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + await this.initialize(); + + const values = vectors.map((vector, index) => { + this.assertVectorDimension(vector, "Vector"); + const payload = payloads[index] || {}; + const sessionValues = this.extractSessionValues(payload); + return `( + ${formatSqlValue(ids[index])}, + ${formatSqlValue(vector)}, + ${formatSqlValue(JSON.stringify(payload))}, + ${formatSqlValue(lemmatizedText(payload))}, + ${formatSqlValue(sessionValues.user_id)}, + ${formatSqlValue(sessionValues.agent_id)}, + ${formatSqlValue(sessionValues.run_id)} + )`; + }); + + await this.executeSql(` + INSERT INTO ${this.fullTableName} + (memory_id, embedding, payload, text_lemmatized, user_id, agent_id, run_id) + VALUES ${values.join(", ")} + `); + this.requestIndexSync(); + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + this.assertVectorDimension(query, "Query"); + + if (this.queryType === "HYBRID") { + throw new Error( + "Databricks HYBRID search requires query_text, but search() only receives query vectors.", + ); + } + + const requestFilters = buildDatabricksServerFilters( + this.endpointType, + filters, + ); + + return this.queryIndex( + { + columns: ["memory_id", "payload"], + query_type: this.queryType, + query_vector: query, + ...requestFilters, + }, + filters, + topK, + ); + } + + async keywordSearch( + query: string, + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + const requestFilters = buildDatabricksServerFilters( + this.endpointType, + filters, + ); + + // ponytail: `text_lemmatized` is synced so BM25 has clean text to score, but this query + // does not scope matching to it -- the `query_columns` parameter that would is Beta and + // gated behind the Full-Text Search public preview. Until it is GA the serialized + // `payload` column is also matchable, which adds noise (hashes, JSON keys) to ranking. + // Upgrade path: add `query_columns: ["text_lemmatized"]` once the preview ships. + return this.queryIndex( + { + columns: ["memory_id", "payload"], + query_type: "FULL_TEXT", + query_text: query, + ...requestFilters, + }, + filters, + topK, + ); + } + + async get(vectorId: string): Promise { + await this.initialize(); + + const rows = await this.executeSql(` + SELECT memory_id, payload + FROM ${this.fullTableName} + WHERE memory_id = ${formatSqlValue(vectorId)} + LIMIT 1 + `); + const row = rows[0]; + + if (!row) { + return null; + } + + return { + id: String(extractRowValue(row, ["memory_id"]) || vectorId), + payload: this.parsePayload(extractRowValue(row, ["payload"])), + }; + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + await this.initialize(); + this.assertVectorDimension(vector, "Vector"); + + // Session columns (user_id/agent_id/run_id) are the scope keys that search/list filter on and + // are written once at insert(). Mirror Python's `excluded_keys`: update() must never rewrite + // them from the payload -- a partial payload that omits these keys would otherwise SET them to + // NULL and hide the row from every session-scoped search and list(). + await this.executeSql(` + UPDATE ${this.fullTableName} + SET embedding = ${formatSqlValue(vector)}, + payload = ${formatSqlValue(JSON.stringify(payload || {}))}, + text_lemmatized = ${formatSqlValue(lemmatizedText(payload || {}))} + WHERE memory_id = ${formatSqlValue(vectorId)} + `); + this.requestIndexSync(); + } + + async delete(vectorId: string): Promise { + await this.initialize(); + + await this.executeSql(` + DELETE FROM ${this.fullTableName} + WHERE memory_id = ${formatSqlValue(vectorId)} + `); + this.requestIndexSync(); + } + + async deleteCol(): Promise { + await this.initialize(); + + try { + await this.httpClient.delete( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + } + + await this.executeSql(`DROP TABLE IF EXISTS ${this.fullTableName}`); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + // `limit` below is interpolated directly into the SQL string (not a bound parameter, + // and not passed through formatSqlValue()), so a non-integer or non-positive topK must + // be rejected here rather than reaching the query -- otherwise a caller that skips + // TypeScript's compile-time check (e.g. anything passing user input straight through) + // could inject arbitrary SQL via the LIMIT clause. + if (!Number.isSafeInteger(topK) || topK <= 0) { + // isSafeInteger (not isInteger): an unsafe/huge integer like 1e21 stringifies as "1e+21", + // which is meaningless as a LIMIT and would slip past a plain integer check. + throw new Error( + `Databricks vector store: topK must be a positive integer, got ${topK}`, + ); + } + + await this.initialize(); + + // Push the SQL-translatable conjunctive filters (session keys) into a WHERE + // clause so a filtered list does not pull the whole table to the client. + // filterVector below still enforces the complete filter, so this clause is a + // best-effort narrowing: untranslatable filters ($or/$not/metadata) yield an + // empty clause and fall back to a bounded scan (see LIMIT below) + local filtering. + const conjunctiveFilters = collectConjunctiveDatabricksFilters(filters); + const clauses = conjunctiveFilters.map(([key, value]) => + buildStorageOptimizedDatabricksFilterClause(key, value), + ); + const whereClause = clauses + .filter((clause): clause is string => Boolean(clause)) + .join(" AND "); + + // The WHERE clause fully expresses `filters` (so filterVector below is a + // no-op) only if there's no $or/$not anywhere AND every entry translated. + const isFullyExpressed = + isFullyConjunctiveDatabricksFilter(filters) && + clauses.every((clause) => clause !== null); + // ponytail: 10k scan ceiling when the filter can't be fully pushed down; paginate if a user hits it. + const limit = isFullyExpressed ? topK : MAX_QUERY_RESULTS; + + const rows = await this.executeSql(` + SELECT memory_id, payload + FROM ${this.fullTableName} + ${whereClause ? `WHERE ${whereClause}` : ""} + LIMIT ${limit} + `); + const results: VectorStoreResult[] = []; + + for (const row of rows) { + const item: DatabricksVector = { + id: String(extractRowValue(row, ["memory_id"])), + payload: this.parsePayload(extractRowValue(row, ["payload"])), + }; + if (!this.filterVector(item, filters)) { + continue; + } + results.push({ + id: item.id, + payload: item.payload, + }); + } + + return [results.slice(0, topK), results.length]; + } + + async getUserId(): Promise { + await this.initialize(); + + const rows = await this.executeSql(` + SELECT user_id + FROM ${this.catalog}.${this.schema}.memory_migrations + LIMIT 1 + `); + const existing = rows[0] + ? extractRowValue(rows[0], ["user_id"]) + : undefined; + + if (typeof existing === "string" && existing.length > 0) { + return existing; + } + + const userId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await this.setUserId(userId); + return userId; + } + + async setUserId(userId: string): Promise { + await this.initialize(); + + await this.executeSql( + `DELETE FROM ${this.catalog}.${this.schema}.memory_migrations`, + ); + await this.executeSql(` + INSERT INTO ${this.catalog}.${this.schema}.memory_migrations (user_id) + VALUES (${formatSqlValue(userId)}) + `); + } + + private async ensureEndpointExists(): Promise { + try { + await this.httpClient.get( + `/endpoints/${encodeURIComponent(this.endpointName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + + await this.httpClient.post("/endpoints", { + name: this.endpointName, + endpoint_type: this.endpointType, + }); + } + } + + private async ensureIndexExists(): Promise { + try { + await this.httpClient.get( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + + await this.httpClient.post("/indexes", { + name: this.fullIndexName, + endpoint_name: this.endpointName, + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: this.fullTableName, + pipeline_type: this.pipelineType, + // ponytail: `payload` stays synced because query results are hydrated straight + // from it. Drop it here and hydrate by memory_id over SQL if index size bites. + columns_to_sync: + this.endpointType === "STANDARD" + ? [ + "memory_id", + "payload", + "text_lemmatized", + "user_id", + "agent_id", + "run_id", + ] + : undefined, + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: this.dimension, + }, + ], + }, + }); + } + } + + private async ensureChangeDataFeedEnabled(): Promise { + if (this.endpointType !== "STANDARD") { + return; + } + + await this.executeSql(` + ALTER TABLE ${this.fullTableName} + SET TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true') + `); + } + + private createHttpClient(): AxiosInstance { + const baseURL = `${this.workspaceUrl}/api/2.0/vector-search`; + + if (this.accessToken) { + return axios.create({ + baseURL, + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + "Databricks vector store requires accessToken or clientId/clientSecret when httpClient is not provided.", + ); + } + + const baseClient = axios.create({ baseURL }); + return { + get: async (url: string, config?: Record) => + baseClient.get(url, await this.withOAuthHeaders("GET", url, config)), + post: async (url: string, data?: any, config?: Record) => + baseClient.post( + url, + data, + await this.withOAuthHeaders("POST", url, config), + ), + delete: async (url: string, config?: Record) => + baseClient.delete( + url, + await this.withOAuthHeaders("DELETE", url, config), + ), + } as DatabricksHttpClientLike as AxiosInstance; + } + + private async withOAuthHeaders( + method: "GET" | "POST" | "DELETE", + url: string, + config?: Record, + ): Promise> { + const token = await this.getOAuthAccessToken( + this.getOAuthAuthorizationDetails(method, url), + ); + return { + ...(config || {}), + headers: { + ...(config?.headers || {}), + Authorization: `Bearer ${token}`, + }, + }; + } + + private getOAuthAuthorizationDetails( + method: "GET" | "POST" | "DELETE", + url: string, + ): string | undefined { + if ( + method === "POST" && + (url.endsWith("/query") || url.endsWith("/query-next-page")) + ) { + return buildDatabricksAuthorizationDetails( + this.fullIndexName, + "ReadVectorIndex", + ); + } + + if ( + method === "POST" && + (url.endsWith("/sync") || + url.endsWith("/upsert-data") || + url.endsWith("/delete-data")) + ) { + return buildDatabricksAuthorizationDetails( + this.fullIndexName, + "WriteVectorIndex", + ); + } + + return undefined; + } + + private async getOAuthAccessToken( + authorizationDetails?: string, + ): Promise { + const cacheKey = authorizationDetails || "__management__"; + const cached = this.oauthTokens.get(cacheKey); + if (cached && Date.now() < cached.expiresAt - 60_000) { + return cached.accessToken; + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + "Databricks vector store requires clientId/clientSecret for OAuth token refresh.", + ); + } + + const formData = new URLSearchParams({ + grant_type: "client_credentials", + scope: "all-apis", + }); + if (authorizationDetails) { + formData.set("authorization_details", authorizationDetails); + } + + const response = await axios.post( + `${this.workspaceUrl}/oidc/v1/token`, + formData, + { + auth: { + username: this.clientId, + password: this.clientSecret, + }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + const token = response?.data?.access_token; + if (typeof token !== "string" || token.length === 0) { + throw new Error( + "Databricks OAuth token response did not include access_token.", + ); + } + + const expiresInSeconds = Number(response?.data?.expires_in ?? 3600); + this.oauthTokens.set(cacheKey, { + accessToken: token, + expiresAt: Date.now() + Math.max(1, expiresInSeconds) * 1000, + }); + return token; + } + + private async getSession(): Promise { + if (this.session) { + return this.session; + } + + if (!this._sessionPromise) { + this._sessionPromise = this.openSession(); + } + + try { + this.session = await this._sessionPromise; + } catch (error) { + // The first connection attempt failed (cold/auto-suspended warehouse, or a token that + // expired before first use). This runs before executeSql()'s own try/catch, so drop the + // rejected promise here too -- otherwise the next call replays it forever. + this.resetSession(); + throw error; + } + return this.session; + } + + /** + * `@databricks/sql` is an OPTIONAL peer, and `index.ts` re-exports this module eagerly, so a + * top-level `import` makes `import "mem0ai/oss"` throw MODULE_NOT_FOUND for every user who + * never installed the driver -- including everyone who uses a different vector store. Load it + * on the first SQL call instead, the way milvus.ts and baidu.ts load theirs. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite `require()` in + * the published ESM bundle (`dist/oss/index.mjs`) into a `__require` shim that throws + * `Dynamic require of "..." is not supported`, so every ESM consumer would hit a dead + * provider even with the driver installed. + */ + private async getSqlModule(): Promise<{ + DBSQLClient: new () => DatabricksSqlClientLike; + }> { + if (!this.sqlModulePromise) { + this.sqlModulePromise = import("@databricks/sql").then( + (mod) => + mod as unknown as { DBSQLClient: new () => DatabricksSqlClientLike }, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sqlModulePromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@databricks/sql' package is required to use the Databricks vector store. " + + `Install it with: npm install @databricks/sql (original error: ${detail})`, + ); + }, + ); + } + return this.sqlModulePromise; + } + + private async getSqlClient(): Promise { + if (!this.sqlClient) { + const { DBSQLClient } = await this.getSqlModule(); + this.sqlClient = new DBSQLClient(); + } + return this.sqlClient; + } + + private async openSession(): Promise { + const sqlClient = await this.getSqlClient(); + const connected = await sqlClient.connect(this.buildSqlConnectionOptions()); + return connected.openSession(); + } + + private buildSqlConnectionOptions(): Record { + const base = { + host: this.host, + path: this.httpPath, + } as Record; + + if (this.clientId && this.clientSecret) { + return { + ...base, + authType: "databricks-oauth", + oauthClientId: this.clientId, + oauthClientSecret: this.clientSecret, + }; + } + + if (!this.accessToken) { + throw new Error( + "Databricks vector store requires accessToken or clientId/clientSecret for SQL connections.", + ); + } + + return { + ...base, + token: this.accessToken, + }; + } + + /** + * Drop the cached session/session-promise so the next `getSession()` opens a fresh one. + * Called after any SQL failure below -- a stale session (expired token, dropped socket) + * would otherwise keep being handed out and keep failing forever. + */ + private resetSession(): void { + this.session = undefined; + this._sessionPromise = undefined; + } + + private async executeSql( + statement: string, + ): Promise>> { + const session = await this.getSession(); + + let operation; + try { + operation = await session.executeStatement(statement); + } catch (error) { + // ponytail: no retry here -- statement could be a non-idempotent write, so we only + // clear the bad session and let the caller's own retry (if any) reconnect. + this.resetSession(); + throw error; + } + + try { + return await operation.fetchAll(); + } catch (error) { + this.resetSession(); + throw error; + } finally { + if (typeof operation.close === "function") { + await operation.close(); + } + } + } + + /** + * A TRIGGERED pipeline only ingests new rows when it is explicitly synced, so every write + * must request one. Writers fire the sync and return; only readers wait for it to land. + * A sync already in flight absorbs later writes -- deleteAll()'s N sequential deletes + * collapse into far fewer than N pipeline runs. + */ + private requestIndexSync(): void { + if (this.pipelineType !== "TRIGGERED") { + return; + } + + this.indexSyncQueued = true; + if (this.indexSyncRunning) { + // A drain is live and has not yet re-checked the queue, so it will pick this up. + return; + } + this.indexSyncRunning = true; + this.indexSyncWait = this.drainIndexSync(); + } + + private async drainIndexSync(): Promise { + try { + // Re-checking the flag and clearing `indexSyncRunning` both happen with no `await` + // between them, so a write can never land on a drain that has stopped looking. + while (this.indexSyncQueued) { + this.indexSyncQueued = false; + await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/sync`, + ); + await this.waitForIndexReadiness(); + } + this.indexSyncError = null; + } catch (error) { + // Surfaced to the next reader: the writer that scheduled this has already returned. + this.indexSyncError = error; + } finally { + this.indexSyncRunning = false; + } + } + + private async awaitIndexSync(): Promise { + while (this.indexSyncWait) { + const wait = this.indexSyncWait; + await wait; + // Only retire the promise we actually awaited; a newer drain may have replaced it, + // and that one still owes us its rows. Clearing unconditionally would drop it. + if (this.indexSyncWait === wait) { + this.indexSyncWait = null; + } + } + if (this.indexSyncError) { + const error = this.indexSyncError; + this.indexSyncError = null; + throw error; + } + } + + private async waitForEndpointReadiness(): Promise { + const deadline = Date.now() + this.syncTimeoutMs; + + while (Date.now() <= deadline) { + const response = await this.httpClient.get( + `/endpoints/${encodeURIComponent(this.endpointName)}`, + ); + const state = + response?.data?.endpoint_status?.state ?? response?.data?.state; + + if (state === "ONLINE") { + return; + } + + if (typeof state !== "string") { + throw new Error( + "Databricks endpoint status did not report a state during initialization.", + ); + } + + if (this.syncPollIntervalMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.syncPollIntervalMs), + ); + } + } + + throw new Error( + `Timed out waiting for Databricks endpoint ${this.endpointName} to become ready.`, + ); + } + + private async waitForIndexReadiness(): Promise { + const deadline = Date.now() + this.syncTimeoutMs; + + while (Date.now() <= deadline) { + const response = await this.httpClient.get( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + const ready = response?.data?.status?.ready; + + if (ready === true) { + return; + } + + if (ready !== false) { + throw new Error( + "Databricks index status did not report a readiness flag after sync.", + ); + } + + if (this.syncPollIntervalMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.syncPollIntervalMs), + ); + } + } + + throw new Error( + `Timed out waiting for Databricks index ${this.fullIndexName} to become ready after sync.`, + ); + } + + private shouldPaginateForLocalFiltering(filters?: SearchFilters): boolean { + if (!filters || Object.keys(filters).length === 0) { + return false; + } + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + return true; + } + if ( + value.some( + (entry) => + !isPlainObject(entry) || + this.shouldPaginateForLocalFiltering(entry as SearchFilters), + ) + ) { + return true; + } + continue; + } + + if (key === "$or" || key === "$not") { + return true; + } + + const translated = + this.endpointType === "STORAGE_OPTIMIZED" + ? buildStorageOptimizedDatabricksFilterClause(key, value) + : buildSimpleDatabricksFilter(key, value); + + if (!translated) { + return true; + } + } + + return false; + } + + private extractNextPageToken(responseData: any): string | undefined { + const token = + responseData?.next_page_token ?? responseData?.result?.next_page_token; + return typeof token === "string" && token.length > 0 ? token : undefined; + } + + private async queryIndex( + requestBody: Record, + filters: SearchFilters | undefined, + topK: number, + ): Promise { + // Read-after-write: writers only request the sync, the reader is what blocks on it. + // get()/list() skip this -- they read the source Delta table, which is always current. + await this.awaitIndexSync(); + const requiresLocalFilteringPagination = + this.shouldPaginateForLocalFiltering(filters); + const queryResultCap = + requestBody.query_type === "FULL_TEXT" + ? MAX_FULL_TEXT_RESULTS + : MAX_QUERY_RESULTS; + const response = await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/query`, + { + ...requestBody, + num_results: Math.min( + requiresLocalFilteringPagination + ? queryResultCap + : Math.max(topK, DEFAULT_PAGE_SIZE), + queryResultCap, + ), + }, + ); + + const results = this.normalizeQueryResults( + response.data, + ["memory_id", "payload"], + filters, + ); + if (results.length >= topK) { + return results.slice(0, topK); + } + + let nextPageToken = this.extractNextPageToken(response.data); + while (nextPageToken && results.length < topK) { + const nextPage = await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/query-next-page`, + { + endpoint_name: this.endpointName, + page_token: nextPageToken, + }, + ); + results.push( + ...this.normalizeQueryResults( + nextPage.data, + ["memory_id", "payload"], + filters, + ), + ); + nextPageToken = this.extractNextPageToken(nextPage.data); + } + + return results.slice(0, topK); + } + + private normalizeQueryResults( + responseData: any, + fallbackColumns: string[], + filters: SearchFilters | undefined, + ): VectorStoreResult[] { + const resultData = responseData?.result || responseData; + const dataArray = Array.isArray(resultData?.data_array) + ? resultData.data_array + : []; + const manifestColumns = Array.isArray(resultData?.manifest?.columns) + ? resultData.manifest.columns.map((column: any) => + typeof column === "string" ? column : column.name, + ) + : fallbackColumns; + const results: VectorStoreResult[] = []; + + for (const row of dataArray) { + const rowDict = this.rowToObject(row, manifestColumns); + const item: DatabricksVector = { + id: String(rowDict.memory_id || rowDict.id || rowDict.vector_id), + payload: this.parsePayload(rowDict.payload), + }; + if (!this.filterVector(item, filters)) { + continue; + } + const rawScore = + rowDict.score ?? + (Array.isArray(row) && row.length > manifestColumns.length + ? row[row.length - 1] + : undefined); + results.push({ + id: item.id, + payload: item.payload, + score: + rawScore === undefined || rawScore === null + ? undefined + : Number(rawScore), + }); + } + + return results; + } + + private rowToObject(row: any, columns: string[]): Record { + if (!Array.isArray(row)) { + return row || {}; + } + + return Object.fromEntries( + columns.map((column, index) => [column, row[index]]), + ); + } + + private parsePayload(rawValue: any): Record { + if (typeof rawValue === "string") { + try { + const parsed = JSON.parse(rawValue); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + return {}; + } + } + + if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) { + return rawValue; + } + + return {}; + } + + private extractSessionValues(payload: Record): { + user_id: any; + agent_id: any; + run_id: any; + } { + return { + user_id: payload.user_id, + agent_id: payload.agent_id, + run_id: payload.run_id, + }; + } + + private assertVectorDimension(vector: number[], label: string): void { + if (vector.length !== this.dimension) { + throw new Error( + `${label} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`, + ); + } + for (const value of vector) { + if (!Number.isFinite(value)) { + throw new Error( + `${label} values must be finite numbers for Databricks vector search.`, + ); + } + } + } + + private matchFieldCondition( + vector: DatabricksVector, + key: string, + value: any, + ): boolean { + const fieldValue = key === "memory_id" ? vector.id : vector.payload[key]; + + if (typeof value !== "object" || value === null) { + if (value === "*") { + return true; + } + return fieldValue === value; + } + + if (Array.isArray(value)) { + return value.includes(fieldValue); + } + + let sawOperator = false; + + if ("eq" in value) { + sawOperator = true; + if (fieldValue !== value.eq) { + return false; + } + } + if ("ne" in value) { + sawOperator = true; + if (fieldValue === value.ne) { + return false; + } + } + if ("gt" in value) { + sawOperator = true; + if (!(fieldValue > value.gt)) { + return false; + } + } + if ("gte" in value) { + sawOperator = true; + if (!(fieldValue >= value.gte)) { + return false; + } + } + if ("lt" in value) { + sawOperator = true; + if (!(fieldValue < value.lt)) { + return false; + } + } + if ("lte" in value) { + sawOperator = true; + if (!(fieldValue <= value.lte)) { + return false; + } + } + if ("in" in value) { + sawOperator = true; + if (!Array.isArray(value.in) || !value.in.includes(fieldValue)) { + return false; + } + } + if ("nin" in value) { + sawOperator = true; + if (Array.isArray(value.nin) && value.nin.includes(fieldValue)) { + return false; + } + } + if ("contains" in value) { + sawOperator = true; + if ( + typeof fieldValue !== "string" || + !fieldValue.includes(value.contains) + ) { + return false; + } + } + if ("icontains" in value) { + sawOperator = true; + if ( + typeof fieldValue !== "string" || + !fieldValue.toLowerCase().includes(value.icontains.toLowerCase()) + ) { + return false; + } + } + + return sawOperator ? true : fieldValue === value; + } + + private filterVector( + vector: DatabricksVector, + filters?: SearchFilters, + ): boolean { + if (!filters || Object.keys(filters).length === 0) { + return true; + } + + const keyMap: Record = { + $and: "AND", + $or: "OR", + $not: "NOT", + }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(filters)) { + const normalizedKey = keyMap[key] || key; + if (!(normalizedKey in normalized)) { + normalized[normalizedKey] = value; + } + } + + for (const [key, value] of Object.entries(normalized)) { + if (key === "AND") { + if (!Array.isArray(value)) { + throw new Error( + `AND filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.every((entry: SearchFilters) => + this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (key === "OR") { + if (!Array.isArray(value)) { + throw new Error( + `OR filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.some((entry: SearchFilters) => + this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (key === "NOT") { + if (!Array.isArray(value)) { + throw new Error( + `NOT filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.every( + (entry: SearchFilters) => !this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (!this.matchFieldCondition(vector, key, value)) { + return false; + } + } + + return true; + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts b/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts index 4b036ae52..27ec5dde6 100644 --- a/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts @@ -1,9 +1,12 @@ -import { Client } from "@elastic/elasticsearch"; +import type { Client } from "@elastic/elasticsearch"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface ElasticsearchConfig extends VectorStoreConfig { - client?: Client; + /** Pre-configured Elasticsearch client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; host?: string; port?: number; cloudId?: string; @@ -41,17 +44,28 @@ function validateFilter(key: string, value: unknown): void { } export class ElasticsearchDB implements VectorStore { - private client: Client; + private client!: Client; + private readonly config: ElasticsearchConfig; private readonly collectionName: string; private readonly dimension: number; private readonly autoCreateIndex: boolean; private _initPromise?: Promise; constructor(config: ElasticsearchConfig) { + this.config = config; this.collectionName = config.collectionName; this.dimension = config.dimension || config.embeddingModelDims || 1536; this.autoCreateIndex = config.autoCreateIndex !== false; + this.initialize().catch(console.error); + } + + // The client is created lazily on first initialize() so the optional + // `@elastic/elasticsearch` peer is only loaded when the store is actually used. + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; if (config.client) { this.client = config.client; } else { @@ -87,10 +101,14 @@ export class ElasticsearchDB implements VectorStore { params.headers = config.headers; } - this.client = new Client(params); - } + const sdk = await loadPeer( + "@elastic/elasticsearch", + "Elasticsearch vector store", + () => import("@elastic/elasticsearch"), + ); - this.initialize().catch(console.error); + this.client = new sdk.Client(params); + } } async initialize(): Promise { @@ -101,6 +119,7 @@ export class ElasticsearchDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { if (this.autoCreateIndex) { await this.ensureIndex(this.collectionName, this.dimension); @@ -149,6 +168,7 @@ export class ElasticsearchDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const operations: any[] = []; for (let i = 0; i < vectors.length; i++) { operations.push( @@ -165,6 +185,7 @@ export class ElasticsearchDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const searchBody: Record = { knn: { field: "vector", @@ -195,6 +216,7 @@ export class ElasticsearchDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { const response = await this.client.get({ index: this.collectionName, @@ -215,6 +237,7 @@ export class ElasticsearchDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const doc: Record = {}; if (vector) doc.vector = vector; if (payload) doc.metadata = payload; @@ -227,6 +250,7 @@ export class ElasticsearchDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); await this.client.delete({ index: this.collectionName, id: vectorId, @@ -234,6 +258,7 @@ export class ElasticsearchDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); await this.client.indices.delete({ index: this.collectionName }); } @@ -241,6 +266,7 @@ export class ElasticsearchDB implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const query: Record = { query: { match_all: {} } }; if (filters && Object.keys(filters).length > 0) { @@ -275,6 +301,7 @@ export class ElasticsearchDB implements VectorStore { } async getUserId(): Promise { + await this.initialize(); try { const response = await this.client.search({ index: "memory_migrations", @@ -308,6 +335,7 @@ export class ElasticsearchDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { const response = await this.client.search({ index: "memory_migrations", diff --git a/mem0-ts/src/oss/src/vector_stores/langchain.ts b/mem0-ts/src/oss/src/vector_stores/langchain.ts index cdc8e0a74..8ea0f75c2 100644 --- a/mem0-ts/src/oss/src/vector_stores/langchain.ts +++ b/mem0-ts/src/oss/src/vector_stores/langchain.ts @@ -1,5 +1,4 @@ -import { VectorStore as LangchainVectorStoreInterface } from "@langchain/core/vectorstores"; -import { Document } from "@langchain/core/documents"; +import type { VectorStore as LangchainVectorStoreInterface } from "@langchain/core/vectorstores"; import { VectorStore } from "./base"; // mem0's VectorStore interface import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -77,6 +76,7 @@ export class LangchainVectorStore implements VectorStore { } // Convert payloads to Langchain Document metadata format + const { Document } = await import("@langchain/core/documents"); const documents = payloads.map((payload, i) => { // Provide empty pageContent, store mem0 id and other data in metadata return new Document({ diff --git a/mem0-ts/src/oss/src/vector_stores/milvus.ts b/mem0-ts/src/oss/src/vector_stores/milvus.ts index 9d63b0023..3f5d9a790 100644 --- a/mem0-ts/src/oss/src/vector_stores/milvus.ts +++ b/mem0-ts/src/oss/src/vector_stores/milvus.ts @@ -235,6 +235,13 @@ export class Milvus implements VectorStore { if (!Milvus.SAFE_FILTER_KEY.test(key)) { throw new Error(`Invalid filter key: ${JSON.stringify(key)}`); } + if (value === "*") { + // Wildcard - match any value. Milvus has no direct wildcard, so skip + // the clause rather than emitting a literal `== "*"` that matches + // nothing. Mirrors the Python provider (#6187) and the chroma/pinecone + // stores. + continue; + } if (typeof value === "string") { // Escape backslashes before quotes so a value can't break out of the // string literal (order matters, exactly as in the Python provider). @@ -252,13 +259,13 @@ export class Milvus implements VectorStore { } /** - * Text fed to the BM25 sparse index for a payload. Prefers the lemmatized - * text, falls back to the raw memory `data`, and truncates to the VarChar - * limit (mirrors the Python provider). + * Text fed to the BM25 sparse index for a payload. Prefers `textLemmatized`, + * then `text_lemmatized`, then raw `data`; truncates to the VarChar limit. */ private bm25Text(payload?: Record): string { if (!payload) return ""; - const raw = payload.text_lemmatized || payload.data || ""; + const raw = + payload.textLemmatized || payload.text_lemmatized || payload.data || ""; return String(raw).slice(0, 65535); } diff --git a/mem0-ts/src/oss/src/vector_stores/mongodb.ts b/mem0-ts/src/oss/src/vector_stores/mongodb.ts index 7eb3d0b9c..a3bcecf21 100644 --- a/mem0-ts/src/oss/src/vector_stores/mongodb.ts +++ b/mem0-ts/src/oss/src/vector_stores/mongodb.ts @@ -1,6 +1,7 @@ -import { MongoClient, Collection, Db } from "mongodb"; +import type { MongoClient, Collection, Db } from "mongodb"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; export interface MongoDBConfig extends VectorStoreConfig { url?: string; @@ -8,13 +9,16 @@ export interface MongoDBConfig extends VectorStoreConfig { collectionName?: string; embeddingModelDims?: number; dimension?: number; - client?: MongoClient; + /** Pre-configured MongoDB client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; } export class MongoDB implements VectorStore { - private client: MongoClient; - private db: Db; + private client!: MongoClient; + private db!: Db; private collection!: Collection; + private readonly config: MongoDBConfig; private readonly collectionName: string; private readonly dbName: string; private readonly embeddingModelDims: number; @@ -22,17 +26,30 @@ export class MongoDB implements VectorStore { private _initPromise?: Promise; constructor(config: MongoDBConfig) { + this.config = config; this.collectionName = config.collectionName || "mem0"; this.dbName = config.dbName || "mem0_db"; this.embeddingModelDims = config.embeddingModelDims || config.dimension || 1536; this.indexName = `${this.collectionName}_vector_index`; + // The client/db are created lazily on first initialize() so the optional + // `mongodb` peer is only loaded when the store is actually used. + } + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; if (config.client) { this.client = config.client; } else { + const sdk = await loadPeer( + "mongodb", + "MongoDB vector store", + () => import("mongodb"), + ); const url = config.url || "mongodb://localhost:27017"; - this.client = new MongoClient(url, { appName: "Mem0" }); + this.client = new sdk.MongoClient(url, { appName: "Mem0" }); } this.db = this.client.db(this.dbName); @@ -45,7 +62,48 @@ export class MongoDB implements VectorStore { return this._initPromise; } + private textSearchIndexDefinition(textIndexName: string) { + return { + name: textIndexName, + definition: { + mappings: { + dynamic: false, + fields: { + payload: { + type: "document", + fields: { + data: { type: "string" }, + textLemmatized: { type: "string" }, + text_lemmatized: { type: "string" }, + }, + }, + }, + }, + }, + }; + } + + private textSearchIndexMappingIsCurrent( + index: Record, + ): boolean { + const definition = + (index.latestDefinition as Record | undefined) ?? + (index.definition as Record | undefined); + const payloadFields = ( + (definition?.mappings as Record | undefined)?.fields as + | Record + | undefined + )?.payload as Record | undefined; + const fields = + (payloadFields?.fields as Record | undefined) ?? {}; + const textLemmatized = fields.textLemmatized as + | { type?: string } + | undefined; + return textLemmatized?.type === "string"; + } + private async _doInitialize(): Promise { + await this.ensureClient(); try { const collections = await this.db .listCollections({ name: this.collectionName }) @@ -93,32 +151,24 @@ export class MongoDB implements VectorStore { // Create Text Search Index for keywordSearch const textIndexName = `${this.collectionName}_text_search_index`; try { - let foundTextIndex = false; + let existingTextIndex: Record | null = null; try { const indexes = await this.collection.listSearchIndexes().toArray(); - foundTextIndex = indexes.some((idx) => idx.name === textIndexName); + existingTextIndex = + (indexes.find((idx) => idx.name === textIndexName) as + | Record + | undefined) ?? null; } catch (e) { // ignore } - if (!foundTextIndex) { - await this.collection.createSearchIndex({ - name: textIndexName, - definition: { - mappings: { - dynamic: false, - fields: { - payload: { - type: "document", - fields: { - data: { type: "string" }, - text_lemmatized: { type: "string" }, - }, - }, - }, - }, - }, - }); + const textSearchIndex = this.textSearchIndexDefinition(textIndexName); + + if (!existingTextIndex) { + await this.collection.createSearchIndex(textSearchIndex); + } else if (!this.textSearchIndexMappingIsCurrent(existingTextIndex)) { + await this.collection.dropSearchIndex(textIndexName); + await this.collection.createSearchIndex(textSearchIndex); } } catch (e: any) { console.warn( @@ -268,7 +318,11 @@ export class MongoDB implements VectorStore { index: textIndexName, text: { query: query, - path: ["payload.data", "payload.text_lemmatized"], + path: [ + "payload.data", + "payload.text_lemmatized", + "payload.textLemmatized", + ], }, }, }, diff --git a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts new file mode 100644 index 000000000..79f6e5c9a --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts @@ -0,0 +1,1120 @@ +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +/** + * The `@aws-sdk/client-neptune-graph` dependency is loaded on first use via dynamic + * `import()` so the package stays optional (mirrors `aws_bedrock.ts`). + */ +interface NeptuneAnalyticsConfig extends VectorStoreConfig { + graphIdentifier?: string; + endpoint?: string; + collectionName: string; + dimension?: number; + client?: NeptuneGraphClientLike; +} + +interface NeptuneGraphClientLike { + send(command: any): Promise; +} + +interface NeptuneSDK { + NeptuneGraphClient: new ( + config: Record, + ) => NeptuneGraphClientLike; + ExecuteQueryCommand: new (input: Record) => any; +} + +interface NeptuneExecuteQueryOutput { + payload?: { + transformToString(encoding?: string): Promise; + }; +} + +type NeptuneQueryRecord = Record; +type NeptuneVertexFilter = Record; + +interface WhereClauseResult { + clause: string; + parameters: Record; + nextIndex: number; +} + +export class NeptuneAnalyticsVectorStore implements VectorStore { + private clientConfig: Record; + private clientOverride?: NeptuneGraphClientLike; + private sdkPromise?: Promise; + private clientPromise?: Promise; + private readonly graphIdentifier: string; + private readonly collectionName: string; + private readonly collectionLabel: string; + private readonly collectionLabelExpr: string; + private readonly userLabel: string; + private readonly userLabelExpr: string; + private readonly userNodeId: string; + private readonly dimension: number; + private _initPromise?: Promise; + private cachedUserId?: string; + + constructor(config: NeptuneAnalyticsConfig) { + this.graphIdentifier = this.resolveGraphIdentifier(config); + this.collectionName = config.collectionName || "memories"; + this.collectionLabel = `MEM0_VECTOR_${this.collectionName}`; + this.collectionLabelExpr = this.escapeLabel(this.collectionLabel); + this.userLabel = "MEM0_VECTOR_memory_migrations"; + this.userLabelExpr = this.escapeLabel(this.userLabel); + this.userNodeId = "mem0-user"; + this.dimension = config.dimension || 1536; + this.clientConfig = this.buildClientConfig(config); + this.clientOverride = config.client; + + void this.initialize().catch(console.error); + } + + initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize(); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + return; + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + this.assertBatchDimensions(vectors, "Insert"); + const existingIds = await this.findExistingIds(ids); + + const rows = vectors.map((vector, index) => ({ + node_id: ids[index], + properties: this.buildStoredPayload(payloads[index] || {}), + embedding: vector, + })); + + const propertiesQuery = ` + UNWIND $rows AS row + MERGE (n:${this.collectionLabelExpr} {\`~id\`: row.node_id}) + ON CREATE SET n = row.properties + ON MATCH SET n += row.properties + `; + + const vectorQuery = ` + UNWIND $rows AS row + MATCH (n:${this.collectionLabelExpr} {\`~id\`: row.node_id}) + WITH n, row.embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `; + + try { + await this.executeQuery(propertiesQuery, { rows }); + const results = await this.executeQuery(vectorQuery, { rows }); + this.assertSuccessfulResults(results, "Insert"); + } catch (error) { + await this.cleanupFailedInsert(ids.filter((id) => !existingIds.has(id))); + throw error; + } + } + + async keywordSearch(): Promise { + return null; + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + this.assertVectorDimension(query, "Query"); + + const vertexFilter = this.buildVertexFilter(filters); + const results = await this.executeQuery( + ` + CALL neptune.algo.vectors.topK.byEmbedding( + ${this.serializeAlgorithmInput({ + topK, + embedding: query, + vertexFilter, + })} + ) + YIELD node, score + RETURN node, score + `, + ); + + return results.map((record) => this.normalizeSearchResult(record)); + } + + async get(vectorId: string): Promise { + const results = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + RETURN n + LIMIT 1 + `, + { + vectorId, + }, + ); + + if (results.length === 0) { + return null; + } + + return this.normalizeNodeResult(results[0]); + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + if (vector.length > 0) { + this.assertVectorDimension(vector, "Vector"); + } + + const hasPayload = !!payload && Object.keys(payload).length > 0; + const hasVector = vector.length > 0; + + // ponytail: a combined update writes the payload before the embedding, and Neptune's vector + // index isn't transactional -- if the upsert below fails, the new payload would otherwise be + // left committed against the stale embedding (searches would match the old vector but return + // the new metadata). Capture the prior node so a failed upsert can be restored; this is + // best-effort compensation, not a rollback. Only needed when both writes happen -- a + // payload-only or vector-only update can't desync. + // The restore assumes a single writer per vectorId -- concurrent updates to the same node can + // interleave and clobber each other's compensation. AWS advises against concurrent same-vertex + // writes to the Neptune Analytics vector index for exactly this reason. + const priorResult = + hasPayload && hasVector ? await this.get(vectorId) : null; + + if (hasPayload) { + const properties = this.buildStoredPayload(payload); + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + SET n = $properties + RETURN n + `, + { + vectorId, + properties, + }, + ); + } + + if (hasVector) { + try { + const updateResults = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + WITH n, $embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `, + { + vectorId, + embedding: vector, + }, + ); + this.assertSuccessfulResults(updateResults, "Update"); + } catch (error) { + if (priorResult) { + try { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + SET n = $properties + RETURN n + `, + { + vectorId, + properties: priorResult.payload, + }, + ); + } catch (restoreError) { + // Do not mask the original failure with a compensation failure. + console.error( + "Neptune Analytics: failed to restore prior payload after a failed update upsert", + restoreError, + ); + } + } + throw error; + } + } + } + + async delete(vectorId: string): Promise { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + DETACH DELETE n + `, + { + vectorId, + }, + ); + } + + async deleteCol(): Promise { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + DETACH DELETE n + `, + ); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + const { clause, parameters } = this.buildWhereClause(filters); + const whereClause = clause ? `WHERE ${clause}` : ""; + const [results, countResults] = await Promise.all([ + this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + ${whereClause} + RETURN n + LIMIT $limit + `, + { + ...parameters, + limit: topK, + }, + ), + this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + ${whereClause} + RETURN count(n) AS count + `, + parameters, + ), + ]); + + const items = results.map((record) => this.normalizeNodeResult(record)); + const count = Number(countResults[0]?.count); + return [items, Number.isFinite(count) ? count : items.length]; + } + + async getUserId(): Promise { + if (this.cachedUserId) { + return this.cachedUserId; + } + + const results = await this.executeQuery( + ` + MATCH (n:${this.userLabelExpr} {\`~id\`: $userNodeId}) + RETURN n + LIMIT 1 + `, + { + userNodeId: this.userNodeId, + }, + ); + + const existing = results[0]; + const userId = existing ? this.extractUserId(existing) : undefined; + if (userId) { + this.cachedUserId = userId; + return userId; + } + + const randomUserId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await this.setUserId(randomUserId); + return randomUserId; + } + + async setUserId(userId: string): Promise { + await this.executeQuery( + ` + MERGE (n:${this.userLabelExpr} {\`~id\`: $userNodeId}) + SET n.user_id = $userId + RETURN n + `, + { + userNodeId: this.userNodeId, + userId, + }, + ); + this.cachedUserId = userId; + } + + private async findExistingIds(nodeIds: string[]): Promise> { + if (nodeIds.length === 0) { + return new Set(); + } + + const results = await this.executeQuery( + ` + UNWIND $nodeIds AS nodeId + MATCH (n:${this.collectionLabelExpr} {\`~id\`: nodeId}) + RETURN nodeId + `, + { + nodeIds, + }, + ); + + return new Set( + results + .map((record) => record.nodeId) + .filter((nodeId): nodeId is string => typeof nodeId === "string"), + ); + } + + private async cleanupFailedInsert(nodeIds: string[]): Promise { + if (nodeIds.length === 0) { + return; + } + + try { + await this.executeQuery( + ` + UNWIND $nodeIds AS nodeId + MATCH (n:${this.collectionLabelExpr} {\`~id\`: nodeId}) + DETACH DELETE n + `, + { + nodeIds, + }, + ); + } catch (error) { + console.error( + "Neptune Analytics: failed to clean up node(s) after a failed insert", + error, + ); + } + } + + private resolveGraphIdentifier(config: NeptuneAnalyticsConfig): string { + if (config.graphIdentifier) { + return config.graphIdentifier; + } + + const rawIdentifier = config.endpoint; + + if (!rawIdentifier) { + throw new Error( + "Neptune Analytics vector store requires graphIdentifier or endpoint.", + ); + } + + if (/^https?:\/\//i.test(rawIdentifier)) { + throw new Error( + "Neptune Analytics HTTPS endpoints require graphIdentifier; pass graphIdentifier separately or use neptune-graph://.", + ); + } + + if (rawIdentifier.startsWith("neptune-graph://")) { + return rawIdentifier.slice("neptune-graph://".length); + } + + return rawIdentifier; + } + + private buildClientConfig(config: NeptuneAnalyticsConfig): { + [key: string]: any; + endpoint?: string; + } { + const { + client: _client, + collectionName: _collectionName, + dimension: _dimension, + graphIdentifier: _graphIdentifier, + endpoint, + ...clientConfig + } = config; + + if (endpoint && /^https?:\/\//i.test(endpoint)) { + return { + ...clientConfig, + endpoint, + }; + } + + return clientConfig; + } + + private escapeLabel(label: string): string { + return `\`${label.replace(/`/g, "``")}\``; + } + + private buildStoredPayload( + payload: Record, + ): Record { + return { + ...payload, + updatedAt: new Date().toISOString(), + }; + } + + private buildVertexFilter(filters?: SearchFilters): NeptuneVertexFilter { + const conditions: NeptuneVertexFilter[] = [ + { + equals: { + property: "~label", + value: this.collectionLabel, + }, + }, + ]; + + const metadataFilter = this.buildMetadataVertexFilter(filters); + if (metadataFilter) { + conditions.push(metadataFilter); + } + + return this.combineVertexFilters("andAll", conditions)!; + } + + private buildMetadataVertexFilter( + filters?: SearchFilters, + ): NeptuneVertexFilter | undefined { + const operations: NeptuneVertexFilter[] = []; + + for (const [key, value] of Object.entries(filters || {})) { + if (value === undefined) { + continue; + } + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + throw new Error(`${key} filter value must be an array.`); + } + + const nested = value + .map((entry) => this.buildMetadataVertexFilter(entry)) + .filter((entry): entry is NeptuneVertexFilter => !!entry); + const joiner = key === "$and" ? "andAll" : "orAll"; + const combined = this.combineVertexFilters(joiner, nested); + if (combined) { + operations.push(combined); + } + continue; + } + + if (key === "$not") { + if (!Array.isArray(value)) { + throw new Error("$not filter value must be an array."); + } + + const nested = value + .map((entry) => this.buildMetadataVertexFilter(entry)) + .filter((entry): entry is NeptuneVertexFilter => !!entry) + .map((entry) => this.negateVertexFilter(entry)); + const combined = this.combineVertexFilters("andAll", nested); + if (combined) { + operations.push(combined); + } + continue; + } + + operations.push(this.buildFieldVertexFilter(key, value)); + } + + return this.combineVertexFilters("andAll", operations); + } + + private combineVertexFilters( + joiner: "andAll" | "orAll", + operations: NeptuneVertexFilter[], + ): NeptuneVertexFilter | undefined { + if (operations.length === 0) { + return undefined; + } + + if (operations.length === 1) { + return operations[0]; + } + + return { + [joiner]: operations, + }; + } + + private buildFieldVertexFilter(key: string, value: any): NeptuneVertexFilter { + if (value === "*") { + throw new Error( + "Neptune Analytics vector search does not support property-existence filters.", + ); + } + + if (Array.isArray(value)) { + return { + in: { + property: key, + value, + }, + }; + } + + if (typeof value !== "object" || value === null) { + return { + equals: { + property: key, + value, + }, + }; + } + + const operations = Object.entries(value).map(([operator, operand]) => + this.buildSingleVertexFilter(key, operator, operand), + ); + + return this.combineVertexFilters("andAll", operations)!; + } + + private buildSingleVertexFilter( + key: string, + operator: string, + operand: any, + ): NeptuneVertexFilter { + switch (operator) { + case "eq": + return { + equals: { + property: key, + value: operand, + }, + }; + case "ne": + return { + notEquals: { + property: key, + value: operand, + }, + }; + case "gt": + return { + greaterThan: { + property: key, + value: operand, + }, + }; + case "gte": + return { + greaterThanOrEquals: { + property: key, + value: operand, + }, + }; + case "lt": + return { + lessThan: { + property: key, + value: operand, + }, + }; + case "lte": + return { + lessThanOrEquals: { + property: key, + value: operand, + }, + }; + case "in": + return { + in: { + property: key, + value: operand, + }, + }; + case "nin": + return { + notIn: { + property: key, + value: operand, + }, + }; + case "contains": + return { + stringContains: { + property: key, + value: operand, + }, + }; + case "startsWith": + return { + startsWith: { + property: key, + value: operand, + }, + }; + case "icontains": + throw new Error( + "Neptune Analytics vector search does not support case-insensitive contains filters.", + ); + default: + throw new Error( + `Unsupported Neptune Analytics filter operator: ${operator}`, + ); + } + } + + private negateVertexFilter(filter: NeptuneVertexFilter): NeptuneVertexFilter { + if (Array.isArray(filter.andAll)) { + return this.combineVertexFilters( + "orAll", + filter.andAll.map((entry: NeptuneVertexFilter) => + this.negateVertexFilter(entry), + ), + )!; + } + + if (Array.isArray(filter.orAll)) { + return this.combineVertexFilters( + "andAll", + filter.orAll.map((entry: NeptuneVertexFilter) => + this.negateVertexFilter(entry), + ), + )!; + } + + if (filter.equals) { + return { + notEquals: filter.equals, + }; + } + + if (filter.notEquals) { + return { + equals: filter.notEquals, + }; + } + + if (filter.greaterThan) { + return { + lessThanOrEquals: filter.greaterThan, + }; + } + + if (filter.greaterThanOrEquals) { + return { + lessThan: filter.greaterThanOrEquals, + }; + } + + if (filter.lessThan) { + return { + greaterThanOrEquals: filter.lessThan, + }; + } + + if (filter.lessThanOrEquals) { + return { + greaterThan: filter.lessThanOrEquals, + }; + } + + if (filter.in) { + return { + notIn: filter.in, + }; + } + + if (filter.notIn) { + return { + in: filter.notIn, + }; + } + + throw new Error( + "Neptune Analytics cannot negate this filter shape for vector search.", + ); + } + + private buildWhereClause( + filters?: SearchFilters, + startIndex: number = 1, + ): WhereClauseResult { + const clauses: string[] = []; + const parameters: Record = {}; + let nextIndex = startIndex; + + for (const [key, value] of Object.entries(filters || {})) { + if (value === undefined) { + continue; + } + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + throw new Error(`${key} filter value must be an array.`); + } + + const nestedClauses: string[] = []; + for (const entry of value) { + const nested = this.buildWhereClause(entry, nextIndex); + nextIndex = nested.nextIndex; + Object.assign(parameters, nested.parameters); + if (nested.clause) { + nestedClauses.push(nested.clause); + } + } + + if (nestedClauses.length > 0) { + const joiner = key === "$and" ? " AND " : " OR "; + clauses.push(`(${nestedClauses.join(joiner)})`); + } + continue; + } + + if (key === "$not") { + if (!Array.isArray(value)) { + throw new Error("$not filter value must be an array."); + } + + const nestedClauses: string[] = []; + for (const entry of value) { + const nested = this.buildWhereClause(entry, nextIndex); + nextIndex = nested.nextIndex; + Object.assign(parameters, nested.parameters); + if (nested.clause) { + nestedClauses.push(nested.clause); + } + } + + if (nestedClauses.length > 0) { + clauses.push(`NOT (${nestedClauses.join(" OR ")})`); + } + continue; + } + + const fieldResult = this.buildFieldWhereClauses(key, value, nextIndex); + nextIndex = fieldResult.nextIndex; + Object.assign(parameters, fieldResult.parameters); + clauses.push(...fieldResult.clauses); + } + + return { + clause: clauses.join(" AND "), + parameters, + nextIndex, + }; + } + + private buildFieldWhereClauses( + key: string, + value: any, + startIndex: number, + ): { + clauses: string[]; + parameters: Record; + nextIndex: number; + } { + const field = `n.${this.escapeProperty(key)}`; + const parameters: Record = {}; + const clauses: string[] = []; + let nextIndex = startIndex; + + const addParameter = (prefix: string, rawValue: any) => { + const parameterName = `${prefix}_${key.replace(/[^\w]/g, "_")}_${nextIndex}`; + parameters[parameterName] = rawValue; + nextIndex += 1; + return parameterName; + }; + + if (value === "*") { + return { + clauses: [`${field} IS NOT NULL`], + parameters, + nextIndex, + }; + } + + if (Array.isArray(value)) { + const parameterName = addParameter("filter_in", value); + return { + clauses: [`${field} IN $${parameterName}`], + parameters, + nextIndex, + }; + } + + if (typeof value !== "object" || value === null) { + const parameterName = addParameter("filter", value); + return { + clauses: [`${field} = $${parameterName}`], + parameters, + nextIndex, + }; + } + + for (const [operator, operand] of Object.entries(value)) { + const parameterName = addParameter(`filter_${operator}`, operand); + switch (operator) { + case "eq": + clauses.push(`${field} = $${parameterName}`); + break; + case "ne": + clauses.push(`${field} <> $${parameterName}`); + break; + case "gt": + clauses.push(`${field} > $${parameterName}`); + break; + case "gte": + clauses.push(`${field} >= $${parameterName}`); + break; + case "lt": + clauses.push(`${field} < $${parameterName}`); + break; + case "lte": + clauses.push(`${field} <= $${parameterName}`); + break; + case "in": + clauses.push(`${field} IN $${parameterName}`); + break; + case "nin": + clauses.push(`NOT ${field} IN $${parameterName}`); + break; + case "contains": + clauses.push(`toString(${field}) CONTAINS $${parameterName}`); + break; + case "icontains": + throw new Error( + "Neptune Analytics list filters do not support case-insensitive contains filters.", + ); + case "startsWith": + clauses.push(`toString(${field}) STARTS WITH $${parameterName}`); + break; + default: + throw new Error( + `Unsupported Neptune Analytics filter operator: ${operator}`, + ); + } + } + + return { + clauses, + parameters, + nextIndex, + }; + } + + private escapeProperty(key: string): string { + return `\`${key.replace(/`/g, "``")}\``; + } + + private serializeAlgorithmInput(value: any): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => this.serializeAlgorithmInput(entry)).join(", ")}]`; + } + + if (value === null) { + return "null"; + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (typeof value === "object") { + return `{ ${Object.entries(value) + .map( + ([key, entry]) => + `${this.serializeAlgorithmKey(key)}: ${this.serializeAlgorithmInput(entry)}`, + ) + .join(", ")} }`; + } + + throw new Error( + `Unsupported Neptune Analytics algorithm value type: ${typeof value}`, + ); + } + + private serializeAlgorithmKey(key: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return key; + } + + return JSON.stringify(key); + } + + private assertVectorDimension(vector: number[], context: string): void { + if (vector.length !== this.dimension) { + throw new Error( + `${context} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`, + ); + } + } + + private assertBatchDimensions(vectors: number[][], context: string): void { + for (const vector of vectors) { + this.assertVectorDimension(vector, context); + } + } + + private normalizeNodeResult(record: NeptuneQueryRecord): VectorStoreResult { + const node = this.extractNode(record); + const payload = this.normalizePayload(this.extractPayload(node)); + + return { + id: this.extractId(node, record), + payload, + }; + } + + private normalizeSearchResult(record: NeptuneQueryRecord): VectorStoreResult { + const base = this.normalizeNodeResult(record); + const score = this.normalizeScore(record.score); + + return { + ...base, + score, + }; + } + + private extractNode(record: NeptuneQueryRecord): Record { + return ( + record.n || record.node || record.m || record.item || record.v || record + ); + } + + private extractPayload(node: Record): Record { + return node["~properties"] || node.properties || node.payload || {}; + } + + private extractId( + node: Record, + record: NeptuneQueryRecord, + ): string { + const rawId = node["~id"] || node.id || record.id; + return String(rawId); + } + + private extractUserId(record: NeptuneQueryRecord): string | undefined { + const node = this.extractNode(record); + const payload = this.extractPayload(node); + const userId = payload.user_id || payload.userId; + return userId ? String(userId) : undefined; + } + + private normalizePayload(payload: Record): Record { + return { ...payload }; + } + + private normalizeScore(score: unknown): number | undefined { + if (score === undefined || score === null) { + return undefined; + } + + const numericScore = Number(score); + if (!Number.isFinite(numericScore)) { + return undefined; + } + + // Neptune returns squared Euclidean distance, while Memory search expects higher-is-better scores. + return 1 / (1 + Math.max(0, numericScore)); + } + + private assertSuccessfulResults( + results: NeptuneQueryRecord[], + context: string, + ): void { + for (const record of results) { + if ("success" in record && record.success !== true) { + throw new Error(`${context} failed in Neptune Analytics`); + } + } + } + + /** + * Load the optional AWS SDK on first use. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite + * `require()` in the published ESM bundle (`dist/oss/index.mjs`) into a + * `__require` shim that throws `Dynamic require of "..." is not supported`, + * so every ESM consumer would hit a dead provider even with the SDK installed. + */ + private async getSDK(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-neptune-graph").then( + (sdk) => sdk as unknown as NeptuneSDK, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sdkPromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@aws-sdk/client-neptune-graph' package is required to use the Neptune Analytics vector store. " + + `Install it with: npm install @aws-sdk/client-neptune-graph (original error: ${detail})`, + ); + }, + ); + } + return this.sdkPromise; + } + + /** Memoized Neptune client; an injected `config.client` short-circuits the SDK. */ + private async getClient(): Promise { + if (this.clientOverride) return this.clientOverride; + if (!this.clientPromise) { + this.clientPromise = this.getSDK() + .then( + ({ NeptuneGraphClient }) => new NeptuneGraphClient(this.clientConfig), + ) + .catch((err) => { + // Mirror getSDK(): drop the rejected promise so a later call retries rather than + // replaying a cached rejection forever (a rejected promise is still truthy, so the + // `!this.clientPromise` guard above would otherwise never re-enter). + this.clientPromise = undefined; + throw err; + }); + } + return this.clientPromise; + } + + private async executeQuery( + queryString: string, + parameters: Record = {}, + ): Promise { + const [client, sdk] = await Promise.all([this.getClient(), this.getSDK()]); + const response = await client.send( + new sdk.ExecuteQueryCommand({ + graphIdentifier: this.graphIdentifier, + language: "OPEN_CYPHER", + queryString, + parameters: Object.keys(parameters).length > 0 ? parameters : undefined, + }), + ); + + const rawPayload = response.payload + ? await response.payload.transformToString("utf-8") + : ""; + + if (!rawPayload.trim()) { + return []; + } + + const parsed = JSON.parse(rawPayload) as + | NeptuneQueryRecord[] + | { results?: NeptuneQueryRecord[]; result?: NeptuneQueryRecord[] }; + + if (Array.isArray(parsed)) { + return parsed; + } + + if (Array.isArray(parsed.results)) { + return parsed.results; + } + + if (Array.isArray(parsed.result)) { + return parsed.result; + } + + return []; + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/opensearch.ts b/mem0-ts/src/oss/src/vector_stores/opensearch.ts index 204209503..10f42e46a 100644 --- a/mem0-ts/src/oss/src/vector_stores/opensearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/opensearch.ts @@ -1,6 +1,7 @@ -import { Client } from "@opensearch-project/opensearch"; +import type { Client } from "@opensearch-project/opensearch"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; type OpenSearchAuth = | { @@ -14,7 +15,9 @@ type OpenSearchAuth = | Record; interface OpenSearchConfig extends VectorStoreConfig { - client?: Client; + /** Pre-configured OpenSearch client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; host?: string; port?: number; httpAuth?: OpenSearchAuth | [string, string]; @@ -61,17 +64,29 @@ function escapeWildcard(value: string): string { } export class OpenSearchDB implements VectorStore { - private client: Client; + private client!: Client; + private readonly config: OpenSearchConfig; private readonly collectionName: string; private readonly embeddingModelDims: number; private readonly autoRefresh: boolean; private _initPromise?: Promise; constructor(config: OpenSearchConfig) { + this.config = config; this.collectionName = config.collectionName; this.embeddingModelDims = config.embeddingModelDims; this.autoRefresh = config.autoRefresh ?? false; + this.initialize().catch(console.error); + } + + // The client is created lazily on first initialize() so the optional + // `@opensearch-project/opensearch` peer is only loaded when the store is + // actually used. + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; if (config.client) { this.client = config.client; } else { @@ -84,7 +99,13 @@ export class OpenSearchDB implements VectorStore { ? { username: config.user, password: config.password } : undefined); - this.client = new Client({ + const sdk = await loadPeer( + "@opensearch-project/opensearch", + "OpenSearch vector store", + () => import("@opensearch-project/opensearch"), + ); + + this.client = new sdk.Client({ node: `${useSSL ? "https" : "http"}://${host}:${port}`, auth: this.normalizeAuth(auth), ssl: { @@ -94,8 +115,6 @@ export class OpenSearchDB implements VectorStore { }, }); } - - this.initialize().catch(console.error); } async initialize(): Promise { @@ -107,6 +126,7 @@ export class OpenSearchDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); await this.createCol(this.collectionName, this.embeddingModelDims); await this.ensureMigrationIndex(); } @@ -210,6 +230,7 @@ export class OpenSearchDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); vectors.forEach((vector, index) => this.validateVector(vector, index)); const operations = vectors.flatMap((vector, index) => { @@ -250,10 +271,12 @@ export class OpenSearchDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const boolQuery: Record = { should: [ { match: { "payload.data": query } }, { match: { "payload.text_lemmatized": query } }, + { match: { "payload.textLemmatized": query } }, ], minimum_should_match: 1, }; @@ -281,6 +304,7 @@ export class OpenSearchDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const knnQuery = { knn: { vector_field: { @@ -316,6 +340,7 @@ export class OpenSearchDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { const response = responseBody<{ _source?: OpenSearchHit["_source"] }>( await this.client.get({ @@ -343,6 +368,7 @@ export class OpenSearchDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); if (vector) { this.validateVector(vector, 0); } @@ -362,6 +388,7 @@ export class OpenSearchDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); try { await this.client.delete({ index: this.collectionName, @@ -377,6 +404,7 @@ export class OpenSearchDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); if (!(await this.indexExists(this.collectionName))) return; await this.client.indices.delete({ index: this.collectionName }); } @@ -385,6 +413,7 @@ export class OpenSearchDB implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const filter = this.buildFilterClauses(filters); const query = filter.length ? { bool: { filter } } : { match_all: {} }; @@ -413,11 +442,13 @@ export class OpenSearchDB implements VectorStore { } async reset(): Promise { + await this.initialize(); await this.deleteCol(); await this.createCol(this.collectionName, this.embeddingModelDims); } async getUserId(): Promise { + await this.initialize(); await this.ensureMigrationIndex(); const response = responseBody<{ hits: { hits: OpenSearchHit[] } }>( @@ -442,6 +473,7 @@ export class OpenSearchDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); await this.ensureMigrationIndex(); await this.client.index({ index: "memory_migrations", diff --git a/mem0-ts/src/oss/src/vector_stores/oracledb.ts b/mem0-ts/src/oss/src/vector_stores/oracledb.ts new file mode 100644 index 000000000..46cf016b8 --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/oracledb.ts @@ -0,0 +1,764 @@ +import type { BindParameters, Connection, Pool } from "oracledb"; +import { v4 as uuidv4 } from "uuid"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; + +const DISTANCE_METRICS = [ + "COSINE", + "EUCLIDEAN", + "EUCLIDEAN_SQUARED", + "DOT", + "HAMMING", + "MANHATTAN", +] as const; + +type DistanceMetric = (typeof DISTANCE_METRICS)[number]; +type IndexType = "HNSW" | "IVF"; + +const SCORE_FROM_DISTANCE: Record number> = { + COSINE: (d) => Math.max(0, Math.min(1, 1 - d)), + EUCLIDEAN: (d) => 1 / (1 + Math.max(0, d)), + EUCLIDEAN_SQUARED: (d) => 1 / (1 + Math.sqrt(Math.max(0, d))), + HAMMING: (d) => 1 / (1 + Math.max(0, d)), + MANHATTAN: (d) => 1 / (1 + Math.max(0, d)), + DOT: (d) => -d, +}; + +const INDEX_PARAMETER_RANGES: Record< + IndexType, + Record +> = { + HNSW: { + neighbors: [2, 2048], + efconstruction: [1, 65535], + }, + IVF: { + "neighbor partitions": [1, 10_000_000], + samples_per_partition: [1, Number.MAX_SAFE_INTEGER], + min_vectors_per_partition: [0, Number.MAX_SAFE_INTEGER], + }, +}; + +const IDENTIFIER_RE = /^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$/; +const METADATA_KEY_RE = /^[a-zA-Z0-9_.[\],\s*]+$/; + +export function quoteIdentifier(name: string): string { + const trimmed = name.trim(); + if (!IDENTIFIER_RE.test(trimmed)) { + throw new Error(`Identifier name ${name} is not valid.`); + } + return [...trimmed.matchAll(/"([^"]+)"|([^".]+)/g)] + .map((m) => `"${m[1] ?? m[2]}"`) + .join("."); +} + +function jsonPath(metadataKey: string): string { + if (!METADATA_KEY_RE.test(metadataKey)) { + throw new Error( + `Invalid metadata key '${metadataKey}'. Only letters, numbers, underscores, ` + + `nesting via '.', and array wildcards '[*]' are allowed.`, + ); + } + return metadataKey + .split(".") + .map((part) => + part.endsWith("[*]") ? `."${part.slice(0, -3)}"[*]` : `."${part}"`, + ) + .join(""); +} + +const COMPARISON_OPERATORS: Record = { + eq: "==", + ne: "!=", + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", +}; + +const FIELD_OPERATORS = new Set([ + ...Object.keys(COMPARISON_OPERATORS), + "in", + "nin", + "contains", + "icontains", +]); + +const LOGICAL_OPERATORS: Record = { + $and: "and", + $or: "or", + $not: "not", + AND: "and", + OR: "or", + NOT: "not", +}; + +function isScalar(value: any): boolean { + return value === null || (typeof value !== "object" && !Array.isArray(value)); +} + +function bindFilterValue( + value: any, + binds: Record, +): [string, string] { + const name = `f_${Object.keys(binds).length}`; + binds[name] = value; + return [`$${name}`, `:${name} AS "${name}"`]; +} + +function jsonExists( + path: string, + predicate: string, + passings: string[], +): string { + const passingClause = + passings.length > 0 ? ` PASSING ${passings.join(", ")}` : ""; + return `JSON_EXISTS(payload, '$${path}?(${predicate})'${passingClause})`; +} + +function buildFieldCondition( + metadataKey: string, + value: any, + binds: Record, +): string { + const path = jsonPath(metadataKey); + + if (value === "*") { + return `JSON_EXISTS(payload, '$${path}')`; + } + + if (isScalar(value)) { + if (value === null) { + return jsonExists(path, "@ == null", []); + } + const [variable, passing] = bindFilterValue(value, binds); + return jsonExists(path, `@ == ${variable}`, [passing]); + } + + if (Array.isArray(value)) { + throw new Error( + `Oracle filter for field '${metadataKey}' must be a scalar or an operator object`, + ); + } + + const operators = Object.entries(value); + if (operators.length === 0) { + throw new Error( + `Operator filter for field '${metadataKey}' must not be empty`, + ); + } + + const unsupported = operators + .map(([op]) => op) + .filter((op) => !FIELD_OPERATORS.has(op)); + if (unsupported.length > 0) { + throw new Error( + `Unsupported Oracle filter operator(s) for field '${metadataKey}': ${unsupported.sort().join(", ")}`, + ); + } + + const predicates: string[] = []; + const passings: string[] = []; + const additionalClauses: string[] = []; + + for (const [operator, operand] of operators) { + if (operator in COMPARISON_OPERATORS) { + if (!isScalar(operand)) { + throw new Error( + `Oracle filter operator '${operator}' requires a scalar value`, + ); + } + if (operand === null) { + if (operator !== "eq" && operator !== "ne") { + throw new Error( + `Oracle filter operator '${operator}' does not support null`, + ); + } + predicates.push(`@ ${COMPARISON_OPERATORS[operator]} null`); + continue; + } + const [variable, passing] = bindFilterValue(operand, binds); + predicates.push(`@ ${COMPARISON_OPERATORS[operator]} ${variable}`); + passings.push(passing); + continue; + } + + if (operator === "in" || operator === "nin") { + if (!Array.isArray(operand) || operand.length === 0) { + throw new Error( + `Oracle filter operator '${operator}' requires a non-empty array`, + ); + } + + const variables: string[] = []; + const listPassings: string[] = []; + for (const item of operand) { + if (!isScalar(item)) { + throw new Error( + `Oracle filter operator '${operator}' requires scalar values`, + ); + } + if (item === null) { + variables.push("null"); + continue; + } + const [variable, passing] = bindFilterValue(item, binds); + variables.push(variable); + listPassings.push(passing); + } + + const membership = jsonExists( + path, + `@ in (${variables.join(", ")})`, + listPassings, + ); + additionalClauses.push( + operator === "in" ? membership : `NOT (${membership})`, + ); + continue; + } + + if (typeof operand !== "string") { + throw new Error( + `Oracle filter operator '${operator}' requires a string value`, + ); + } + + if (operator === "contains") { + const [variable, passing] = bindFilterValue(operand, binds); + predicates.push(`@ has substring ${variable}`); + passings.push(passing); + } else { + const [variable, passing] = bindFilterValue(operand.toLowerCase(), binds); + predicates.push(`@.lower() has substring ${variable}`); + passings.push(passing); + } + } + + const clauses = [...additionalClauses]; + if (predicates.length > 0) { + clauses.unshift(jsonExists(path, predicates.join(" && "), passings)); + } + + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`; +} + +export function buildFilterGroup( + filters: Record, + binds: Record, +): string { + const entries = Object.entries(filters ?? {}); + if (entries.length === 0) { + throw new Error("Oracle filter groups must be non-empty objects"); + } + + const clauses: string[] = []; + for (const [key, value] of entries) { + const logicalOperator = LOGICAL_OPERATORS[key]; + if (logicalOperator) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + `Logical filter operator '${key}' requires a non-empty array`, + ); + } + const nested = value.map((condition) => + buildFilterGroup(condition, binds), + ); + if (logicalOperator === "not") { + clauses.push(`NOT (${nested.join(" OR ")})`); + } else { + clauses.push( + `(${nested.join(logicalOperator === "and" ? " AND " : " OR ")})`, + ); + } + continue; + } + + if (key.startsWith("$")) { + throw new Error(`Unsupported Oracle logical filter operator: ${key}`); + } + + clauses.push(buildFieldCondition(key, value, binds)); + } + + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`; +} + +export function buildWhereClause( + filters?: SearchFilters, +): [string, Record] { + if (!filters || Object.keys(filters).length === 0) { + return ["", {}]; + } + const binds: Record = {}; + return [`WHERE ${buildFilterGroup(filters, binds)}`, binds]; +} + +interface OracleDBConfig extends VectorStoreConfig { + connectionParams?: Record; + useConnectionPool?: boolean; + client?: Connection | Pool; + collectionName?: string; + embeddingModelDims?: number; + distanceMetric?: DistanceMetric; + doCreateIndex?: boolean; + indexType?: IndexType; + indexName?: string; + indexParameters?: Record; + indexAccuracy?: number; +} + +export class OracleAIVectorSearch implements VectorStore { + private readonly collectionName: string; + private readonly indexName: string; + private readonly embeddingModelDims: number; + private readonly distanceMetric: DistanceMetric; + private readonly indexType: IndexType; + private readonly indexParameters: Record; + private readonly indexAccuracy?: number; + private readonly doCreateIndex: boolean; + private readonly config: OracleDBConfig; + private oracledb: any; + private client?: Connection | Pool; + private ownsClient = false; + private _initPromise?: Promise; + + constructor(config: OracleDBConfig) { + if (!config.connectionParams && !config.client) { + throw new Error( + "Must provide at least one of `connectionParams` and `client`", + ); + } + + this.collectionName = quoteIdentifier(config.collectionName || "mem0"); + this.indexName = quoteIdentifier( + config.indexName || `${config.collectionName || "mem0"}_VEC_IDX`, + ); + + this.embeddingModelDims = config.embeddingModelDims ?? 1536; + if ( + !Number.isInteger(this.embeddingModelDims) || + this.embeddingModelDims <= 0 + ) { + throw new Error("`embeddingModelDims` must be a positive integer"); + } + + const distanceMetric = (config.distanceMetric ?? + "COSINE") as string as DistanceMetric; + this.distanceMetric = distanceMetric.toUpperCase() as DistanceMetric; + if (!DISTANCE_METRICS.includes(this.distanceMetric)) { + throw new Error(`Unsupported distance metric: ${config.distanceMetric}`); + } + + const indexType = (config.indexType ?? "HNSW") as string; + this.indexType = indexType.toUpperCase() as IndexType; + if (this.indexType !== "HNSW" && this.indexType !== "IVF") { + throw new Error(`Unsupported index type: ${config.indexType}`); + } + + this.indexAccuracy = config.indexAccuracy; + if ( + this.indexAccuracy !== undefined && + (!Number.isInteger(this.indexAccuracy) || + this.indexAccuracy <= 0 || + this.indexAccuracy > 100) + ) { + throw new Error("`indexAccuracy` must be an integer between 1 and 100"); + } + + this.indexParameters = this.validateIndexParameters(config.indexParameters); + this.doCreateIndex = config.doCreateIndex ?? true; + this.config = config; + } + + private validateIndexParameters( + parameters?: Record, + ): Record { + if (!parameters) return {}; + + const allowed = INDEX_PARAMETER_RANGES[this.indexType]; + const validated: Record = {}; + + for (const [key, value] of Object.entries(parameters)) { + const range = allowed[key]; + if (!range) { + throw new Error( + `Unsupported ${this.indexType} index parameter '${key}'. ` + + `Allowed: ${Object.keys(allowed).join(", ")}`, + ); + } + if (!Number.isInteger(value) || value < range[0] || value > range[1]) { + throw new Error( + `Index parameter '${key}' must be an integer between ${range[0]} and ${range[1]}`, + ); + } + validated[key] = value; + } + + return validated; + } + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize().catch(async (error) => { + if (this.ownsClient && this.client) { + await Promise.resolve(this.client.close()).catch(() => {}); + this.client = undefined; + this.ownsClient = false; + } + this._initPromise = undefined; + throw error; + }); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + const sdk = await loadPeer( + "oracledb", + "Oracle AI Vector Search", + () => import("oracledb"), + ); + this.oracledb = sdk.default ?? sdk; + + if (this.config.client) { + this.client = this.config.client; + } else if (this.config.useConnectionPool ?? true) { + this.client = await this.oracledb.createPool({ + poolMin: 1, + poolMax: 4, + ...this.config.connectionParams, + }); + this.ownsClient = true; + } else { + this.client = await this.oracledb.getConnection( + this.config.connectionParams, + ); + this.ownsClient = true; + } + + await this.assertVectorSupport(); + await this.createCol(); + } + + private isPool(client: Connection | Pool): client is Pool { + return typeof (client as Pool).getConnection === "function"; + } + + private async withConnection( + fn: (connection: Connection) => Promise, + commit = false, + ): Promise { + const client = this.client!; + + if (!this.isPool(client)) { + const connection = client as Connection; + try { + const result = await fn(connection); + if (commit) await connection.commit(); + return result; + } catch (err) { + await connection.rollback(); + throw err; + } + } + + const connection = await client.getConnection(); + try { + const result = await fn(connection); + if (commit) await connection.commit(); + return result; + } catch (err) { + await connection.rollback(); + throw err; + } finally { + await connection.close(); + } + } + + private async assertVectorSupport(): Promise { + if (!this.oracledb.thin) { + const [major, minor] = [ + Math.floor(this.oracledb.oracleClientVersion / 100000000), + Math.floor(this.oracledb.oracleClientVersion / 100000) % 100, + ]; + if (major < 23 || (major === 23 && minor < 4)) { + throw new Error( + `Oracle DB client driver version ${this.oracledb.oracleClientVersionString} ` + + "not supported, must be >=23.4 for vector support", + ); + } + } + + const version = await this.withConnection( + async (connection) => connection.oracleServerVersionString, + ); + const [major, minor] = version.split(".").map(Number); + if (major < 23 || (major === 23 && minor < 4)) { + throw new Error( + `Oracle DB version ${version} not supported, must be >=23.4 for vector support`, + ); + } + } + + private createIndexDdl(): string { + const accuracy = this.indexAccuracy + ? `WITH TARGET ACCURACY ${this.indexAccuracy}` + : ""; + + const parameterEntries = Object.entries(this.indexParameters); + const parameters = + parameterEntries.length > 0 + ? `PARAMETERS (${[ + `type ${this.indexType}`, + ...parameterEntries.map(([key, value]) => `${key} ${value}`), + ].join(", ")})` + : ""; + + const organization = + this.indexType === "HNSW" + ? "INMEMORY NEIGHBOR GRAPH" + : "NEIGHBOR PARTITIONS"; + + return ( + `CREATE VECTOR INDEX IF NOT EXISTS ${this.indexName} ON ${this.collectionName} (vector) ` + + `ORGANIZATION ${organization} DISTANCE ${this.distanceMetric} ${accuracy} ${parameters}` + ); + } + + private async createCol(): Promise { + await this.withConnection(async (connection) => { + await connection.execute(` + CREATE TABLE IF NOT EXISTS ${this.collectionName} ( + id VARCHAR2(36) PRIMARY KEY, + vector VECTOR(${this.embeddingModelDims}), + payload JSON + ) + `); + + await connection.execute(` + CREATE TABLE IF NOT EXISTS memory_migrations ( + id NUMBER PRIMARY KEY, + user_id VARCHAR2(255) NOT NULL + ) + `); + + if (this.doCreateIndex) { + await connection.execute(this.createIndexDdl()); + } + }, true); + } + + private loadPayload(value: any): Record { + if (value === null || value === undefined) return {}; + if (typeof value === "string") return JSON.parse(value); + if (Buffer.isBuffer(value)) return JSON.parse(value.toString("utf-8")); + return value; + } + + private vectorBind(vector: number[]) { + return { + type: this.oracledb.DB_TYPE_VECTOR, + val: new Float32Array(vector), + }; + } + + private payloadBind(payload: Record) { + return { type: this.oracledb.DB_TYPE_JSON, val: payload }; + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + if (ids.length !== vectors.length) { + throw new Error("ids and vectors must have the same length"); + } + if (payloads.length !== vectors.length) { + throw new Error("payloads and vectors must have the same length"); + } + + if (vectors.length === 0) return; + + await this.initialize(); + + await this.withConnection(async (connection) => { + await connection.executeMany( + `INSERT INTO ${this.collectionName} (id, vector, payload) VALUES (:id, :vector, :payload)`, + vectors.map((vector, i) => ({ + id: ids[i], + vector: new Float32Array(vector), + payload: payloads[i] ?? {}, + })) as BindParameters[], + { + bindDefs: { + id: { type: this.oracledb.DB_TYPE_VARCHAR, maxSize: 36 }, + vector: { type: this.oracledb.DB_TYPE_VECTOR }, + payload: { type: this.oracledb.DB_TYPE_JSON }, + }, + }, + ); + }, true); + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + + const [whereClause, filterBinds] = buildWhereClause(filters); + const hasFilter = whereClause.length > 0; + const selectClause = hasFilter + ? "SELECT" + : `SELECT /*+ VECTOR_INDEX_TRANSFORM(${this.collectionName}) */`; + const sql = + `${selectClause} id, payload, VECTOR_DISTANCE(vector, :query_vec, ${this.distanceMetric}) distance ` + + `FROM ${this.collectionName} ${whereClause} ORDER BY distance FETCH APPROX FIRST :max_rows ROWS ONLY`; + + const rows = await this.withConnection(async (connection) => { + const result = await connection.execute(sql, { + query_vec: this.vectorBind(query), + max_rows: topK, + ...filterBinds, + }); + return result.rows ?? []; + }); + + return rows.map((row) => ({ + id: row[0], + payload: this.loadPayload(row[1]), + score: SCORE_FROM_DISTANCE[this.distanceMetric](Number(row[2])), + })); + } + + async get(vectorId: string): Promise { + await this.initialize(); + + const rows = await this.withConnection(async (connection) => { + const result = await connection.execute( + `SELECT id, payload FROM ${this.collectionName} WHERE id = :vector_id`, + { vector_id: vectorId }, + ); + return result.rows ?? []; + }); + + if (rows.length === 0) return null; + return { id: rows[0][0], payload: this.loadPayload(rows[0][1]) }; + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + await this.initialize(); + + const assignments: string[] = []; + const binds: Record = { vector_id: vectorId }; + + if (vector) { + assignments.push("vector = :vector"); + binds.vector = this.vectorBind(vector); + } + if (payload) { + assignments.push("payload = :payload"); + binds.payload = this.payloadBind(payload); + } + if (assignments.length === 0) return; + + await this.withConnection( + (connection) => + connection.execute( + `UPDATE ${this.collectionName} SET ${assignments.join(", ")} WHERE id = :vector_id`, + binds, + ), + true, + ); + } + + async delete(vectorId: string): Promise { + await this.initialize(); + + await this.withConnection( + (connection) => + connection.execute( + `DELETE FROM ${this.collectionName} WHERE id = :vector_id`, + { vector_id: vectorId }, + ), + true, + ); + } + + async deleteCol(): Promise { + await this.initialize(); + + await this.withConnection( + (connection) => + connection.execute(`DROP TABLE ${this.collectionName} PURGE`), + true, + ); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); + + const [whereClause, filterBinds] = buildWhereClause(filters); + + return this.withConnection(async (connection) => { + const listResult = await connection.execute( + `SELECT id, payload, COUNT(*) OVER () total FROM ${this.collectionName} ${whereClause} FETCH FIRST :max_rows ROWS ONLY`, + { ...filterBinds, max_rows: topK }, + ); + + const rows = listResult.rows ?? []; + const results = rows.map((row) => ({ + id: row[0], + payload: this.loadPayload(row[1]), + })); + + return [results, Number(rows[0]?.[2] ?? 0)]; + }); + } + + async getUserId(): Promise { + await this.initialize(); + + const rows = await this.withConnection(async (connection) => { + const result = await connection.execute( + "SELECT user_id FROM memory_migrations WHERE id = 1", + ); + return result.rows ?? []; + }); + + if (rows.length > 0) return rows[0][0]; + + const generatedUserId = uuidv4(); + await this.setUserId(generatedUserId); + return generatedUserId; + } + + async setUserId(userId: string): Promise { + await this.initialize(); + + await this.withConnection(async (connection) => { + await connection.execute("DELETE FROM memory_migrations WHERE id = 1"); + await connection.execute( + "INSERT INTO memory_migrations (id, user_id) VALUES (1, :user_id)", + { user_id: userId }, + ); + }, true); + } + + async close(): Promise { + if (this.client && this.ownsClient) { + await this.client.close(); + } + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/pinecone.ts b/mem0-ts/src/oss/src/vector_stores/pinecone.ts index 4e8ef7212..3d8f4e851 100644 --- a/mem0-ts/src/oss/src/vector_stores/pinecone.ts +++ b/mem0-ts/src/oss/src/vector_stores/pinecone.ts @@ -1,7 +1,7 @@ -import { Pinecone } from "@pinecone-database/pinecone"; -import type { Index } from "@pinecone-database/pinecone"; +import type { Pinecone, Index } from "@pinecone-database/pinecone"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; const MIGRATIONS_NAMESPACE = "__mem0_migrations__"; const MIGRATIONS_RECORD_ID = "mem0-user-id"; @@ -9,7 +9,9 @@ const MIGRATIONS_RECORD_ID = "mem0-user-id"; interface PineconeDBConfig extends VectorStoreConfig { collectionName: string; embeddingModelDims: number; - client?: Pinecone; + /** Pre-configured Pinecone client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; apiKey?: string; serverlessConfig?: { cloud: string; region: string }; podConfig?: { @@ -26,7 +28,8 @@ interface PineconeDBConfig extends VectorStoreConfig { } export class PineconeDB implements VectorStore { - private client: Pinecone; + private client!: Pinecone; + private readonly config: PineconeDBConfig; private readonly collectionName: string; private readonly dimension: number; private readonly metric: "cosine" | "dotproduct" | "euclidean"; @@ -45,18 +48,16 @@ export class PineconeDB implements VectorStore { private _initPromise?: Promise; constructor(config: PineconeDBConfig) { - if (config.client) { - this.client = config.client; - } else { + if (!config.client) { const apiKey = config.apiKey || process.env.PINECONE_API_KEY; if (!apiKey) { throw new Error( "Pinecone API key required: pass apiKey or set PINECONE_API_KEY env var", ); } - this.client = new Pinecone({ apiKey }); } + this.config = config; this.collectionName = config.collectionName; this.dimension = config.embeddingModelDims || config.dimension || 1536; this.metric = config.metric || "cosine"; @@ -69,6 +70,28 @@ export class PineconeDB implements VectorStore { this.initialize().catch(console.error); } + /** + * Lazily construct (or reuse) the Pinecone client, importing the optional + * `@pinecone-database/pinecone` peer only when the store is first used so + * consumers that never touch Pinecone don't need it installed. + */ + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; + if (config.client) { + this.client = config.client; + } else { + const apiKey = config.apiKey || process.env.PINECONE_API_KEY; + const sdk = await loadPeer( + "@pinecone-database/pinecone", + "Pinecone vector store", + () => import("@pinecone-database/pinecone"), + ); + this.client = new sdk.Pinecone({ apiKey }); + } + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -77,6 +100,7 @@ export class PineconeDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); await this._ensureIndex(); this._index = this.client.index({ name: this.collectionName }); } @@ -292,6 +316,11 @@ export class PineconeDB implements VectorStore { } async deleteCol(): Promise { + if (this.namespace) { + await this.initialize(); + await this.namespacedIndex().deleteAll(); + return; + } if (this._initPromise) { await this._initPromise.catch(() => {}); } diff --git a/mem0-ts/src/oss/src/vector_stores/qdrant.ts b/mem0-ts/src/oss/src/vector_stores/qdrant.ts index 43a93031c..e84a138f1 100644 --- a/mem0-ts/src/oss/src/vector_stores/qdrant.ts +++ b/mem0-ts/src/oss/src/vector_stores/qdrant.ts @@ -1,8 +1,17 @@ -import { QdrantClient } from "@qdrant/js-client-rest"; +import type { QdrantClient } from "@qdrant/js-client-rest"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; import * as fs from "fs"; +// BM25 keyword search via Qdrant's built-in server-side inference (requires +// Qdrant >= 1.15.2). The Python adapter encodes BM25 client-side with fastembed; +// this server-side path avoids that dependency, but IDF weights, and therefore the +// scores, may differ between the two implementations. + +const BM25_VECTOR_NAME = "bm25"; +const BM25_MODEL = "Qdrant/bm25"; + interface QdrantConfig extends VectorStoreConfig { /** * Pre-configured QdrantClient instance. If using Qdrant Cloud, you must pass @@ -55,53 +64,70 @@ const KEY_MAP: Record = { }; export class Qdrant implements VectorStore { - private client: QdrantClient; + private client!: QdrantClient; + private readonly config: QdrantConfig; private readonly collectionName: string; private dimension: number; private _initPromise?: Promise; + // Off for collections without the `bm25` slot (e.g. pre-hybrid-search). + private _hasBm25Slot = false; + // Payload indexes apply to a real server, not embedded/local mode. + private _isRemote = true; constructor(config: QdrantConfig) { - if (config.client) { - this.client = config.client; - } else { - const params: Record = {}; - if (config.apiKey) { - params.apiKey = config.apiKey; - } - if (config.url) { - params.url = config.url; - // Workaround for qdrant/qdrant-js#59: explicitly pass port to avoid "Illegal host" error - try { - const parsedUrl = new URL(config.url); - params.port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 6333; - } catch (_) { - params.port = 6333; - } - } - if (config.host && config.port) { - params.host = config.host; - params.port = config.port; - } - if (!Object.keys(params).length) { - params.path = config.path; - if (!config.onDisk && config.path) { - if ( - fs.existsSync(config.path) && - fs.statSync(config.path).isDirectory() - ) { - fs.rmSync(config.path, { recursive: true }); - } - } - } - - this.client = new QdrantClient(params); - } - + this.config = config; this.collectionName = config.collectionName; this.dimension = config.dimension || 1536; // Default OpenAI dimension this.initialize().catch(console.error); } + private async ensureClient(): Promise { + if (this.client) return; + const config = this.config; + if (config.client) { + // pre-configured client β†’ treat as remote (mirrors Python is_local=False). + this.client = config.client; + return; + } + const params: Record = {}; + if (config.apiKey) { + params.apiKey = config.apiKey; + } + if (config.url) { + params.url = config.url; + // Workaround for qdrant/qdrant-js#59: explicitly pass port to avoid "Illegal host" error + try { + const parsedUrl = new URL(config.url); + params.port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 6333; + } catch (_) { + params.port = 6333; + } + } + if (config.host && config.port) { + params.host = config.host; + params.port = config.port; + } + if (!Object.keys(params).length) { + this._isRemote = false; + params.path = config.path; + if (!config.onDisk && config.path) { + if ( + fs.existsSync(config.path) && + fs.statSync(config.path).isDirectory() + ) { + fs.rmSync(config.path, { recursive: true }); + } + } + } + + const sdk = await loadPeer( + "@qdrant/js-client-rest", + "Qdrant vector store", + () => import("@qdrant/js-client-rest"), + ); + this.client = new sdk.QdrantClient(params); + } + /** * Build a single field condition from a key-value filter pair. * Supports enhanced filter syntax with comparison operators. @@ -270,19 +296,99 @@ export class Qdrant implements VectorStore { ids: string[], payloads: Record[], ): Promise { - const points = vectors.map((vector, idx) => ({ - id: ids[idx], - vector: vector, - payload: payloads[idx] || {}, - })); - - await this.client.upsert(this.collectionName, { - points, + await this.initialize(); + const points = vectors.map((vector, idx) => { + const payload = payloads[idx] || {}; + return { + id: ids[idx], + vector: this.buildPointVector(vector, payload), + payload, + }; }); + + await this.upsertPoints(points); } - async keywordSearch(): Promise { - return null; + // A server can accept a bm25 `sparse_vectors` config yet be unable to run + // inference (Qdrant < 1.15.2, or a Cloud cluster created before 2025-07-07 + // where inference was never activated), so no version check catches it and + // the first upsert failure is the only signal. Retry dense-only rather than + // lose the write. + // + // `as any`: the JS client types predate server-side sparse_vectors, so the + // named-vector and BM25-inference shapes are not typed. + private async upsertPoints( + points: { id: string | number; vector: any; payload?: any }[], + ): Promise { + try { + await this.client.upsert(this.collectionName, { points: points as any }); + } catch (error) { + if (!this._hasBm25Slot) { + throw error; + } + this._hasBm25Slot = false; + console.warn( + `Qdrant rejected the server-side BM25 vector for collection '${this.collectionName}'; ` + + "disabling hybrid keyword search. This requires Qdrant >= 1.15.2 with inference enabled. " + + "Retrying the write with a plain dense vector.", + ); + const denseOnly = points.map((p) => ({ + ...p, + vector: Array.isArray(p.vector) ? p.vector : p.vector[""], + })); + await this.client.upsert(this.collectionName, { + points: denseOnly as any, + }); + } + } + + // With a bm25 slot, return named vectors so Qdrant encodes BM25 server-side; + // otherwise return the plain dense vector (legacy behavior). + private buildPointVector( + vector: number[], + payload: Record, + ): number[] | Record { + if (!this._hasBm25Slot) { + return vector; + } + const named: Record = { "": vector }; + const text = payload?.textLemmatized || payload?.data || ""; + if (text) { + named[BM25_VECTOR_NAME] = { text, model: BM25_MODEL }; + } + return named; + } + + // BM25 keyword search; returns null (semantic-only fallback) when there is no + // bm25 slot or the query fails. + async keywordSearch( + query: string, + topK: number = 5, + filters?: SearchFilters, + ): Promise { + if (!this._hasBm25Slot) { + return null; + } + + try { + const queryFilter = this.createFilter(filters); + const response = await this.client.query(this.collectionName, { + query: { text: query, model: BM25_MODEL } as any, + using: BM25_VECTOR_NAME, + filter: queryFilter, + limit: topK, + with_payload: true, + }); + + return response.points.map((point) => ({ + id: String(point.id), + payload: (point.payload as Record) || {}, + score: point.score, + })); + } catch (error) { + console.error("Error during Qdrant keyword search:", error); + return null; + } } async search( @@ -290,6 +396,7 @@ export class Qdrant implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const queryFilter = this.createFilter(filters); const results = await this.client.search(this.collectionName, { vector: query, @@ -305,6 +412,7 @@ export class Qdrant implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); const results = await this.client.retrieve(this.collectionName, { ids: [vectorId], with_payload: true, @@ -323,24 +431,26 @@ export class Qdrant implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const point = { id: vectorId, - vector: vector, + // Re-encode BM25 so edited memories don't keep a stale sparse vector. + vector: this.buildPointVector(vector, payload), payload, }; - await this.client.upsert(this.collectionName, { - points: [point], - }); + await this.upsertPoints([point]); } async delete(vectorId: string): Promise { + await this.initialize(); await this.client.delete(this.collectionName, { points: [vectorId], }); } async deleteCol(): Promise { + await this.initialize(); await this.client.deleteCollection(this.collectionName); } @@ -348,6 +458,7 @@ export class Qdrant implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const scrollRequest = { limit: topK, filter: this.createFilter(filters), @@ -380,6 +491,7 @@ export class Qdrant implements VectorStore { } async getUserId(): Promise { + await this.initialize(); try { // Ensure collection exists (idempotent β€” handles race conditions) await this.ensureCollection("memory_migrations", 1); @@ -417,6 +529,7 @@ export class Qdrant implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { // Get existing point ID const result = await this.client.scroll("memory_migrations", { @@ -442,14 +555,31 @@ export class Qdrant implements VectorStore { } } - private async ensureCollection(name: string, size: number): Promise { + private async ensureCollection( + name: string, + size: number, + enableBm25: boolean = false, + ): Promise { try { - await this.client.createCollection(name, { + const createParams: Record = { vectors: { size, distance: "Cosine", }, - }); + }; + if (enableBm25) { + // `idf` lets Qdrant compute IDF over the live corpus at query time. + createParams.sparse_vectors = { + [BM25_VECTOR_NAME]: { modifier: "idf" }, + }; + } + await this.client.createCollection(name, createParams as any); + if (enableBm25) { + this._hasBm25Slot = true; + } + if (name === this.collectionName) { + await this.createFilterIndexes(name); + } } catch (error: any) { if ( error?.status === 409 || @@ -468,6 +598,22 @@ export class Qdrant implements VectorStore { `Expected: ${size}, got: ${vectorConfig.size}`, ); } + + if (enableBm25) { + // Existing collection: enable BM25 only if the slot is present. + const sparseConfig = (collectionInfo.config?.params as any) + ?.sparse_vectors; + this._hasBm25Slot = !!( + sparseConfig && BM25_VECTOR_NAME in sparseConfig + ); + if (!this._hasBm25Slot) { + console.warn( + `Collection '${name}' predates hybrid search (no '${BM25_VECTOR_NAME}' sparse slot). ` + + "BM25 keyword scoring is disabled for this collection; semantic search works normally. " + + "Use a fresh collection to enable hybrid keyword search.", + ); + } + } } catch (verifyError: any) { // Re-throw dimension mismatch errors if (verifyError?.message?.includes("wrong vector size")) { @@ -479,6 +625,8 @@ export class Qdrant implements VectorStore { `Collection '${name}' exists (409) but dimension verification failed: ${verifyError?.message || verifyError}. Proceeding anyway.`, ); } + // Ensure filter indexes exist even for pre-existing collections. + await this.createFilterIndexes(name); } // Otherwise collection exists and is fine β€” proceed } else { @@ -487,6 +635,26 @@ export class Qdrant implements VectorStore { } } + // Index the fields mem0 filters by; remote Qdrant rejects filtering on + // un-indexed fields. Mirrors Python's `_create_filter_indexes`. + private async createFilterIndexes(name: string): Promise { + if (!this._isRemote) { + return; + } + const commonFields = ["user_id", "agent_id", "run_id", "actor_id"]; + for (const field of commonFields) { + try { + await this.client.createPayloadIndex(name, { + field_name: field, + field_schema: "keyword", + }); + } catch (err) { + // Non-fatal: index likely already exists, or the server rejected it. + console.debug(`Qdrant: skipped payload index for '${field}':`, err); + } + } + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -496,7 +664,8 @@ export class Qdrant implements VectorStore { private async _doInitialize(): Promise { try { - await this.ensureCollection(this.collectionName, this.dimension); + await this.ensureClient(); + await this.ensureCollection(this.collectionName, this.dimension, true); await this.ensureCollection("memory_migrations", 1); } catch (error) { console.error("Error initializing Qdrant:", error); diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index 5b72e53b5..ecb326ac2 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -1,4 +1,3 @@ -import { createClient } from "redis"; import type { RedisClientType, RedisDefaultModules, @@ -8,6 +7,8 @@ import type { } from "redis"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; +import { toCamelCasePreservingIds } from "../utils/casing"; /** * Escape RediSearch TAG filter special characters. Any punctuation in the @@ -133,28 +134,34 @@ function toSnakeCase(obj: Record): Record { ); } -// Utility function to convert object keys to camelCase -function toCamelCase(obj: Record): Record { - if (typeof obj !== "object" || obj === null) return obj; - - return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [ - key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), - value, - ]), - ); +// Build a RediSearch pre-filter expression from filters. Returns "*" (match +// all) when there are no usable conditions β€” including an empty filters object +// or one whose values are all null/undefined β€” since an empty expression is an +// invalid RediSearch query (e.g. ` =>[KNN ...]`). +export function buildRedisFilterExpr(filters?: SearchFilters): string { + if (!filters) return "*"; + const conditions = Object.entries(toSnakeCase(filters)) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`); + return conditions.length > 0 ? conditions.join(" ") : "*"; } export class RedisDB implements VectorStore { - private client: RedisClientType< + private client!: RedisClientType< RedisDefaultModules & RedisModules & RedisFunctions & RedisScripts >; + private readonly redisUrl: string; + private readonly username?: string; + private readonly password?: string; private readonly indexName: string; private readonly indexPrefix: string; private readonly schema: RedisSchema; private _initPromise?: Promise; constructor(config: RedisConfig) { + this.redisUrl = config.redisUrl; + this.username = config.username; + this.password = config.password; this.indexName = config.collectionName; this.indexPrefix = `mem0:${config.collectionName}`; @@ -177,12 +184,24 @@ export class RedisDB implements VectorStore { }), }; - this.client = createClient({ - url: config.redisUrl, - username: config.username, - password: config.password, + this.initialize().catch((err) => { + console.error("Failed to initialize Redis:", err); + }); + } + + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "redis", + "Redis vector store", + () => import("redis"), + ); + this.client = sdk.createClient({ + url: this.redisUrl, + username: this.username, + password: this.password, socket: { - reconnectStrategy: (retries) => { + reconnectStrategy: (retries: number) => { if (retries > 10) { console.error("Max reconnection attempts reached"); return new Error("Max reconnection attempts reached"); @@ -194,10 +213,6 @@ export class RedisDB implements VectorStore { this.client.on("error", (err) => console.error("Redis Client Error:", err)); this.client.on("connect", () => console.log("Redis Client Connected")); - - this.initialize().catch((err) => { - console.error("Failed to initialize Redis:", err); - }); } private async createIndex(): Promise { @@ -260,6 +275,7 @@ export class RedisDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { await this.client.connect(); console.log("Connected to Redis"); @@ -331,6 +347,7 @@ export class RedisDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const data = vectors.map((vector, idx) => { const payload = toSnakeCase(payloads[idx]); const id = ids[idx]; @@ -389,13 +406,8 @@ export class RedisDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { - const snakeFilters = filters ? toSnakeCase(filters) : undefined; - const filterExpr = snakeFilters - ? Object.entries(snakeFilters) - .filter(([_, value]) => value !== null && value !== undefined) - .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`) - .join(" ") - : "*"; + await this.initialize(); + const filterExpr = buildRedisFilterExpr(filters); const queryVector = new Float32Array(query).buffer; @@ -445,7 +457,7 @@ export class RedisDB implements VectorStore { return { id: doc.value.memory_id, - payload: toCamelCase(resultPayload), + payload: toCamelCasePreservingIds(resultPayload), score: Math.max(0, 1 - (Number(doc.value.__vector_score) ?? 0)), }; }); @@ -456,6 +468,7 @@ export class RedisDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { // Check if the memory exists first const exists = await this.client.exists( @@ -549,7 +562,7 @@ export class RedisDB implements VectorStore { return { id: vectorId, - payload: toCamelCase(payload), + payload: toCamelCasePreservingIds(payload), }; } catch (error) { console.error("Error getting vector:", error); @@ -562,6 +575,7 @@ export class RedisDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const snakePayload = toSnakeCase(payload); const createdAt = snakePayload.created_at ? new Date(snakePayload.created_at).getTime() @@ -601,6 +615,7 @@ export class RedisDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); try { // Check if memory exists first const key = `${this.indexPrefix}:${vectorId}`; @@ -626,6 +641,7 @@ export class RedisDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); await this.client.ft.dropIndex(this.indexName); } @@ -633,13 +649,8 @@ export class RedisDB implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { - const snakeFilters = filters ? toSnakeCase(filters) : undefined; - const filterExpr = snakeFilters - ? Object.entries(snakeFilters) - .filter(([_, value]) => value !== null && value !== undefined) - .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`) - .join(" ") - : "*"; + await this.initialize(); + const filterExpr = buildRedisFilterExpr(filters); const searchOptions = { SORTBY: "created_at", @@ -658,7 +669,7 @@ export class RedisDB implements VectorStore { const items = results.documents.map((doc) => ({ id: doc.value.memory_id, - payload: toCamelCase({ + payload: toCamelCasePreservingIds({ hash: doc.value.hash, data: doc.value.memory, created_at: new Date(parseInt(doc.value.created_at)).toISOString(), @@ -676,10 +687,11 @@ export class RedisDB implements VectorStore { } async close(): Promise { - await this.client.quit(); + if (this.client) await this.client.quit(); } async getUserId(): Promise { + await this.initialize(); try { // Check if the user ID exists in Redis const userId = await this.client.get("memory_migrations:1"); @@ -702,6 +714,7 @@ export class RedisDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { await this.client.set("memory_migrations:1", userId); } catch (error) { diff --git a/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts b/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts index cc2edf6b5..ae6c074b9 100644 --- a/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts +++ b/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts @@ -1,21 +1,8 @@ -import { - CreateIndexCommand, - CreateVectorBucketCommand, - DeleteIndexCommand, - DeleteVectorsCommand, - GetIndexCommand, - GetVectorBucketCommand, - GetVectorsCommand, - ListVectorsCommand, - PutVectorsCommand, - QueryVectorsCommand, - S3VectorsClient, - type DistanceMetric, - type GetOutputVector, - type ListOutputVector, - type QueryOutputVector, - type S3VectorsClientConfig, - type VectorData, +import type { + GetOutputVector, + ListOutputVector, + QueryOutputVector, + VectorData, } from "@aws-sdk/client-s3vectors"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -32,11 +19,13 @@ interface S3VectorsConfig extends VectorStoreConfig { collectionName: string; embeddingModelDims?: number; dimension?: number; - distanceMetric?: DistanceMetric | "cosine" | "euclidean"; + distanceMetric?: "cosine" | "euclidean"; region?: string; regionName?: string; client?: S3VectorsClientLike; - clientConfig?: S3VectorsClientConfig; + /** Pre-configured S3 Vectors client options (typed as `any` to keep the + * optional SDK's types out of the published type declarations). */ + clientConfig?: any; } interface S3VectorsClientLike { @@ -44,11 +33,14 @@ interface S3VectorsClientLike { } export class S3Vectors implements VectorStore { - private readonly client: S3VectorsClientLike; + private readonly config: S3VectorsConfig; private readonly vectorBucketName: string; private readonly collectionName: string; private readonly dimension: number; - private readonly distanceMetric: DistanceMetric | "cosine" | "euclidean"; + private readonly distanceMetric: "cosine" | "euclidean"; + private client?: S3VectorsClientLike; + private clientPromise?: Promise; + private sdkPromise?: Promise; private _initPromise?: Promise; private cachedUserId?: string; @@ -65,22 +57,55 @@ export class S3Vectors implements VectorStore { throw new Error("embeddingModelDims or dimension is required"); } + this.config = config; this.vectorBucketName = config.vectorBucketName; this.collectionName = config.collectionName; this.dimension = dimension; this.distanceMetric = config.distanceMetric || "cosine"; - this.client = - config.client || - new S3VectorsClient({ - ...(config.clientConfig || {}), - ...(config.region || config.regionName - ? { region: config.region || config.regionName } - : {}), - }); void this.initialize().catch(console.error); } + /** + * Lazily import the optional `@aws-sdk/client-s3vectors` peer so consumers + * who never use the S3 Vectors store don't need it installed. + */ + private getSdk(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-s3vectors").catch(() => { + throw new Error( + "The '@aws-sdk/client-s3vectors' package is required to use the S3 Vectors store. Install it with: npm install @aws-sdk/client-s3vectors", + ); + }); + } + return this.sdkPromise; + } + + /** Lazily construct (or reuse) the S3 Vectors client. */ + private async getClient(): Promise { + if (this.client) return this.client; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.client = await this.clientPromise; + return this.client; + } + + private async createClient(): Promise { + const config = this.config; + if (config.client) { + return config.client; + } + + const sdk = await this.getSdk(); + return new sdk.S3VectorsClient({ + ...(config.clientConfig || {}), + ...(config.region || config.regionName + ? { region: config.region || config.regionName } + : {}), + }); + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -105,8 +130,10 @@ export class S3Vectors implements VectorStore { await this.initialize(); this.assertBatchDimensions(vectors, "Insert"); - await this.client.send( - new PutVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.PutVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, vectors: vectors.map((vector, index) => ({ @@ -134,8 +161,10 @@ export class S3Vectors implements VectorStore { if (filter && this.isAlwaysFalseFilter(filter)) { return []; } - const response = await this.client.send( - new QueryVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + const response = await client.send( + new sdk.QueryVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, queryVector: this.toVectorData(query), @@ -158,9 +187,11 @@ export class S3Vectors implements VectorStore { async get(vectorId: string): Promise { await this.initialize(); + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - const response = await this.client.send( - new GetVectorsCommand({ + const response = await client.send( + new sdk.GetVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, keys: [vectorId], @@ -203,8 +234,10 @@ export class S3Vectors implements VectorStore { this.assertVectorDimension(nextVector, "Vector"); - await this.client.send( - new PutVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.PutVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, vectors: [ @@ -221,8 +254,10 @@ export class S3Vectors implements VectorStore { async delete(vectorId: string): Promise { await this.initialize(); - await this.client.send( - new DeleteVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.DeleteVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, keys: [vectorId], @@ -233,9 +268,11 @@ export class S3Vectors implements VectorStore { async deleteCol(): Promise { await this.initialize(); + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - await this.client.send( - new DeleteIndexCommand({ + await client.send( + new sdk.DeleteIndexCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, }), @@ -257,14 +294,16 @@ export class S3Vectors implements VectorStore { const filter = this.convertFilters(filters); const results: VectorStoreResult[] = []; let nextToken: string | undefined; + const sdk = await this.getSdk(); + const client = await this.getClient(); // Stop paginating once we have topK matches. Both callers of list() // discard the count, so scanning the whole index just to total every // match is wasted round-trips (O(index size) on getAll/deleteAll). // Return the page length like qdrant does. do { - const response = await this.client.send( - new ListVectorsCommand({ + const response = await client.send( + new sdk.ListVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, maxResults: DEFAULT_PAGE_SIZE, @@ -298,8 +337,10 @@ export class S3Vectors implements VectorStore { await this.ensureMigrationIndex(); - const response = await this.client.send( - new GetVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + const response = await client.send( + new sdk.GetVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: MIGRATION_INDEX_NAME, keys: [MIGRATION_VECTOR_KEY], @@ -325,8 +366,10 @@ export class S3Vectors implements VectorStore { await this.initialize(); await this.ensureMigrationIndex(); - await this.client.send( - new PutVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.PutVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: MIGRATION_INDEX_NAME, vectors: [ @@ -343,9 +386,11 @@ export class S3Vectors implements VectorStore { } private async ensureBucketExists(): Promise { + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - await this.client.send( - new GetVectorBucketCommand({ + await client.send( + new sdk.GetVectorBucketCommand({ vectorBucketName: this.vectorBucketName, }), ); @@ -355,8 +400,8 @@ export class S3Vectors implements VectorStore { } try { - await this.client.send( - new CreateVectorBucketCommand({ + await client.send( + new sdk.CreateVectorBucketCommand({ vectorBucketName: this.vectorBucketName, }), ); @@ -371,11 +416,13 @@ export class S3Vectors implements VectorStore { private async ensureIndexExists( indexName: string, dimension: number, - distanceMetric: DistanceMetric | "cosine" | "euclidean", + distanceMetric: "cosine" | "euclidean", ): Promise { + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - await this.client.send( - new GetIndexCommand({ + await client.send( + new sdk.GetIndexCommand({ vectorBucketName: this.vectorBucketName, indexName, }), @@ -386,8 +433,8 @@ export class S3Vectors implements VectorStore { } try { - await this.client.send( - new CreateIndexCommand({ + await client.send( + new sdk.CreateIndexCommand({ vectorBucketName: this.vectorBucketName, indexName, dataType: "float32", @@ -410,8 +457,10 @@ export class S3Vectors implements VectorStore { private async fetchStoredVector( vectorId: string, ): Promise<{ vector: number[]; payload: Record } | null> { - const response = await this.client.send( - new GetVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + const response = await client.send( + new sdk.GetVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, keys: [vectorId], @@ -770,7 +819,7 @@ export class S3Vectors implements VectorStore { private normalizeQueryVector( vector: QueryOutputVector, - distanceMetric: DistanceMetric | "cosine" | "euclidean", + distanceMetric: "cosine" | "euclidean", ): VectorStoreResult { return { id: String(vector.key), @@ -794,8 +843,7 @@ export class S3Vectors implements VectorStore { private normalizeScore( distance?: number, - distanceMetric: DistanceMetric | "cosine" | "euclidean" = this - .distanceMetric, + distanceMetric: "cosine" | "euclidean" = this.distanceMetric, ): number | undefined { if (distance === undefined || distance === null) { return undefined; diff --git a/mem0-ts/src/oss/src/vector_stores/supabase.ts b/mem0-ts/src/oss/src/vector_stores/supabase.ts index 748fe8283..785f6e054 100644 --- a/mem0-ts/src/oss/src/vector_stores/supabase.ts +++ b/mem0-ts/src/oss/src/vector_stores/supabase.ts @@ -1,6 +1,7 @@ -import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface VectorData { id: string; @@ -30,6 +31,8 @@ interface SupabaseConfig extends VectorStoreConfig { metadataColumnName?: string; } +const POSTGREST_MAX_ROWS = 1000; + /* SQL Migration to run in Supabase SQL Editor: @@ -82,14 +85,17 @@ $$; */ export class SupabaseDB implements VectorStore { - private client: SupabaseClient; + private client!: SupabaseClient; + private readonly supabaseUrl: string; + private readonly supabaseKey: string; private readonly tableName: string; private readonly embeddingColumnName: string; private readonly metadataColumnName: string; private _initPromise?: Promise; constructor(config: SupabaseConfig) { - this.client = createClient(config.supabaseUrl, config.supabaseKey); + this.supabaseUrl = config.supabaseUrl; + this.supabaseKey = config.supabaseKey; this.tableName = config.tableName; this.embeddingColumnName = config.embeddingColumnName || "embedding"; this.metadataColumnName = config.metadataColumnName || "metadata"; @@ -99,6 +105,16 @@ export class SupabaseDB implements VectorStore { }); } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "@supabase/supabase-js", + "Supabase vector store", + () => import("@supabase/supabase-js"), + ); + this.client = sdk.createClient(this.supabaseUrl, this.supabaseKey); + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -107,35 +123,21 @@ export class SupabaseDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { - // Verify table exists and vector operations work by attempting a test insert - const testVector = Array(1536).fill(0); - - // First try to delete any existing test vector - try { - await this.client.from(this.tableName).delete().eq("id", "test_vector"); - } catch { - // Ignore delete errors - table might not exist yet - } - - // Try to insert the test vector - const { error: insertError } = await this.client + const { error: probeError } = await this.client .from(this.tableName) - .insert({ - id: "test_vector", - [this.embeddingColumnName]: testVector, - [this.metadataColumnName]: {}, - }) - .select(); + .select(this.embeddingColumnName) + .limit(1); - // If we get a duplicate key error, that's actually fine - it means the table exists - if (insertError && insertError.code !== "23505") { - console.error("Test insert error:", insertError); + if (probeError) { + console.error("Table probe error:", probeError); throw new Error( `Vector operations failed. Please ensure: 1. The vector extension is enabled 2. The table "${this.tableName}" exists with correct schema 3. The match_vectors function is created +4. Row Level Security policies allow the configured Supabase key to read the table RUN THE FOLLOWING SQL IN YOUR SUPABASE SQL EDITOR: @@ -190,13 +192,6 @@ See the SQL migration instructions in the code comments.`, ); } - // Clean up test vector - ignore errors here too - try { - await this.client.from(this.tableName).delete().eq("id", "test_vector"); - } catch { - // Ignore delete errors - } - console.log("Connected to Supabase successfully"); } catch (error) { console.error("Error during Supabase initialization:", error); @@ -209,6 +204,7 @@ See the SQL migration instructions in the code comments.`, ids: string[], payloads: Record[], ): Promise { + await this.initialize(); try { const data = vectors.map((vector, idx) => ({ id: ids[idx], @@ -237,6 +233,7 @@ See the SQL migration instructions in the code comments.`, topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const rpcQuery: VectorQueryParams = { query_embedding: query, @@ -253,6 +250,13 @@ See the SQL migration instructions in the code comments.`, if (!data) return []; const results = data as VectorSearchResult[]; + + if (topK > POSTGREST_MAX_ROWS && results.length === POSTGREST_MAX_ROWS) { + console.warn( + `Supabase search requested topK=${topK} but match_vectors returned exactly the PostgREST row cap of ${POSTGREST_MAX_ROWS}; results were likely truncated.`, + ); + } + return results.map((result) => ({ id: result.id, payload: result.metadata, @@ -265,6 +269,7 @@ See the SQL migration instructions in the code comments.`, } async get(vectorId: string): Promise { + await this.initialize(); try { const { data, error } = await this.client .from(this.tableName) @@ -290,6 +295,7 @@ See the SQL migration instructions in the code comments.`, vector: number[], payload: Record, ): Promise { + await this.initialize(); try { const { error } = await this.client .from(this.tableName) @@ -310,6 +316,7 @@ See the SQL migration instructions in the code comments.`, } async delete(vectorId: string): Promise { + await this.initialize(); try { const { error } = await this.client .from(this.tableName) @@ -324,6 +331,7 @@ See the SQL migration instructions in the code comments.`, } async deleteCol(): Promise { + await this.initialize(); try { const { error } = await this.client .from(this.tableName) @@ -341,28 +349,47 @@ See the SQL migration instructions in the code comments.`, filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); try { - let query = this.client - .from(this.tableName) - .select("*", { count: "exact" }) - .limit(topK); + const results: VectorStoreResult[] = []; + let totalCount = 0; + let offset = 0; - if (filters) { - Object.entries(filters).forEach(([key, value]) => { - query = query.eq(`${this.metadataColumnName}->>${key}`, value); - }); + while (results.length < topK) { + const to = Math.min(offset + POSTGREST_MAX_ROWS, topK) - 1; + + let query = this.client + .from(this.tableName) + .select("*", { count: "exact" }) + .order("id", { ascending: true }); + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + query = query.eq(`${this.metadataColumnName}->>${key}`, value); + }); + } + + const { data, error, count } = await query.range(offset, to); + + if (error) throw error; + + totalCount = count ?? totalCount; + + const page = (data ?? []).map((item: VectorData) => ({ + id: item.id, + payload: item[this.metadataColumnName], + })); + results.push(...page); + + const requestedPageSize = to - offset + 1; + if (page.length < requestedPageSize) break; + + offset += page.length; + + if (results.length >= totalCount) break; } - const { data, error, count } = await query; - - if (error) throw error; - - const results = data.map((item: VectorData) => ({ - id: item.id, - payload: item[this.metadataColumnName], - })); - - return [results, count || 0]; + return [results, totalCount]; } catch (error) { console.error("Error listing vectors:", error); throw error; @@ -370,6 +397,7 @@ See the SQL migration instructions in the code comments.`, } async getUserId(): Promise { + await this.initialize(); try { // First check if the table exists const { data: tableExists } = await this.client @@ -421,6 +449,7 @@ See the SQL migration instructions in the code comments.`, } async setUserId(userId: string): Promise { + await this.initialize(); try { const { error: deleteError } = await this.client .from("memory_migrations") diff --git a/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts b/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts index e4bff3d7e..5134aa80a 100644 --- a/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts +++ b/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts @@ -1,6 +1,6 @@ -import Turbopuffer from "@turbopuffer/turbopuffer"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface TurbopufferConfig extends VectorStoreConfig { apiKey?: string; @@ -11,11 +11,10 @@ interface TurbopufferConfig extends VectorStoreConfig { } export class TurbopufferDB implements VectorStore { - private client: Turbopuffer; - private ns: ReturnType["namespace"]>; - private migrationsNs: ReturnType< - InstanceType["namespace"] - >; + private clientInstance?: any; + private clientPromise?: Promise; + private readonly apiKey: string; + private readonly region: string; private readonly collectionName: string; private readonly distanceMetric: string; private readonly batchSize: number; @@ -28,17 +27,52 @@ export class TurbopufferDB implements VectorStore { ); } - this.client = new Turbopuffer({ - apiKey, - region: config.region ?? "gcp-us-central1", - }); + this.apiKey = apiKey; + this.region = config.region ?? "gcp-us-central1"; this.collectionName = config.collectionName; this.distanceMetric = config.distanceMetric ?? "cosine_distance"; this.batchSize = config.batchSize ?? 100; - this.ns = this.client.namespace(this.collectionName); - this.migrationsNs = this.client.namespace( - this.collectionName + "_migrations", + } + + /** + * Lazily construct (or reuse) the Turbopuffer client, importing the optional + * `@turbopuffer/turbopuffer` peer only when the store is first used so + * consumers that never touch Turbopuffer don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + const sdk = await loadPeer( + "@turbopuffer/turbopuffer", + "Turbopuffer vector store", + () => import("@turbopuffer/turbopuffer"), ); + + // @turbopuffer/turbopuffer ships `Turbopuffer` as both the default export + // and a named export pointing at the same class. Use `.default` since + // that's what a plain `import Turbopuffer from "..."` resolves to (and + // what test doubles for this module mock). + return new sdk.default({ + apiKey: this.apiKey, + region: this.region, + }); + } + + private async getNs(): Promise { + const client = await this.getClient(); + return client.namespace(this.collectionName); + } + + private async getMigrationsNs(): Promise { + const client = await this.getClient(); + return client.namespace(this.collectionName + "_migrations"); } async initialize(): Promise { @@ -50,6 +84,7 @@ export class TurbopufferDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + const ns = await this.getNs(); for (let i = 0; i < vectors.length; i += this.batchSize) { const batchVectors = vectors.slice(i, i + this.batchSize); const batchIds = ids.slice(i, i + this.batchSize); @@ -61,7 +96,7 @@ export class TurbopufferDB implements VectorStore { vector, })); - await this.ns.write({ + await ns.write({ upsert_rows, distance_metric: this.distanceMetric as any, }); @@ -82,8 +117,9 @@ export class TurbopufferDB implements VectorStore { const tpufFilters = this.convertFilters(filters); if (tpufFilters !== null) queryParams.filters = tpufFilters; + const ns = await this.getNs(); try { - const result = await this.ns.query(queryParams); + const result = await ns.query(queryParams); return this.parseRows(result.rows ?? []); } catch (err) { console.error("Turbopuffer search error:", err); @@ -96,8 +132,9 @@ export class TurbopufferDB implements VectorStore { } async get(vectorId: string): Promise { + const ns = await this.getNs(); try { - const result = await this.ns.query({ + const result = await ns.query({ rank_by: ["id", "asc"] as any, top_k: 1, include_attributes: true, @@ -116,24 +153,27 @@ export class TurbopufferDB implements VectorStore { vector: number[], payload: Record, ): Promise { + const ns = await this.getNs(); if (vector && vector.length > 0) { - await this.ns.write({ + await ns.write({ upsert_rows: [{ ...payload, id: vectorId, vector }], distance_metric: this.distanceMetric as any, }); } else { - await this.ns.write({ + await ns.write({ patch_rows: [{ ...payload, id: vectorId }], }); } } async delete(vectorId: string): Promise { - await this.ns.write({ deletes: [vectorId] }); + const ns = await this.getNs(); + await ns.write({ deletes: [vectorId] }); } async deleteCol(): Promise { - await this.ns.deleteAll(); + const ns = await this.getNs(); + await ns.deleteAll(); } async list( @@ -149,8 +189,9 @@ export class TurbopufferDB implements VectorStore { const tpufFilters = this.convertFilters(filters); if (tpufFilters !== null) queryParams.filters = tpufFilters; + const ns = await this.getNs(); try { - const result = await this.ns.query(queryParams); + const result = await ns.query(queryParams); const rows = this.parseRows(result.rows ?? []); return [rows, rows.length]; } catch (err) { @@ -161,9 +202,10 @@ export class TurbopufferDB implements VectorStore { async getUserId(): Promise { try { + const migrationsNs = await this.getMigrationsNs(); let rows: any[] = []; try { - const result = await this.migrationsNs.query({ + const result = await migrationsNs.query({ rank_by: ["id", "asc"] as any, top_k: 1, include_attributes: true, @@ -181,7 +223,7 @@ export class TurbopufferDB implements VectorStore { const randomId = Math.random().toString(36).slice(2, 15) + Math.random().toString(36).slice(2, 15); - await this.migrationsNs.write({ + await migrationsNs.write({ upsert_rows: [{ id: "1", vector: [0.0], user_id: randomId }], distance_metric: "cosine_distance" as any, }); @@ -194,7 +236,8 @@ export class TurbopufferDB implements VectorStore { async setUserId(userId: string): Promise { try { - await this.migrationsNs.write({ + const migrationsNs = await this.getMigrationsNs(); + await migrationsNs.write({ upsert_rows: [{ id: "1", vector: [0.0], user_id: userId }], distance_metric: "cosine_distance" as any, }); diff --git a/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts b/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts index 4de38e2b2..2fa539dd6 100644 --- a/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts +++ b/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts @@ -1,41 +1,62 @@ -import { Index, QueryResult, Vector } from "@upstash/vector"; +import type { Index, QueryResult, Vector } from "@upstash/vector"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface UpstashVectorConfig extends VectorStoreConfig { collectionName: string; url?: string; token?: string; - client?: Index>; + /** Pre-configured Upstash Vector client instance (typed as `any` to keep + * the optional driver's types out of the published type declarations). */ + client?: any; } type UpstashMetadata = Record; export class UpstashVector implements VectorStore { - private readonly client: Index; + private client!: Index; + private readonly config: UpstashVectorConfig; private readonly collectionName: string; constructor(config: UpstashVectorConfig) { if (!config.collectionName) { throw new Error("collectionName is required for Upstash Vector."); } - - if (config.client) { - this.client = config.client; - } else if (config.url && config.token) { - this.client = new Index({ - url: config.url, - token: config.token, - }); - } else { + if (!config.client && !(config.url && config.token)) { throw new Error("Either a client or url and token must be provided."); } + this.config = config; this.collectionName = config.collectionName; } + /** + * Lazily construct (or reuse) the Upstash Vector client, importing the + * optional `@upstash/vector` peer only when the store is first used so + * consumers that never touch Upstash Vector don't need it installed. + */ + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; + if (config.client) { + this.client = config.client; + } else { + const sdk = await loadPeer( + "@upstash/vector", + "Upstash Vector store", + () => import("@upstash/vector"), + ); + this.client = new sdk.Index({ + url: config.url, + token: config.token, + }); + } + } + async initialize(): Promise { - return; + await this.ensureClient(); } async insert( @@ -43,6 +64,7 @@ export class UpstashVector implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const upsertData = vectors.map((vector, idx) => { return { id: ids[idx], @@ -59,6 +81,7 @@ export class UpstashVector implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const response = await this.client.query( { vector: query, @@ -77,6 +100,7 @@ export class UpstashVector implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const response = await this.client.query( { @@ -96,6 +120,7 @@ export class UpstashVector implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); const response = await this.client.fetch([vectorId], { includeMetadata: true, namespace: this.collectionName, @@ -117,6 +142,7 @@ export class UpstashVector implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); // Upstash's `update` can't set the vector and metadata in one call (its // payload is a discriminated union of vector | data | metadata), so a // single `upsert` replaces both atomically, the same way insert() writes. @@ -131,10 +157,12 @@ export class UpstashVector implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); await this.client.delete(vectorId, { namespace: this.collectionName }); } async deleteCol(): Promise { + await this.initialize(); await this.client.reset({ namespace: this.collectionName }); } @@ -142,6 +170,7 @@ export class UpstashVector implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const results: VectorStoreResult[] = []; let cursor = "0"; diff --git a/mem0-ts/src/oss/src/vector_stores/valkey.ts b/mem0-ts/src/oss/src/vector_stores/valkey.ts index adf4f6ed9..cd4f85920 100644 --- a/mem0-ts/src/oss/src/vector_stores/valkey.ts +++ b/mem0-ts/src/oss/src/vector_stores/valkey.ts @@ -1,6 +1,8 @@ import { VectorStore } from "./base"; import { SearchFilters, VectorStoreResult } from "../types"; import { ValkeyConfig } from "../types/valkey"; +import { loadPeer } from "../utils/load_peer"; +import { toCamelCasePreservingIds } from "../utils/casing"; interface ValkeyClient { call: (...args: (string | number | Buffer)[]) => Promise; @@ -44,16 +46,6 @@ function toSnakeCase(obj: Record): Record { ); } -function toCamelCase(obj: Record): Record { - if (typeof obj !== "object" || obj === null) return obj; - return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [ - key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), - value, - ]), - ); -} - interface ValkeySearchDoc { memory_id?: string; hash?: string; @@ -142,14 +134,8 @@ function formatTimestamp(timestamp: number, timezone: string = "UTC"): string { return `${yyyy}-${MM}-${dd}T${HH}:${mm}:${ss}${sign}${offHH}:${offMM}`; } -async function loadIovalkey(): Promise { - try { - return await import("iovalkey"); - } catch { - throw new Error( - "iovalkey is required for the Valkey vector store. Install it with: npm install iovalkey", - ); - } +function loadIovalkey(): Promise { + return loadPeer("iovalkey", "Valkey vector store", () => import("iovalkey")); } export class ValkeyDB implements VectorStore { @@ -393,7 +379,7 @@ export class ValkeyDB implements VectorStore { return { id: doc.memory_id ?? "", - payload: toCamelCase(resultPayload), + payload: toCamelCasePreservingIds(resultPayload), score, }; } diff --git a/mem0-ts/src/oss/src/vector_stores/vectorize.ts b/mem0-ts/src/oss/src/vector_stores/vectorize.ts index 8e900c142..c58966663 100644 --- a/mem0-ts/src/oss/src/vector_stores/vectorize.ts +++ b/mem0-ts/src/oss/src/vector_stores/vectorize.ts @@ -1,7 +1,8 @@ -import Cloudflare from "cloudflare"; +import type Cloudflare from "cloudflare"; import type { Vectorize, VectorizeVector } from "@cloudflare/workers-types"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface VectorizeConfig extends VectorStoreConfig { apiKey?: string; @@ -17,24 +18,36 @@ interface CloudflareVector { export class VectorizeDB implements VectorStore { private client: Cloudflare | null = null; + private apiKey?: string; private dimensions: number; private indexName: string; private accountId: string; private _initPromise?: Promise; constructor(config: VectorizeConfig) { - this.client = new Cloudflare({ apiToken: config.apiKey }); + this.apiKey = config.apiKey; this.dimensions = config.dimension || 1536; this.indexName = config.indexName; this.accountId = config.accountId; this.initialize().catch(console.error); } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "cloudflare", + "Vectorize vector store", + () => import("cloudflare"), + ); + this.client = new sdk.default({ apiToken: this.apiKey }); + } + async insert( vectors: number[][], ids: string[], payloads: Record[], ): Promise { + await this.initialize(); try { const vectorObjects: CloudflareVector[] = vectors.map( (vector, index) => ({ @@ -83,6 +96,7 @@ export class VectorizeDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const result = await this.client?.vectorize.indexes.query( this.indexName, @@ -111,6 +125,7 @@ export class VectorizeDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { const result = (await this.client?.vectorize.indexes.getByIds( this.indexName, @@ -139,6 +154,7 @@ export class VectorizeDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); try { const data: VectorizeVector = { id: vectorId, @@ -173,6 +189,7 @@ export class VectorizeDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); try { await this.client?.vectorize.indexes.deleteByIds(this.indexName, { account_id: this.accountId, @@ -187,6 +204,7 @@ export class VectorizeDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); try { await this.client?.vectorize.indexes.delete(this.indexName, { account_id: this.accountId, @@ -203,6 +221,7 @@ export class VectorizeDB implements VectorStore { filters?: SearchFilters, topK: number = 20, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); try { const result = await this.client?.vectorize.indexes.query( this.indexName, @@ -243,6 +262,7 @@ export class VectorizeDB implements VectorStore { } async getUserId(): Promise { + await this.initialize(); try { let found = false; for await (const index of this.client!.vectorize.indexes.list({ @@ -309,6 +329,7 @@ export class VectorizeDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { // Get existing point ID const result: any = await this.client?.vectorize.indexes.query( @@ -355,6 +376,7 @@ export class VectorizeDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { // Check if the index already exists let indexFound = false; diff --git a/mem0-ts/src/oss/src/vector_stores/weaviate.ts b/mem0-ts/src/oss/src/vector_stores/weaviate.ts index 994a92da4..3c7d51246 100644 --- a/mem0-ts/src/oss/src/vector_stores/weaviate.ts +++ b/mem0-ts/src/oss/src/vector_stores/weaviate.ts @@ -1,10 +1,13 @@ -import weaviate, { Filters, type WeaviateClient } from "weaviate-client"; +import type { WeaviateClient } from "weaviate-client"; import { v4 as uuidv4 } from "uuid"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface WeaviateConfig extends VectorStoreConfig { - client?: WeaviateClient; + /** Pre-configured Weaviate client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; clusterUrl?: string; apiKey?: string; additionalHeaders?: Record; @@ -28,6 +31,7 @@ const RETURN_PROPERTIES = [ export class WeaviateDB implements VectorStore { private _config: WeaviateConfig; private _client!: WeaviateClient; + private _sdk: any; private _col!: any; private _userId: string; private _initPromise?: Promise; @@ -42,9 +46,20 @@ export class WeaviateDB implements VectorStore { return (this._initPromise ??= this._doInitialize()); } - private async _doInitialize(): Promise { - const { client, clusterUrl, apiKey, additionalHeaders, collectionName } = - this._config; + // Loaded dynamically: weaviate-client is an optional peer dependency, so a static + // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. + private async ensureClient(): Promise { + if (this._client) return; + + const sdk = await loadPeer( + "weaviate-client", + "Weaviate vector store", + () => import("weaviate-client"), + ); + this._sdk = sdk; + + const { client, clusterUrl, apiKey, additionalHeaders } = this._config; + const weaviate = sdk.default; if (client) { this._client = client; @@ -79,6 +94,12 @@ export class WeaviateDB implements VectorStore { headers: additionalHeaders, }); } + } + + private async _doInitialize(): Promise { + await this.ensureClient(); + const { collectionName } = this._config; + const weaviate = this._sdk.default; const exists = await this._client.collections.exists(collectionName); if (!exists) { @@ -101,7 +122,7 @@ export class WeaviateDB implements VectorStore { const conditions = (["user_id", "agent_id", "run_id"] as const) .filter((key) => filters[key] != null) .map((key) => this._col.filter.byProperty(key).equal(filters[key])); - return conditions.length ? Filters.and(...conditions) : undefined; + return conditions.length ? this._sdk.Filters.and(...conditions) : undefined; } async insert( diff --git a/mem0-ts/src/oss/tests/anthropic-llm.test.ts b/mem0-ts/src/oss/tests/anthropic-llm.test.ts index a8d5d3276..af1262003 100644 --- a/mem0-ts/src/oss/tests/anthropic-llm.test.ts +++ b/mem0-ts/src/oss/tests/anthropic-llm.test.ts @@ -23,11 +23,16 @@ describe("AnthropicLLM (unit)", () => { // Regression #5665: a configured baseURL must reach the Anthropic client so // proxy/gateway users are not silently bypassed (TS parity with #5626). - it("forwards baseURL to the Anthropic client when set", () => { - new AnthropicLLM({ + // The client is constructed lazily on first use, so drive generateResponse. + it("forwards baseURL to the Anthropic client when set", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + }); + const llm = new AnthropicLLM({ apiKey: "test-key", baseURL: "https://proxy.example/v1", }); + await llm.generateResponse([{ role: "user", content: "Hi" }]); expect(mockConstructor).toHaveBeenCalledTimes(1); const ctorArgs = mockConstructor.mock.calls[0][0]; @@ -37,8 +42,12 @@ describe("AnthropicLLM (unit)", () => { // When no baseURL is configured the client must not receive a baseURL key // (so the SDK default endpoint is used). - it("does NOT set baseURL when none is configured", () => { - new AnthropicLLM({ apiKey: "test-key" }); + it("does NOT set baseURL when none is configured", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + }); + const llm = new AnthropicLLM({ apiKey: "test-key" }); + await llm.generateResponse([{ role: "user", content: "Hi" }]); expect(mockConstructor).toHaveBeenCalledTimes(1); const ctorArgs = mockConstructor.mock.calls[0][0]; @@ -63,6 +72,37 @@ describe("AnthropicLLM (unit)", () => { expect(callArgs.tool_choice).toBeUndefined(); }); + // Regression: thinking-enabled models emit a thinking block before the text + // block. Indexing content[0] threw "Unexpected response type"; the text block + // must be found by type instead (TS parity with #6481). + it("returns the text block when a thinking block precedes it (no tools)", async () => { + mockCreate.mockResolvedValueOnce({ + content: [ + { type: "thinking", thinking: "Let me reason about this." }, + { type: "text", text: '{"facts": ["fact1"]}' }, + ], + }); + + const llm = new AnthropicLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse([ + { role: "user", content: "Hello" }, + ]); + + expect(result).toBe('{"facts": ["fact1"]}'); + }); + + // A response carrying no text block at all must resolve to "" rather than throw. + it("returns an empty string when no text block is present (no tools)", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "thinking", thinking: "Thinking only." }], + }); + + const llm = new AnthropicLLM({ apiKey: "test-key" }); + await expect( + llm.generateResponse([{ role: "user", content: "Hello" }]), + ).resolves.toBe(""); + }); + // Bug #1 regression: bare string "auto" must NOT be sent; object form required it("forwards tool_choice as { type: 'auto' } (not bare string) when tools are provided", async () => { mockCreate.mockResolvedValueOnce({ diff --git a/mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts b/mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts new file mode 100644 index 000000000..056a7bc61 --- /dev/null +++ b/mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts @@ -0,0 +1,517 @@ +import type { InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; +import { AWSBedrockEmbedder } from "../src/embeddings/aws_bedrock"; +import type { Embedder } from "../src/embeddings/base"; +import { EmbedderFactory } from "../src/utils/factory"; + +/** + * Only the network boundary is faked: `BedrockRuntimeClient.send` never leaves + * the process. `InvokeModelCommand` stays the real class from the AWS SDK, so + * every assertion below runs against the exact payload Bedrock would receive. + */ +const mockSend = jest.fn(); +const mockClientConfigs: any[] = []; +const mockClientConstructor = jest + .fn() + .mockImplementation((config: unknown) => { + mockClientConfigs.push(config); + return { send: mockSend }; + }); + +jest.mock("@aws-sdk/client-bedrock-runtime", () => { + const actual = jest.requireActual("@aws-sdk/client-bedrock-runtime"); + return { + ...actual, + BedrockRuntimeClient: mockClientConstructor, + }; +}); + +const encode = (payload: unknown) => ({ + body: new TextEncoder().encode(JSON.stringify(payload)), +}); + +const titanReply = (embedding: number[]) => + encode({ embedding, inputTextTokenCount: embedding.length }); + +const cohereReply = (embeddings: number[][]) => + encode({ embeddings, id: "req-1", response_type: "embeddings_floats" }); + +const commandAt = (index: number): InvokeModelCommand => + mockSend.mock.calls[index][0]; + +const requestBodyAt = (index: number) => + JSON.parse(new TextDecoder().decode(commandAt(index).input.body)); + +describe("AWSBedrockEmbedder", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientConfigs.length = 0; + delete process.env.AWS_REGION; + }); + + afterAll(() => { + process.env = savedEnv; + }); + + describe("Titan models", () => { + it("sends inputText and returns the embedding vector", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2, 0.3])); + const embedder = new AWSBedrockEmbedder({}); + + const embedding = await embedder.embed("hello world"); + + expect(embedding).toEqual([0.1, 0.2, 0.3]); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(commandAt(0).input.modelId).toBe("amazon.titan-embed-text-v1"); + expect(commandAt(0).input.contentType).toBe("application/json"); + expect(commandAt(0).input.accept).toBe("application/json"); + expect(requestBodyAt(0)).toEqual({ inputText: "hello world" }); + }); + + it("forwards dimensions to Titan V2 when embeddingDims is set", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2])); + const embedder = new AWSBedrockEmbedder({ + model: "amazon.titan-embed-text-v2:0", + embeddingDims: 512, + }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello", dimensions: 512 }); + }); + + it("omits dimensions on Titan V1, which rejects the field", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2])); + const embedder = new AWSBedrockEmbedder({ + model: "amazon.titan-embed-text-v1", + embeddingDims: 512, + }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello" }); + }); + + // F6: the old guard was `model.includes("v2")`, which would also match + // any future/other Titan model whose id merely contains "v2" somewhere + // (e.g. an image model), wrongly sending `dimensions` to a model that may + // reject it. Only Titan Text Embeddings V2 should get the field. + it("does not forward dimensions to a non-Titan-V2 model whose name merely contains v2", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2])); + const embedder = new AWSBedrockEmbedder({ + model: "amazon.titan-embed-image-v2:0", + embeddingDims: 512, + }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello" }); + }); + + it("embedBatch issues one request per text and preserves order", async () => { + mockSend + .mockResolvedValueOnce(titanReply([1, 1])) + .mockResolvedValueOnce(titanReply([2, 2])); + const embedder = new AWSBedrockEmbedder({}); + + const embeddings = await embedder.embedBatch(["first", "second"]); + + expect(embeddings).toEqual([ + [1, 1], + [2, 2], + ]); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(requestBodyAt(0)).toEqual({ inputText: "first" }); + expect(requestBodyAt(1)).toEqual({ inputText: "second" }); + }); + }); + + describe("Cohere models", () => { + it("sends texts with a search_document input type", async () => { + mockSend.mockResolvedValueOnce(cohereReply([[0.4, 0.5]])); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + const embedding = await embedder.embed("hello"); + + expect(embedding).toEqual([0.4, 0.5]); + expect(requestBodyAt(0)).toEqual({ + texts: ["hello"], + input_type: "search_document", + }); + }); + + it("embedBatch sends every text in a single request", async () => { + mockSend.mockResolvedValueOnce(cohereReply([[1], [2], [3]])); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-multilingual-v3", + }); + + const embeddings = await embedder.embedBatch(["a", "b", "c"]); + + expect(embeddings).toEqual([[1], [2], [3]]); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(requestBodyAt(0).texts).toEqual(["a", "b", "c"]); + }); + + it("embedBatch splits requests at Cohere's 96 text limit", async () => { + const texts = Array.from({ length: 100 }, (_, i) => `text-${i}`); + mockSend + .mockResolvedValueOnce( + cohereReply(texts.slice(0, 96).map((_, i) => [i])), + ) + .mockResolvedValueOnce(cohereReply(texts.slice(96).map((_, i) => [i]))); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + const embeddings = await embedder.embedBatch(texts); + + expect(embeddings).toHaveLength(100); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(requestBodyAt(0).texts).toHaveLength(96); + expect(requestBodyAt(1).texts).toEqual([ + "text-96", + "text-97", + "text-98", + "text-99", + ]); + }); + }); + + describe("Cohere Embed v4", () => { + // F5: only Embed v4 understands embedding_types / output_dimension, and + // (when embedding_types is requested) replies with a nested + // `{ embeddings: { float: [...] } }` shape instead of v3's flat array. + it("requests embedding_types and output_dimension for v4 models", async () => { + mockSend.mockResolvedValueOnce( + encode({ embeddings: { float: [[0.1, 0.2, 0.3]] } }), + ); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-v4:0", + embeddingDims: 512, + }); + + const embedding = await embedder.embed("hello"); + + expect(embedding).toEqual([0.1, 0.2, 0.3]); + expect(requestBodyAt(0)).toEqual({ + texts: ["hello"], + input_type: "search_document", + embedding_types: ["float"], + output_dimension: 512, + }); + }); + + it("omits output_dimension for v4 when embeddingDims is unset", async () => { + mockSend.mockResolvedValueOnce( + encode({ embeddings: { float: [[0.1, 0.2]] } }), + ); + const embedder = new AWSBedrockEmbedder({ model: "cohere.embed-v4:0" }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ + texts: ["hello"], + input_type: "search_document", + embedding_types: ["float"], + }); + }); + + it("parses the nested embeddings.float response shape", async () => { + mockSend.mockResolvedValueOnce( + encode({ + embeddings: { + float: [ + [1, 2], + [3, 4], + ], + }, + }), + ); + const embedder = new AWSBedrockEmbedder({ model: "cohere.embed-v4:0" }); + + const embeddings = await embedder.embedBatch(["a", "b"]); + + expect(embeddings).toEqual([ + [1, 2], + [3, 4], + ]); + }); + }); + + describe("client configuration", () => { + it("defaults to the us-west-2 region", async () => { + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({}).embed("hello"); + + expect(mockClientConfigs[0].region).toBe("us-west-2"); + }); + + it("prefers awsRegion over the AWS_REGION environment variable", async () => { + process.env.AWS_REGION = "eu-central-1"; + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({ awsRegion: "ap-south-1" }).embed("hello"); + + expect(mockClientConfigs[0].region).toBe("ap-south-1"); + }); + + it("falls back to the AWS_REGION environment variable", async () => { + process.env.AWS_REGION = "eu-central-1"; + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({}).embed("hello"); + + expect(mockClientConfigs[0].region).toBe("eu-central-1"); + }); + + it("passes explicitly configured credentials to the client", async () => { + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({ + awsAccessKeyId: "AKIA_TEST", + awsSecretAccessKey: "secret", + awsSessionToken: "token", + }).embed("hello"); + + expect(mockClientConfigs[0].credentials).toEqual({ + accessKeyId: "AKIA_TEST", + secretAccessKey: "secret", + sessionToken: "token", + }); + }); + + it("leaves credentials unset so the AWS default credential chain applies", async () => { + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({}).embed("hello"); + + expect(mockClientConfigs[0].credentials).toBeUndefined(); + }); + + it("rejects a half-configured credential pair", () => { + expect( + () => new AWSBedrockEmbedder({ awsAccessKeyId: "AKIA_TEST" }), + ).toThrow(/awsAccessKeyId and awsSecretAccessKey/); + expect( + () => new AWSBedrockEmbedder({ awsSecretAccessKey: "secret" }), + ).toThrow(/awsAccessKeyId and awsSecretAccessKey/); + }); + + // Silently ignoring a lone session token would fall back to the ambient + // credential chain, embedding under an identity the caller never chose. + it("rejects a session token supplied without the key pair", () => { + expect( + () => new AWSBedrockEmbedder({ awsSessionToken: "token" }), + ).toThrow(/awsAccessKeyId and awsSecretAccessKey/); + }); + }); + + describe("provider registration", () => { + it("is constructed by EmbedderFactory for the aws_bedrock provider", () => { + const embedder = EmbedderFactory.create("aws_bedrock", { + model: "amazon.titan-embed-text-v2:0", + }); + + expect(embedder).toBeInstanceOf(AWSBedrockEmbedder); + }); + }); + + describe("error handling", () => { + it("wraps Bedrock failures with the model id", async () => { + mockSend.mockRejectedValueOnce(new Error("AccessDeniedException")); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "Error getting embedding from AWS Bedrock model amazon.titan-embed-text-v1: AccessDeniedException", + ); + }); + + it("fails when the response carries no embedding", async () => { + mockSend.mockResolvedValueOnce(encode({ inputTextTokenCount: 3 })); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + /returned no embedding/, + ); + }); + + // F7: `[]` is truthy, so `payload.embedding && [payload.embedding]` used to + // turn `{"embedding": []}` into `[[]]` -- length 1, which satisfied the + // length check for a single-input call and handed the caller an empty vector. + it("rejects a zero-length Titan embedding as no embedding", async () => { + mockSend.mockResolvedValueOnce(encode({ embedding: [] })); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + /returned no embedding/, + ); + }); + + it("returns an empty array for an empty batch without calling Bedrock", async () => { + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embedBatch([])).resolves.toEqual([]); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + describe("dynamic SDK import", () => { + // These tests replace the module registered for + // @aws-sdk/client-bedrock-runtime for a single resolution. Restore the + // working mock afterward so every other test in this file keeps getting + // the mocked client instead of hitting module resolution for real. + afterEach(() => { + jest.resetModules(); + jest.doMock("@aws-sdk/client-bedrock-runtime", () => { + const actual = jest.requireActual("@aws-sdk/client-bedrock-runtime"); + return { ...actual, BedrockRuntimeClient: mockClientConstructor }; + }); + }); + + // F1: loadSdk()'s catch used to rewrite *every* import failure into the + // "package is required" hint, even when the package is installed but + // failed to load for an unrelated reason. That discarded the real error. + it("propagates a non-resolution import error unchanged", async () => { + jest.resetModules(); + jest.doMock("@aws-sdk/client-bedrock-runtime", () => { + const err: any = new Error("boom: unrelated crash while loading"); + err.code = "ERR_SOMETHING_ELSE"; + throw err; + }); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "boom: unrelated crash while loading", + ); + }); + + // F1: a genuine resolution failure should still get the friendly install + // hint, with the original error preserved as `cause` for debugging. + it("gives an install hint for a genuine module-not-found error, preserving the cause", async () => { + jest.resetModules(); + jest.doMock("@aws-sdk/client-bedrock-runtime", () => { + const err: any = new Error( + "Cannot find module '@aws-sdk/client-bedrock-runtime'", + ); + err.code = "MODULE_NOT_FOUND"; + throw err; + }); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + /npm install @aws-sdk\/client-bedrock-runtime/, + ); + await expect(embedder.embed("hello")).rejects.toMatchObject({ + cause: expect.objectContaining({ code: "MODULE_NOT_FOUND" }), + }); + }); + }); + + describe("client promise retry", () => { + // F2: getClient() used to memoize the client promise before it resolved, + // so a rejected construction (e.g. a transient credentials failure) was + // cached forever -- every later embed() call on that instance would + // reject immediately without ever retrying. + it("retries client construction after a failure instead of caching the rejection", async () => { + mockClientConstructor.mockImplementationOnce(() => { + throw new Error("credentials not ready"); + }); + mockSend.mockResolvedValueOnce(titanReply([1, 2, 3])); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "credentials not ready", + ); + await expect(embedder.embed("hello")).resolves.toEqual([1, 2, 3]); + }); + }); + + describe("memoryAction -> Cohere input_type", () => { + // F3: buildRequestBody() used to hardcode `input_type: "search_document"` + // regardless of the caller's action, so `Memory.search()` (which calls + // `embed(query, "search")`) embedded the query in document mode. + // + // Typed as `Embedder` (not `AWSBedrockEmbedder`) because that is how + // memory/index.ts actually calls it: the interface already declares an + // optional `memoryAction` second parameter, so a narrower concrete + // `embed(text: string)` satisfies it structurally and tsc stays silent -- + // the bug is a silent behavioral one, not a compile error. + it("sends search_query for a search action", async () => { + mockSend.mockResolvedValueOnce(cohereReply([[0.1]])); + const embedder: Embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + await embedder.embed("query text", "search"); + + expect(requestBodyAt(0)).toEqual({ + texts: ["query text"], + input_type: "search_query", + }); + }); + + it("sends search_document for add and update actions", async () => { + mockSend + .mockResolvedValueOnce(cohereReply([[0.1]])) + .mockResolvedValueOnce(cohereReply([[0.2]])); + const embedder: Embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + await embedder.embed("doc one", "add"); + await embedder.embed("doc two", "update"); + + expect(requestBodyAt(0).input_type).toBe("search_document"); + expect(requestBodyAt(1).input_type).toBe("search_document"); + }); + + // Titan has no input_type concept; buildRequestBody() must not add one + // even when a memoryAction is explicitly passed through. + it("Titan ignores memoryAction and never sends input_type", async () => { + mockSend.mockResolvedValueOnce(titanReply([1, 2])); + const embedder: Embedder = new AWSBedrockEmbedder({}); + + await embedder.embed("hello", "search"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello" }); + }); + }); + + describe("Titan embedBatch concurrency", () => { + afterEach(() => { + // This test sets a persistent mockImplementation (not a *Once), so + // clear it explicitly -- jest.clearAllMocks() in the top beforeEach + // clears call data but not implementations. + mockSend.mockReset(); + }); + + // F4: embedBatch() used to Promise.all-fan-out one InvokeModel call per + // text with no cap, so a large batch could open hundreds of concurrent + // requests at once. TITAN_MAX_CONCURRENCY bounds this to a small pool + // while still preserving output order. + it("never runs more than TITAN_MAX_CONCURRENCY Titan requests at once, and preserves order", async () => { + let active = 0; + let peak = 0; + mockSend.mockImplementation(async (command: InvokeModelCommand) => { + active++; + peak = Math.max(peak, active); + const body = JSON.parse( + new TextDecoder().decode(command.input.body as Uint8Array), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + const index = Number(body.inputText.split("-")[1]); + return titanReply([index]); + }); + const embedder = new AWSBedrockEmbedder({}); + const texts = Array.from({ length: 10 }, (_, i) => `text-${i}`); + + const embeddings = await embedder.embedBatch(texts); + + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(4); + expect(embeddings).toEqual(texts.map((_, i) => [i])); + expect(mockSend).toHaveBeenCalledTimes(10); + }); + }); +}); diff --git a/mem0-ts/src/oss/tests/baidu.test.ts b/mem0-ts/src/oss/tests/baidu.test.ts new file mode 100644 index 000000000..a2a50013d --- /dev/null +++ b/mem0-ts/src/oss/tests/baidu.test.ts @@ -0,0 +1,649 @@ +import { + AutoBuildPolicyType, + FieldType, + IndexType, + InvertedIndexFieldAttribute, + MetricType, + PartitionType, + ServerErrCode, + TableState, +} from "@mochow/mochow-sdk-node"; +import { BaiduDB } from "../src/vector_stores/baidu"; + +// No jest.mock() here on purpose: the real SDK supplies the enums and the search request +// classes (which carry an internal `set` map the client reads, so they cannot be hand-rolled +// as plain literals). Only the network-facing MochowClient is faked, via the `client` config. + +const OK = { code: 0, msg: "" }; +const DIMS = 1536; + +const normalTable = (schema: unknown = { fields: [], indexes: [] }) => ({ + ...OK, + table: { state: TableState.Normal, schema }, +}); + +const CORE_FIELDS = [ + { fieldName: "id", fieldType: FieldType.String }, + { fieldName: "data", fieldType: FieldType.Text }, + { fieldName: "vector", fieldType: FieldType.FloatVector, dimension: DIMS }, + { fieldName: "metadata", fieldType: "JSON" }, +]; + +const BM25_FIELDS = [ + ...CORE_FIELDS, + { fieldName: "textLemmatized", fieldType: FieldType.Text }, +]; + +/** Records call order, so ordering regressions (deleteCol vs. in-flight init) are visible. */ +function fakeClient(overrides: Record any> = {}) { + const calls: string[] = []; + const track = + (name: string, impl: (...args: any[]) => any) => + (...args: any[]) => { + calls.push(name); + return impl(...args); + }; + + const client: any = { + calls, + createDatabase: jest.fn(track("createDatabase", async () => OK)), + createTable: jest.fn(track("createTable", async () => OK)), + dropTable: jest.fn(track("dropTable", async () => OK)), + descTable: jest.fn(track("descTable", async () => normalTable())), + upsert: jest.fn(async () => OK), + delete: jest.fn(async () => OK), + query: jest.fn(), + select: jest.fn(), + vectorSearch: jest.fn(), + bm25Search: jest.fn(), + }; + + for (const [name, impl] of Object.entries(overrides)) { + client[name] = jest.fn(track(name, impl)); + } + return client; +} + +const makeStore = (client: any, extra: Record = {}) => + new BaiduDB({ + endpoint: "http://127.0.0.1:5287", + account: "root", + apiKey: "test-key", + databaseName: "mem0_db", + tableName: "mem0", + embeddingModelDims: DIMS, + client, + ...extra, + } as any); + +/** Run the poll loop's setTimeout inline so tests never wait the real 2s interval. */ +const runTimersInline = () => + jest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void) => { + fn(); + return 0; + }) as any); + +beforeEach(() => { + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe("BaiduDB config", () => { + it("rejects a missing required field", () => { + expect(() => makeStore(fakeClient(), { tableName: "" })).toThrow( + /non-empty 'tableName'/, + ); + }); + + it("does not require endpoint credentials when a client is injected", () => { + expect(() => + makeStore(fakeClient(), { endpoint: "", account: "", apiKey: "" }), + ).not.toThrow(); + }); +}); + +describe("BaiduDB table provisioning", () => { + it("creates the table with the schema mem0 needs", async () => { + const client = fakeClient(); + await makeStore(client).initialize(); + + const spec = client.createTable.mock.calls[0][0]; + expect(client.createDatabase).toHaveBeenCalledWith("mem0_db"); + expect(spec.database).toBe("mem0_db"); + expect(spec.table).toBe("mem0"); + expect(spec.enableDynamicField).toBe(false); + // Mochow rejects a partition without partitionType. + expect(spec.partition).toEqual({ + partitionType: PartitionType.HASH, + partitionNum: 1, + }); + + const fields = spec.schema.fields.map((f: any) => [ + f.fieldName, + f.fieldType, + ]); + expect(fields).toEqual([ + ["id", FieldType.String], + ["data", FieldType.Text], + ["vector", FieldType.FloatVector], + ["textLemmatized", FieldType.Text], + ["metadata", "JSON"], + ]); + expect(spec.schema.fields[0]).toMatchObject({ + primaryKey: true, + partitionKey: true, + notNull: true, + }); + expect(spec.schema.fields[2].dimension).toBe(DIMS); + }); + + it("builds a vector index with a genuine row-count-increment auto-build policy", async () => { + const client = fakeClient(); + await makeStore(client).initialize(); + + const [vectorIndex] = client.createTable.mock.calls[0][0].schema.indexes; + expect(vectorIndex).toMatchObject({ + indexName: "vector_idx", + indexType: IndexType.HNSW, + field: "vector", + metricType: MetricType.L2, + params: { M: 16, efConstruction: 200 }, + autoBuild: true, + }); + // Regression guard: sdk.AutoBuildIncrement() stamps policyType "TIMING" in 2.1.5. + expect(vectorIndex.autoBuildPolicy).toEqual({ + policyType: AutoBuildPolicyType.Increment, + rowCountIncrement: 10000, + }); + expect(AutoBuildPolicyType.Increment).not.toBe(AutoBuildPolicyType.Timing); + }); + + it("declares the filtering and BM25 indexes with an indexType", async () => { + const client = fakeClient(); + await makeStore(client).initialize(); + + const [, filtering, bm25] = + client.createTable.mock.calls[0][0].schema.indexes; + expect(filtering).toEqual({ + indexName: "metadata_filtering_idx", + indexType: IndexType.FilteringIndex, + fields: ["metadata"], + }); + // Memory.search() hands keywordSearch() an already-lemmatized query, so raw `data` is + // not worth indexing β€” only the lemmatized column is. The index is therefore named for + // that column and must never be called "data_bm25_idx": that is the name Python's + // keyword_search() queries with a raw, unstemmed query, and it must keep missing (and so + // falling back to vector search) rather than half-matching this stemmed index. + expect(bm25).toMatchObject({ + indexName: "text_lemmatized_bm25_idx", + indexType: IndexType.InvertedIndex, + fields: ["textLemmatized"], + fieldAttributes: [InvertedIndexFieldAttribute.Analyzed], + }); + }); + + it("honours a configured metric type", async () => { + const client = fakeClient(); + await makeStore(client, { metricType: "COSINE" }).initialize(); + const [vectorIndex] = client.createTable.mock.calls[0][0].schema.indexes; + expect(vectorIndex.metricType).toBe(MetricType.COSINE); + }); + + it("tolerates an existing database and table", async () => { + const client = fakeClient({ + createDatabase: async () => ({ + code: ServerErrCode.DBAlreadyExist, + msg: "db exists", + }), + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "table exists", + }), + descTable: async () => normalTable({ fields: BM25_FIELDS, indexes: [] }), + }); + await expect(makeStore(client).initialize()).resolves.toBeUndefined(); + }); + + it("waits for a CREATING table to become NORMAL", async () => { + runTimersInline(); + const states = [ + TableState.Creating, + TableState.Creating, + TableState.Normal, + ]; + const client = fakeClient({ + descTable: async () => ({ + ...OK, + table: { state: states.shift(), schema: { fields: [], indexes: [] } }, + }), + }); + + await makeStore(client).initialize(); + expect(client.descTable).toHaveBeenCalledTimes(3); + }); + + it("surfaces a non-zero envelope as an error rather than succeeding", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.InvalidTableSchema, + msg: "bad schema", + }), + }); + await expect(makeStore(client).initialize()).rejects.toThrow( + /createTable 'mem0' failed \(code 60\): bad schema/, + ); + }); + + it("rejects an existing table whose vector dimension disagrees", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => + normalTable({ + fields: [ + CORE_FIELDS[0], + CORE_FIELDS[1], + { + fieldName: "vector", + fieldType: FieldType.FloatVector, + dimension: 768, + }, + CORE_FIELDS[3], + ], + indexes: [], + }), + }); + await expect(makeStore(client).initialize()).rejects.toThrow( + /stores 768-dimensional vectors, but 'embeddingModelDims' is 1536/, + ); + }); + + it("rejects an existing table missing the core schema", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => + normalTable({ fields: [CORE_FIELDS[0]], indexes: [] }), + }); + await expect(makeStore(client).initialize()).rejects.toThrow( + /missing the id\/data\/vector\/metadata schema/, + ); + }); +}); + +describe("BaiduDB keyword search support detection", () => { + it("fails closed when an existing table has no inverted index", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => normalTable({ fields: CORE_FIELDS, indexes: [] }), + }); + const store = makeStore(client); + + await expect(store.keywordSearch("hello")).resolves.toBeNull(); + expect(client.bm25Search).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("text_lemmatized_bm25_idx"), + ); + }); + + it("enables keyword search when the existing table carries the BM25 index", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => + normalTable({ + fields: BM25_FIELDS, + indexes: [{ indexName: "text_lemmatized_bm25_idx" }], + }), + }); + client.bm25Search.mockResolvedValue({ ...OK, rows: [] }); + + await expect(makeStore(client).keywordSearch("hello")).resolves.toEqual([]); + expect(client.bm25Search).toHaveBeenCalled(); + }); + + it("queries the inverted index with the caller's already-lemmatized text", async () => { + const client = fakeClient(); + client.bm25Search.mockResolvedValue({ + ...OK, + rows: [ + { row: { id: "m1", data: "loves pizza", metadata: {} }, score: 3.5 }, + ], + }); + + const results = await makeStore(client).keywordSearch("love pizza", 7, { + userId: "alice", + }); + expect(results).toEqual([ + { id: "m1", payload: { data: "loves pizza" }, score: 3.5 }, + ]); + + const { request, ...ns } = client.bm25Search.mock.calls[0][0]; + expect(ns).toEqual({ database: "mem0_db", table: "mem0" }); + expect(request.indexName).toBe("text_lemmatized_bm25_idx"); + expect(request.searchText).toBe("love pizza"); + expect(request.limit).toBe(7); + expect(request.filter).toBe('metadata["userId"] = "alice"'); + }); +}); + +describe("BaiduDB writes", () => { + it("upserts the whole batch in one call and mirrors textLemmatized out of the payload", async () => { + const client = fakeClient(); + + await makeStore(client).insert( + [ + [1, 2], + [3, 4], + ], + ["a", "b"], + [ + { data: "loves pizza", textLemmatized: "love pizza" }, + { data: "runs daily" }, + ], + ); + + expect(client.upsert).toHaveBeenCalledTimes(1); + expect(client.upsert.mock.calls[0][0]).toEqual({ + database: "mem0_db", + table: "mem0", + rows: [ + { + id: "a", + data: "loves pizza", + vector: [1, 2], + textLemmatized: "love pizza", + metadata: {}, + }, + // Falls back to `data` when the caller did not lemmatize. + { + id: "b", + data: "runs daily", + vector: [3, 4], + textLemmatized: "runs daily", + metadata: {}, + }, + ], + }); + }); + + it("refuses a ragged batch instead of silently truncating it", async () => { + await expect( + makeStore(fakeClient()).insert([[1]], ["a", "b"], [{}]), + ).rejects.toThrow(/equal length \(got 1\/2\/1\)/); + }); + + it("updates and deletes by primary key", async () => { + const client = fakeClient(); + const store = makeStore(client); + + await store.update("m1", [9], { data: "new" }); + expect(client.upsert.mock.calls[0][0].rows).toEqual([ + { + id: "m1", + data: "new", + vector: [9], + textLemmatized: "new", + metadata: {}, + }, + ]); + + await store.delete("m1"); + expect(client.delete).toHaveBeenCalledWith({ + database: "mem0_db", + table: "mem0", + primaryKey: { id: "m1" }, + }); + }); + + it("throws when the server rejects an upsert", async () => { + const client = fakeClient(); + client.upsert.mockResolvedValue({ code: 100, msg: "duplicate key" }); + + await expect(makeStore(client).insert([[1]], ["a"], [{}])).rejects.toThrow( + /upsert failed \(code 100\): duplicate key/, + ); + }); +}); + +describe("BaiduDB reads", () => { + it("maps vector search hits out of the nested row envelope", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ + ...OK, + rows: [ + { + row: { id: "m1", data: "x", metadata: {} }, + distance: 0.2, + score: 0.8, + }, + ], + }); + + // Non-L2 metrics already return a higher-is-better score and pass through untouched. + const results = await makeStore(client, { metricType: "COSINE" }).search( + [1, 2, 3], + 5, + { userId: "alice" }, + ); + expect(results).toEqual([{ id: "m1", payload: { data: "x" }, score: 0.8 }]); + + const { request } = client.vectorSearch.mock.calls[0][0]; + expect(request.vectorField).toBe("vector"); + expect(request.vector).toEqual({ vector: [1, 2, 3] }); + expect(request.limit).toBe(5); + expect(request.filter).toBe('metadata["userId"] = "alice"'); + expect(request.projections).toEqual(["id", "data", "metadata"]); + expect(request.config.params).toEqual({ ef: 200 }); + }); + + it("converts an L2 distance into a similarity score (higher = better)", async () => { + // Mirrors the Python provider (#6435): 1 / (1 + distance), so closer scores higher. + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ + ...OK, + rows: [ + { row: { id: "near", metadata: {} }, score: 0.5 }, + { row: { id: "far", metadata: {} }, score: 2.0 }, + ], + }); + + const results = await makeStore(client).search([1, 2, 3], 2); + + expect(results[0].score).toBeCloseTo(1 / 1.5, 10); + expect(results[1].score).toBeCloseTo(1 / 3.0, 10); + }); + + it("leaves an unscored L2 row's score undefined instead of treating it as the closest match", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ + ...OK, + rows: [{ row: { id: "unscored", metadata: {} } }], + }); + + const results = await makeStore(client).search([1, 2, 3], 1); + + expect(results[0].score).toBeUndefined(); + }); + + it("omits the filter when no filters are supplied", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ ...OK, rows: [] }); + await makeStore(client).search([1], 5); + expect(client.vectorSearch.mock.calls[0][0].request.filter).toBeUndefined(); + }); + + it("escapes quotes and rejects unsafe filter keys and values", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ ...OK, rows: [] }); + const store = makeStore(client); + + await store.search([1], 5, { userId: 'a"b', runId: 3, agentId: true }); + expect(client.vectorSearch.mock.calls[0][0].request.filter).toBe( + 'metadata["userId"] = "a\\"b" AND metadata["runId"] = 3 AND metadata["agentId"] = true', + ); + + await expect(store.search([1], 5, { "bad key": "x" })).rejects.toThrow( + /Invalid filter key/, + ); + await expect( + store.search([1], 5, { userId: ["a"] as any }), + ).rejects.toThrow(/must be str, int, float, or bool, got array/); + }); + + it("returns null for a missing id and throws on a real query failure", async () => { + const client = fakeClient(); + const store = makeStore(client); + + client.query.mockResolvedValue({ ...OK, row: {} }); + await expect(store.get("nope")).resolves.toBeNull(); + + client.query.mockResolvedValue({ + ...OK, + row: { id: "m1", data: "stored text", metadata: { a: 1 } }, + }); + await expect(store.get("m1")).resolves.toEqual({ + id: "m1", + payload: { a: 1, data: "stored text" }, + }); + + client.query.mockResolvedValue({ code: 2, msg: "invalid parameter" }); + await expect(store.get("m1")).rejects.toThrow( + /query 'm1' failed \(code 2\): invalid parameter/, + ); + }); + + // The server signals a missing primary key with code 101; pymochow and mochow-sdk-go both + // name it (ROW_KEY_NOT_FOUND / RowKeyNotFound). The Node SDK's ServerErrCode stops at 100, + // so it has to be spelled out. Memory.get()/update()/delete() all branch on a null here. + it("returns null when the server reports the row key is missing", async () => { + const client = fakeClient(); + const store = makeStore(client); + + client.query.mockResolvedValue({ code: 101, msg: "row key not found" }); + await expect(store.get("nope")).resolves.toBeNull(); + }); + + it("lists flat select rows and reports how many came back", async () => { + const client = fakeClient(); + client.select.mockResolvedValue({ + ...OK, + isTruncated: false, + nextMarker: "", + rows: [{ id: "m1", data: "x", metadata: {} }, { id: "m2" }], + }); + + await expect( + makeStore(client).list({ userId: "alice" }, 50), + ).resolves.toEqual([ + [ + { id: "m1", payload: { data: "x" } }, + { id: "m2", payload: {} }, + ], + 2, + ]); + expect(client.select).toHaveBeenCalledWith({ + database: "mem0_db", + table: "mem0", + filter: 'metadata["userId"] = "alice"', + projections: ["id", "data", "metadata"], + limit: 50, + }); + }); +}); + +describe("BaiduDB deleteCol", () => { + it("waits for the drop to land before returning", async () => { + runTimersInline(); + const client = fakeClient(); + const store = makeStore(client); + await store.initialize(); + + client.descTable + .mockResolvedValueOnce({ ...OK, table: { state: TableState.Deleting } }) + .mockResolvedValueOnce({ + code: ServerErrCode.TableNotExist, + msg: "gone", + }); + + await store.deleteCol(); + expect(client.dropTable).toHaveBeenCalledWith("mem0_db", "mem0"); + expect(client.descTable).toHaveBeenCalledTimes(3); // 1 from initialize + 2 polls + }); + + it("is a no-op when the table is already gone", async () => { + const client = fakeClient(); + const store = makeStore(client); + await store.initialize(); + + client.dropTable.mockResolvedValue({ + code: ServerErrCode.TableNotExist, + msg: "gone", + }); + await expect(store.deleteCol()).resolves.toBeUndefined(); + }); + + // Regression: deleteCol() used to run alongside the fire-and-forget initialize() the + // constructor starts, so the in-flight createTable landed *after* dropTable and the table + // survived reset(). + it("does not race the initialize() the constructor kicks off", async () => { + runTimersInline(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + // The table exists after the first init, is gone once dropTable lands, then exists again. + const descQueue: unknown[] = [ + normalTable(), + { code: ServerErrCode.TableNotExist, msg: "gone" }, + ]; + const client = fakeClient({ + createDatabase: async () => { + await gate; + return OK; + }, + descTable: async () => descQueue.shift() ?? normalTable(), + }); + + const store = makeStore(client); // initialize() is now in flight, parked on `gate` + const resetting = store.reset(); + release(); + await resetting; + + expect(client.calls).toEqual([ + "createDatabase", + "createTable", + "descTable", + "dropTable", + "descTable", + "createDatabase", + "createTable", + "descTable", + ]); + expect(client.calls.indexOf("dropTable")).toBeGreaterThan( + client.calls.indexOf("createTable"), + ); + expect(client.createTable).toHaveBeenCalledTimes(2); + }); +}); + +describe("BaiduDB user id", () => { + it("round-trips the store user id", async () => { + const store = makeStore(fakeClient()); + await expect(store.getUserId()).resolves.toBe("anonymous-baidu-user"); + await store.setUserId("alice"); + await expect(store.getUserId()).resolves.toBe("alice"); + }); +}); diff --git a/mem0-ts/src/oss/tests/cassandra.unit.test.ts b/mem0-ts/src/oss/tests/cassandra.unit.test.ts new file mode 100644 index 000000000..2cd95a06d --- /dev/null +++ b/mem0-ts/src/oss/tests/cassandra.unit.test.ts @@ -0,0 +1,63 @@ +/// +/** + * Cassandra vector store β€” filter matching unit tests. + * + * Cassandra has no server-side metadata filter, so search()/list() scan rows + * and apply filters in-app via matchFieldCondition(). These tests drive that + * matcher through the public list() API with an injected fake client. + */ +import { CassandraDB } from "../src/vector_stores/cassandra"; + +type Row = { id: string; payload: Record }; + +// Minimal fake driver: CREATE statements during initialize() return nothing; +// a SELECT returns the seeded rows in one page (pageState undefined => stop). +function fakeClient(rows: Row[]) { + return { + async connect() {}, + async execute(query: string) { + if (/^\s*SELECT/i.test(query)) { + return { rows, pageState: undefined }; + } + return { rows: [], pageState: undefined }; + }, + async shutdown() {}, + }; +} + +function makeStore(rows: Row[]) { + return new CassandraDB({ + keyspace: "mem0", + collectionName: "mem0", + dimension: 3, + client: fakeClient(rows) as any, + } as any); +} + +describe("CassandraDB filter matching", () => { + const rows: Row[] = [ + { id: "a", payload: { data: "a", age: 5 } }, + { id: "b", payload: { data: "b", age: 15 } }, + { id: "c", payload: { data: "c", age: 25 } }, + ]; + + it("applies every operator in a compound range filter (not just the first)", async () => { + const store = makeStore(rows); + // age in [10, 20]: only "b" (15) qualifies. The old matcher returned on the + // first operator (gte), so "c" (25) leaked through because lte was ignored. + const [results] = await store.list({ age: { gte: 10, lte: 20 } }); + expect(results.map((r) => r.id)).toEqual(["b"]); + }); + + it("still matches a single-operator filter", async () => { + const store = makeStore(rows); + const [results] = await store.list({ age: { gte: 15 } }); + expect(results.map((r) => r.id).sort()).toEqual(["b", "c"]); + }); + + it("treats a plain equality filter as before", async () => { + const store = makeStore(rows); + const [results] = await store.list({ age: 15 }); + expect(results.map((r) => r.id)).toEqual(["b"]); + }); +}); diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 8655055ae..7c1856ad8 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -378,6 +378,55 @@ describe("ConfigManager", () => { }); }); + describe("mergeConfig - AWS Bedrock credential passthrough", () => { + const baseEmbedder = { provider: "openai", config: { apiKey: "k" } }; + const baseVectorStore = { provider: "memory", config: {} }; + + it("preserves explicit AWS Bedrock credentials through MemoryConfigSchema.parse", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: baseVectorStore, + llm: { + provider: "aws_bedrock", + config: { + model: "anthropic.claude-3-sonnet-20240229-v1:0", + awsRegion: "us-east-1", + awsAccessKeyId: "AKIAEXAMPLE", + awsSecretAccessKey: "secret-example", + awsSessionToken: "session-example", + } as any, + }, + }); + + // Regression: zod default parse strips undeclared keys, which silently + // dropped credentials before .passthrough() + typed fields were added. + expect(cfg.llm.config.awsRegion).toBe("us-east-1"); + expect(cfg.llm.config.awsAccessKeyId).toBe("AKIAEXAMPLE"); + expect(cfg.llm.config.awsSecretAccessKey).toBe("secret-example"); + expect(cfg.llm.config.awsSessionToken).toBe("session-example"); + }); + + it("normalizes snake_case AWS credentials from the raw config", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: baseVectorStore, + llm: { + provider: "aws_bedrock", + config: { + model: "anthropic.claude-3-sonnet-20240229-v1:0", + aws_region: "eu-west-1", + aws_access_key_id: "AKIASNAKE", + aws_secret_access_key: "snake-secret", + } as any, + }, + }); + + expect(cfg.llm.config.awsRegion).toBe("eu-west-1"); + expect(cfg.llm.config.awsAccessKeyId).toBe("AKIASNAKE"); + expect(cfg.llm.config.awsSecretAccessKey).toBe("snake-secret"); + }); + }); + describe("mergeConfig - full OpenClaw-style LM Studio config", () => { it("handles the exact config from issue #4235", () => { const cfg = ConfigManager.mergeConfig({ @@ -422,6 +471,85 @@ describe("ConfigManager", () => { expect(cfg.vectorStore.config.port).toBe(6333); }); }); + + describe("mergeConfig - provider-specific embedder fields", () => { + // The embedder config used to be rebuilt from a fixed key list, which + // dropped every provider-specific field before the embedder was + // constructed. Vertex AI then authenticated against whatever ambient + // project ADC resolved to and ignored the configured task types. + it("preserves Vertex AI fields through the merge", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "vertexai", + config: { + model: "gemini-embedding-001", + googleProjectId: "my-proj", + location: "europe-west4", + vertexCredentialsJson: "/creds.json", + memoryAddEmbeddingType: "SEMANTIC_SIMILARITY", + }, + }, + vectorStore: { provider: "memory", config: { collectionName: "test" } }, + llm: { provider: "openai", config: { apiKey: "test-key" } }, + }); + + expect(cfg.embedder.config).toMatchObject({ + model: "gemini-embedding-001", + googleProjectId: "my-proj", + location: "europe-west4", + vertexCredentialsJson: "/creds.json", + memoryAddEmbeddingType: "SEMANTIC_SIMILARITY", + }); + }); + + it("still lets normalized values win over the raw user config", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + lmstudio_base_url: "http://localhost:1234/v1", + embedding_dims: 768, + }, + } as never, + vectorStore: { provider: "memory", config: { collectionName: "test" } }, + llm: { provider: "openai", config: { apiKey: "test-key" } }, + }); + + expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1"); + expect(cfg.embedder.config.embeddingDims).toBe(768); + // Snake_case aliases are normalized, not passed through to the provider. + expect(cfg.embedder.config).not.toHaveProperty("lmstudio_base_url"); + expect(cfg.embedder.config).not.toHaveProperty("embedding_dims"); + }); + }); + + describe("mergeConfig - vector store provider normalization", () => { + const baseLlm = { provider: "openai", config: { apiKey: "test-key" } }; + const baseEmbedder = { provider: "openai", config: { apiKey: "test-key" } }; + + it.each(["Memory", "DATABRICKS", "QdRaNt"])( + "lowercases the vector store provider %p", + (provider) => { + const config = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { provider, config: { collectionName: "test" } }, + llm: baseLlm, + }); + + expect(config.vectorStore.provider).toBe(provider.toLowerCase()); + }, + ); + + it("still falls back to the default provider when none is given", () => { + const config = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { config: { collectionName: "test" } } as any, + llm: baseLlm, + }); + + expect(config.vectorStore.provider).toBe("memory"); + }); + }); }); // ───────────────────────────────────────────────────────────────────────── @@ -629,7 +757,10 @@ describe("Memory – LM Studio end-to-end flow", () => { filters: { user_id: "u1" }, }); - expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?"); + expect(mockEmbedder.embed).toHaveBeenCalledWith( + "What does the user like?", + "search", + ); expect(mockVStore.search).toHaveBeenCalled(); expect(result.results).toHaveLength(1); expect(result.results[0].memory).toBe("User likes hiking"); @@ -659,6 +790,47 @@ describe("Memory – LM Studio end-to-end flow", () => { ); }); + it("passes AWS Bedrock credentials to LLMFactory through the Memory stack", async () => { + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + embeddingDims: 768, + }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test", dimension: 768 }, + }, + llm: { + provider: "aws_bedrock", + config: { + model: "anthropic.claude-3-sonnet-20240229-v1:0", + awsRegion: "us-east-1", + awsAccessKeyId: "AKIAEXAMPLE", + awsSecretAccessKey: "secret-example", + }, + }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + + // Regression: credentials must survive ConfigManager -> Memory -> LLMFactory, + // otherwise Bedrock silently falls back to the ambient AWS credential chain. + expect(mockLlmFactory.create).toHaveBeenCalledWith( + "aws_bedrock", + expect.objectContaining({ + model: "anthropic.claude-3-sonnet-20240229-v1:0", + awsRegion: "us-east-1", + awsAccessKeyId: "AKIAEXAMPLE", + awsSecretAccessKey: "secret-example", + }), + ); + }); + it("add flow works with lmstudio LLM for fact extraction", async () => { mockLlm.generateResponse.mockResolvedValueOnce( '{"facts":["User loves sushi"]}', diff --git a/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts b/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts index 69a78a27c..2793bf6a5 100644 --- a/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts +++ b/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts @@ -50,8 +50,10 @@ beforeEach(() => { describe("ElasticsearchDB", () => { describe("constructor", () => { - it("creates client with self-hosted host:port", () => { - new ElasticsearchDB({ + // The client is built lazily on first use, so await initialize() before + // asserting how it was constructed. + it("creates client with self-hosted host:port", async () => { + const store = new ElasticsearchDB({ collectionName: "mem0", embeddingModelDims: 768, host: "localhost", @@ -59,6 +61,7 @@ describe("ElasticsearchDB", () => { username: "user", password: "pass", }); + await store.initialize(); expect(mockClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -68,14 +71,15 @@ describe("ElasticsearchDB", () => { ); }); - it("creates client with cloud config", () => { - new ElasticsearchDB({ + it("creates client with cloud config", async () => { + const store = new ElasticsearchDB({ collectionName: "mem0", embeddingModelDims: 1536, cloudId: "my-cloud:dXMtZWFzdDQuZ2NwLmVsYXN0aWMtY2xvdWQuY29tOjQ0MyQxMjM0NTY3ODkw", apiKey: "base64-key", }); + await store.initialize(); expect(mockClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -98,12 +102,13 @@ describe("ElasticsearchDB", () => { expect(mockClient).toHaveBeenCalledTimes(1); }); - it("defaults to port 9200 and https", () => { - new ElasticsearchDB({ + it("defaults to port 9200 and https", async () => { + const store = new ElasticsearchDB({ collectionName: "mem0", embeddingModelDims: 384, host: "es.example.com", }); + await store.initialize(); expect(mockClient).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index af59c47c5..706c178b3 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -40,6 +40,11 @@ jest.mock("../src/embeddings/lmstudio", () => ({ .fn() .mockImplementation((config) => ({ type: "lmstudio-embedder", config })), })); +jest.mock("../src/embeddings/vertexai", () => ({ + VertexAIEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "vertexai-embedder", config })), +})); jest.mock("../src/embeddings/together", () => ({ TogetherEmbedder: jest .fn() @@ -138,6 +143,11 @@ jest.mock("../src/vector_stores/qdrant", () => ({ .fn() .mockImplementation((config) => ({ type: "qdrant", config })), })); +jest.mock("../src/vector_stores/baidu", () => ({ + BaiduDB: jest + .fn() + .mockImplementation((config) => ({ type: "baidu", config })), +})); jest.mock("../src/vector_stores/redis", () => ({ RedisDB: jest .fn() @@ -173,6 +183,17 @@ jest.mock("../src/vector_stores/pgvector", () => ({ .fn() .mockImplementation((config) => ({ type: "pgvector", config })), })); +jest.mock("../src/vector_stores/databricks", () => ({ + DatabricksVectorStore: jest + .fn() + .mockImplementation((config) => ({ type: "databricks", config })), +})); +jest.mock("../src/vector_stores/neptune_analytics", () => ({ + NeptuneAnalyticsVectorStore: jest.fn().mockImplementation((config) => ({ + type: "neptune-analytics", + config, + })), +})); jest.mock("../src/vector_stores/upstash_vector", () => ({ UpstashVector: jest .fn() @@ -236,6 +257,7 @@ describe("EmbedderFactory", () => { ["fastembed"], ["langchain"], ["lmstudio"], + ["vertexai"], ["together"], ])("creates embedder for provider '%s'", (provider) => { expect(() => @@ -320,6 +342,7 @@ describe("VectorStoreFactory", () => { }); test.each([ + ["baidu"], ["qdrant"], ["redis"], ["valkey"], @@ -328,6 +351,9 @@ describe("VectorStoreFactory", () => { ["vectorize"], ["azure-ai-search"], ["pgvector"], + ["databricks"], + ["neptune"], + ["neptune-analytics"], ["upstash_vector"], ["azure_mysql"], ["cassandra"], @@ -335,9 +361,32 @@ describe("VectorStoreFactory", () => { ["s3_vectors"], ["weaviate"], ])("creates vector store for provider '%s'", (provider) => { - expect(() => - VectorStoreFactory.create(provider, dummyVSConfig), - ).not.toThrow(); + const result = VectorStoreFactory.create(provider, dummyVSConfig) as any; + expect(result.config).toBe(dummyVSConfig); + }); + + test("passes Neptune endpoint URI config through the factory", () => { + const config = { + collectionName: "test", + dimension: 4, + endpoint: "neptune-graph://g-1234567890", + region: "us-east-1", + }; + const store = VectorStoreFactory.create("neptune", config) as any; + + expect(store.config).toEqual(config); + }); + + test("keeps neptune-analytics as a compatibility alias", () => { + const config = { + collectionName: "test", + dimension: 4, + endpoint: "neptune-graph://g-1234567890", + region: "us-east-1", + }; + const store = VectorStoreFactory.create("neptune-analytics", config) as any; + + expect(store.config).toEqual(config); }); test("throws for unsupported provider", () => { diff --git a/mem0-ts/src/oss/tests/google-llm.test.ts b/mem0-ts/src/oss/tests/google-llm.test.ts index 373229665..018a94204 100644 --- a/mem0-ts/src/oss/tests/google-llm.test.ts +++ b/mem0-ts/src/oss/tests/google-llm.test.ts @@ -185,6 +185,37 @@ describe("GoogleLLM (unit)", () => { expect(response.toolCalls[1].name).toBe("add_graph_memory"); }); + // Regression: generateResponse accepted a responseFormat argument but never + // forwarded it, so Gemini was never told to emit JSON (parity with the Python + // SDK's mem0/llms/gemini.py, which sets response_mime_type/response_schema). + it("forwards json_object responseFormat as responseMimeType", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: '{"facts": ["fact1"]}', + functionCalls: null, + }); + + const llm = new GoogleLLM({ apiKey: "test-key" }); + await llm.generateResponse([{ role: "user", content: "Extract facts" }], { + type: "json_object", + }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + expect(callArgs.config.responseMimeType).toBe("application/json"); + }); + + it("does not set responseMimeType when no responseFormat is given", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: "plain text", + functionCalls: null, + }); + + const llm = new GoogleLLM({ apiKey: "test-key" }); + await llm.generateResponse([{ role: "user", content: "Hello" }]); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + expect(callArgs.config.responseMimeType).toBeUndefined(); + }); + it("formats generateChat messages and joins Gemini response parts", async () => { mockGenerateContent.mockResolvedValueOnce({ candidates: [ diff --git a/mem0-ts/src/oss/tests/memory.add.test.ts b/mem0-ts/src/oss/tests/memory.add.test.ts index 4e7ebe773..94fee5846 100644 --- a/mem0-ts/src/oss/tests/memory.add.test.ts +++ b/mem0-ts/src/oss/tests/memory.add.test.ts @@ -8,55 +8,78 @@ import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types"; jest.setTimeout(15000); -// Mock Google modules to prevent @google/genai crash in CI -jest.mock("../src/embeddings/google", () => ({ - GoogleEmbedder: jest.fn(), -})); -jest.mock("../src/llms/google", () => ({ - GoogleLLM: jest.fn(), -})); +jest.mock("../src/utils/factory", () => { + const { MemoryVectorStore } = jest.requireActual( + "../src/vector_stores/memory", + ); + const { MemoryHistoryManager } = jest.requireActual( + "../src/storage/MemoryHistoryManager", + ); + const testEmbedding = new Array(1536).fill(0.1); -jest.mock("../src/llms/openai", () => ({ - OpenAILLM: jest.fn().mockImplementation(() => ({ - generateResponse: jest - .fn() - .mockImplementation( - (messages: Array<{ role: string; content: string }>) => { - // V3 pipeline: single LLM call with additive extraction prompt. - const userMsg = messages.find((m) => m.role === "user"); - const content = userMsg?.content ?? ""; - const newMsgMatch = content.match( - /## New Messages\n([\s\S]*?)(?=\n##|$)/, + class MockEmbedder { + embeddingDims = 1536; + + async embed(): Promise { + return testEmbedding; + } + + async embedBatch(texts: string[]): Promise { + return texts.map(() => testEmbedding); + } + } + + class MockLLM { + async generateResponse(messages: Array<{ role: string; content: string }>) { + const userMsg = messages.find((m) => m.role === "user"); + const content = userMsg?.content ?? ""; + const newMsgMatch = content.match( + /## New Messages\n([\s\S]*?)(?=\n##|$)/, + ); + const extracted = newMsgMatch + ? newMsgMatch[1].trim() + : "extracted fact from input"; + + return JSON.stringify({ + memory: [ + { + id: "0", + text: extracted, + attributed_to: "user", + }, + ], + }); + } + } + + return { + __esModule: true, + EmbedderFactory: { + create: jest.fn(() => new MockEmbedder()), + }, + LLMFactory: { + create: jest.fn(() => new MockLLM()), + }, + VectorStoreFactory: { + create: jest.fn((provider: string, config: any) => { + if (provider.toLowerCase() !== "memory") { + throw new Error( + `Unsupported vector store provider in test: ${provider}`, ); - const extracted = newMsgMatch - ? newMsgMatch[1].trim() - : "extracted fact from input"; - return JSON.stringify({ - memory: [ - { - id: "0", - text: extracted, - 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, - })), -})); + } + return new MemoryVectorStore(config); + }), + }, + HistoryManagerFactory: { + create: jest.fn(() => new MemoryHistoryManager()), + }, + RerankerFactory: { + create: jest.fn(() => { + throw new Error("RerankerFactory is not used in memory.add.test.ts"); + }), + }, + }; +}); function createMemory(overrides: Partial = {}): Memory { return new Memory({ @@ -172,6 +195,132 @@ describe("Memory - add()", () => { ); }); + test("does not allow metadata to set identity scope", async () => { + const result: SearchResult = await memory.add("I am a software engineer", { + userId: "u1", + metadata: { + agent_id: "other", + agentId: "other-camel", + run_id: "other-run", + runId: "other-run-camel", + actor_id: "x", + source: "issue-6371", + nested: { preserved: true }, + }, + }); + const stored: MemoryItem | null = await memory.get(result.results[0].id); + + expect(stored).toEqual( + expect.objectContaining({ + user_id: "u1", + metadata: expect.objectContaining({ + source: "issue-6371", + nested: { preserved: true }, + }), + }), + ); + expect(stored).not.toHaveProperty("agent_id"); + expect(stored).not.toHaveProperty("run_id"); + expect(stored!.metadata).not.toHaveProperty("agent_id"); + expect(stored!.metadata).not.toHaveProperty("agentId"); + expect(stored!.metadata).not.toHaveProperty("run_id"); + expect(stored!.metadata).not.toHaveProperty("runId"); + expect(stored!.metadata).not.toHaveProperty("actor_id"); + }); + + test.each([ + ["userId", { userId: "u1" }, true, "user_id", "u1"], + ["agentId", { agentId: "a1" }, false, "agent_id", "a1"], + ["runId", { runId: "r1" }, true, "run_id", "r1"], + ] as const)( + "preserves typed %s scope while stripping conflicting metadata identities", + async (_mode, scope, infer, canonicalKey, canonicalValue) => { + const result: SearchResult = await memory.add("scoped content", { + ...scope, + infer, + metadata: { + user_id: "metadata-user", + userId: "metadata-user-camel", + agent_id: "metadata-agent", + agentId: "metadata-agent-camel", + run_id: "metadata-run", + runId: "metadata-run-camel", + actor_id: "metadata-actor", + ordinary: "preserved", + }, + }); + const stored: MemoryItem | null = await memory.get(result.results[0].id); + + expect(stored).toHaveProperty(canonicalKey, canonicalValue); + for (const key of ["user_id", "agent_id", "run_id"]) { + if (key !== canonicalKey) { + expect(stored).not.toHaveProperty(key); + } + } + expect(stored!.metadata).toEqual( + expect.objectContaining({ ordinary: "preserved" }), + ); + for (const key of [ + "user_id", + "userId", + "agent_id", + "agentId", + "run_id", + "runId", + "actor_id", + ]) { + expect(stored!.metadata).not.toHaveProperty(key); + } + }, + ); + + test.each([ + [true, "user_id", "filter-user"], + [false, "user_id", "filter-user"], + [false, "agent_id", "filter-agent"], + [false, "run_id", "filter-run"], + ] as const)( + "preserves infer=%s %s filters scope after sanitization", + async (infer, filterKey, filterValue) => { + const result: SearchResult = await memory.add("filter-scoped content", { + filters: { [filterKey]: filterValue }, + infer, + metadata: { + user_id: "metadata-user", + userId: "metadata-user-camel", + agent_id: "metadata-agent", + agentId: "metadata-agent-camel", + run_id: "metadata-run", + runId: "metadata-run-camel", + actor_id: "metadata-actor", + ordinary: "preserved", + }, + }); + const stored: MemoryItem | null = await memory.get(result.results[0].id); + + expect(stored).toHaveProperty(filterKey, filterValue); + for (const key of ["user_id", "agent_id", "run_id"]) { + if (key !== filterKey) { + expect(stored).not.toHaveProperty(key); + } + } + expect(stored!.metadata).toEqual( + expect.objectContaining({ ordinary: "preserved" }), + ); + for (const key of [ + "user_id", + "userId", + "agent_id", + "agentId", + "run_id", + "runId", + "actor_id", + ]) { + expect(stored!.metadata).not.toHaveProperty(key); + } + }, + ); + test("with infer=false skips LLM and stores messages directly", async () => { const result: SearchResult = await memory.add("Direct storage content", { userId, diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index de2565c16..6e45eaa01 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -128,6 +128,16 @@ describe("Memory - get()", () => { expect(item!.createdAt).toBeDefined(); expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date"); }); + + test("does not duplicate the entity id inside metadata", async () => { + const addResult: SearchResult = await memory.add("Metadata leak test", { + userId, + }); + const item: any = await memory.get(addResult.results[0].id); + expect(item.user_id).toBe(userId); + expect(item.metadata).not.toHaveProperty("user_id"); + expect(item.metadata).not.toHaveProperty("userId"); + }); }); // ─── update() ──────────────────────────────────────────── @@ -207,6 +217,79 @@ describe("Memory - update()", () => { expect.objectContaining({ category: "hobbies", priority: "high" }), ); }); + + // Regression: metadata passed to update() must never overwrite a memory's + // identity fields (issues #6277 / #6278). + test("metadata cannot overwrite identity fields (tenant isolation)", async () => { + const tenantA = `tenant_a_${Date.now()}`; + const tenantB = `tenant_b_${Date.now()}`; + const addResult: SearchResult = await memory.add("Tenant A secret", { + userId: tenantA, + infer: false, + }); + const id = addResult.results[0].id; + + // Caller metadata attempts to re-scope the memory to tenant B. + await memory.update(id, { + text: "Updated text", + metadata: { user_id: tenantB, agent_id: "attacker", run_id: "attacker" }, + }); + + // The memory must remain in tenant A's scope... + const aList: SearchResult = await memory.getAll({ + filters: { user_id: tenantA }, + }); + expect(aList.results.map((m) => m.id)).toContain(id); + + // ...and must never leak into tenant B's scope. + const bList: SearchResult = await memory.getAll({ + filters: { user_id: tenantB }, + }); + expect(bList.results.map((m) => m.id)).not.toContain(id); + }); + + // A memory created with only some identity fields (e.g. run_id only) must not + // let update() metadata inject a brand-new identity key. The default vector + // store promotes camelCase aliases to snake_case on read, so both casings must be covered. + test("metadata cannot inject an identity field on update (snake_case or camelCase)", async () => { + const runId = `run_only_${Date.now()}`; + const attacker = `attacker_${Date.now()}`; + const addResult: SearchResult = await memory.add("Run-scoped secret", { + runId, + infer: false, + }); + const id = addResult.results[0].id; + + // Memory has no user_id/agent_id/actor_id β€” metadata tries to inject them, + // in both snake_case and camelCase. + await memory.update(id, { + text: "Updated text", + metadata: { + user_id: attacker, + agent_id: attacker, + actor_id: attacker, + userId: attacker, + agentId: attacker, + }, + }); + + // Still reachable under its original run_id scope... + const runList: SearchResult = await memory.getAll({ + filters: { run_id: runId }, + }); + expect(runList.results.map((m) => m.id)).toContain(id); + + // ...and the injected identity scopes must not resolve to it. + const userList: SearchResult = await memory.getAll({ + filters: { user_id: attacker }, + }); + expect(userList.results.map((m) => m.id)).not.toContain(id); + + const agentList: SearchResult = await memory.getAll({ + filters: { agent_id: attacker }, + }); + expect(agentList.results.map((m) => m.id)).not.toContain(id); + }); }); // ─── update() options: text / data / metadata / expirationDate ─── @@ -576,6 +659,66 @@ describe("Memory - deleteAll()", () => { "At least one filter is required to delete all memories", ); }); + + test("deletes past the vector store default page limit", async () => { + const mem = createMemory(); + const internals = mem as any; + await internals._ensureInitialized(); + const pagedUserId = `deleteall_paged_${Date.now()}`; + const total = 150; + + await internals.vectorStore.insert( + Array.from({ length: total }, () => new Array(1536).fill(0.1)), + Array.from({ length: total }, (_, index) => `paged-${index}`), + Array.from({ length: total }, (_, index) => ({ + data: `Fact ${index}`, + hash: `hash-${index}`, + user_id: pagedUserId, + createdAt: new Date().toISOString(), + })), + ); + + const [before] = await internals.vectorStore.list( + { user_id: pagedUserId }, + total * 2, + ); + expect(before).toHaveLength(total); + + const result = await mem.deleteAll({ userId: pagedUserId }); + expect(result.message).toBe("Memories deleted successfully!"); + + const [after] = await internals.vectorStore.list( + { user_id: pagedUserId }, + total * 2, + ); + expect(after).toHaveLength(0); + }, 60000); + + test("stops when the vector store keeps returning the same batch", async () => { + const mem = createMemory(); + const internals = mem as any; + await internals._ensureInitialized(); + const stale = [ + { id: "stale-1", payload: { data: "x", user_id: "u1" } }, + { id: "stale-2", payload: { data: "x", user_id: "u1" } }, + ]; + const listSpy = jest + .spyOn(internals.vectorStore, "list") + .mockResolvedValueOnce([stale, stale.length]) + .mockResolvedValueOnce([stale, stale.length]) + .mockImplementation(async () => { + throw new Error("deleteAll should have stopped after a repeated batch"); + }); + const deleteSpy = jest + .spyOn(internals, "deleteMemory") + .mockResolvedValue("stale-1"); + + const result = await mem.deleteAll({ userId: "u1" }); + + expect(result.message).toBe("Memories deleted successfully!"); + expect(listSpy).toHaveBeenCalledTimes(2); + expect(deleteSpy).toHaveBeenCalledTimes(2); + }); }); // ─── getAll() ──────────────────────────────────────────── diff --git a/mem0-ts/src/oss/tests/memory.embedding-action.test.ts b/mem0-ts/src/oss/tests/memory.embedding-action.test.ts new file mode 100644 index 000000000..2f315681f --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.embedding-action.test.ts @@ -0,0 +1,115 @@ +/// +/** + * Verifies the memory pipeline threads the correct memory action + * ("add" | "update" | "search") into the embedder. Task-type-aware providers + * (e.g. Vertex AI) embed queries and documents differently based on this, and + * the argument is silently ignored by every other embedder, so only a + * pipeline-level test catches a dropped action. + */ +import { Memory } from "../src/memory"; + +const mockEmbedding = new Array(1536).fill(0.1); +// Prefixed `mock*` so jest's hoisted module factory may reference them. +const mockEmbed = jest.fn().mockResolvedValue(mockEmbedding); +const mockEmbedBatch = jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ); + +const mockGenerateResponse = jest + .fn() + .mockResolvedValue(JSON.stringify({ memory: [] })); + +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: mockGenerateResponse, + })), +})); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: mockEmbed, + embedBatch: mockEmbedBatch, + embeddingDims: 1536, + })), +})); + +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-action-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("embedder memory-action threading", () => { + let memory: Memory; + + beforeEach(() => { + memory = createMemory(); + mockEmbed.mockClear(); + mockEmbedBatch.mockClear(); + mockGenerateResponse.mockResolvedValue(JSON.stringify({ memory: [] })); + }); + + afterEach(async () => { + await memory.reset(); + }); + + test("search() embeds the query with the 'search' action", async () => { + await memory.search("what do I like", { filters: { user_id: "u1" } }); + expect(mockEmbed).toHaveBeenCalledWith("what do I like", "search"); + }); + + test("update() embeds the new value with the 'update' action", async () => { + // Missing id: update embeds the value before it throws on the absent row. + await memory.update("missing-id", "new value").catch(() => {}); + expect(mockEmbed).toHaveBeenCalledWith("new value", "update"); + }); + + test("add() batch-embeds extracted memories and entities with the 'add' action", async () => { + mockGenerateResponse.mockResolvedValue( + JSON.stringify({ + memory: [ + { id: "1", text: "John loves sci-fi movies", attributed_to: "user" }, + ], + }), + ); + + await memory.add("I love sci-fi movies", { userId: "u1" }); + + // Phase 1 retrieval embeds the incoming turn as a query. + expect(mockEmbed).toHaveBeenCalledWith( + expect.stringContaining("I love sci-fi movies"), + "search", + ); + // Phase 3 (extracted memories) and phase 7 (linked entities) both batch + // embed as documents. Without an explicit action, a task-type-aware + // embedder falls back to its own default and silently mis-embeds. + expect(mockEmbedBatch).toHaveBeenCalledWith( + ["John loves sci-fi movies"], + "add", + ); + expect(mockEmbedBatch.mock.calls.length).toBeGreaterThan(0); + for (const call of mockEmbedBatch.mock.calls) { + expect(call[1]).toBe("add"); + } + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.llm-error.test.ts b/mem0-ts/src/oss/tests/memory.llm-error.test.ts new file mode 100644 index 000000000..326f37802 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.llm-error.test.ts @@ -0,0 +1,104 @@ +/** + * Regression test for #6101 / #5903: LLM extraction transport failures + * (rate limits, timeouts, connection errors) must propagate as a typed + * `LLMError`, not be silently swallowed into an empty result. Mirrors the + * Python SDK regression test added in #5878 + * (`test_llm_extraction_exception_is_reraised`). + */ +/// +import { Memory, LLMError } from "../src/memory"; +import type { SearchResult } from "../src/types"; + +jest.setTimeout(15000); + +// Mock Google modules to prevent @google/genai crash in CI +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +class _ProviderError extends Error {} + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest + .fn() + .mockRejectedValue(new _ProviderError("429 rate limit")), + })), +})); + +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 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-llm-error-${Date.now()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("Memory - LLM extraction transport failures", () => { + let memory: Memory; + const userId = `llm_error_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("add() rejects with LLMError instead of returning an empty result", async () => { + await expect( + memory.add("this should trigger a provider failure", { userId }), + ).rejects.toBeInstanceOf(LLMError); + }); + + test("thrown LLMError preserves the original error as its cause", async () => { + let caught: unknown; + try { + const result: SearchResult = await memory.add("trigger failure again", { + userId, + }); + // Should never reach here β€” fail loudly if the call resolves. + throw new Error( + `Expected add() to reject, but it resolved with: ${JSON.stringify(result)}`, + ); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(LLMError); + expect((caught as LLMError).cause).toBeInstanceOf(_ProviderError); + expect((caught as LLMError).message).toContain("429 rate limit"); + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.prototype-key-embedding.test.ts b/mem0-ts/src/oss/tests/memory.prototype-key-embedding.test.ts new file mode 100644 index 000000000..b96588f51 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.prototype-key-embedding.test.ts @@ -0,0 +1,125 @@ +/// +/** add() with infer=false must embed Object.prototype-colliding text instead of resolving it off the prototype chain. */ +import { Memory } from "../src/memory"; +import { MemoryVectorStore } from "../src/vector_stores/memory"; +import type { SearchResult } from "../src/types"; + +const mockEmbedding = new Array(1536).fill(0.1); +const mockEmbed = jest.fn().mockResolvedValue(mockEmbedding); + +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: mockEmbed, + embedBatch: jest.fn(), + embeddingDims: 1536, + })), +})); + +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-proto-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("add() with infer=false and Object.prototype-colliding text", () => { + let memory: Memory; + let insertSpy: jest.SpyInstance; + + beforeEach(() => { + memory = createMemory(); + mockEmbed.mockClear(); + insertSpy = jest.spyOn(MemoryVectorStore.prototype, "insert"); + }); + + afterEach(async () => { + insertSpy.mockRestore(); + await memory.reset(); + }); + + test.each([ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "__proto__", + ])( + 'embeds "%s" instead of resolving it off Object.prototype', + async (text) => { + const result: SearchResult = await memory.add(text, { + userId: "u1", + infer: false, + }); + + expect(mockEmbed).toHaveBeenCalledWith(text, "add"); + + const [storedVectors] = + insertSpy.mock.calls[insertSpy.mock.calls.length - 1]; + expect(Array.isArray(storedVectors[0])).toBe(true); + expect(storedVectors[0]).toEqual(mockEmbedding); + expect( + storedVectors[0].every((n: unknown) => typeof n === "number"), + ).toBe(true); + + expect(result.results[0].memory).toBe(text); + }, + ); +}); + +describe("update() metadata-only on Object.prototype-colliding text (#6323)", () => { + let memory: Memory; + let updateSpy: jest.SpyInstance; + + beforeEach(() => { + memory = createMemory(); + updateSpy = jest.spyOn(MemoryVectorStore.prototype, "update"); + }); + + afterEach(async () => { + updateSpy.mockRestore(); + await memory.reset(); + }); + + test.each([ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "__proto__", + ])( + 're-embeds "%s" on a metadata-only update instead of resolving it off Object.prototype', + async (text) => { + const added: SearchResult = await memory.add(text, { + userId: "u1", + infer: false, + }); + const memoryId = added.results[0].id!; + mockEmbed.mockClear(); + + await memory.update(memoryId, { metadata: { pinned: true } }); + + expect(mockEmbed).toHaveBeenCalledWith(text, "update"); + + const [, storedVector] = + updateSpy.mock.calls[updateSpy.mock.calls.length - 1]; + expect(Array.isArray(storedVector)).toBe(true); + expect(storedVector).toEqual(mockEmbedding); + }, + ); +}); diff --git a/mem0-ts/src/oss/tests/memory.rerank.test.ts b/mem0-ts/src/oss/tests/memory.rerank.test.ts new file mode 100644 index 000000000..2ad477580 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.rerank.test.ts @@ -0,0 +1,184 @@ +/** + * Reranker integration tests for Memory.search(). + * + * Verifies the per-search `rerank` flag: when a reranker is configured and + * `rerank: true` is passed, search reorders results by the reranker's output; + * otherwise results pass through unchanged. Failures degrade gracefully. + */ +/// +import { Memory } from "../src/memory"; +import { CohereReranker } from "../src/rerankers/cohere"; +import type { RerankResult } from "../src/rerankers/base"; + +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: [] })), + })), +})); + +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 createMemory(config: Record = {}): 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-rerank-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + ...config, + }); +} + +// Two semantic results whose natural (score-sorted) order is [alpha, bravo]. +async function primeSearch(m: any) { + await m._ensureInitialized(); + m.embedder = { embed: jest.fn().mockResolvedValue(mockEmbedding) }; + m.vectorStore.search = jest.fn().mockResolvedValue([ + { id: "a", score: 0.9, payload: { data: "alpha" } }, + { id: "b", score: 0.8, payload: { data: "bravo" } }, + ]); + m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null); +} + +describe("Memory.search reranking", () => { + it("reorders results by the reranker when rerank:true, adding rerankScore while preserving the original vector score", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + + const rerank = jest + .fn, [string, string[], number?]>() + .mockResolvedValue([ + { index: 1, rerankScore: 0.99 }, // bravo + { index: 0, rerankScore: 0.4 }, // alpha + ]); + m.reranker = { rerank }; + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + rerank: true, + }); + + expect(rerank).toHaveBeenCalledWith( + "what did i eat", + ["alpha", "bravo"], + expect.any(Number), + ); + expect(result.results.map((r: any) => r.memory)).toEqual([ + "bravo", + "alpha", + ]); + expect(result.results[0].rerankScore).toBe(0.99); + expect(result.results[1].rerankScore).toBe(0.4); + // The original vector similarity `score` must survive reranking. + expect(result.results[0].score).toBe(0.8); // bravo's original vector score + expect(result.results[1].score).toBe(0.9); // alpha's original vector score + + await memory.reset(); + }); + + it("leaves results untouched and does not call the reranker when rerank is omitted", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + const rerank = jest.fn(); + m.reranker = { rerank }; + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + }); + + expect(rerank).not.toHaveBeenCalled(); + expect(result.results.map((r: any) => r.memory)).toEqual([ + "alpha", + "bravo", + ]); + expect(result.results[0].rerankScore).toBeUndefined(); + + await memory.reset(); + }); + + it("is a no-op (no throw) when rerank:true but no reranker is configured", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + rerank: true, + }); + + expect(result.results.map((r: any) => r.memory)).toEqual([ + "alpha", + "bravo", + ]); + + await memory.reset(); + }); + + it("falls back to the original results when the reranker throws", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + m.reranker = { + rerank: jest.fn().mockRejectedValue(new Error("provider down")), + }; + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + rerank: true, + }); + + expect(result.results.map((r: any) => r.memory)).toEqual([ + "alpha", + "bravo", + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + await memory.reset(); + }); + + it("wires a reranker from config in the constructor", () => { + const memory = createMemory({ + reranker: { provider: "cohere", config: { apiKey: "test-key" } }, + }); + + expect((memory as any).reranker).toBeInstanceOf(CohereReranker); + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.validation.test.ts b/mem0-ts/src/oss/tests/memory.validation.test.ts index 5503a8902..02aad751f 100644 --- a/mem0-ts/src/oss/tests/memory.validation.test.ts +++ b/mem0-ts/src/oss/tests/memory.validation.test.ts @@ -253,6 +253,34 @@ describe("Memory Input Validation", () => { }); }); + describe("non-string entity ID coercion", () => { + it("coerces an integer user_id in getAll filters to its string form", async () => { + const listSpy = jest + .spyOn((memory as any).vectorStore, "list") + .mockResolvedValue([[], 0]); + + await memory.getAll({ filters: { user_id: 42 as any } }); + + const passedFilters = listSpy.mock.calls[0][0] as Record; + expect(passedFilters.user_id).toBe("42"); + + listSpy.mockRestore(); + }); + + it("coerces an integer user_id in search filters to its string form", async () => { + const searchSpy = jest + .spyOn((memory as any).vectorStore, "search") + .mockResolvedValue([]); + + await memory.search("q", { filters: { user_id: 42 as any } }); + + const passedFilters = searchSpy.mock.calls[0][2] as Record; + expect(passedFilters.user_id).toBe("42"); + + searchSpy.mockRestore(); + }); + }); + describe("search() filter entity ID validation", () => { it("should throw error when user_id in filters is whitespace-only", async () => { await expect( @@ -334,7 +362,10 @@ describe("Memory Input Validation", () => { await memory.deleteAll({ userId: " alice " }); - expect(listSpy).toHaveBeenCalledWith({ user_id: "alice" }); + expect(listSpy).toHaveBeenCalledWith( + { user_id: "alice" }, + expect.any(Number), + ); }); }); }); diff --git a/mem0-ts/src/oss/tests/opensearch.unit.test.ts b/mem0-ts/src/oss/tests/opensearch.unit.test.ts index 11682c860..e53a1dbc0 100644 --- a/mem0-ts/src/oss/tests/opensearch.unit.test.ts +++ b/mem0-ts/src/oss/tests/opensearch.unit.test.ts @@ -153,4 +153,26 @@ describe("OpenSearchDB", () => { await expect(store.get("missing")).resolves.toBeNull(); }); + + it("keywordSearch queries lemmatized payload fields", async () => { + const client = createClient(); + const store = await createStore(client); + + await store.keywordSearch("stud french", 5, { user_id: "alice" }); + + const searchCall = client.search.mock.calls.find( + ([arg]: any[]) => arg.index === collectionName, + ); + expect(searchCall).toBeDefined(); + const should = searchCall![0].body.query.bool.should; + expect(should).toContainEqual({ + match: { "payload.textLemmatized": "stud french" }, + }); + expect(should).toContainEqual({ + match: { "payload.text_lemmatized": "stud french" }, + }); + expect(should).toContainEqual({ + match: { "payload.data": "stud french" }, + }); + }); }); diff --git a/mem0-ts/src/oss/tests/optional-peers.test.ts b/mem0-ts/src/oss/tests/optional-peers.test.ts new file mode 100644 index 000000000..e017d33fb --- /dev/null +++ b/mem0-ts/src/oss/tests/optional-peers.test.ts @@ -0,0 +1,79 @@ +import { readdirSync, readFileSync, statSync } from "fs"; +import { join, relative, resolve } from "path"; + +// Optional peers are not installed by npm/pnpm. A static value import of one therefore throws +// MODULE_NOT_FOUND the moment anything pulls in `mem0ai/oss`, because src/index.ts re-exports every +// vector store. Load them with `await import(...)` inside the code path that needs them instead. + +const packageRoot = resolve(__dirname, "../../.."); + +function optionalPeers(): string[] { + const pkg = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ); + return Object.entries( + (pkg.peerDependenciesMeta ?? {}) as Record, + ) + .filter(([, meta]) => meta.optional) + .map(([name]) => name); +} + +function sourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + // examples/ are dev scripts and community/ is the separate @mem0/community + // package β€” neither ships in files:["dist"] nor is reachable via the mem0ai/oss + // barrel, so a static import there cannot crash a Memory() consumer. + if ( + entry !== "tests" && + entry !== "__tests__" && + entry !== "examples" && + entry !== "community" + ) + sourceFiles(full, acc); + } else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) { + acc.push(full); + } + } + return acc; +} + +// Matches `import ... from "pkg"` and `import "pkg"`, but not `import type ... from "pkg"`, +// `typeof import("pkg")`, or `await import("pkg")` β€” those are erased or already lazy. +function hasStaticValueImport(pkg: string, source: string): boolean { + const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const specifier = `["']${escaped}(?:\\/[^"']*)?["']`; + return ( + new RegExp( + `(?:^|\\n)\\s*import\\s+(?!type\\b)[^;]*?from\\s+${specifier}`, + ).test(source) || + new RegExp(`(?:^|\\n)\\s*import\\s+${specifier}`).test(source) + ); +} + +describe("optional peer dependencies", () => { + const peers = optionalPeers(); + + it("are discoverable from package.json", () => { + expect(peers.length).toBeGreaterThan(0); + }); + + it("are never statically imported by src", () => { + const files = sourceFiles(join(packageRoot, "src")); + const sources = new Map( + files.map((file) => [file, readFileSync(file, "utf8")]), + ); + + const offenders: string[] = []; + for (const peer of peers) { + for (const [file, source] of sources) { + if (hasStaticValueImport(peer, source)) { + offenders.push(`${relative(packageRoot, file)} imports ${peer}`); + } + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/mem0-ts/src/oss/tests/oracledb.unit.test.ts b/mem0-ts/src/oss/tests/oracledb.unit.test.ts new file mode 100644 index 000000000..3afdbac6e --- /dev/null +++ b/mem0-ts/src/oss/tests/oracledb.unit.test.ts @@ -0,0 +1,475 @@ +/// +/** Oracle AI Vector Search filter, config and SQL tests. The driver is mocked, so no database is needed. */ +const DB_TYPE_VECTOR = { name: "DB_TYPE_VECTOR" }; +const DB_TYPE_JSON = { name: "DB_TYPE_JSON" }; +const DB_TYPE_VARCHAR = { name: "DB_TYPE_VARCHAR" }; + +const mockCreatePool = jest.fn(); + +jest.mock( + "oracledb", + () => ({ + thin: true, + DB_TYPE_VECTOR, + DB_TYPE_JSON, + DB_TYPE_VARCHAR, + createPool: mockCreatePool, + }), + { virtual: true }, +); + +import { + OracleAIVectorSearch, + buildWhereClause, + quoteIdentifier, +} from "../src/vector_stores/oracledb"; + +type Call = { sql: string; binds: any; options?: any }; + +function fakeConnection(calls: Call[], resultsBySql: Array) { + let selectIndex = 0; + return { + oracleServerVersionString: "23.4.0.24.05", + async execute(sql: string, binds: any = {}) { + calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds }); + if (/^\s*SELECT/i.test(sql)) { + return { rows: resultsBySql[selectIndex++] ?? [] }; + } + return { rows: [] }; + }, + async executeMany(sql: string, binds: any[], options: any = {}) { + calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds, options }); + return { rows: [] }; + }, + async commit() {}, + async rollback() {}, + async close() {}, + }; +} + +function makeStore( + calls: Call[], + results: Array = [], + overrides = {}, +) { + return new OracleAIVectorSearch({ + client: fakeConnection(calls, results) as any, + collectionName: "mem0", + embeddingModelDims: 3, + ...overrides, + } as any); +} + +describe("quoteIdentifier", () => { + it("quotes a bare name", () => { + expect(quoteIdentifier("mem0")).toBe('"mem0"'); + }); + + it("quotes each segment of a schema-qualified name", () => { + expect(quoteIdentifier("app.mem0")).toBe('"app"."mem0"'); + }); + + it("preserves already-quoted segments", () => { + expect(quoteIdentifier('"App"."Mem0"')).toBe('"App"."Mem0"'); + }); + + it("rejects a name that would break out of the quoting", () => { + expect(() => quoteIdentifier('mem0" (x); DROP TABLE t--')).toThrow( + /is not valid/, + ); + }); +}); + +describe("buildWhereClause", () => { + it("returns no clause for empty filters", () => { + expect(buildWhereClause(undefined)).toEqual(["", {}]); + expect(buildWhereClause({})).toEqual(["", {}]); + }); + + it("binds a scalar equality instead of inlining it", () => { + const [clause, binds] = buildWhereClause({ user_id: "alice" }); + expect(clause).toBe( + `WHERE JSON_EXISTS(payload, '$."user_id"?(@ == $f_0)' PASSING :f_0 AS "f_0")`, + ); + expect(binds).toEqual({ f_0: "alice" }); + }); + + it("ANDs multiple fields", () => { + const [clause, binds] = buildWhereClause({ + user_id: "alice", + agent_id: "bot", + }); + expect(clause.startsWith("WHERE (")).toBe(true); + expect(clause).toContain(" AND "); + expect(binds).toEqual({ f_0: "alice", f_1: "bot" }); + }); + + it("applies every operator in a compound range filter", () => { + const [clause, binds] = buildWhereClause({ age: { gte: 10, lte: 20 } }); + expect(clause).toContain("@ >= $f_0 && @ <= $f_1"); + expect(binds).toEqual({ f_0: 10, f_1: 20 }); + }); + + it("builds an existence check for the wildcard filter", () => { + const [clause, binds] = buildWhereClause({ user_id: "*" }); + expect(clause).toBe(`WHERE JSON_EXISTS(payload, '$."user_id"')`); + expect(binds).toEqual({}); + }); + + it("builds membership for in and negates it for nin", () => { + const [inClause] = buildWhereClause({ user_id: { in: ["a", "b"] } }); + expect(inClause).toContain("@ in ($f_0, $f_1)"); + expect(inClause).not.toContain("NOT ("); + + const [ninClause] = buildWhereClause({ user_id: { nin: ["a"] } }); + expect(ninClause).toContain("NOT ("); + }); + + it("lowercases the operand for icontains", () => { + const [clause, binds] = buildWhereClause({ data: { icontains: "SciFi" } }); + expect(clause).toContain("@.lower() has substring $f_0"); + expect(binds).toEqual({ f_0: "scifi" }); + }); + + it("ORs the branches of a $or group", () => { + const [clause, binds] = buildWhereClause({ + $or: [{ user_id: "alice" }, { agent_id: "bot" }], + }); + expect(clause).toContain(" OR "); + expect(binds).toEqual({ f_0: "alice", f_1: "bot" }); + }); + + it("negates a $not group", () => { + const [clause] = buildWhereClause({ $not: [{ user_id: "alice" }] }); + expect(clause.startsWith("WHERE NOT (")).toBe(true); + }); + + it("nests logical groups", () => { + const [clause, binds] = buildWhereClause({ + user_id: "alice", + $or: [{ agent_id: "bot" }, { run_id: "r1" }], + }); + expect(clause).toContain(" AND "); + expect(clause).toContain(" OR "); + expect(Object.keys(binds)).toEqual(["f_0", "f_1", "f_2"]); + }); + + it("compares against JSON null without a bind", () => { + const [clause, binds] = buildWhereClause({ agent_id: null }); + expect(clause).toBe( + `WHERE JSON_EXISTS(payload, '$."agent_id"?(@ == null)')`, + ); + expect(binds).toEqual({}); + }); + + it("rejects a metadata key that could escape the JSON path", () => { + expect(() => buildWhereClause({ 'a"?(1==1))--': "x" })).toThrow( + /Invalid metadata key/, + ); + }); + + it("rejects an unsupported field operator", () => { + expect(() => buildWhereClause({ age: { regex: "^a" } })).toThrow( + /Unsupported Oracle filter operator/, + ); + }); + + it("rejects an unsupported logical operator", () => { + expect(() => buildWhereClause({ $nor: [{ a: 1 }] })).toThrow( + /Unsupported Oracle logical filter operator/, + ); + }); + + it("rejects an empty in list", () => { + expect(() => buildWhereClause({ user_id: { in: [] } })).toThrow( + /non-empty array/, + ); + }); + + it("rejects a non-scalar comparison operand", () => { + expect(() => buildWhereClause({ age: { gt: [1] } })).toThrow( + /requires a scalar value/, + ); + }); +}); + +describe("OracleAIVectorSearch config validation", () => { + it("requires connectionParams or client", () => { + expect(() => new OracleAIVectorSearch({} as any)).toThrow( + /connectionParams.*client/, + ); + }); + + it("rejects an unsupported distance metric", () => { + expect(() => makeStore([], [], { distanceMetric: "JACCARD" })).toThrow( + /Unsupported distance metric/, + ); + }); + + it("rejects a non-positive embedding dimension", () => { + expect(() => makeStore([], [], { embeddingModelDims: 0 })).toThrow( + /positive integer/, + ); + }); + + it("rejects an out-of-range index accuracy", () => { + expect(() => makeStore([], [], { indexAccuracy: 101 })).toThrow( + /between 1 and 100/, + ); + }); + + it("rejects an index parameter that does not belong to the index type", () => { + expect(() => + makeStore([], [], { + indexType: "HNSW", + indexParameters: { samples_per_partition: 10 }, + }), + ).toThrow(/Unsupported HNSW index parameter/); + }); + + it("rejects an index parameter outside its allowed range", () => { + expect(() => + makeStore([], [], { indexParameters: { neighbors: 1 } }), + ).toThrow(/between 2 and 2048/); + }); +}); + +describe("OracleAIVectorSearch SQL", () => { + it("creates the table and a vector index on initialize", async () => { + const calls: Call[] = []; + await makeStore(calls, [], { + indexParameters: { neighbors: 32, efconstruction: 200 }, + indexAccuracy: 95, + }).initialize(); + + const ddl = calls.map((c) => c.sql).join("\n"); + expect(ddl).toContain( + 'CREATE TABLE IF NOT EXISTS "mem0" ( id VARCHAR2(36) PRIMARY KEY, vector VECTOR(3), payload JSON )', + ); + expect(ddl).toContain( + 'CREATE VECTOR INDEX IF NOT EXISTS "mem0_VEC_IDX" ON "mem0" (vector) ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE WITH TARGET ACCURACY 95 PARAMETERS (type HNSW, neighbors 32, efconstruction 200)', + ); + }); + + it("skips index creation when doCreateIndex is false", async () => { + const calls: Call[] = []; + await makeStore(calls, [], { doCreateIndex: false }).initialize(); + expect(calls.map((c) => c.sql).join("\n")).not.toContain( + "CREATE VECTOR INDEX", + ); + }); + + it("binds vectors as DB_TYPE_VECTOR and payloads as DB_TYPE_JSON on insert", async () => { + const calls: Call[] = []; + await makeStore(calls).insert([[1, 2, 3]], ["id-1"], [{ data: "hello" }]); + + const insert = calls.find((c) => c.sql.startsWith("INSERT INTO"))!; + expect(insert.binds).toEqual([ + { + id: "id-1", + vector: new Float32Array([1, 2, 3]), + payload: { data: "hello" }, + }, + ]); + expect(insert.options.bindDefs.id).toEqual({ + type: DB_TYPE_VARCHAR, + maxSize: 36, + }); + expect(insert.options.bindDefs.vector.type).toBe(DB_TYPE_VECTOR); + expect(insert.options.bindDefs.payload.type).toBe(DB_TYPE_JSON); + }); + + it("issues a single executeMany call with one bind row per vector on a multi-row insert", async () => { + const calls: Call[] = []; + await makeStore(calls).insert( + [ + [1, 2, 3], + [4, 5, 6], + ], + ["id-1", "id-2"], + [{ a: 1 }, { b: 2 }], + ); + + const inserts = calls.filter((c) => c.sql.startsWith("INSERT INTO")); + expect(inserts).toHaveLength(1); + expect(inserts[0].binds).toHaveLength(2); + expect(inserts[0].binds[0].id).toBe("id-1"); + expect(inserts[0].binds[1].id).toBe("id-2"); + }); + + it("issues no insert statement when inserting an empty batch", async () => { + const calls: Call[] = []; + await makeStore(calls).insert([], [], []); + expect(calls.filter((c) => c.sql.startsWith("INSERT INTO"))).toHaveLength( + 0, + ); + }); + + it("rejects insert batches whose IDs or payloads do not match vectors", async () => { + const store = makeStore([]); + + await expect(store.insert([[1, 2, 3]], [], [{}])).rejects.toThrow( + "ids and vectors must have the same length", + ); + await expect(store.insert([[1, 2, 3]], ["id-1"], [])).rejects.toThrow( + "payloads and vectors must have the same length", + ); + }); + + it("converts cosine distance to a similarity score", async () => { + const calls: Call[] = []; + const store = makeStore(calls, [[["id-1", { data: "hello" }, 0.25]]]); + const results = await store.search([1, 2, 3], 5); + + expect(results).toEqual([ + { id: "id-1", payload: { data: "hello" }, score: 0.75 }, + ]); + const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!; + expect(select.sql).toContain( + "VECTOR_DISTANCE(vector, :query_vec, COSINE) distance", + ); + expect(select.sql).toContain("FETCH APPROX FIRST :max_rows ROWS ONLY"); + expect(select.binds.max_rows).toBe(5); + }); + + it("inverts the sign of a DOT distance", async () => { + const store = makeStore([], [[["id-1", {}, -0.4]]], { + distanceMetric: "DOT", + }); + const [result] = await store.search([1, 2, 3]); + expect(result.score).toBeCloseTo(0.4); + }); + + it("adds the VECTOR_INDEX_TRANSFORM hint when searching without filters", async () => { + const calls: Call[] = []; + const store = makeStore(calls, [[["id-1", {}, 0.1]]]); + await store.search([1, 2, 3], 5); + + const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!; + expect(select.sql).toContain('/*+ VECTOR_INDEX_TRANSFORM("mem0") */'); + }); + + it("omits the VECTOR_INDEX_TRANSFORM hint when searching with filters", async () => { + const calls: Call[] = []; + const store = makeStore(calls, [[["id-1", {}, 0.1]]]); + await store.search([1, 2, 3], 5, { user_id: "alice" }); + + const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!; + expect(select.sql).not.toContain("VECTOR_INDEX_TRANSFORM"); + }); + + it("parses a payload returned as a JSON string", async () => { + const store = makeStore([], [[["id-1", '{"data":"hello"}']]]); + expect(await store.get("id-1")).toEqual({ + id: "id-1", + payload: { data: "hello" }, + }); + }); + + it("returns null when get finds no row", async () => { + expect(await makeStore([], [[]]).get("missing")).toBeNull(); + }); + + it("generates and persists a UUID user id when none is stored", async () => { + const calls: Call[] = []; + const userId = await makeStore(calls, [[]]).getUserId(); + + expect(userId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + const insert = calls.find((c) => + c.sql.startsWith("INSERT INTO memory_migrations"), + )!; + expect(insert.binds).toEqual({ user_id: userId }); + }); + + it("returns the stored user id when one exists", async () => { + expect(await makeStore([], [[["alice"]]]).getUserId()).toBe("alice"); + }); + + it("returns rows and the total count from list", async () => { + const calls: Call[] = []; + const store = makeStore(calls, [[["id-1", { data: "hello" }, 7]]]); + const [results, count] = await store.list({ user_id: "alice" }, 10); + + expect(results).toEqual([{ id: "id-1", payload: { data: "hello" } }]); + expect(count).toBe(7); + + const selects = calls.filter((c) => /^SELECT/i.test(c.sql)); + expect(selects).toHaveLength(1); + const [list] = selects; + expect(list.sql).toContain( + "SELECT id, payload, COUNT(*) OVER () total FROM", + ); + expect(list.sql).toContain("WHERE JSON_EXISTS(payload,"); + expect(list.binds).toEqual({ f_0: "alice", max_rows: 10 }); + }); + + it("returns a total of 0 from list when no rows match", async () => { + const store = makeStore([], [[]]); + const [results, count] = await store.list(); + expect(results).toEqual([]); + expect(count).toBe(0); + }); +}); + +describe("OracleAIVectorSearch initialization failure", () => { + beforeEach(() => mockCreatePool.mockReset()); + + function fakePool(serverVersion: string) { + const connection = { + ...fakeConnection([], []), + oracleServerVersionString: serverVersion, + }; + return { + close: jest.fn(async () => {}), + getConnection: jest.fn(async () => connection), + }; + } + + function makePoolStore() { + return new OracleAIVectorSearch({ + connectionParams: { user: "u", password: "p", connectString: "d" }, + collectionName: "mem0", + embeddingModelDims: 3, + } as any); + } + + it("closes the pool it owns when initialization fails", async () => { + const pool = fakePool("23.3.0.24.05"); + mockCreatePool.mockResolvedValue(pool); + + await expect(makePoolStore().initialize()).rejects.toThrow( + "Oracle DB version 23.3.0.24.05 not supported", + ); + expect(pool.close).toHaveBeenCalledTimes(1); + }); + + it("does not cache the rejection, so a later attempt can succeed", async () => { + const failing = fakePool("23.3.0.24.05"); + const working = fakePool("23.4.0.24.05"); + mockCreatePool + .mockResolvedValueOnce(failing) + .mockResolvedValueOnce(working); + + const store = makePoolStore(); + await expect(store.initialize()).rejects.toThrow("not supported"); + await expect(store.initialize()).resolves.toBeUndefined(); + expect(mockCreatePool).toHaveBeenCalledTimes(2); + }); + + it("leaves a caller-supplied client open when initialization fails", async () => { + const client = fakeConnection([], []); + client.oracleServerVersionString = "23.3.0.24.05"; + const close = jest.spyOn(client, "close"); + + const store = new OracleAIVectorSearch({ + client: client as any, + collectionName: "mem0", + embeddingModelDims: 3, + } as any); + + await expect(store.initialize()).rejects.toThrow("not supported"); + expect(close).not.toHaveBeenCalled(); + }); +}); diff --git a/mem0-ts/src/oss/tests/qdrant-keyword-search.test.ts b/mem0-ts/src/oss/tests/qdrant-keyword-search.test.ts new file mode 100644 index 000000000..2746238cc --- /dev/null +++ b/mem0-ts/src/oss/tests/qdrant-keyword-search.test.ts @@ -0,0 +1,568 @@ +/// +/** + * Tests for Qdrant native BM25 keyword search (server-side `Qdrant/bm25` + * inference). Fully mocked β€” no real Qdrant server is required. + * + * Covers both the happy path and the failure modes that matter for an opt-in, + * capability-gated feature: + * - fresh collections get the `bm25` sparse slot (modifier idf); migrations don't, + * - insert/update attach the server-side BM25 inference vector, + * - keywordSearch queries the `bm25` slot and maps results to the common shape, + * - collection-init variations (slot present/absent, wrong size, 401/403/409, + * transient verify failure, fatal error), + * - malformed/hostile query responses and filter construction, + * all proving the adapter degrades to `null` (semantic-only) instead of crashing. + */ + +jest.setTimeout(15000); + +type MockClient = Record; + +function buildMockClient( + overrides: Partial = {}, + collectionInfo: any = { config: { params: { vectors: { size: 768 } } } }, +): MockClient { + return { + createCollection: jest.fn().mockResolvedValue(undefined), + createPayloadIndex: jest.fn().mockResolvedValue(undefined), + getCollection: jest.fn().mockResolvedValue(collectionInfo), + scroll: jest.fn().mockResolvedValue({ points: [] }), + upsert: jest.fn().mockResolvedValue(undefined), + retrieve: jest.fn().mockResolvedValue([]), + search: jest.fn().mockResolvedValue([]), + query: jest.fn().mockResolvedValue({ points: [] }), + delete: jest.fn().mockResolvedValue(undefined), + deleteCollection: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +jest.mock("@qdrant/js-client-rest", () => ({ + QdrantClient: jest.fn().mockImplementation(() => buildMockClient()), +})); + +import { Qdrant } from "../src/vector_stores/qdrant"; + +const BASE_CONFIG = { + collectionName: "test_memories", + embeddingModelDims: 768, + dimension: 768, +}; + +// Existing collection that already carries a bm25 slot. +const INFO_WITH_SLOT = { + config: { + params: { vectors: { size: 768 }, sparse_vectors: { bm25: {} } }, + }, +}; +// Existing collection without a bm25 slot (legacy / pre-hybrid-search). +const INFO_NO_SLOT = { + config: { params: { vectors: { size: 768 } } }, +}; + +let warnSpy: jest.SpyInstance; +let errSpy: jest.SpyInstance; + +beforeEach(() => { + jest.clearAllMocks(); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + errSpy = jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + warnSpy.mockRestore(); + errSpy.mockRestore(); +}); + +function makeStore(client: MockClient): Qdrant { + return new Qdrant({ client: client as any, ...BASE_CONFIG }); +} + +describe("Qdrant BM25 keyword search β€” collection setup", () => { + it("creates the main collection with the bm25 sparse slot (modifier idf)", async () => { + const client = buildMockClient(); + const store = makeStore(client); + await store.initialize(); + + const createCall = client.createCollection.mock.calls.find( + (c) => c[0] === "test_memories", + ); + expect(createCall).toBeDefined(); + expect(createCall![1].sparse_vectors).toEqual({ + bm25: { modifier: "idf" }, + }); + }); + + it("does NOT add the bm25 slot to the memory_migrations collection", async () => { + const client = buildMockClient(); + const store = makeStore(client); + await store.initialize(); + + const migrationsCall = client.createCollection.mock.calls.find( + (c) => c[0] === "memory_migrations", + ); + expect(migrationsCall).toBeDefined(); + expect(migrationsCall![1].sparse_vectors).toBeUndefined(); + }); + + it("409 with an existing bm25 slot β†’ enables keyword search, no warning", async () => { + const client = buildMockClient( + { createCollection: jest.fn().mockRejectedValue({ status: 409 }) }, + INFO_WITH_SLOT, + ); + const store = makeStore(client); + await store.initialize(); + + expect(await store.keywordSearch("anything")).not.toBeNull(); + expect(client.query).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("409 without a bm25 slot β†’ disabled + warns once", async () => { + const client = buildMockClient( + { createCollection: jest.fn().mockRejectedValue({ status: 409 }) }, + INFO_NO_SLOT, + ); + const store = makeStore(client); + await store.initialize(); + + expect(await store.keywordSearch("anything")).toBeNull(); + expect(client.query).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("401 (auth-restricted existing collection) with slot β†’ enabled", async () => { + const client = buildMockClient( + { createCollection: jest.fn().mockRejectedValue({ status: 401 }) }, + INFO_WITH_SLOT, + ); + const store = makeStore(client); + await store.initialize(); + expect(await store.keywordSearch("x")).not.toBeNull(); + }); + + it("403 with no slot β†’ disabled (graceful)", async () => { + const client = buildMockClient( + { createCollection: jest.fn().mockRejectedValue({ status: 403 }) }, + INFO_NO_SLOT, + ); + const store = makeStore(client); + await store.initialize(); + expect(await store.keywordSearch("x")).toBeNull(); + }); + + it("existing collection with WRONG vector size β†’ init rejects (size guard intact)", async () => { + const client = buildMockClient( + { createCollection: jest.fn().mockRejectedValue({ status: 409 }) }, + { config: { params: { vectors: { size: 1536 } } } }, // mismatch vs 768 + ); + const store = makeStore(client); + await expect(store.initialize()).rejects.toThrow(/wrong vector size/i); + }); + + it("transient getCollection failure during verify β†’ does NOT crash init, stays disabled", async () => { + const client = buildMockClient({ + createCollection: jest.fn().mockRejectedValue({ status: 409 }), + getCollection: jest + .fn() + .mockRejectedValue({ status: 500, message: "committing" }), + }); + const store = makeStore(client); + await expect(store.initialize()).resolves.toBeUndefined(); + expect(await store.keywordSearch("x")).toBeNull(); + }); + + it("fatal createCollection error (500, not 401/403/409) β†’ init rejects", async () => { + const client = buildMockClient({ + createCollection: jest.fn().mockRejectedValue({ status: 500 }), + }); + const store = makeStore(client); + await expect(store.initialize()).rejects.toBeDefined(); + }); +}); + +describe("Qdrant BM25 keyword search β€” insert / update", () => { + async function freshStore(): Promise<{ store: Qdrant; client: MockClient }> { + const client = buildMockClient(); + const store = makeStore(client); + await store.initialize(); + return { store, client }; + } + + it("attaches a server-side BM25 inference vector on insert", async () => { + const { store, client } = await freshStore(); + await store.insert( + [[0.1, 0.2, 0.3]], + ["mem-1"], + [ + { + data: "Alice reported a duplicate AWS invoice", + textLemmatized: "alice report duplicate aws invoice", + }, + ], + ); + + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.id).toBe("mem-1"); + expect(point.vector[""]).toEqual([0.1, 0.2, 0.3]); + expect(point.vector.bm25).toEqual({ + text: "alice report duplicate aws invoice", + model: "Qdrant/bm25", + }); + }); + + it("falls back to payload.data when textLemmatized is absent", async () => { + const { store, client } = await freshStore(); + await store.insert([[0.1]], ["mem-2"], [{ data: "plain text" }]); + + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.vector.bm25).toEqual({ + text: "plain text", + model: "Qdrant/bm25", + }); + }); + + it("empty textLemmatized falls through to data", async () => { + const { store, client } = await freshStore(); + await store.insert( + [[0.1]], + ["m"], + [{ textLemmatized: "", data: "fallback" }], + ); + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.vector.bm25).toEqual({ + text: "fallback", + model: "Qdrant/bm25", + }); + }); + + it("omits the bm25 vector when the point has no text (dense still present)", async () => { + const { store, client } = await freshStore(); + await store.insert([[0.1, 0.2]], ["m"], [{ textLemmatized: "", data: "" }]); + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.vector[""]).toEqual([0.1, 0.2]); + expect(point.vector.bm25).toBeUndefined(); + }); + + it("preserves the given id on insert", async () => { + const { store, client } = await freshStore(); + await store.insert([[0.1]], ["123"], [{ data: "x" }]); + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.id).toBe("123"); + }); + + it("batch insert attaches bm25 to every text-bearing point", async () => { + const { store, client } = await freshStore(); + await store.insert( + [[0.1], [0.2], [0.3]], + ["a", "b", "c"], + [{ data: "one" }, { data: "" }, { textLemmatized: "three" }], + ); + const points = client.upsert.mock.calls.at(-1)![1].points; + expect(points[0].vector.bm25.text).toBe("one"); + expect(points[1].vector.bm25).toBeUndefined(); + expect(points[2].vector.bm25.text).toBe("three"); + }); + + it("re-encodes the bm25 vector on update", async () => { + const { store, client } = await freshStore(); + await store.update("mem-1", [0.4, 0.5], { + data: "updated", + textLemmatized: "updat", + }); + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.vector[""]).toEqual([0.4, 0.5]); + expect(point.vector.bm25).toEqual({ text: "updat", model: "Qdrant/bm25" }); + }); + + it("update with no text β†’ named dense only (bm25 not re-attached)", async () => { + const { store, client } = await freshStore(); + await store.update("m", [0.9], { user_id: "u" }); + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.vector[""]).toEqual([0.9]); + expect(point.vector.bm25).toBeUndefined(); + }); + + it("inserts a plain dense vector (no named/sparse) on legacy collections", async () => { + const client = buildMockClient( + { createCollection: jest.fn().mockRejectedValue({ status: 409 }) }, + INFO_NO_SLOT, + ); + const store = makeStore(client); + await store.initialize(); + + await store.insert( + [[0.1, 0.2]], + ["mem-1"], + [{ data: "hello", textLemmatized: "hello" }], + ); + + const point = client.upsert.mock.calls.at(-1)![1].points[0]; + expect(point.vector).toEqual([0.1, 0.2]); + }); +}); + +describe("Qdrant BM25 keyword search β€” querying & result mapping", () => { + it("queries the bm25 slot and maps results to {id, payload, score}", async () => { + const client = buildMockClient({ + query: jest.fn().mockResolvedValue({ + points: [ + { id: "mem-1", score: 4.2, payload: { data: "AWS invoice" } }, + { id: "mem-2", score: 1.1, payload: { data: "other" } }, + ], + }), + }); + const store = makeStore(client); + await store.initialize(); + + const results = await store.keywordSearch("aws invoice", 5, { + user_id: "u1", + }); + + expect(client.query).toHaveBeenCalledWith( + "test_memories", + expect.objectContaining({ + query: { text: "aws invoice", model: "Qdrant/bm25" }, + using: "bm25", + limit: 5, + with_payload: true, + }), + ); + expect(results).toEqual([ + { id: "mem-1", payload: { data: "AWS invoice" }, score: 4.2 }, + { id: "mem-2", payload: { data: "other" }, score: 1.1 }, + ]); + }); + + async function storeWith(queryImpl: jest.Mock): Promise { + const client = buildMockClient({ query: queryImpl }); + const store = makeStore(client); + await store.initialize(); + return store; + } + + it("query rejects β†’ null (no throw)", async () => { + const store = await storeWith( + jest.fn().mockRejectedValue(new Error("boom")), + ); + expect(await store.keywordSearch("x")).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + }); + + it("response missing the `points` array β†’ null (no throw)", async () => { + const store = await storeWith(jest.fn().mockResolvedValue({})); + expect(await store.keywordSearch("x")).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + }); + + it("response is null β†’ null (no throw)", async () => { + const store = await storeWith(jest.fn().mockResolvedValue(null)); + expect(await store.keywordSearch("x")).toBeNull(); + }); + + it("point with numeric id β†’ stringified", async () => { + const store = await storeWith( + jest.fn().mockResolvedValue({ + points: [{ id: 42, score: 1.0, payload: { data: "d" } }], + }), + ); + const res = await store.keywordSearch("x"); + expect(res![0].id).toBe("42"); + }); + + it("point with missing score β†’ score undefined (pipeline coerces to 0)", async () => { + const store = await storeWith( + jest.fn().mockResolvedValue({ points: [{ id: "a", payload: {} }] }), + ); + const res = await store.keywordSearch("x"); + expect(res![0].score).toBeUndefined(); + }); + + it("point with missing payload β†’ {}", async () => { + const store = await storeWith( + jest.fn().mockResolvedValue({ points: [{ id: "a", score: 2 }] }), + ); + const res = await store.keywordSearch("x"); + expect(res![0].payload).toEqual({}); + }); + + it("empty query string is forwarded and yields [] (graceful)", async () => { + const queryImpl = jest.fn().mockResolvedValue({ points: [] }); + const store = await storeWith(queryImpl); + const res = await store.keywordSearch(""); + expect(res).toEqual([]); + expect(queryImpl.mock.calls[0][1].query).toEqual({ + text: "", + model: "Qdrant/bm25", + }); + }); +}); + +describe("Qdrant BM25 keyword search β€” query construction & filters", () => { + async function freshStore(): Promise<{ store: Qdrant; client: MockClient }> { + const client = buildMockClient(); + const store = makeStore(client); + await store.initialize(); + return { store, client }; + } + + it("defaults topK to 5 when omitted", async () => { + const { store, client } = await freshStore(); + await store.keywordSearch("x"); + expect(client.query.mock.calls[0][1].limit).toBe(5); + }); + + it("passes a simple equality filter through createFilter", async () => { + const { store, client } = await freshStore(); + await store.keywordSearch("x", 3, { user_id: "u1" }); + const arg = client.query.mock.calls[0][1]; + expect(arg.limit).toBe(3); + expect(arg.using).toBe("bm25"); + expect(arg.filter).toEqual({ + must: [{ key: "user_id", match: { value: "u1" } }], + }); + }); + + it("builds an OR (should) clause for logical filters", async () => { + const { store, client } = await freshStore(); + await store.keywordSearch("x", 5, { + $or: [{ user_id: "u1" }, { agent_id: "a1" }], + }); + const filter = client.query.mock.calls[0][1].filter; + expect(filter.should).toHaveLength(2); + }); + + it("empty filter object β†’ filter is undefined", async () => { + const { store, client } = await freshStore(); + await store.keywordSearch("x", 5, {}); + expect(client.query.mock.calls[0][1].filter).toBeUndefined(); + }); +}); + +describe("Qdrant BM25 keyword search β€” reactive downgrade on upsert failure", () => { + it("insert(): upsert fails once, retries with plain dense vector, disables bm25", async () => { + const upsert = jest + .fn() + .mockRejectedValueOnce({ + status: 500, + message: "InferenceService is not initialized.", + }) + .mockResolvedValueOnce(undefined); + const client = buildMockClient({ upsert }); + const store = makeStore(client); + await store.initialize(); + + await store.insert( + [[0.1, 0.2]], + ["mem-1"], + [{ data: "hello", textLemmatized: "hello" }], + ); + + expect(upsert).toHaveBeenCalledTimes(2); + const retryPoint = upsert.mock.calls[1][1].points[0]; + expect(retryPoint.vector).toEqual([0.1, 0.2]); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(await store.keywordSearch("hello")).toBeNull(); + }); + + it("update(): upsert fails once, retries with plain dense vector, disables bm25", async () => { + const upsert = jest + .fn() + .mockRejectedValueOnce({ + status: 500, + message: "InferenceService is not initialized.", + }) + .mockResolvedValueOnce(undefined); + const client = buildMockClient({ upsert }); + const store = makeStore(client); + await store.initialize(); + + await store.update("mem-1", [0.4, 0.5], { + data: "updated", + textLemmatized: "updat", + }); + + expect(upsert).toHaveBeenCalledTimes(2); + const retryPoint = upsert.mock.calls[1][1].points[0]; + expect(retryPoint.vector).toEqual([0.4, 0.5]); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(await store.keywordSearch("updat")).toBeNull(); + }); + + it("legacy collection (no bm25 slot): a genuine upsert failure still rejects, not swallowed", async () => { + const client = buildMockClient( + { + createCollection: jest.fn().mockRejectedValue({ status: 409 }), + upsert: jest.fn().mockRejectedValue(new Error("network down")), + }, + INFO_NO_SLOT, + ); + const store = makeStore(client); + await store.initialize(); + + await expect(store.insert([[0.1]], ["m"], [{ data: "x" }])).rejects.toThrow( + "network down", + ); + expect(client.upsert).toHaveBeenCalledTimes(1); + }); + + it("after a downgrade, a subsequent insert() sends a plain dense vector on the first attempt", async () => { + const upsert = jest + .fn() + .mockRejectedValueOnce({ + status: 500, + message: "InferenceService is not initialized.", + }) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const client = buildMockClient({ upsert }); + const store = makeStore(client); + await store.initialize(); + + await store.insert([[0.1]], ["mem-1"], [{ data: "first" }]); + expect(upsert).toHaveBeenCalledTimes(2); + + await store.insert([[0.2]], ["mem-2"], [{ data: "second" }]); + expect(upsert).toHaveBeenCalledTimes(3); + const secondInsertPoint = upsert.mock.calls[2][1].points[0]; + expect(secondInsertPoint.vector).toEqual([0.2]); + }); +}); + +describe("Qdrant BM25 keyword search β€” memory_migrations indexing", () => { + it("does not create payload indexes for memory_migrations on the existing-collection path", async () => { + const client = buildMockClient({ + createCollection: jest.fn().mockRejectedValue({ status: 409 }), + }); + const store = makeStore(client); + await store.initialize(); + + const migrationsIndexCalls = client.createPayloadIndex.mock.calls.filter( + (c) => c[0] === "memory_migrations", + ); + expect(migrationsIndexCalls).toHaveLength(0); + }); +}); + +describe("Qdrant BM25 keyword search β€” semantic (dense) path", () => { + it("search() still targets the unnamed dense slot after BM25 init", async () => { + const client = buildMockClient({ + search: jest + .fn() + .mockResolvedValue([ + { id: "mem-1", score: 0.9, payload: { data: "x" } }, + ]), + }); + const store = makeStore(client); + await store.initialize(); + + const res = await store.search([0.1, 0.2, 0.3], 5); + + // Plain number[] (not a named vector) keeps hitting the default dense slot. + expect(client.search).toHaveBeenCalledWith( + "test_memories", + expect.objectContaining({ vector: [0.1, 0.2, 0.3] }), + ); + expect(res[0].id).toBe("mem-1"); + }); +}); diff --git a/mem0-ts/src/oss/tests/qdrant-url-port.test.ts b/mem0-ts/src/oss/tests/qdrant-url-port.test.ts index ae86ea471..bcb857a55 100644 --- a/mem0-ts/src/oss/tests/qdrant-url-port.test.ts +++ b/mem0-ts/src/oss/tests/qdrant-url-port.test.ts @@ -40,14 +40,15 @@ beforeEach(() => { }); describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { - it("extracts port from HTTPS URL with explicit port", () => { - new Qdrant({ + it("extracts port from HTTPS URL with explicit port", async () => { + const store = new Qdrant({ url: "https://my-cluster.us-west-1-0.aws.cloud.qdrant.io:6333", apiKey: "test-key", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe( @@ -57,47 +58,50 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { expect(capturedParams!.apiKey).toBe("test-key"); }); - it("extracts port from HTTP URL with explicit port", () => { - new Qdrant({ + it("extracts port from HTTP URL with explicit port", async () => { + const store = new Qdrant({ url: "http://localhost:6333", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe("http://localhost:6333"); expect(capturedParams!.port).toBe(6333); }); - it("defaults to port 6333 when HTTPS URL has no explicit port", () => { - new Qdrant({ + it("defaults to port 6333 when HTTPS URL has no explicit port", async () => { + const store = new Qdrant({ url: "https://my-cluster.cloud.qdrant.io", apiKey: "test-key", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe("https://my-cluster.cloud.qdrant.io"); expect(capturedParams!.port).toBe(6333); }); - it("defaults to port 6333 when HTTP URL has no explicit port", () => { - new Qdrant({ + it("defaults to port 6333 when HTTP URL has no explicit port", async () => { + const store = new Qdrant({ url: "http://localhost", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.port).toBe(6333); }); - it("host+port config overrides URL-extracted port", () => { - new Qdrant({ + it("host+port config overrides URL-extracted port", async () => { + const store = new Qdrant({ url: "https://my-cluster.cloud.qdrant.io:6333", host: "custom-host", port: 9999, @@ -106,28 +110,28 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.host).toBe("custom-host"); expect(capturedParams!.port).toBe(9999); }); - it("handles invalid URL gracefully without crashing", () => { - expect(() => { - new Qdrant({ - url: "not-a-valid-url", - collectionName: "test", - embeddingModelDims: 768, - dimension: 768, - }); - }).not.toThrow(); + it("handles invalid URL gracefully without crashing", async () => { + const store = new Qdrant({ + url: "not-a-valid-url", + collectionName: "test", + embeddingModelDims: 768, + dimension: 768, + }); + await expect(store.initialize()).resolves.not.toThrow(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe("not-a-valid-url"); expect(capturedParams!.port).toBe(6333); }); - it("does not pass port when using pre-configured client", () => { + it("does not pass port when using pre-configured client", async () => { const mockClient: any = { createCollection: jest.fn().mockResolvedValue(undefined), getCollection: jest.fn().mockResolvedValue({ @@ -141,12 +145,13 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { deleteCollection: jest.fn().mockResolvedValue(undefined), }; - new Qdrant({ + const store = new Qdrant({ client: mockClient, collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); // QdrantClient constructor should NOT have been called expect( @@ -154,14 +159,15 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { ).not.toHaveBeenCalled(); }); - it("defaults to 6333 when HTTPS URL uses default port 443", () => { - new Qdrant({ + it("defaults to 6333 when HTTPS URL uses default port 443", async () => { + const store = new Qdrant({ url: "https://my-cluster.cloud.qdrant.io:443", apiKey: "test-key", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); // 443 is default for HTTPS, so URL.port returns empty string β€” we default to 6333 expect(capturedParams).toBeDefined(); diff --git a/mem0-ts/src/oss/tests/redis.filter-expr.test.ts b/mem0-ts/src/oss/tests/redis.filter-expr.test.ts new file mode 100644 index 000000000..43ed6c63b --- /dev/null +++ b/mem0-ts/src/oss/tests/redis.filter-expr.test.ts @@ -0,0 +1,35 @@ +import { buildRedisFilterExpr } from "../src/vector_stores/redis"; + +describe("buildRedisFilterExpr", () => { + it("returns '*' when filters is undefined", () => { + expect(buildRedisFilterExpr(undefined)).toBe("*"); + }); + + it("returns '*' for an empty filters object", () => { + // Regression: previously produced "" -> invalid RediSearch query + // (` =>[KNN ...]`). + expect(buildRedisFilterExpr({})).toBe("*"); + }); + + it("returns '*' when every filter value is null/undefined", () => { + expect( + buildRedisFilterExpr({ user_id: null as any, agent_id: undefined }), + ).toBe("*"); + }); + + it("builds a tag clause for a single filter", () => { + expect(buildRedisFilterExpr({ user_id: "alice" })).toBe("@user_id:{alice}"); + }); + + it("snake-cases keys and joins multiple clauses", () => { + expect(buildRedisFilterExpr({ userId: "alice", runId: "r1" })).toBe( + "@user_id:{alice} @run_id:{r1}", + ); + }); + + it("drops null/undefined values but keeps the rest", () => { + expect( + buildRedisFilterExpr({ user_id: "alice", agent_id: null as any }), + ).toBe("@user_id:{alice}"); + }); +}); diff --git a/mem0-ts/src/oss/tests/redis.unit.test.ts b/mem0-ts/src/oss/tests/redis.unit.test.ts index cf3d47174..03343b501 100644 --- a/mem0-ts/src/oss/tests/redis.unit.test.ts +++ b/mem0-ts/src/oss/tests/redis.unit.test.ts @@ -125,4 +125,88 @@ describe("RedisDB – entity payload handling", () => { expect(entry.created_at).toBeGreaterThan(0); expect(Number.isNaN(entry.created_at)).toBe(false); }); + + test("search returns entity ids as snake_case", async () => { + mockClient.ft.search.mockResolvedValue({ + total: 1, + documents: [ + { + id: "mem0:test:mem-1", + value: { + memory_id: "mem-1", + hash: "h1", + memory: "likes coffee", + created_at: "1700000000000", + agent_id: "agent-1", + run_id: "run-1", + user_id: "user-1", + metadata: "{}", + __vector_score: 0.1, + }, + }, + ], + }); + + const results = await store.search([0.1, 0.2, 0.3, 0.4], 5); + + expect(results[0].payload.user_id).toBe("user-1"); + expect(results[0].payload.agent_id).toBe("agent-1"); + expect(results[0].payload.run_id).toBe("run-1"); + expect(results[0].payload).not.toHaveProperty("userId"); + expect(results[0].payload).not.toHaveProperty("agentId"); + expect(results[0].payload).not.toHaveProperty("runId"); + }); + + test("get returns entity ids as snake_case", async () => { + mockClient.exists.mockResolvedValue(1); + mockClient.hGetAll.mockResolvedValue({ + memory_id: "mem-1", + hash: "h1", + memory: "likes coffee", + created_at: "1700000000000", + agent_id: "agent-1", + run_id: "run-1", + user_id: "user-1", + metadata: "{}", + }); + + const result = await store.get("mem-1"); + + expect(result?.payload.user_id).toBe("user-1"); + expect(result?.payload.agent_id).toBe("agent-1"); + expect(result?.payload.run_id).toBe("run-1"); + expect(result?.payload).not.toHaveProperty("userId"); + expect(result?.payload).not.toHaveProperty("agentId"); + expect(result?.payload).not.toHaveProperty("runId"); + }); + + test("list returns entity ids as snake_case", async () => { + mockClient.ft.search.mockResolvedValue({ + total: 1, + documents: [ + { + id: "mem0:test:mem-1", + value: { + memory_id: "mem-1", + hash: "h1", + memory: "likes coffee", + created_at: "1700000000000", + agent_id: "agent-1", + run_id: "run-1", + user_id: "user-1", + metadata: "{}", + }, + }, + ], + }); + + const [items] = await store.list(); + + expect(items[0].payload.user_id).toBe("user-1"); + expect(items[0].payload.agent_id).toBe("agent-1"); + expect(items[0].payload.run_id).toBe("run-1"); + expect(items[0].payload).not.toHaveProperty("userId"); + expect(items[0].payload).not.toHaveProperty("agentId"); + expect(items[0].payload).not.toHaveProperty("runId"); + }); }); diff --git a/mem0-ts/src/oss/tests/session-scope.unit.test.ts b/mem0-ts/src/oss/tests/session-scope.unit.test.ts new file mode 100644 index 000000000..e8ed40303 --- /dev/null +++ b/mem0-ts/src/oss/tests/session-scope.unit.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for Memory.buildSessionScope. The recent-conversation buffer key + * derived from user_id/agent_id/run_id must be unique per id combination. + */ +/// +jest.mock("../src/utils/factory", () => { + const { MemoryVectorStore } = jest.requireActual( + "../src/vector_stores/memory", + ); + const { SQLiteManager } = jest.requireActual("../src/storage/SQLiteManager"); + const testEmbedding = new Array(1536).fill(0.1); + class MockEmbedder { + embeddingDims = 1536; + async embed(): Promise { + return testEmbedding; + } + async embedBatch(texts: string[]): Promise { + return texts.map(() => testEmbedding); + } + } + class MockLLM { + async generateResponse() { + return JSON.stringify({ memory: [] }); + } + } + return { + __esModule: true, + EmbedderFactory: { create: jest.fn(() => new MockEmbedder()) }, + LLMFactory: { create: jest.fn(() => new MockLLM()) }, + VectorStoreFactory: { + create: jest.fn( + () => new MemoryVectorStore({ collectionName: "t", dimension: 1536 }), + ), + }, + HistoryManagerFactory: { + create: jest.fn(() => new SQLiteManager(":memory:")), + }, + }; +}); + +import { Memory } from "../src/memory"; +import { SearchFilters } from "../src/types"; + +function scopeOf(memory: Memory, filters: SearchFilters): string { + return (memory as any).buildSessionScope(filters); +} + +function cartesian(values: string[], length: number): string[][] { + if (length === 0) return [[]]; + const rest = cartesian(values, length - 1); + const result: string[][] = []; + for (const value of values) { + for (const tail of rest) { + result.push([value, ...tail]); + } + } + return result; +} + +describe("Memory session scope key", () => { + let memory: Memory; + + beforeAll(async () => { + memory = new Memory(); + await (memory as any)._initPromise; + }); + + it("keeps the unchanged key format for ids without delimiter characters", () => { + expect( + scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }), + ).toBe("user_id=550e8400-e29b-41d4-a716-446655440000"); + expect(scopeOf(memory, { agent_id: "agent.assistant:v2" })).toBe( + "agent_id=agent.assistant:v2", + ); + expect(scopeOf(memory, { run_id: "12345" })).toBe("run_id=12345"); + expect( + scopeOf(memory, { user_id: "user@example.com", agent_id: "support-bot" }), + ).toBe("agent_id=support-bot&user_id=user@example.com"); + }); + + it("no longer collides an id embedding the join syntax with the equivalent split filters", () => { + const collapsedRun = scopeOf(memory, { run_id: "proj-x&user_id=u1" }); + const splitRun = scopeOf(memory, { user_id: "u1", run_id: "proj-x" }); + expect(collapsedRun).not.toBe(splitRun); + + const collapsedAgent = scopeOf(memory, { run_id: "proj-y&agent_id=a1" }); + const splitAgent = scopeOf(memory, { agent_id: "a1", run_id: "proj-y" }); + expect(collapsedAgent).not.toBe(splitAgent); + }); + + it("maps every distinct filter combination of delimiter-heavy ids to a distinct key", () => { + const keys: string[] = ["user_id", "agent_id", "run_id"]; + const values = [ + "u1", + "r1", + "a1", + "%", + "&", + "=", + "a==", + "a1&run_id=r1", + "a1&user_id=u1", + "r1&user_id=u1", + "a1&run_id=r1&user_id=u1", + ]; + const seen = new Map(); + + for (let mask = 1; mask < 1 << keys.length; mask++) { + const subset = keys.filter((_key, i) => mask & (1 << i)); + for (const combo of cartesian(values, subset.length)) { + const filters: SearchFilters = {}; + subset.forEach((key, i) => (filters[key] = combo[i])); + const scope = scopeOf(memory, filters); + if (seen.has(scope)) { + expect(seen.get(scope)).toEqual(filters); + } else { + seen.set(scope, filters); + } + } + } + }); + + it("gives ids containing delimiter characters a new key format", () => { + expect(scopeOf(memory, { user_id: "dXNlcl9pZDE=" })).toBe( + "user_id=dXNlcl9pZDE%3D", + ); + expect(scopeOf(memory, { agent_id: "x&y" })).toBe("agent_id=x%26y"); + expect(scopeOf(memory, { run_id: "50% off" })).toBe("run_id=50%25 off"); + }); + + it("routes the add pipeline through the builder", async () => { + const db = (memory as any).db; + const spy = jest.spyOn(db, "getLastMessages"); + await memory.add([{ role: "user", content: "hello" }], { + runId: "proj-x&user_id=u1", + }); + expect(spy).toHaveBeenCalledWith("run_id=proj-x%26user_id%3Du1", 10); + spy.mockRestore(); + }); + + it("pins the exact key strings shared with the Python test suite", () => { + expect( + scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }), + ).toBe("user_id=550e8400-e29b-41d4-a716-446655440000"); + expect( + scopeOf(memory, { user_id: "u1", agent_id: "a1", run_id: "r1" }), + ).toBe("agent_id=a1&run_id=r1&user_id=u1"); + expect(scopeOf(memory, { run_id: "proj-x&user_id=u1" })).toBe( + "run_id=proj-x%26user_id%3Du1", + ); + }); +}); diff --git a/mem0-ts/src/oss/tests/together-embedder.test.ts b/mem0-ts/src/oss/tests/together-embedder.test.ts index 6c667ac29..dd65372df 100644 --- a/mem0-ts/src/oss/tests/together-embedder.test.ts +++ b/mem0-ts/src/oss/tests/together-embedder.test.ts @@ -20,6 +20,7 @@ describe("TogetherEmbedder (unit)", () => { jest.resetModules(); process.env = { ...originalEnv }; delete process.env.TOGETHER_API_KEY; + delete process.env.TOGETHER_API_BASE; mockOpenAI.mockClear(); mockEmbeddingsCreate.mockReset(); mockEmbeddingsCreate.mockResolvedValue({ @@ -81,6 +82,35 @@ describe("TogetherEmbedder (unit)", () => { }); }); + it("honors TOGETHER_API_BASE when no baseURL is configured", async () => { + process.env.TOGETHER_API_BASE = "https://gateway.example/v1"; + + const embedder = new TogetherEmbedder({ apiKey: "test-key" }); + + await embedder.embed("hello"); + + expect(mockOpenAI).toHaveBeenCalledWith({ + apiKey: "test-key", + baseURL: "https://gateway.example/v1", + }); + }); + + it("prefers an explicit baseURL over TOGETHER_API_BASE", async () => { + process.env.TOGETHER_API_BASE = "https://gateway.example/v1"; + + const embedder = new TogetherEmbedder({ + apiKey: "test-key", + baseURL: "https://explicit.example/v1", + }); + + await embedder.embed("hello"); + + expect(mockOpenAI).toHaveBeenCalledWith({ + apiKey: "test-key", + baseURL: "https://explicit.example/v1", + }); + }); + it("uses url as a baseURL fallback", async () => { const embedder = new TogetherEmbedder({ apiKey: "test-key", diff --git a/mem0-ts/src/oss/tests/tsup-externals.test.ts b/mem0-ts/src/oss/tests/tsup-externals.test.ts index 0512a4ee2..bdc50272e 100644 --- a/mem0-ts/src/oss/tests/tsup-externals.test.ts +++ b/mem0-ts/src/oss/tests/tsup-externals.test.ts @@ -47,4 +47,11 @@ describe("tsup.config.ts externals", () => { it("should have peerDependencies defined in package.json", () => { expect(peerDeps.length).toBeGreaterThan(0); }); + + it("should not list any dependency twice", () => { + const duplicates = externalDeps.filter( + (dep, i) => externalDeps.indexOf(dep) !== i, + ); + expect(duplicates).toEqual([]); + }); }); diff --git a/mem0-ts/src/oss/tests/valkey.test.ts b/mem0-ts/src/oss/tests/valkey.test.ts index fe8b5530c..c40471122 100644 --- a/mem0-ts/src/oss/tests/valkey.test.ts +++ b/mem0-ts/src/oss/tests/valkey.test.ts @@ -166,7 +166,7 @@ describe("Valkey – mocked iovalkey client", () => { const result = await store.get("mem-1"); expect(result?.id).toBe("mem-1"); expect(result?.payload.data).toBe("hello valkey"); - expect(result?.payload.userId).toBe("alice"); + expect(result?.payload.user_id).toBe("alice"); // created_at is persisted as unix seconds and rendered back to its ISO instant. expect(result?.payload.createdAt).toBe("2024-01-01T00:00:00.000Z"); }); @@ -244,6 +244,91 @@ describe("Valkey – mocked iovalkey client", () => { expect(searchCall[2]).toContain("@user_id:{a\\|b\\ c}"); }); + it("get returns entity ids as snake_case", async () => { + const store = new ValkeyDB({ + collectionName: "test", + embeddingModelDims: 4, + valkeyUrl: "valkey://localhost:6379", + }); + await store.initialize(); + + await store.insert( + [[0.1, 0.2, 0.3, 0.4]], + ["mem-ids"], + [ + { + data: "likes tea", + user_id: "user-1", + agent_id: "agent-1", + run_id: "run-1", + }, + ], + ); + + const result = await store.get("mem-ids"); + expect(result?.payload.user_id).toBe("user-1"); + expect(result?.payload.agent_id).toBe("agent-1"); + expect(result?.payload.run_id).toBe("run-1"); + expect(result?.payload).not.toHaveProperty("userId"); + expect(result?.payload).not.toHaveProperty("agentId"); + expect(result?.payload).not.toHaveProperty("runId"); + }); + + it("search and list return entity ids as snake_case", async () => { + const store = new ValkeyDB({ + collectionName: "test", + embeddingModelDims: 4, + valkeyUrl: "valkey://localhost:6379", + }); + await store.initialize(); + + const iovalkey = require("iovalkey"); + const mockClient = iovalkey.__mockClient; + mockClient.call.mockImplementation(async (...args: any[]) => { + if (args[0] === "FT.SEARCH") { + return [ + 1, + "mem0:test:mem-search", + [ + "memory_id", + "mem-search", + "hash", + "h1", + "memory", + "likes tea", + "created_at", + "1700000000", + "agent_id", + "agent-1", + "run_id", + "run-1", + "user_id", + "user-1", + "metadata", + "{}", + "vector_score", + "0.1", + ], + ]; + } + return "OK"; + }); + + const results = await store.search([0.1, 0.2, 0.3, 0.4], 5); + expect(results[0].payload.user_id).toBe("user-1"); + expect(results[0].payload.agent_id).toBe("agent-1"); + expect(results[0].payload.run_id).toBe("run-1"); + expect(results[0].payload).not.toHaveProperty("userId"); + expect(results[0].payload).not.toHaveProperty("agentId"); + expect(results[0].payload).not.toHaveProperty("runId"); + + const [listed] = await store.list(); + expect(listed[0].payload.user_id).toBe("user-1"); + expect(listed[0].payload.agent_id).toBe("agent-1"); + expect(listed[0].payload.run_id).toBe("run-1"); + expect(listed[0].payload).not.toHaveProperty("userId"); + }); + it("does not raise an unhandled rejection when initialization fails", async () => { const iovalkey = require("iovalkey"); iovalkey.__mockClient.call.mockImplementationOnce(async () => { diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index b923760a7..d294a5124 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -531,34 +531,43 @@ describe("Redis – backward compat with mocked client", () => { // ─────────────────────────────────────────────────────────────────────────── describe("Supabase – backward compat with mocked client", () => { let SupabaseDB: any; + let mockClient: any; + let mockTableApi: any; + let mockSelectBuilder: any; beforeEach(() => { jest.resetModules(); - jest.doMock("@supabase/supabase-js", () => { - const mockClient = { - from: jest.fn().mockReturnValue({ - insert: jest.fn().mockReturnValue({ - select: jest.fn().mockReturnValue({ error: null }), - }), - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ data: [], error: null }), - }), - delete: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ error: null }), - }), - update: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ error: null }), - }), - upsert: jest.fn().mockReturnValue({ error: null }), - }), - rpc: jest.fn().mockResolvedValue({ data: [], error: null }), - }; - return { - createClient: jest.fn().mockReturnValue(mockClient), - __mockClient: mockClient, - }; - }); + mockSelectBuilder = { + limit: jest.fn().mockResolvedValue({ data: [{}], error: null }), + range: jest.fn().mockResolvedValue({ data: [], error: null, count: 0 }), + maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), + }; + mockSelectBuilder.eq = jest.fn().mockReturnValue(mockSelectBuilder); + mockSelectBuilder.order = jest.fn().mockReturnValue(mockSelectBuilder); + + mockTableApi = { + select: jest.fn().mockReturnValue(mockSelectBuilder), + insert: jest.fn().mockReturnValue({ error: null }), + delete: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ error: null }), + neq: jest.fn().mockReturnValue({ error: null }), + }), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ error: null }), + }), + upsert: jest.fn().mockReturnValue({ error: null }), + }; + + mockClient = { + from: jest.fn().mockReturnValue(mockTableApi), + rpc: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + + jest.doMock("@supabase/supabase-js", () => ({ + createClient: jest.fn().mockReturnValue(mockClient), + __mockClient: mockClient, + })); SupabaseDB = require("../src/vector_stores/supabase").SupabaseDB; }); @@ -598,7 +607,86 @@ describe("Supabase – backward compat with mocked client", () => { const p1 = store.initialize(); const p2 = store.initialize(); await Promise.all([p1, p2]); - // No crash = idempotent (Supabase init runs test insert only once) + // No crash = idempotent (Supabase init runs a read-only probe query only once) + }); + + it("_doInitialize probes with a non-destructive select, not an insert", async () => { + const store = new SupabaseDB({ + supabaseUrl: "https://example.supabase.co", + supabaseKey: "fake-key", + tableName: "memories", + collectionName: "test", + }); + + await store.initialize(); + + expect(mockTableApi.insert).not.toHaveBeenCalled(); + expect(mockTableApi.select).toHaveBeenCalledWith("embedding"); + expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1); + }); + + it("list() paginates via .range() across multiple pages when count exceeds page size", async () => { + const totalRows = 1500; + const rangeMock = jest + .fn() + .mockImplementation((from: number, to: number) => { + const remaining = totalRows - from; + const rowCount = Math.max(0, Math.min(to - from + 1, remaining)); + const data = Array.from({ length: rowCount }, (_, i) => ({ + id: `id-${from + i}`, + metadata: { index: from + i }, + })); + return Promise.resolve({ data, error: null, count: totalRows }); + }); + + const listBuilder: any = { + limit: jest.fn().mockResolvedValue({ data: [{}], error: null }), + range: rangeMock, + }; + listBuilder.eq = jest.fn().mockReturnValue(listBuilder); + listBuilder.order = jest.fn().mockReturnValue(listBuilder); + mockTableApi.select.mockReturnValue(listBuilder); + + const store = new SupabaseDB({ + supabaseUrl: "https://example.supabase.co", + supabaseKey: "fake-key", + tableName: "memories", + collectionName: "test", + }); + + const [results, count] = await store.list(undefined, totalRows); + + expect(count).toBe(totalRows); + expect(results).toHaveLength(totalRows); + expect(rangeMock.mock.calls.length).toBeGreaterThan(1); + expect(listBuilder.order).toHaveBeenCalledWith("id", { ascending: true }); + expect(new Set(results.map((r) => r.id)).size).toBe(totalRows); + }); + + it("search() warns when results are truncated at the PostgREST row cap", async () => { + const cappedResults = Array.from({ length: 1000 }, (_, i) => ({ + id: `id-${i}`, + similarity: 0.9, + metadata: {}, + })); + + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const store = new SupabaseDB({ + supabaseUrl: "https://example.supabase.co", + supabaseKey: "fake-key", + tableName: "memories", + collectionName: "test", + }); + mockClient.rpc.mockResolvedValue({ data: cappedResults, error: null }); + + const results = await store.search([0.1, 0.2, 0.3], 5000); + + expect(results).toHaveLength(1000); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("5000")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("1000")); + + warnSpy.mockRestore(); }); it("constructor does not emit an unhandled rejection when init fails", async () => { @@ -606,14 +694,15 @@ describe("Supabase – backward compat with mocked client", () => { jest.doMock("@supabase/supabase-js", () => { const failing = { from: jest.fn().mockReturnValue({ - insert: jest.fn().mockReturnValue({ - select: jest.fn().mockResolvedValue({ - error: { code: "42P01", message: "no table" }, + select: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue({ + data: null, + error: { + code: "42501", + message: "permission denied for table memories", + }, }), }), - delete: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ error: null }), - }), }), }; return { createClient: jest.fn().mockReturnValue(failing) }; @@ -719,6 +808,1511 @@ describe("AzureAISearch – backward compat with mocked client", () => { }); }); +// ─────────────────────────────────────────────────────────────────────────── +// 6. Databricks β€” mock SQL + REST clients, test interface + idempotent init +// ─────────────────────────────────────────────────────────────────────────── +describe("Databricks – backward compat with mocked clients", () => { + let DatabricksVectorStore: any; + + beforeEach(() => { + jest.resetModules(); + + jest.doMock("@databricks/sql", () => { + let migrationUserId: string | undefined; + + const executeStatement = jest + .fn() + .mockImplementation(async (sql: string) => { + const normalized = sql.replace(/\s+/g, " ").trim(); + + if ( + normalized.startsWith("CREATE SCHEMA IF NOT EXISTS") || + normalized.startsWith("CREATE TABLE IF NOT EXISTS") || + normalized.startsWith("ALTER TABLE main.default.memories") + ) { + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith( + "SELECT user_id FROM main.default.memory_migrations LIMIT 1", + ) + ) { + return { + fetchAll: jest + .fn() + .mockResolvedValue( + migrationUserId ? [{ user_id: migrationUserId }] : [], + ), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith("DELETE FROM main.default.memory_migrations") + ) { + migrationUserId = undefined; + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith( + "INSERT INTO main.default.memory_migrations (user_id)", + ) + ) { + const match = normalized.match(/VALUES \('([^']*)'\)/); + migrationUserId = match ? match[1] : undefined; + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + }); + + const session = { + executeStatement, + close: jest.fn().mockResolvedValue(undefined), + }; + const client = { + connect: jest.fn().mockResolvedValue(undefined), + openSession: jest.fn().mockResolvedValue(session), + close: jest.fn().mockResolvedValue(undefined), + }; + + client.connect.mockResolvedValue(client); + + return { + DBSQLClient: jest.fn().mockImplementation(() => client), + __mockClient: client, + __mockSession: session, + }; + }); + + jest.doMock("axios", () => { + let endpointExists = false; + let endpointPendingReadyResponses = 0; + let indexExists = false; + let indexPendingReadyResponses = 0; + let syncPendingReadyResponses = 0; + let indexNeverReady = false; + let pagedQueryResponses: any[] = []; + + const defaultQueryResponse = { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.98], + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.75], + ], + }, + }; + + const httpClient = { + get: jest.fn().mockImplementation(async (url: string) => { + if (url === "/endpoints/mem0_vector_search") { + if (!endpointExists) { + const error: any = new Error("missing endpoint"); + error.response = { status: 404 }; + throw error; + } + if (endpointPendingReadyResponses > 0) { + endpointPendingReadyResponses -= 1; + return { + data: { + name: "mem0_vector_search", + endpoint_status: { state: "PROVISIONING" }, + }, + }; + } + return { + data: { + name: "mem0_vector_search", + endpoint_status: { state: "ONLINE" }, + }, + }; + } + + if (url === "/indexes/main.default.memories") { + if (!indexExists) { + const error: any = new Error("missing index"); + error.response = { status: 404 }; + throw error; + } + if (indexNeverReady) { + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + if (indexPendingReadyResponses > 0) { + indexPendingReadyResponses -= 1; + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + if (syncPendingReadyResponses > 0) { + syncPendingReadyResponses -= 1; + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + return { + data: { + name: "main.default.memories", + status: { ready: true }, + }, + }; + } + + throw new Error(`Unexpected Databricks GET ${url}`); + }), + post: jest.fn().mockImplementation(async (url: string, body: any) => { + if (url === "/endpoints") { + endpointExists = true; + endpointPendingReadyResponses = 1; + return { data: { endpoint: body.name } }; + } + + if (url === "/indexes") { + indexExists = true; + indexPendingReadyResponses = 1; + return { data: { index: body.name } }; + } + + if (url === "/indexes/main.default.memories/sync") { + syncPendingReadyResponses = 1; + return { data: { status: "queued" } }; + } + + if (url === "/indexes/main.default.memories/query") { + return { + data: + pagedQueryResponses.shift() ?? + JSON.parse(JSON.stringify(defaultQueryResponse)), + }; + } + + if (url === "/indexes/main.default.memories/query-next-page") { + return { + data: + pagedQueryResponses.shift() ?? + JSON.parse(JSON.stringify(defaultQueryResponse)), + }; + } + + throw new Error(`Unexpected Databricks POST ${url}`); + }), + delete: jest.fn().mockImplementation(async (url: string) => { + if (url === "/indexes/main.default.memories") { + indexExists = false; + return { data: {} }; + } + throw new Error(`Unexpected Databricks DELETE ${url}`); + }), + }; + + const create = jest.fn().mockReturnValue(httpClient); + const authPost = jest.fn().mockResolvedValue({ + data: { access_token: "oauth-token", expires_in: 3600 }, + }); + + return { + __esModule: true, + default: { create, post: authPost }, + create, + post: authPost, + __mockHttpClient: httpClient, + __setPagedQueryResponses: (responses: any[]) => { + pagedQueryResponses = responses.map((response) => + JSON.parse(JSON.stringify(response)), + ); + }, + __setIndexNeverReady: (value: boolean) => { + indexNeverReady = value; + }, + __mockAuthPost: authPost, + }; + }); + + DatabricksVectorStore = + require("../src/vector_stores/databricks").DatabricksVectorStore; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("implements full VectorStore interface", () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + expect(typeof store.insert).toBe("function"); + expect(typeof store.search).toBe("function"); + expect(typeof store.get).toBe("function"); + expect(typeof store.update).toBe("function"); + expect(typeof store.delete).toBe("function"); + expect(typeof store.deleteCol).toBe("function"); + expect(typeof store.list).toBe("function"); + expect(typeof store.getUserId).toBe("function"); + expect(typeof store.setUserId).toBe("function"); + expect(typeof store.initialize).toBe("function"); + }); + + it("initialize() is idempotent (same promise returned)", async () => { + const databricksSql = require("@databricks/sql"); + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const p1 = store.initialize(); + const p2 = store.initialize(); + const p3 = store.initialize(); + await Promise.all([p1, p2, p3]); + + const clientInstance = databricksSql.DBSQLClient.mock.results[0].value; + const session = databricksSql.__mockSession; + const httpClient = axiosModule.__mockHttpClient; + expect(clientInstance.connect).toHaveBeenCalledTimes(1); + expect(clientInstance.openSession).toHaveBeenCalledTimes(1); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/endpoints/mem0_vector_search", + ).length, + ).toBeGreaterThan(1); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories", + ).length, + ).toBeGreaterThan(1); + expect(httpClient.post).toHaveBeenCalledWith("/endpoints", { + name: "mem0_vector_search", + endpoint_type: "STANDARD", + }); + expect(httpClient.post).toHaveBeenCalledWith("/indexes", { + name: "main.default.memories", + endpoint_name: "mem0_vector_search", + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "main.default.memories", + pipeline_type: "TRIGGERED", + columns_to_sync: [ + "memory_id", + "payload", + "text_lemmatized", + "user_id", + "agent_id", + "run_id", + ], + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: 3, + }, + ], + }, + }); + expect( + session.executeStatement.mock.calls.some( + ([sql]: [string]) => + sql.includes("ALTER TABLE main.default.memories") && + sql.includes("delta.enableChangeDataFeed"), + ), + ).toBe(true); + }); + + it("retries loading @databricks/sql after a failed dynamic import instead of caching the rejection", async () => { + let shouldFail = true; + jest.doMock("@databricks/sql", () => { + if (shouldFail) { + throw new Error("Cannot find module '@databricks/sql'"); + } + const session = { + executeStatement: jest.fn().mockResolvedValue({ + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }), + close: jest.fn().mockResolvedValue(undefined), + }; + const client = { + connect: jest.fn().mockResolvedValue(undefined), + openSession: jest.fn().mockResolvedValue(session), + close: jest.fn().mockResolvedValue(undefined), + }; + client.connect.mockResolvedValue(client); + return { DBSQLClient: jest.fn().mockImplementation(() => client) }; + }); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await expect((store as any).getSqlModule()).rejects.toThrow( + "The '@databricks/sql' package is required to use the Databricks vector store. " + + "Install it with: npm install @databricks/sql (original error: Cannot find module '@databricks/sql')", + ); + + shouldFail = false; + const sqlModule = await (store as any).getSqlModule(); + expect(typeof sqlModule.DBSQLClient).toBe("function"); + }); + + it("executeSql(): reconnects on the next call after a session failure instead of reusing a dead session", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + const openSessionSpy = jest.spyOn(store as any, "openSession"); + + session.executeStatement.mockRejectedValueOnce( + new Error("session expired"), + ); + await expect((store as any).executeSql("SELECT 1")).rejects.toThrow( + "session expired", + ); + expect(openSessionSpy).not.toHaveBeenCalled(); + + await (store as any).executeSql("SELECT 1"); + expect(openSessionSpy).toHaveBeenCalledTimes(1); + }); + + it("getSession(): retries after a failed connection attempt instead of caching the rejection", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + // Simulate the warehouse dropping so the next getSession() must reconnect from scratch + // (this clears both the cached session and any in-flight session promise). + (store as any).resetSession(); + + let attempts = 0; + jest.spyOn(store as any, "openSession").mockImplementation(async () => { + attempts++; + if (attempts === 1) { + throw new Error("warehouse cold start"); + } + return databricksSql.__mockSession; + }); + + // First connection attempt fails (e.g. a cold/auto-suspended warehouse at startup)... + await expect((store as any).getSession()).rejects.toThrow( + "warehouse cold start", + ); + + // ...the next call must open a fresh session rather than replay the rejected promise. + // Without getSession()'s catch-and-reset, openSession would never be called a second time. + const session = await (store as any).getSession(); + expect(session).toBe(databricksSql.__mockSession); + expect(attempts).toBe(2); + }); + + it("supports service-principal credentials for REST and SQL setup", async () => { + const axiosModule = require("axios"); + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + clientId: "client-id", + clientSecret: "client-secret", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + await store.insert([[1, 0, 0]], ["id-1"], [{ user_id: "u1" }]); + await store.search([1, 0, 0], 1); + + expect(axiosModule.__mockAuthPost).toHaveBeenCalledWith( + "https://workspace.databricks.com/oidc/v1/token", + expect.any(URLSearchParams), + expect.objectContaining({ + auth: { + username: "client-id", + password: "client-secret", + }, + }), + ); + const authDetails = axiosModule.__mockAuthPost.mock.calls.map( + ([, params]: [string, URLSearchParams]) => + params.get("authorization_details"), + ); + const readDetails = JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: "main.default.memories", + operation: "ReadVectorIndex", + }, + ]); + const writeDetails = JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: "main.default.memories", + operation: "WriteVectorIndex", + }, + ]); + + expect(authDetails).toContain(null); + expect(authDetails).toContain(readDetails); + expect(authDetails).toContain(writeDetails); + expect(databricksSql.__mockClient.connect).toHaveBeenCalledWith( + expect.objectContaining({ + authType: "databricks-oauth", + oauthClientId: "client-id", + oauthClientSecret: "client-secret", + }), + ); + }); + + it("shapes Databricks SQL writes, syncs triggered indexes, and normalizes search results", async () => { + const databricksSql = require("@databricks/sql"); + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + // Non-zero: a 0ms poll skips the sleep entirely, so a sync would drain within the + // microtask queue and never overlap the next write. 1ms models a real readiness wait. + syncPollIntervalMs: 1, + }); + + await store.initialize(); + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ user_id: "u1", topic: "alpha" }], + ); + + const session = databricksSql.__mockSession; + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO main.default.memories"), + ); + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("'id-1'"), + ); + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("array(1, 0, 0)"), + ); + await store.update("id-1", [0, 1, 0], { user_id: "u1", topic: "beta" }); + await store.delete("id-1"); + + const results = await store.search([1, 0, 0], 5, { + user_id: "u1", + topic: "alpha", + }); + const httpClient = axiosModule.__mockHttpClient; + const syncPosts = httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ); + // insert/update/delete each request a sync, but writes that land while one is already + // in flight coalesce into it -- so three writes never cost three pipeline runs. + expect(syncPosts.length).toBeGreaterThanOrEqual(1); + expect(syncPosts.length).toBeLessThan(3); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories", + ).length, + ).toBeGreaterThan(4); + expect(httpClient.post).toHaveBeenCalledWith( + "/indexes/main.default.memories/query", + expect.objectContaining({ + columns: ["memory_id", "payload"], + filters_json: JSON.stringify({ user_id: "u1" }), + query_type: "ANN", + query_vector: [1, 0, 0], + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("keeps metadata-only filters local for standard endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { topic: "alpha" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + columns: ["memory_id", "payload"], + query_type: "ANN", + query_vector: [1, 0, 0], + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("applies multiple local metadata operators on the same field", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + [ + "id-1", + JSON.stringify({ user_id: "u1", importance: 1.0, topic: "high" }), + 0.99, + ], + [ + "id-2", + JSON.stringify({ + user_id: "u1", + importance: 0.75, + topic: "within-range", + }), + 0.98, + ], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { + importance: { gte: 0.5, lte: 0.9 }, + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-2", + payload: { user_id: "u1", importance: 0.75, topic: "within-range" }, + score: 0.98, + }, + ]); + }); + + it("paginates when local-only filters need matches beyond the first page", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.99], + ], + next_page_token: "page-2", + }, + }, + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.98], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 1, { topic: "alpha" }); + expect( + axiosModule.__mockHttpClient.post.mock.calls.some( + ([url]: [string]) => + url === "/indexes/main.default.memories/query-next-page", + ), + ).toBe(true); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("paginates across Databricks result pages when topK exceeds the first page", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.99], + ], + next_page_token: "page-2", + }, + }, + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.98], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 1001); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + num_results: 1001, + }), + ); + expect( + axiosModule.__mockHttpClient.post.mock.calls.some( + ([url]: [string]) => + url === "/indexes/main.default.memories/query-next-page", + ), + ).toBe(true); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.99, + }, + { + id: "id-2", + payload: { user_id: "u2", topic: "beta" }, + score: 0.98, + }, + ]); + }); + + it("caps full-text local-fallback requests at Databricks' 200-result limit", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.keywordSearch("alpha", 5, { topic: "alpha" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + num_results: 200, + query_type: "FULL_TEXT", + query_text: "alpha", + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("uses SQL-like filters for storage-optimized endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + const query = new Array(16).fill(0); + query[0] = 1; + + const results = await store.search(query, 5, { + user_id: "u1", + topic: "alpha", + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + columns: ["memory_id", "payload"], + filters: "user_id = 'u1'", + query_type: "ANN", + query_vector: query, + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("translates storage-optimized array shorthand filters into IN clauses", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + const query = new Array(16).fill(0); + query[0] = 1; + + await store.search(query, 5, { + user_id: ["u1", "u2"], + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + filters: "user_id IN ('u1', 'u2')", + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + }); + + it("falls back to local filtering for logical operators", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const orResults = await store.search([1, 0, 0], 5, { + $or: [{ user_id: "u1" }, { topic: "beta" }], + }); + const notResults = await store.search([1, 0, 0], 5, { + $not: [{ user_id: "u2" }], + }); + const queryCalls = axiosModule.__mockHttpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCalls).toHaveLength(2); + expect(queryCalls[0][1]).not.toHaveProperty("filters_json"); + expect(queryCalls[1][1]).not.toHaveProperty("filters_json"); + expect(orResults.map((result: any) => result.id)).toEqual(["id-1", "id-2"]); + expect(notResults.map((result: any) => result.id)).toEqual(["id-1"]); + }); + + it("keeps memory_id filters aligned between Databricks and local fallback", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { memory_id: "id-1" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + filters_json: JSON.stringify({ memory_id: "id-1" }), + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("omits columns_to_sync for storage-optimized endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + + await store.initialize(); + + expect(axiosModule.__mockHttpClient.post).toHaveBeenCalledWith("/indexes", { + name: "main.default.memories", + endpoint_name: "mem0_vector_search", + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "main.default.memories", + pipeline_type: "TRIGGERED", + columns_to_sync: undefined, + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: 16, + }, + ], + }, + }); + }); + + it("rejects invalid storage-optimized embedding dimensions", () => { + expect( + () => + new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + endpointType: "STORAGE_OPTIMIZED", + }), + ).toThrow("require dimensions divisible by 16"); + }); + + it("rejects HYBRID vector search without query text", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + queryType: "HYBRID", + syncPollIntervalMs: 0, + }); + + await expect(store.search([1, 0, 0], 5)).rejects.toThrow( + "Databricks HYBRID search requires query_text", + ); + expect( + axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ), + ).toBeUndefined(); + }); + + it("roundtrips migration user ids", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + }); + + await store.setUserId("custom-user"); + expect(await store.getUserId()).toBe("custom-user"); + }); + + it("escapes backslashes and quotes in SQL string literals (injection-safe)", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + // The id/user_id carry a single quote and a backslash. Databricks SQL treats + // "\" as an escape char, so without doubling it, "\'" would break out of the + // literal (injection) and any backslash would be dropped on write. + await store.insert([[1, 0, 0]], ["a'b\\c"], [{ user_id: "x'y\\z" }]); + + const session = databricksSql.__mockSession; + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0]) + .find((sql: string) => sql.includes("INSERT INTO main.default.memories")); + + expect(insertSql).toBeDefined(); + // Backslash doubled AND quote doubled: 'a''b\\c' + expect(insertSql).toContain("'a''b\\\\c'"); + // The un-escaped single-backslash form (the breakout vector) must be absent. + expect(insertSql).not.toContain("'a''b\\c'"); + }); + + it("pushes session filters into the SQL WHERE clause when listing", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.list({ user_id: "u1" }, 10); + + const listSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + + expect(listSql).toBeDefined(); + expect(listSql).toContain("WHERE user_id = 'u1'"); + }); + + it("list(): pushes null-safe ne/nin predicates into the WHERE clause", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + + session.executeStatement.mockClear(); + await store.list({ agent_id: { ne: "a2" } }, 10); + const neSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(neSql).toBeDefined(); + expect(neSql).toContain("(agent_id IS NULL OR agent_id != 'a2')"); + expect(neSql).not.toContain("WHERE agent_id != 'a2'"); + + session.executeStatement.mockClear(); + await store.list({ agent_id: { nin: ["a2", "a3"] } }, 10); + const ninSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(ninSql).toBeDefined(); + expect(ninSql).toContain( + "(agent_id IS NULL OR agent_id NOT IN ('a2', 'a3'))", + ); + expect(ninSql).not.toContain("WHERE agent_id NOT IN ('a2', 'a3')"); + }); + + it("list(): bounds the SQL scan with LIMIT topK when the filter fully pushes down", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + + session.executeStatement.mockClear(); + await store.list({ user_id: "u1" }); + const defaultTopKSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(defaultTopKSql).toContain("LIMIT 100"); + + session.executeStatement.mockClear(); + await store.list({ user_id: "u1" }, 7); + const customTopKSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(customTopKSql).toContain("LIMIT 7"); + }); + + it("list(): rejects a topK that isn't a positive integer before it reaches SQL", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + for (const badTopK of [ + 0, + -1, + 2.5, + NaN, + "10; DROP TABLE t" as unknown as number, + ]) { + await expect(store.list({ user_id: "u1" }, badTopK)).rejects.toThrow( + `Databricks vector store: topK must be a positive integer, got ${badTopK}`, + ); + } + }); + + it("list(): falls back to the 10k scan ceiling (no WHERE) when $or can't be pushed down", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.list({ $or: [{ user_id: "u1" }, { topic: "beta" }] }); + + const listSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + + expect(listSql).toBeDefined(); + expect(listSql).not.toContain("WHERE"); + expect(listSql).toContain("LIMIT 10000"); + }); + + it("provisions a dedicated text_lemmatized column for BM25 keyword search", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + + const session = databricksSql.__mockSession; + const createSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("CREATE TABLE IF NOT EXISTS main.default.memories"), + ); + + expect(createSql).toBeDefined(); + expect(createSql).toContain("text_lemmatized STRING"); + }); + + it("writes lemmatized text on insert and refreshes it on update", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ data: "running shoes", textLemmatized: "run shoe" }], + ); + + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("INSERT INTO")); + + expect(insertSql).toContain( + "(memory_id, embedding, payload, text_lemmatized, user_id, agent_id, run_id)", + ); + expect(insertSql).toContain("'run shoe'"); + + session.executeStatement.mockClear(); + await store.update("id-1", [0, 1, 0], { + data: "walking boots", + textLemmatized: "walk boot", + }); + + const updateSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("UPDATE")); + + expect(updateSql).toContain("text_lemmatized = 'walk boot'"); + }); + + it("falls back to payload.data when textLemmatized is absent", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.insert([[1, 0, 0]], ["id-1"], [{ data: "plain text" }]); + + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("INSERT INTO")); + + expect(insertSql).toContain("'plain text'"); + }); + + it("coalesces triggered-index syncs across sequential deletes", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + const httpClient = axiosModule.__mockHttpClient; + httpClient.post.mockClear(); + + // deleteAll() awaits each deleteMemory in turn -- exactly this shape. + for (let index = 0; index < 8; index += 1) { + await store.delete(`id-${index}`); + } + await store.search([1, 0, 0], 5); + + const syncPosts = httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ); + + expect(syncPosts.length).toBeGreaterThanOrEqual(1); + expect(syncPosts.length).toBeLessThan(8); + }); + + it("starts one sync for concurrent writers, and still drains the second write", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + const httpClient = axiosModule.__mockHttpClient; + httpClient.post.mockClear(); + + const syncPosts = () => + httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ).length; + + await Promise.all([ + store.insert([[1, 0, 0]], ["id-a"], [{ data: "alpha" }]), + store.insert([[0, 1, 0]], ["id-b"], [{ data: "beta" }]), + ]); + + // Both writers ran before any sync settled. Exactly one drain may own the pipeline -- + // if both had raced past the guard they would each have POSTed their own sync. + expect(syncPosts()).toBe(1); + + // The second writer's rows are not lost: the live drain re-checks the queue and syncs again. + await store.search([1, 0, 0], 5); + expect(syncPosts()).toBe(2); + }); + + it("does not block writes on index readiness, and surfaces sync failures on the next read", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + syncTimeoutMs: 10, + }); + + await store.initialize(); + // Every readiness poll from here on reports not-ready, so the sync can only time out. + axiosModule.__setIndexNeverReady(true); + + // The write still resolves: it schedules the sync, it does not wait for it. + await expect( + store.insert([[1, 0, 0]], ["id-1"], [{ data: "alpha" }]), + ).resolves.toBeUndefined(); + + // The reader is what pays -- and inherits the deferred failure. + await expect(store.search([1, 0, 0], 5)).rejects.toThrow(); + + // The error is consumed once, not latched forever. + axiosModule.__setIndexNeverReady(false); + await expect(store.search([1, 0, 0], 5)).resolves.toBeDefined(); + }); + + it("update() does not null out session columns on a partial payload", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ user_id: "u1", topic: "alpha" }], + ); + // Partial payload -- omits user_id/agent_id/run_id, the way a caller updating just one + // field would. Mirrors Python's `excluded_keys`: these must survive untouched. + await store.update("id-1", [0, 1, 0], { topic: "beta" }); + + const session = databricksSql.__mockSession; + const updateSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0]) + .find((sql: string) => sql.includes("UPDATE")); + + expect(updateSql).toBeDefined(); + expect(updateSql).not.toMatch(/\buser_id\s*=/); + expect(updateSql).not.toMatch(/\bagent_id\s*=/); + expect(updateSql).not.toMatch(/\brun_id\s*=/); + expect(updateSql).toMatch(/embedding\s*=/); + expect(updateSql).toMatch(/payload\s*=/); + }); + + it("list() rejects an unsafe-integer topK that would slip past Number.isInteger", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + // 1e21 is Number.isInteger()-true but not representable exactly, and stringifies as + // "1e+21" -- meaningless (and unguarded) as a SQL LIMIT. + await expect(store.list({ user_id: "u1" }, 1e21)).rejects.toThrow( + /topK must be a positive integer/, + ); + // Sanity: a normal topK is still accepted (does not throw). + await expect(store.list({ user_id: "u1" }, 10)).resolves.toBeDefined(); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── // Cassandra β€” mock client, test interface + idempotent init // ─────────────────────────────────────────────────────────────────────────── @@ -1530,6 +3124,1421 @@ describe("S3 Vectors – backward compat with mocked client", () => { }); }); +// 6. Neptune Analytics β€” mock NeptuneGraph client, test interface + init +// ─────────────────────────────────────────────────────────────────────────── +describe("Neptune Analytics – backward compat with mocked client", () => { + // `@aws-sdk/client-neptune-graph` is an optional peer loaded via dynamic `import()` + // (see neptune_analytics.ts). Register a default virtual mock so every test that + // exercises `executeQuery()` -- even ones that inject their own `config.client` -- + // can resolve `sdk.ExecuteQueryCommand` without the real package being installed. + // Tests that care about the AWS client constructor itself override this locally + // with their own `jest.doMock(..., { virtual: true })` before requiring the module. + beforeEach(() => { + jest.resetModules(); + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) {} + + async send() { + throw new Error( + "Neptune Analytics tests must inject a mock `client`; the default virtual SDK has no server to talk to.", + ); + } + }, + }), + { virtual: true }, + ); + }); + + afterEach(() => { + jest.resetModules(); + }); + + function createMockResponse(body: Record) { + return { + payload: { + transformToString: jest.fn().mockResolvedValue(JSON.stringify(body)), + }, + }; + } + + function createMockNeptuneClient(options?: { + failInsertUpsert?: boolean; + throwInsertUpsert?: boolean; + failUpdateUpsert?: boolean; + failPayloadWrite?: boolean; + }) { + const nodes = new Map< + string, + { + embedding?: number[]; + labels: string[]; + properties: Record; + } + >(); + let storedUserId: string | undefined; + + const getPropertyValue = ( + node: { labels: string[]; properties: Record }, + property: string, + ) => { + if (property === "~label") { + return node.labels; + } + + return node.properties[property]; + }; + + const matchesFilter = ( + node: { labels: string[]; properties: Record }, + filter: any, + ): boolean => { + if (!filter) { + return true; + } + + if (Array.isArray(filter.andAll)) { + return filter.andAll.every((entry: any) => matchesFilter(node, entry)); + } + + if (Array.isArray(filter.orAll)) { + return filter.orAll.some((entry: any) => matchesFilter(node, entry)); + } + + const propertyMatcher = ( + property: string, + predicate: (value: any) => boolean, + ) => { + const value = getPropertyValue(node, property); + if (property === "~label") { + return ( + Array.isArray(value) && value.some((label) => predicate(label)) + ); + } + + return predicate(value); + }; + + if (filter.equals) { + return propertyMatcher( + filter.equals.property, + (value) => value === filter.equals.value, + ); + } + + if (filter.notEquals) { + return propertyMatcher( + filter.notEquals.property, + (value) => value !== filter.notEquals.value, + ); + } + + if (filter.greaterThan) { + return propertyMatcher( + filter.greaterThan.property, + (value) => + typeof value === "number" && value > filter.greaterThan.value, + ); + } + + if (filter.greaterThanOrEquals) { + return propertyMatcher( + filter.greaterThanOrEquals.property, + (value) => + typeof value === "number" && + value >= filter.greaterThanOrEquals.value, + ); + } + + if (filter.lessThan) { + return propertyMatcher( + filter.lessThan.property, + (value) => typeof value === "number" && value < filter.lessThan.value, + ); + } + + if (filter.lessThanOrEquals) { + return propertyMatcher( + filter.lessThanOrEquals.property, + (value) => + typeof value === "number" && value <= filter.lessThanOrEquals.value, + ); + } + + if (filter.in) { + return propertyMatcher(filter.in.property, (value) => + filter.in.value.includes(value), + ); + } + + if (filter.notIn) { + return propertyMatcher( + filter.notIn.property, + (value) => !filter.notIn.value.includes(value), + ); + } + + if (filter.stringContains) { + return propertyMatcher( + filter.stringContains.property, + (value) => + typeof value === "string" && + value.includes(filter.stringContains.value), + ); + } + + if (filter.startsWith) { + return propertyMatcher( + filter.startsWith.property, + (value) => + typeof value === "string" && + value.startsWith(filter.startsWith.value), + ); + } + + return false; + }; + + const toNodeRecord = ( + id: string, + node: { labels: string[]; properties: Record }, + ): Record => ({ + "~id": id, + "~labels": [...node.labels], + "~properties": { ...node.properties }, + }); + + const matchesListParameters = ( + properties: Record, + parameters: Record, + ) => + Object.entries(parameters) + .filter(([key]) => key.startsWith("filter_")) + .every(([key, value]) => { + const match = key.match(/^filter_(?:eq_)?(.+)_\d+$/); + if (!match) { + return true; + } + + return properties[match[1]] === value; + }); + + const extractStructuredArgument = ( + queryString: string, + key: string, + ): any => { + const keyIndex = queryString.indexOf(`${key}:`); + if (keyIndex < 0) { + return undefined; + } + + const objectStart = queryString.indexOf("{", keyIndex); + if (objectStart < 0) { + return undefined; + } + + let depth = 0; + for (let index = objectStart; index < queryString.length; index += 1) { + const char = queryString[index]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + const literal = queryString.slice(objectStart, index + 1); + const jsonLiteral = literal.replace( + /([{\[,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, + '$1"$2"$3', + ); + return JSON.parse(jsonLiteral); + } + } + } + + return undefined; + }; + + return { + send: jest.fn().mockImplementation(async (command: any) => { + const queryString = String(command.input.queryString || ""); + const parameters = command.input.parameters || {}; + const collectionLabelMatch = queryString.match(/MERGE \(n:`([^`]+)`/); + const collectionLabel = collectionLabelMatch?.[1] || "MEM0_VECTOR_test"; + + if ( + queryString.includes("UNWIND $nodeIds AS nodeId") && + queryString.includes("RETURN nodeId") + ) { + return createMockResponse({ + results: (parameters.nodeIds || []) + .filter((nodeId: string) => nodes.has(nodeId)) + .map((nodeId: string) => ({ nodeId })), + }); + } + + if ( + queryString.includes("UNWIND $nodeIds AS nodeId") && + queryString.includes("DETACH DELETE n") + ) { + for (const nodeId of parameters.nodeIds || []) { + nodes.delete(nodeId); + } + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("CALL neptune.algo.vectors.upsert") && + Array.isArray(parameters.rows) && + queryString.includes("RETURN success") + ) { + const createsNode = queryString.includes("MERGE"); + const processedRows: any[] = []; + for (const row of parameters.rows) { + const existing = nodes.get(row.node_id); + if (!existing && !createsNode) { + continue; + } + nodes.set(row.node_id, { + embedding: row.embedding, + labels: existing?.labels || [collectionLabel], + properties: existing ? existing.properties : {}, + }); + processedRows.push(row); + } + + if (options?.throwInsertUpsert) { + throw new Error("Neptune upsert rejected"); + } + + return createMockResponse({ + results: processedRows.map(() => ({ + success: !options?.failInsertUpsert, + })), + }); + } + + if ( + queryString.includes("UNWIND $rows AS row") && + queryString.includes("SET n += row.properties") + ) { + if (!options?.failPayloadWrite) { + for (const row of parameters.rows || []) { + const existing = nodes.get(row.node_id); + nodes.set(row.node_id, { + embedding: existing?.embedding, + labels: existing?.labels || [collectionLabel], + properties: existing + ? { ...existing.properties, ...row.properties } + : { ...row.properties }, + }); + } + } + + return createMockResponse({ results: [{ n: {} }] }); + } + + if ( + queryString.includes("CALL neptune.algo.vectors.upsert") && + parameters.vectorId + ) { + if (options?.failUpdateUpsert) { + return createMockResponse({ results: [{ success: false }] }); + } + + const existing = nodes.get(parameters.vectorId); + if (existing) { + nodes.set(parameters.vectorId, { + embedding: parameters.embedding, + labels: existing.labels, + properties: parameters.properties || existing.properties, + }); + } + return createMockResponse({ results: [{ success: true }] }); + } + + if (queryString.includes("topK.byEmbedding")) { + const vertexFilter = extractStructuredArgument( + queryString, + "vertexFilter", + ); + const match = [...nodes.entries()].find(([, node]) => + matchesFilter(node, vertexFilter), + ); + + return createMockResponse({ + results: match + ? [ + { + node: toNodeRecord(match[0], match[1]), + score: 0.25, + }, + ] + : [], + }); + } + + if (queryString.includes("pg_schema")) { + const labels = new Set(); + for (const node of nodes.values()) { + for (const label of node.labels) { + labels.add(label); + } + } + if (storedUserId) { + labels.add("MEM0_VECTOR_memory_migrations"); + } + return createMockResponse({ + results: [{ result: [...labels] }], + }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("RETURN n") && + queryString.includes("LIMIT 1") + ) { + const node = nodes.get(parameters.vectorId); + return createMockResponse({ + results: node + ? [{ n: toNodeRecord(parameters.vectorId, node) }] + : [], + }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("RETURN count(n) AS count") + ) { + const count = [...nodes.values()].filter((node) => + matchesListParameters(node.properties, parameters), + ).length; + + return createMockResponse({ results: [{ count }] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("RETURN n") && + queryString.includes("LIMIT $limit") + ) { + const results = [...nodes.entries()] + .filter(([, node]) => + matchesListParameters(node.properties, parameters), + ) + .slice(0, parameters.limit || 100) + .map(([id, node]) => ({ n: toNodeRecord(id, node) })); + + return createMockResponse({ results }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("SET n = $properties") + ) { + if (options?.failPayloadWrite) { + throw new Error("Neptune property write rejected"); + } + + const existing = nodes.get(parameters.vectorId); + if (existing) { + nodes.set(parameters.vectorId, { + embedding: existing.embedding, + labels: existing.labels, + properties: { ...parameters.properties }, + }); + } + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("DETACH DELETE n") + ) { + nodes.delete(parameters.vectorId); + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("DETACH DELETE n") + ) { + nodes.clear(); + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_memory_migrations`") && + queryString.includes("RETURN n") + ) { + return createMockResponse({ + results: storedUserId + ? [ + { + n: toNodeRecord(parameters.userNodeId, { + labels: ["MEM0_VECTOR_memory_migrations"], + properties: { + user_id: storedUserId, + }, + }), + }, + ] + : [], + }); + } + + if ( + queryString.includes("MERGE (n:`MEM0_VECTOR_memory_migrations`") && + queryString.includes("SET n.user_id = $userId") + ) { + storedUserId = parameters.userId; + return createMockResponse({ results: [] }); + } + + return createMockResponse({ results: [{ success: true }] }); + }), + }; + } + + it("implements full VectorStore interface", () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + expect(typeof store.insert).toBe("function"); + expect(typeof store.search).toBe("function"); + expect(typeof store.get).toBe("function"); + expect(typeof store.update).toBe("function"); + expect(typeof store.delete).toBe("function"); + expect(typeof store.deleteCol).toBe("function"); + expect(typeof store.list).toBe("function"); + expect(typeof store.getUserId).toBe("function"); + expect(typeof store.setUserId).toBe("function"); + expect(typeof store.initialize).toBe("function"); + }); + + it("initialize() is idempotent (same promise returned)", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + const p1 = store.initialize(); + const p2 = store.initialize(); + const p3 = store.initialize(); + + expect(p1).toBe(p2); + expect(p2).toBe(p3); + + await Promise.all([p1, p2, p3]); + expect(mockClient.send).not.toHaveBeenCalled(); + }); + + it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", async () => { + jest.resetModules(); + + const send = jest + .fn() + .mockResolvedValue(createMockResponse({ results: [] })); + const neptuneGraphClient = jest.fn().mockReturnValue({ send }); + + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + }), + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + collectionName: "test", + dimension: 3, + region: "us-east-1", + profile: "dev-profile", + maxAttempts: 3, + }); + await store.initialize(); + + // The client is now constructed lazily on first use, not in the constructor. + await store.search([1, 2, 3], 1); + + expect(neptuneGraphClient).toHaveBeenCalledWith({ + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + maxAttempts: 3, + profile: "dev-profile", + region: "us-east-1", + }); + }); + + it("rejects HTTPS endpoints without an explicit graphIdentifier", () => { + jest.resetModules(); + + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: jest.fn().mockReturnValue({ + send: jest.fn(), + }), + }), + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + expect( + () => + new NeptuneAnalyticsVectorStore({ + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + collectionName: "test", + dimension: 3, + }), + ).toThrow( + "Neptune Analytics HTTPS endpoints require graphIdentifier; pass graphIdentifier separately or use neptune-graph://.", + ); + }); + + it("derives graphIdentifier from a neptune-graph endpoint URI", async () => { + jest.resetModules(); + + const send = jest + .fn() + .mockResolvedValue(createMockResponse({ results: [] })); + const neptuneGraphClient = jest.fn().mockReturnValue({ send }); + + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + }), + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + endpoint: "neptune-graph://g-1234567890", + collectionName: "test", + dimension: 3, + region: "us-east-1", + }); + + await store.search([1, 2, 3], 1); + + expect(neptuneGraphClient).toHaveBeenCalledWith({ + region: "us-east-1", + }); + expect(send).toHaveBeenCalled(); + expect(send.mock.calls[0][0].input.graphIdentifier).toBe("g-1234567890"); + }); + + it("does not construct the Neptune client or load the SDK until the first query", async () => { + jest.resetModules(); + + let clientConstructions = 0; + let sdkLoads = 0; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => { + sdkLoads++; + return { + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) { + clientConstructions++; + } + + async send() { + return createMockResponse({ results: [] }); + } + }, + }; + }, + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + expect(clientConstructions).toBe(0); + expect(sdkLoads).toBe(0); + + await store.search([1, 2, 3], 1); + + expect(sdkLoads).toBe(1); + expect(clientConstructions).toBe(1); + }); + + it("retries constructing the Neptune client after a failed SDK load instead of caching the rejection", async () => { + jest.resetModules(); + + let shouldFail = true; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => { + if (shouldFail) { + throw new Error("Cannot find module '@aws-sdk/client-neptune-graph'"); + } + return { + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) {} + + async send() { + return createMockResponse({ results: [] }); + } + }, + }; + }, + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + // First use fails because the optional SDK can't be loaded... + await expect((store as any).getClient()).rejects.toThrow( + "The '@aws-sdk/client-neptune-graph' package is required", + ); + + // ...but a later call must retry rather than replay the cached rejection. Without + // getClient()'s catch-and-reset this second call would throw the same stale error. + shouldFail = false; + const client = await (store as any).getClient(); + expect(client).toBeDefined(); + expect(typeof client.send).toBe("function"); + }); + + it("shapes Neptune write requests and normalizes search results", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", label: "topic-a", priority: 7, user_id: "u1" }], + ); + + expect(mockClient.send).toHaveBeenCalled(); + const insertCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes("MERGE"), + ); + const vectorCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ), + ); + expect(insertCall).toBeDefined(); + expect(insertCall.input.queryString).toContain("MERGE"); + expect(insertCall.input.queryString).not.toContain("FOREACH"); + expect(insertCall.input.parameters.rows[0].properties.label).toBe( + "topic-a", + ); + expect(insertCall.input.parameters.rows[0].embedding).toEqual([1, 2, 3]); + + expect(vectorCall).toBeDefined(); + expect(vectorCall.input.queryString).toContain( + "CALL neptune.algo.vectors.upsert", + ); + expect(vectorCall.input.queryString).toContain( + "WITH n, row.embedding AS embedding", + ); + expect(vectorCall.input.queryString).not.toContain("MERGE"); + + const results = await store.search([1, 2, 3], 1, { + $or: [{ user_id: "u2" }, { priority: { gte: 5 } }], + data: { contains: "alp" }, + }); + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + id: "id-1", + payload: expect.objectContaining({ + data: "alpha", + label: "topic-a", + user_id: "u1", + }), + score: 0.8, + }); + + const searchCall = + mockClient.send.mock.calls[mockClient.send.mock.calls.length - 1]; + const searchQuery = String(searchCall[0].input.queryString || "").replace( + /\s+/g, + " ", + ); + expect(searchQuery).toContain("topK.byEmbedding"); + expect(searchCall[0].input.parameters).toBeUndefined(); + expect(searchQuery).toContain("topK: 1"); + expect(searchQuery).toContain("embedding: [1, 2, 3]"); + expect(searchQuery).toContain( + 'property: "~label", value: "MEM0_VECTOR_test"', + ); + expect(searchQuery).toContain('property: "user_id", value: "u2"'); + expect(searchQuery).toContain('property: "priority", value: 5'); + expect(searchQuery).toContain( + 'stringContains: { property: "data", value: "alp" }', + ); + }); + + it("escapes adversarial Neptune collection and filter identifiers", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = { + send: jest.fn().mockImplementation(async (command: any) => { + const queryString = String(command.input.queryString || ""); + if (queryString.includes("RETURN count(n) AS count")) { + return createMockResponse({ results: [{ count: 0 }] }); + } + + return createMockResponse({ results: [] }); + }), + }; + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient as any, + graphIdentifier: "g-1234567890", + collectionName: "memo`graph", + dimension: 3, + }); + + await store.list({ "topic`name": ["alpha`1", "beta(2)"] }, 2); + await store.search([1, 2, 3], 4, { + "topic`name": { eq: "lookup`value" }, + }); + + const listCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes("LIMIT $limit"), + )?.[0]; + const searchCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes("topK.byEmbedding"), + )?.[0]; + + expect(listCall).toBeDefined(); + expect(searchCall).toBeDefined(); + + const listQuery = String(listCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + const searchQuery = String(searchCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + + expect(listQuery).toContain("MATCH (n:`MEM0_VECTOR_memo``graph`)"); + expect(listQuery).toContain("n.`topic``name` IN $filter_in_topic_name_1"); + expect(listCall.input.parameters.filter_in_topic_name_1).toEqual([ + "alpha`1", + "beta(2)", + ]); + + expect(searchQuery).toContain("topK.byEmbedding"); + expect(searchQuery).toContain("vertexFilter: {"); + expect(searchQuery).toContain('property: "topic`name"'); + expect(searchQuery).toContain('value: "lookup`value"'); + }); + + it("serializes complex list filters into Cypher clauses and parameters", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.list( + { + $and: [ + { + $or: [{ priority: { gte: 5 } }, { data: { startsWith: "alp" } }], + }, + { $not: [{ archived: true }] }, + { label: { contains: "topic" } }, + { tag: ["a", "b"] }, + { optional: "*" }, + ], + }, + 7, + ); + + const listCall = mockClient.send.mock.calls.find( + ([command]: [any]) => + String(command.input.queryString || "").includes("LIMIT $limit") && + String(command.input.queryString || "").includes("RETURN n"), + )?.[0]; + const countCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes( + "RETURN count(n) AS count", + ), + )?.[0]; + + expect(listCall).toBeDefined(); + expect(countCall).toBeDefined(); + + const listQuery = String(listCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + const countQuery = String(countCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + + expect(listQuery).toContain("WHERE ("); + expect(listQuery).toContain("n.`priority` >= $filter_gte_priority_1"); + expect(listQuery).toContain( + "toString(n.`data`) STARTS WITH $filter_startsWith_data_2", + ); + expect(listQuery).toContain("NOT (n.`archived` = $filter_archived_3)"); + expect(listQuery).toContain( + "toString(n.`label`) CONTAINS $filter_contains_label_4", + ); + expect(listQuery).toContain("n.`tag` IN $filter_in_tag_5"); + expect(listQuery).toContain("n.`optional` IS NOT NULL"); + expect(countQuery).toContain("RETURN count(n) AS count"); + expect(listCall.input.parameters).toEqual({ + filter_gte_priority_1: 5, + filter_startsWith_data_2: "alp", + filter_archived_3: true, + filter_contains_label_4: "topic", + filter_in_tag_5: ["a", "b"], + limit: 7, + }); + expect(countCall.input.parameters).toEqual({ + filter_gte_priority_1: 5, + filter_startsWith_data_2: "alp", + filter_archived_3: true, + filter_contains_label_4: "topic", + filter_in_tag_5: ["a", "b"], + }); + }); + + it("replaces payloads on update and supports user-id storage", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1", stale: "remove-me" }], + ); + + const created = await store.get("id-1"); + expect(created).not.toBeNull(); + expect(created!.payload).toEqual( + expect.objectContaining({ + data: "alpha", + user_id: "u1", + }), + ); + + await store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }); + + const updated = await store.get("id-1"); + expect(updated).not.toBeNull(); + expect(updated!.payload).toEqual( + expect.objectContaining({ + data: "beta", + user_id: "u1", + }), + ); + expect(updated!.payload.stale).toBeUndefined(); + + const combinedUpdateCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ), + ), + ).toBe(true); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes("SET n = $properties"), + ), + ).toBe(true); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes("FOREACH"), + ), + ).toBe(false); + + const [listed, count] = await store.list({ user_id: "u1" }); + expect(count).toBe(1); + expect(listed[0].id).toBe("id-1"); + + const generatedUserId = await store.getUserId(); + expect(typeof generatedUserId).toBe("string"); + expect(generatedUserId.length).toBeGreaterThan(0); + + await store.setUserId("custom-user"); + expect(await store.getUserId()).toBe("custom-user"); + + await store.delete("id-1"); + expect(await store.get("id-1")).toBeNull(); + }); + + it("supports payload-only and vector-only Neptune updates", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1", stale: "remove-me" }], + ); + + await store.update("id-1", [], { data: "payload-only", user_id: "u1" }); + expect((await store.get("id-1"))!.payload).toEqual( + expect.objectContaining({ + data: "payload-only", + user_id: "u1", + }), + ); + + await store.update("id-1", [3, 2, 1], {}); + expect((await store.get("id-1"))!.payload).toEqual( + expect.objectContaining({ + data: "payload-only", + user_id: "u1", + }), + ); + + const updateCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ); + expect( + updateCalls.some((command: any) => + String(command.input.queryString || "").includes("SET n = $properties"), + ), + ).toBe(true); + expect( + updateCalls.some( + (command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ) && !("properties" in (command.input.parameters || {})), + ), + ).toBe(true); + }); + + it("deletes the full Neptune collection with deleteCol()", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [ + [1, 2, 3], + [3, 2, 1], + ], + ["id-1", "id-2"], + [ + { data: "alpha", user_id: "u1" }, + { data: "beta", user_id: "u1" }, + ], + ); + + await store.deleteCol(); + + const [listed, count] = await store.list({ user_id: "u1" }); + expect(listed).toEqual([]); + expect(count).toBe(0); + + const deleteColCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test`)", + ), + ); + expect(deleteColCall).toBeDefined(); + expect(deleteColCall.input.queryString).toContain("DETACH DELETE n"); + }); + + it("returns a real total count for list pagination", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [ + [1, 2, 3], + [3, 2, 1], + ], + ["id-1", "id-2"], + [ + { data: "alpha", user_id: "u1" }, + { data: "beta", user_id: "u1" }, + ], + ); + + const [listed, count] = await store.list({ user_id: "u1" }, 1); + expect(listed).toHaveLength(1); + expect(count).toBe(2); + }); + + it("reads a persisted user id on a fresh store instance", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.setUserId("persisted-user"); + + const freshStore = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + expect(await freshStore.getUserId()).toBe("persisted-user"); + }); + + it("rolls back the payload when Neptune rejects an update upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failUpdateUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + await expect( + store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), + ).rejects.toThrow("Update failed in Neptune Analytics"); + + // Neptune's vector index isn't transactional: the payload write already lands by the + // time the vector step rejects. Without compensation the new metadata would be stuck + // pointing at the stale embedding, so a failed update rolls the payload back to what + // it was before the call instead of leaving the two desynced. + const afterFailedUpsert = await store.get("id-1"); + expect(afterFailedUpsert).not.toBeNull(); + expect(afterFailedUpsert!.payload).toEqual( + expect.objectContaining({ + data: "alpha", + user_id: "u1", + }), + ); + }); + + it("does not leave a phantom record when Neptune rejects an insert upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failInsertUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.insert([[1, 2, 3]], ["id-1"], [{ data: "alpha", user_id: "u1" }]), + ).rejects.toThrow("Insert failed in Neptune Analytics"); + + expect(await store.get("id-1")).toBeNull(); + }); + + it("does not leave a phantom record when Neptune throws during insert upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ throwInsertUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.insert([[1, 2, 3]], ["id-1"], [{ data: "alpha", user_id: "u1" }]), + ).rejects.toThrow("Neptune upsert rejected"); + + expect(await store.get("id-1")).toBeNull(); + }); + + it("does not leave a phantom record when the property write fails", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failPayloadWrite: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + const results = await store.search([1, 2, 3], 1); + expect(results).toEqual([]); + }); + + it("does not make the embedding durable when the update payload write fails", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient({ failPayloadWrite: true }); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), + ).rejects.toThrow(); + + const upsertCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "neptune.algo.vectors.upsert", + ), + ); + expect(upsertCalls).toHaveLength(0); + }); + + it("writes the Neptune payload before the vector on a combined update", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + await store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }); + + const updateQueries = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ) + .map((command: any) => String(command.input.queryString || "")); + + const payloadIndex = updateQueries.findIndex((queryString: string) => + queryString.includes("SET n = $properties"), + ); + const vectorIndex = updateQueries.findIndex((queryString: string) => + queryString.includes("CALL neptune.algo.vectors.upsert"), + ); + + expect(payloadIndex).toBeGreaterThanOrEqual(0); + expect(vectorIndex).toBeGreaterThan(payloadIndex); + }); + + it("throws for unsupported Neptune search and list filter shapes", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect(store.search([1, 2, 3], 1, { optional: "*" })).rejects.toThrow( + "Neptune Analytics vector search does not support property-existence filters.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { icontains: "alp" } }), + ).rejects.toThrow( + "Neptune Analytics vector search does not support case-insensitive contains filters.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { regex: "alp" } }), + ).rejects.toThrow("Unsupported Neptune Analytics filter operator: regex"); + await expect( + store.search([1, 2, 3], 1, { + $not: [{ data: { contains: "alp" } }], + }), + ).rejects.toThrow( + "Neptune Analytics cannot negate this filter shape for vector search.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { eq: () => "alp" } }), + ).rejects.toThrow( + "Unsupported Neptune Analytics algorithm value type: function", + ); + await expect(store.list({ data: { icontains: "alp" } })).rejects.toThrow( + "Neptune Analytics list filters do not support case-insensitive contains filters.", + ); + await expect(store.list({ data: { regex: "alp" } })).rejects.toThrow( + "Unsupported Neptune Analytics filter operator: regex", + ); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────── // 7. Vectorize β€” mock Cloudflare client, test idempotent init // ─────────────────────────────────────────────────────────────────────────── @@ -1609,7 +4618,7 @@ describe("Vectorize – backward compat with mocked client", () => { }); // ─────────────────────────────────────────────────────────────────────────── -// 7. LangchainVectorStore β€” mock Langchain client, verify no-op init +// 8. LangchainVectorStore β€” mock Langchain client, verify no-op init // ─────────────────────────────────────────────────────────────────────────── describe("LangchainVectorStore – backward compat", () => { it("implements full VectorStore interface", () => { @@ -2168,20 +5177,22 @@ describe("Memory class – backward compat with all providers", () => { updated_at: new Date().toISOString(), }, }); - mockVStore.list.mockResolvedValue([ - [ - { - id: memoryId, - payload: { - memory: "test", - hash: "h", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + mockVStore.list + .mockResolvedValueOnce([ + [ + { + id: memoryId, + payload: { + memory: "test", + hash: "h", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, }, - }, - ], - 1, - ]); + ], + 1, + ]) + .mockResolvedValue([[], 0]); mockVectorStoreFactory.create.mockReturnValue(mockVStore); const mem = new MemoryClass({ @@ -2251,6 +5262,92 @@ describe("Memory class – backward compat with all providers", () => { expect(mockVStore2.initialize).toHaveBeenCalled(); }); + it("derives a separate Databricks entity table when provider casing differs", async () => { + const primaryStore = createMockVectorStore(); + const entityStore = createMockVectorStore(); + mockVectorStoreFactory.create + .mockReturnValueOnce(primaryStore) + .mockReturnValueOnce(entityStore); + + const mem = new MemoryClass({ + embedder: { provider: "openai", config: { apiKey: "k" } }, + vectorStore: { + provider: "Databricks", + config: { + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + collectionName: "memories", + tableName: "memory_rows", + dimension: 1536, + }, + }, + llm: { provider: "openai", config: { apiKey: "k" } }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + await (mem as any).getEntityStore(); + + expect(mockVectorStoreFactory.create).toHaveBeenNthCalledWith( + 2, + "databricks", + expect.objectContaining({ + collectionName: "memories_entities", + tableName: "memory_rows_entities", + }), + ); + expect(entityStore.initialize).toHaveBeenCalled(); + }); + + // optional-peers.test.ts proves no optional peer is imported at module scope. This asserts the + // other half for Databricks: the driver is still loaded lazily (memoized dynamic import, with + // retry-on-failure via getSqlModule()) rather than dropped entirely or made static/eager. + it("still loads the optional @databricks/sql driver lazily", () => { + const source = fs.readFileSync( + path.join(__dirname, "../src/vector_stores/databricks.ts"), + "utf8", + ); + expect(source).toContain('import("@databricks/sql")'); + }); + + it("sends file-based entities to their own DB when provider casing differs", async () => { + const primaryStore = createMockVectorStore(); + const entityStore = createMockVectorStore(); + mockVectorStoreFactory.create + .mockReturnValueOnce(primaryStore) + .mockReturnValueOnce(entityStore); + + const mem = new MemoryClass({ + embedder: { provider: "openai", config: { apiKey: "k" } }, + vectorStore: { + provider: "Memory", + config: { + collectionName: "memories", + dbPath: "/tmp/mem0-casing.db", + dimension: 1536, + }, + }, + llm: { provider: "openai", config: { apiKey: "k" } }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + await (mem as any).getEntityStore(); + + // Un-normalized, the `=== "memory"` check misses, dbPath is left alone, and entities land + // in the very file they are supposed to be split out of. + expect(mockVectorStoreFactory.create).toHaveBeenNthCalledWith( + 2, + "memory", + expect.objectContaining({ + collectionName: "memories_entities", + dbPath: "/tmp/mem0-casing_entities.db", + }), + ); + expect(entityStore.initialize).toHaveBeenCalled(); + }); + it("propagates init error to public methods", async () => { const failingEmbedder = { embed: jest.fn().mockRejectedValue(new Error("Embedder unreachable")), diff --git a/mem0-ts/src/oss/tests/vertexai-embedder.test.ts b/mem0-ts/src/oss/tests/vertexai-embedder.test.ts new file mode 100644 index 000000000..08135196d --- /dev/null +++ b/mem0-ts/src/oss/tests/vertexai-embedder.test.ts @@ -0,0 +1,238 @@ +/// + +const mockPredict = jest.fn(); +const mockGetProjectId = jest.fn(); +const mockClientConstructor = jest.fn(); + +jest.mock("@google-cloud/aiplatform", () => { + return { + __esModule: true, + PredictionServiceClient: jest.fn().mockImplementation((...args) => { + mockClientConstructor(...args); + return { + predict: mockPredict, + getProjectId: mockGetProjectId, + }; + }), + helpers: { + toValue: jest.fn().mockImplementation((val) => val), + fromValue: jest.fn().mockImplementation((val) => val), + }, + }; +}); + +import { VertexAIEmbedder } from "../src/embeddings/vertexai"; + +const mockEmbedding = [0.1, 0.2, 0.3, 0.4]; + +/** Echoes one embedding back per instance in the request. */ +function predictEchoingInstances() { + return (req: { instances: unknown[] }) => + Promise.resolve([ + { + predictions: req.instances.map(() => ({ + embeddings: { values: mockEmbedding }, + })), + }, + ]); +} + +describe("VertexAIEmbedder", () => { + beforeEach(() => { + mockPredict.mockReset(); + mockClientConstructor.mockReset(); + mockGetProjectId.mockReset(); + mockGetProjectId.mockResolvedValue("adc-project"); + mockPredict.mockResolvedValue([ + { + predictions: [ + { + embeddings: { + values: mockEmbedding, + }, + }, + ], + }, + ]); + }); + + describe("basic functionality", () => { + it("embed() returns the embedding vector", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + const result = await embedder.embed("hello"); + expect(result).toEqual(mockEmbedding); + expect(mockPredict).toHaveBeenCalledTimes(1); + + const callArgs = mockPredict.mock.calls[0][0]; + expect(callArgs.endpoint).toBe( + "projects/test-project/locations/us-central1/publishers/google/models/gemini-embedding-001", + ); + // task_type belongs on the instance (snake_case), parameters carries + // only outputDimensionality. + expect(callArgs.instances).toEqual([ + { content: "hello", task_type: "SEMANTIC_SIMILARITY" }, + ]); + expect(callArgs.parameters).toEqual({ outputDimensionality: 256 }); + }); + + it("embed() with memory action search uses RETRIEVAL_QUERY", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await embedder.embed("hello", "search"); + const callArgs = mockPredict.mock.calls[0][0]; + expect(callArgs.instances[0].task_type).toBe("RETRIEVAL_QUERY"); + }); + + it("embed() with memory action add uses RETRIEVAL_DOCUMENT", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await embedder.embed("hello", "add"); + const callArgs = mockPredict.mock.calls[0][0]; + expect(callArgs.instances[0].task_type).toBe("RETRIEVAL_DOCUMENT"); + }); + + it("throws error when predictions are empty", async () => { + mockPredict.mockResolvedValue([{}]); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await expect(embedder.embed("hello")).rejects.toThrow( + "No predictions returned from Vertex AI", + ); + }); + }); + + describe("embedBatch() request sizing", () => { + // gemini-embedding-001 (the default model) rejects any predict() call + // carrying more than one input text, so the batch loop must degrade to one + // request per text rather than the 250-instance chunk the older models take. + it("sends one instance per request for gemini-embedding models", async () => { + mockPredict.mockImplementation(predictEchoingInstances()); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + const texts = ["a", "b", "c"]; + const result = await embedder.embedBatch(texts); + + expect(result).toEqual(texts.map(() => mockEmbedding)); + expect(mockPredict).toHaveBeenCalledTimes(3); + for (const call of mockPredict.mock.calls) { + expect(call[0].instances.length).toBe(1); + // batch defaults to the "add" action -> RETRIEVAL_DOCUMENT + expect(call[0].instances[0].task_type).toBe("RETRIEVAL_DOCUMENT"); + expect(call[0].parameters).toEqual({ outputDimensionality: 256 }); + } + expect( + mockPredict.mock.calls.map((c) => c[0].instances[0].content), + ).toEqual(texts); + }); + + it("chunks at 250 instances for text-embedding models", async () => { + mockPredict.mockImplementation(predictEchoingInstances()); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + model: "text-embedding-005", + }); + + const texts = Array.from({ length: 255 }, (_, i) => `text-${i}`); + const result = await embedder.embedBatch(texts, "search"); + + expect(result.length).toBe(255); + expect(mockPredict).toHaveBeenCalledTimes(2); + expect(mockPredict.mock.calls[0][0].instances.length).toBe(250); + expect(mockPredict.mock.calls[0][0].instances[0].task_type).toBe( + "RETRIEVAL_QUERY", + ); + expect(mockPredict.mock.calls[1][0].instances.length).toBe(5); + }); + + it("rejects an unknown memory action", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await expect( + embedder.embedBatch(["a"], "delete" as unknown as "add"), + ).rejects.toThrow("Invalid memory action: delete"); + }); + }); + + describe("client initialization", () => { + const PROJECT_ENV_VARS = [ + "GCP_PROJECT_ID", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT", + ]; + let savedEnv: Record; + + beforeEach(() => { + savedEnv = {}; + for (const key of PROJECT_ENV_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of PROJECT_ENV_VARS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + }); + + it("resolves the project ID from credentials when none is configured", async () => { + const embedder = new VertexAIEmbedder({}); + + await embedder.embed("hello"); + + expect(mockGetProjectId).toHaveBeenCalledTimes(1); + expect(mockPredict.mock.calls[0][0].endpoint).toBe( + "projects/adc-project/locations/us-central1/publishers/google/models/gemini-embedding-001", + ); + }); + + it("surfaces a helpful error when no project ID can be resolved", async () => { + mockGetProjectId.mockRejectedValue( + new Error("Unable to detect a Project Id"), + ); + const embedder = new VertexAIEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "Vertex AI could not determine a Google Cloud project ID", + ); + }); + + it("builds one client for concurrent calls", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await Promise.all([ + embedder.embed("a"), + embedder.embed("b"), + embedder.embed("c"), + ]); + + expect(mockClientConstructor).toHaveBeenCalledTimes(1); + }); + + it("retries initialization after a failure", async () => { + mockGetProjectId.mockRejectedValueOnce(new Error("transient")); + const embedder = new VertexAIEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "Vertex AI could not determine a Google Cloud project ID", + ); + await expect(embedder.embed("hello")).resolves.toEqual(mockEmbedding); + }); + }); +}); diff --git a/mem0-ts/src/oss/tests/vertexai-protobuf.test.ts b/mem0-ts/src/oss/tests/vertexai-protobuf.test.ts new file mode 100644 index 000000000..f058061a8 --- /dev/null +++ b/mem0-ts/src/oss/tests/vertexai-protobuf.test.ts @@ -0,0 +1,139 @@ +/// + +/** + * The sibling `vertexai-embedder.test.ts` stubs `helpers.toValue`/`fromValue` + * as identity functions, so it never exercises the real protobuf `Value` + * encode/decode path. Here we mock only `PredictionServiceClient` (no + * network, no credentials) and keep the REAL `helpers`, so a malformed + * instance shape or a broken decode actually fails. + */ + +const mockPredict = jest.fn(); +const mockGetProjectId = jest.fn(); + +jest.mock("@google-cloud/aiplatform", () => { + const actual = jest.requireActual("@google-cloud/aiplatform"); + return { + ...actual, + __esModule: true, + PredictionServiceClient: jest.fn().mockImplementation(() => ({ + predict: mockPredict, + getProjectId: mockGetProjectId, + })), + }; +}); + +import { VertexAIEmbedder } from "../src/embeddings/vertexai"; +import { helpers } from "@google-cloud/aiplatform"; + +/** A `google.protobuf.Value` always carries `kind`+`structValue`/etc; a plain + * JS object never does. This is what "real encoding happened" looks like. */ +function expectEncodedValue(value: unknown) { + expect(value).toEqual(expect.objectContaining({ kind: expect.any(String) })); +} + +describe("VertexAIEmbedder protobuf boundary (real helpers.toValue/fromValue)", () => { + beforeEach(() => { + mockPredict.mockReset(); + mockGetProjectId.mockReset(); + mockGetProjectId.mockResolvedValue("adc-project"); + }); + + it("encodes the instance as a genuine protobuf Value carrying {content, task_type}", async () => { + mockPredict.mockResolvedValue([ + { + predictions: [helpers.toValue({ embeddings: { values: [0.1, 0.2] } })], + }, + ]); + const embedder = new VertexAIEmbedder({ googleProjectId: "test-project" }); + + await embedder.embed("hello world", "search"); + + const { instances } = mockPredict.mock.calls[0][0]; + expect(instances).toHaveLength(1); + expectEncodedValue(instances[0]); + + // Decode with the REAL fromValue -- proves the encoded instance is + // readable and matches exactly what Vertex expects on the wire. + expect(helpers.fromValue(instances[0])).toEqual({ + content: "hello world", + task_type: "RETRIEVAL_QUERY", + }); + }); + + it("encodes parameters as a genuine protobuf Value carrying {outputDimensionality}", async () => { + mockPredict.mockResolvedValue([ + { + predictions: [helpers.toValue({ embeddings: { values: [0.1] } })], + }, + ]); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + embeddingDims: 768, + }); + + await embedder.embed("hello"); + + const { parameters } = mockPredict.mock.calls[0][0]; + expectEncodedValue(parameters); + expect(helpers.fromValue(parameters)).toEqual({ + outputDimensionality: 768, + }); + }); + + it("decodes a real toValue()-encoded prediction back into the embedding array", async () => { + const values = [0.11, -0.22, 0.33, 0.0]; + mockPredict.mockResolvedValue([ + { + predictions: [helpers.toValue({ embeddings: { values } })], + }, + ]); + const embedder = new VertexAIEmbedder({ googleProjectId: "test-project" }); + + const result = await embedder.embed("hello"); + + expect(result).toEqual(values); + }); + + it("rejects a prediction that isn't a real encoded protobuf Value", async () => { + // A raw JS object (what the identity-stubbed sibling test effectively + // assumed `predict()` returns) is not a valid protobuf Value -- the real + // fromValue() throws on it instead of silently passing it through. + mockPredict.mockResolvedValue([ + { predictions: [{ embeddings: { values: [0.1, 0.2] } }] }, + ]); + const embedder = new VertexAIEmbedder({ googleProjectId: "test-project" }); + + await expect(embedder.embed("hello")).rejects.toThrow(); + }); + + it("embedBatch() round-trips multiple real encoded predictions", async () => { + const vectors = [ + [0.1, 0.2], + [0.3, 0.4], + ]; + mockPredict.mockImplementation( + (req: { instances: unknown[] }) => + Promise.resolve([ + { + predictions: req.instances.map((_, i) => + helpers.toValue({ embeddings: { values: vectors[i] } }), + ), + }, + ]) as any, + ); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + model: "text-embedding-005", + }); + + const result = await embedder.embedBatch(["a", "b"]); + + expect(result).toEqual(vectors); + const { instances } = mockPredict.mock.calls[0][0]; + expect(instances.map((i: unknown) => helpers.fromValue(i as any))).toEqual([ + { content: "a", task_type: "RETRIEVAL_DOCUMENT" }, + { content: "b", task_type: "RETRIEVAL_DOCUMENT" }, + ]); + }); +}); diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index d79558a09..ea0ccc7ab 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -4,8 +4,13 @@ import pkg from "./package.json"; const external = [ "openai", "@anthropic-ai/sdk", + "@aws-sdk/client-neptune-graph", "@aws-sdk/client-s3vectors", + "@mochow/mochow-sdk-node", "groq-sdk", + "cohere-ai", + "@huggingface/transformers", + "zeroentropy", "uuid", "pg", "zod", @@ -22,10 +27,12 @@ const external = [ "@supabase/supabase-js", "@upstash/vector", "@zilliz/milvus2-sdk-node", + "@aws-sdk/client-bedrock-runtime", "@azure/search-documents", "@azure/identity", "cloudflare", "@cloudflare/workers-types", + "@databricks/sql", "@langchain/core", "fastembed", "compromise", @@ -37,6 +44,7 @@ const external = [ "@elastic/elasticsearch", "chromadb", "weaviate-client", + "oracledb", ]; const define = { diff --git a/mem0/AGENTS.md b/mem0/AGENTS.md new file mode 100644 index 000000000..dc1cd48a0 --- /dev/null +++ b/mem0/AGENTS.md @@ -0,0 +1,105 @@ +# Python SDK (`mem0/`) + +The `mem0ai` package on PyPI. Memory core plus five pluggable provider categories. + +## Commands + +```bash +hatch shell dev_py_3_11 # or dev_py_3_9 / dev_py_3_10 / dev_py_3_12 +pre-commit install # first time only; runs ruff + isort on commit + +make lint # ruff check +make format # ruff format +make sort # isort mem0/ +make test # pytest tests/ +make test-py-3.9 # pin a Python version (3.9 through 3.12) +make install_all # optional deps; run before the full test suite +make build # hatch build +``` + +Use `hatch` for environments and dependencies. Do not use `pip` or `conda`. + +## Conventions + +- **Python 3.9 through 3.12.** Code must run on 3.9. +- **Ruff**, line length **120**. `cli/python/` uses 100; do not carry that config across. +- **isort**, `profile = "black"`, first-party `mem0` and `mem0_cli`. +- **Pydantic v2** for every data model and config class. +- **pytest** with pytest-mock and pytest-asyncio. Tests live in `../tests/`. +- Source files are `snake_case.py`. + +## Layout + +``` +mem0/ +β”œβ”€β”€ memory/ Memory, AsyncMemory +β”œβ”€β”€ client/ MemoryClient, AsyncMemoryClient +β”œβ”€β”€ configs/ MemoryConfig and per-category config models +β”œβ”€β”€ llms/ 24 providers +β”œβ”€β”€ embeddings/ 15 providers +β”œβ”€β”€ vector_stores/ 30 providers +β”œβ”€β”€ graphs/ 4 providers +└── reranker/ 5 providers +``` + +## Provider pattern + +Every category follows the same shape: a `base.py` with the abstract class, one module per provider, config models in `configs.py`, registration in `__init__.py`. + +| Category | Count | Examples | +|----------|-------|---------| +| LLMs | 24 | OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, Gemini, Groq, Ollama, Together, DeepSeek, vLLM, LiteLLM, LM Studio, xAI | +| Vector stores | 30 | Qdrant, Pinecone, Chroma, Weaviate, Milvus, MongoDB, Redis, Elasticsearch, pgvector, Supabase, Faiss, S3 Vectors | +| Embeddings | 15 | OpenAI, Azure OpenAI, Gemini, HuggingFace, FastEmbed, Together, AWS Bedrock, Ollama, Vertex AI | +| Graph stores | 4 | Neo4j, Memgraph, Kuzu, Apache AGE | +| Rerankers | 5 | Cohere, HuggingFace, LLM-based, Sentence Transformer, Zero Entropy | + +### Adding a provider + +1. Create `mem0//.py`. +2. Inherit the abstract base class from `mem0//base.py`. +3. Add its config to `mem0//configs.py` if the category uses one. +4. Register it in `mem0//__init__.py`. +5. Add tests under `tests///`. +6. Put new dependencies in an **optional** group in `pyproject.toml`, never in core `dependencies`. +7. Match an existing provider in the same category exactly: method signatures, error handling, config structure. +8. Add an integration guide under `docs/integrations/`. + +## Public API + +| Class | Purpose | Import | +|-------|---------|--------| +| `Memory` | Self-hosted, sync | `from mem0 import Memory` | +| `AsyncMemory` | Self-hosted, async | `from mem0 import AsyncMemory` | +| `MemoryClient` | Hosted platform, sync | `from mem0 import MemoryClient` | +| `AsyncMemoryClient` | Hosted platform, async | `from mem0 import AsyncMemoryClient` | + +Both `Memory` and `MemoryClient` expose the same surface: + +| Method | Purpose | +|--------|---------| +| `add(messages, *, user_id, agent_id, run_id, metadata)` | Store a memory | +| `search(query, *, user_id, agent_id, run_id, limit, filters)` | Search memories | +| `get(memory_id)` | Fetch one memory | +| `get_all(*, user_id, agent_id, run_id, limit)` | List memories | +| `update(memory_id, data)` | Update a memory | +| `delete(memory_id)` | Delete a memory | +| `delete_all(*, user_id, agent_id, run_id)` | Delete a scope | +| `history(memory_id)` | Change history for a memory | + +Changing any of these signatures means updating `docs/` in the same PR. + +### Import paths + +| What | Import | +|------|--------| +| Memory classes | `from mem0 import Memory, AsyncMemory` | +| Platform client | `from mem0 import MemoryClient, AsyncMemoryClient` | +| Configuration | `from mem0.configs.base import MemoryConfig` | +| LLM provider | `from mem0.llms. import ` | +| Embedding provider | `from mem0.embeddings. import ` | +| Vector store provider | `from mem0.vector_stores. import ` | + +## Graph memory + +An optional layer on top of vector memory for relationship-aware retrieval, configured through the `graph` section of `MemoryConfig`. It supplements vector search rather than replacing it. diff --git a/mem0/CLAUDE.md b/mem0/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/mem0/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/mem0/client/main.py b/mem0/client/main.py index 853a8588d..a52986dcd 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -20,7 +20,13 @@ from mem0.client.types import ( from mem0.client.utils import api_error_handler # Exception classes are referenced in docstrings only -from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config +from mem0.memory.setup import ( + get_user_id, + is_aliased, + mark_aliased, + read_anon_ids, + setup_config, +) from mem0.memory.telemetry import capture_client_event, client_telemetry logger = logging.getLogger(__name__) @@ -269,11 +275,8 @@ class MemoryClient: kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) - if "page" in params and "page_size" in params: - query_params = { - "page": params.pop("page"), - "page_size": params.pop("page_size"), - } + query_params = {key: params.pop(key) for key in ("page", "page_size") if key in params} + if query_params: response = self.client.post("/v3/memories/", json=params, params=query_params) else: response = self.client.post("/v3/memories/", json=params) @@ -725,10 +728,10 @@ class MemoryClient: options: Optional[ProjectUpdateOptions] = None, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, memory_depth: Optional[str] = None, usecase_setting: Optional[str] = None, multilingual: Optional[bool] = None, + agent_custom_instructions: Optional[str] = None, ) -> Dict[str, Any]: """Update the project settings. @@ -736,10 +739,10 @@ class MemoryClient: options: Typed options for the update operation (ProjectUpdateOptions). custom_instructions: New instructions for the project. custom_categories: New categories for the project. - retrieval_criteria: New retrieval criteria for the project. memory_depth: Memory depth for the project. usecase_setting: Usecase setting for the project. multilingual: Whether to use the input language for memory storage and retrieval. + agent_custom_instructions: New extraction instructions for agent-scoped memories. Returns: Dictionary containing the API response. @@ -761,10 +764,10 @@ class MemoryClient: for k, v in { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "memory_depth": memory_depth, "usecase_setting": usecase_setting, "multilingual": multilingual, + "agent_custom_instructions": agent_custom_instructions, }.items() if v is not None }, @@ -773,7 +776,7 @@ class MemoryClient: if not kwargs: raise ValueError( "Currently we only support updating custom_instructions or " - "custom_categories or retrieval_criteria, so you must " + "custom_categories, so you must " "provide at least one of them" ) @@ -1192,11 +1195,8 @@ class AsyncMemoryClient: kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) - if "page" in params and "page_size" in params: - query_params = { - "page": params.pop("page"), - "page_size": params.pop("page_size"), - } + query_params = {key: params.pop(key) for key in ("page", "page_size") if key in params} + if query_params: response = await self.async_client.post("/v3/memories/", json=params, params=query_params) else: response = await self.async_client.post("/v3/memories/", json=params) @@ -1630,10 +1630,10 @@ class AsyncMemoryClient: options: Optional[ProjectUpdateOptions] = None, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, memory_depth: Optional[str] = None, usecase_setting: Optional[str] = None, multilingual: Optional[bool] = None, + agent_custom_instructions: Optional[str] = None, ) -> Dict[str, Any]: """Update the project settings. @@ -1641,10 +1641,10 @@ class AsyncMemoryClient: options: Typed options for the update operation (ProjectUpdateOptions). custom_instructions: New instructions for the project. custom_categories: New categories for the project. - retrieval_criteria: New retrieval criteria for the project. memory_depth: Memory depth for the project. usecase_setting: Usecase setting for the project. multilingual: Whether to use the input language for memory storage and retrieval. + agent_custom_instructions: New extraction instructions for agent-scoped memories. Returns: Dictionary containing the API response. @@ -1666,10 +1666,10 @@ class AsyncMemoryClient: for k, v in { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "memory_depth": memory_depth, "usecase_setting": usecase_setting, "multilingual": multilingual, + "agent_custom_instructions": agent_custom_instructions, }.items() if v is not None }, @@ -1678,7 +1678,7 @@ class AsyncMemoryClient: if not kwargs: raise ValueError( "Currently we only support updating custom_instructions or " - "custom_categories or retrieval_criteria, so you must " + "custom_categories, so you must " "provide at least one of them" ) diff --git a/mem0/client/project.py b/mem0/client/project.py index 4255bec18..a7280c00d 100644 --- a/mem0/client/project.py +++ b/mem0/client/project.py @@ -177,7 +177,7 @@ class BaseProject(ABC): self, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, + agent_custom_instructions: Optional[str] = None, ) -> Dict[str, Any]: """ Update project settings. @@ -185,7 +185,7 @@ class BaseProject(ABC): Args: custom_instructions: New instructions for the project custom_categories: New categories for the project - retrieval_criteria: New retrieval criteria for the project + agent_custom_instructions: New extraction instructions for agent-scoped memories Returns: Dictionary containing the API response. @@ -396,9 +396,9 @@ class Project(BaseProject): self, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, + agent_custom_instructions: Optional[str] = None, ) -> Dict[str, Any]: """ Update project settings. @@ -406,11 +406,11 @@ class Project(BaseProject): Args: custom_instructions: New instructions for the project custom_categories: New categories for the project - retrieval_criteria: New retrieval criteria for the project multilingual: Whether to use the input language for memory storage and retrieval decay: Toggle Memory Decay for this project. When True, search-time ranking boosts recently-used memories and gently dampens stale ones; when False, ranking is restored to the pre-decay behaviour. Off by default. + agent_custom_instructions: New extraction instructions for agent-scoped memories Returns: Dictionary containing the API response. @@ -425,23 +425,23 @@ class Project(BaseProject): if ( custom_instructions is None and custom_categories is None - and retrieval_criteria is None and multilingual is None and decay is None + and agent_custom_instructions is None ): raise ValueError( "At least one parameter must be provided for update: " - "custom_instructions, custom_categories, retrieval_criteria, " - "multilingual, decay" + "custom_instructions, custom_categories, multilingual, decay, " + "agent_custom_instructions" ) payload = self._prepare_params( { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, + "agent_custom_instructions": agent_custom_instructions, } ) response = self._client.patch( @@ -455,9 +455,9 @@ class Project(BaseProject): { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, + "agent_custom_instructions": agent_custom_instructions, "sync_type": "sync", }, ) @@ -720,9 +720,9 @@ class AsyncProject(BaseProject): self, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, + agent_custom_instructions: Optional[str] = None, ) -> Dict[str, Any]: """ Update project settings. @@ -730,11 +730,11 @@ class AsyncProject(BaseProject): Args: custom_instructions: New instructions for the project custom_categories: New categories for the project - retrieval_criteria: New retrieval criteria for the project multilingual: Whether to use the input language for memory storage and retrieval decay: Toggle Memory Decay for this project. When True, search-time ranking boosts recently-used memories and gently dampens stale ones; when False, ranking is restored to the pre-decay behaviour. Off by default. + agent_custom_instructions: New extraction instructions for agent-scoped memories Returns: Dictionary containing the API response. @@ -749,23 +749,23 @@ class AsyncProject(BaseProject): if ( custom_instructions is None and custom_categories is None - and retrieval_criteria is None and multilingual is None and decay is None + and agent_custom_instructions is None ): raise ValueError( "At least one parameter must be provided for update: " - "custom_instructions, custom_categories, retrieval_criteria, " - "multilingual, decay" + "custom_instructions, custom_categories, multilingual, decay, " + "agent_custom_instructions" ) payload = self._prepare_params( { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, + "agent_custom_instructions": agent_custom_instructions, } ) response = await self._client.patch( @@ -779,9 +779,9 @@ class AsyncProject(BaseProject): { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, + "agent_custom_instructions": agent_custom_instructions, "sync_type": "async", }, ) diff --git a/mem0/client/types.py b/mem0/client/types.py index 492904a8d..5663dcf5d 100644 --- a/mem0/client/types.py +++ b/mem0/client/types.py @@ -28,6 +28,9 @@ class AddMemoryOptions(BaseModel): default=None, description="Custom categories for memory classification" ) custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction") + agent_custom_instructions: Optional[str] = Field( + default=None, description="Custom instructions for fact extraction from agent-scoped memories" + ) timestamp: Optional[int] = Field(default=None, description="Unix timestamp for the memory") expiration_date: Optional[str] = Field(default=None, description="Expiration date in YYYY-MM-DD format") structured_data_schema: Optional[Dict[str, Any]] = Field( @@ -52,6 +55,11 @@ class SearchMemoryOptions(BaseModel): fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response") categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") + reference_date: Optional[Union[str, int]] = Field( + default=None, description="Reference date for relative time queries (YYYY-MM-DD or Unix timestamp)" + ) + latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version") + keyword_search: Optional[bool] = Field(default=None, description="Whether to use keyword search") class GetAllMemoryOptions(BaseModel): @@ -74,6 +82,7 @@ class GetAllMemoryOptions(BaseModel): ) categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") + latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version") class DeleteAllMemoryOptions(BaseModel): @@ -101,10 +110,12 @@ class ProjectUpdateOptions(BaseModel): """Options for project update operations.""" custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction") + agent_custom_instructions: Optional[str] = Field( + default=None, description="Custom instructions for fact extraction from agent-scoped memories" + ) custom_categories: Optional[List[Dict[str, Any]]] = Field( default=None, description="Custom categories for classification" ) memory_depth: Optional[str] = Field(default=None, description="Memory depth configuration") usecase_setting: Optional[Any] = Field(default=None, description="Use case specific settings") multilingual: Optional[bool] = Field(default=None, description="Whether to enable multilingual support") - retrieval_criteria: Optional[List[Any]] = Field(default=None, description="Criteria for memory retrieval") diff --git a/mem0/configs/llms/anthropic.py b/mem0/configs/llms/anthropic.py index 45552eeba..7c04ebf2b 100644 --- a/mem0/configs/llms/anthropic.py +++ b/mem0/configs/llms/anthropic.py @@ -23,6 +23,7 @@ class AnthropicConfig(BaseLlmConfig): http_client_proxies: Optional[dict] = None, # Anthropic-specific parameters anthropic_base_url: Optional[str] = None, + enable_sampling_parameters: Optional[bool] = None, ): """ Initialize Anthropic configuration. @@ -38,6 +39,7 @@ class AnthropicConfig(BaseLlmConfig): vision_details: Vision detail level, defaults to "auto" http_client_proxies: HTTP client proxy settings, defaults to None anthropic_base_url: Anthropic API base URL, defaults to None + enable_sampling_parameters: Enable sampling parameters, defaults to None """ # Initialize base parameters super().__init__( @@ -54,3 +56,4 @@ class AnthropicConfig(BaseLlmConfig): # Anthropic-specific parameters self.anthropic_base_url = anthropic_base_url + self.enable_sampling_parameters = enable_sampling_parameters diff --git a/mem0/configs/llms/aws_bedrock.py b/mem0/configs/llms/aws_bedrock.py index 9b0e65391..075d62b4e 100644 --- a/mem0/configs/llms/aws_bedrock.py +++ b/mem0/configs/llms/aws_bedrock.py @@ -24,6 +24,7 @@ class AWSBedrockConfig(BaseLlmConfig): aws_session_token: Optional[str] = None, aws_profile: Optional[str] = None, model_kwargs: Optional[Dict[str, Any]] = None, + provider_override: Optional[str] = None, **kwargs, ): """ @@ -42,6 +43,10 @@ class AWSBedrockConfig(BaseLlmConfig): aws_session_token: AWS session token for temporary credentials aws_profile: AWS profile name for credentials model_kwargs: Additional model-specific parameters + provider_override: Explicit provider name (e.g. "anthropic"), required when + model is an application inference profile ARN whose opaque ID has no + provider substring for automatic detection. Defaults to None (uses + automatic detection from the model identifier). **kwargs: Additional arguments passed to base class """ super().__init__( @@ -59,6 +64,7 @@ class AWSBedrockConfig(BaseLlmConfig): self.aws_session_token = aws_session_token self.aws_profile = aws_profile self.model_kwargs = model_kwargs or {} + self.provider_override = provider_override @property def provider(self) -> str: diff --git a/mem0/configs/llms/base.py b/mem0/configs/llms/base.py index 666acef5a..11038737d 100644 --- a/mem0/configs/llms/base.py +++ b/mem0/configs/llms/base.py @@ -31,7 +31,7 @@ class BaseLlmConfig(ABC): Initialize a base configuration class instance for the LLM. Args: - model: The model identifier to use (e.g., "gpt-4.1-nano-2025-04-14", "claude-3-5-sonnet-20240620") + model: The model identifier to use (e.g., "gpt-5-mini", "claude-3-5-sonnet-20240620") Defaults to None (will be set by provider-specific configs) temperature: Controls the randomness of the model's output. Higher values (closer to 1) make output more random, lower values make it more deterministic. diff --git a/mem0/configs/llms/vllm.py b/mem0/configs/llms/vllm.py index 45c6e2651..7d99c3b0a 100644 --- a/mem0/configs/llms/vllm.py +++ b/mem0/configs/llms/vllm.py @@ -1,3 +1,4 @@ +import os from typing import Optional from mem0.configs.llms.base import BaseLlmConfig @@ -53,4 +54,4 @@ class VllmConfig(BaseLlmConfig): ) # vLLM-specific parameters - self.vllm_base_url = vllm_base_url or "http://localhost:8000/v1" + self.vllm_base_url = vllm_base_url or os.getenv("VLLM_BASE_URL") or "http://localhost:8000/v1" diff --git a/mem0/configs/rerankers/cohere.py b/mem0/configs/rerankers/cohere.py index 3a2f27fec..a7d408829 100644 --- a/mem0/configs/rerankers/cohere.py +++ b/mem0/configs/rerankers/cohere.py @@ -1,4 +1,5 @@ from typing import Optional + from pydantic import Field from mem0.configs.rerankers.base import BaseRerankerConfig @@ -10,6 +11,6 @@ class CohereRerankerConfig(BaseRerankerConfig): Inherits from BaseRerankerConfig and adds Cohere-specific settings. """ - model: Optional[str] = Field(default="rerank-english-v3.0", description="The Cohere rerank model to use") + model: Optional[str] = Field(default="rerank-v3.5", description="The Cohere rerank model to use") return_documents: bool = Field(default=False, description="Whether to return the document texts in the response") max_chunks_per_doc: Optional[int] = Field(default=None, description="Maximum number of chunks per document") diff --git a/mem0/configs/rerankers/llm.py b/mem0/configs/rerankers/llm.py index de64818b5..8c398c43e 100644 --- a/mem0/configs/rerankers/llm.py +++ b/mem0/configs/rerankers/llm.py @@ -10,7 +10,7 @@ class LLMRerankerConfig(BaseRerankerConfig): Configuration for LLM-based reranker. Attributes: - model (str): LLM model to use for reranking. Defaults to "gpt-4o-mini". + model (str): LLM model to use for reranking. Defaults to "gpt-5-mini". api_key (str): API key for the LLM provider. provider (str): LLM provider. Defaults to "openai". top_k (int): Number of top documents to return after reranking. @@ -20,7 +20,7 @@ class LLMRerankerConfig(BaseRerankerConfig): """ model: str = Field( - default="gpt-4o-mini", + default="gpt-5-mini", description="LLM model to use for reranking" ) api_key: Optional[str] = Field( diff --git a/mem0/configs/vector_stores/oracledb.py b/mem0/configs/vector_stores/oracledb.py new file mode 100644 index 000000000..3f0dd5893 --- /dev/null +++ b/mem0/configs/vector_stores/oracledb.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. +"""Pydantic configuration for the Oracle AI Vector Search integration.""" + +import re +from typing import Any, Dict, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +def _quote_identifier(name: str) -> str: + name = name.strip() + reg = r'^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$' + pattern_validate = re.compile(reg) + + if not pattern_validate.match(name): + raise ValueError(f"Identifier name {name} is not valid.") + + pattern_match = r'"([^"]+)"|([^".]+)' + groups = re.findall(pattern_match, name) + groups = [m[0] or m[1] for m in groups] + groups = [f'"{g}"' for g in groups] + + return ".".join(groups) + + +class HnswParams(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + neighbors: Optional[int] = Field(None, ge=2, le=2048) + efconstruction: Optional[int] = Field(None, ge=1, le=65535) + + +class IvfParams(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + neighbor_partitions: Optional[int] = Field(None, alias="neighbor partitions", ge=1, le=10_000_000) + samples_per_partition: Optional[int] = Field(None, ge=1) + min_vectors_per_partition: Optional[int] = Field(None, ge=0) + + +class OracleAIVectorSearchConfig(BaseModel): + """Configuration required to connect to an Oracle database with vector search enabled.""" + + connection_params: Optional[dict] = Field(None, description="Database connection parameters, including auth.") + use_connection_pool: bool = Field( + True, + description="Create a ConnectionPool instead of a single Connection when no client is provided", + ) + + client: Optional[Any] = Field( + None, description="Oracle Connection or ConnectionPool (overrides connection string and individual parameters)" + ) + + collection_name: str = Field("mem0", description="Default name for the collection") + embedding_model_dims: int = Field(1536, description="Dimension of the embedding vectors") + distance_metric: Literal["EUCLIDEAN", "EUCLIDEAN_SQUARED", "COSINE", "DOT", "HAMMING", "MANHATTAN"] = Field( + "COSINE", + description="Similarity metric: EUCLIDEAN, EUCLIDEAN_SQUARED, COSINE, DOT, HAMMING or MANHATTAN. Defaults to COSINE", + ) + + do_create_index: Optional[bool] = Field(True, description="Optional whether to create index") + index_type: Literal["HNSW", "IVF"] = Field("HNSW", description="Optional index type, HNSW or IVF") + index_name: Optional[str] = Field(None, description="Optional custom name for the vector index") + index_parameters: Optional[dict] = Field( + None, + description="Optional structured CREATE VECTOR INDEX parameters", + ) + index_accuracy: Optional[int] = Field(None, description="Optional index accuracy") + + @field_validator("distance_metric", "index_type", mode="before") + @classmethod + def _normalize_uppercase(cls, value: Any) -> Any: + return value.upper() if isinstance(value, str) else value + + @model_validator(mode="after") + def _validate_model(self): + """Normalise attributes and validate identifiers/metrics.""" + + if not self.connection_params and not self.client: + raise ValueError("Must provide at least one of `connection_params` and `client`") + + if self.index_name is None: + self.index_name = f"{self.collection_name}_VEC_IDX" + + self.index_name = _quote_identifier(self.index_name) + self.collection_name = _quote_identifier(self.collection_name) + + if self.index_parameters is not None: + parameter_model = HnswParams if self.index_type == "HNSW" else IvfParams + self.index_parameters = parameter_model.model_validate(self.index_parameters).model_dump( + by_alias=True, + exclude_none=True, + ) + + if self.index_accuracy is not None: + if not (0 < self.index_accuracy <= 100): + raise ValueError("`index_accuracy` must be between 1 and 100") + + if not (0 < self.embedding_model_dims): + raise ValueError("`embedding_model_dims` must be bigger than 0") + + return self + + @model_validator(mode="before") + @classmethod + def validate_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]: + allowed_fields = set(cls.model_fields.keys()) + extra_fields = set(values.keys()) - allowed_fields + if extra_fields: + raise ValueError( + "Extra fields not allowed: {}. Please input only the following fields: {}".format( + ", ".join(sorted(extra_fields)), ", ".join(sorted(allowed_fields)) + ) + ) + return values diff --git a/mem0/embeddings/fastembed.py b/mem0/embeddings/fastembed.py index fadaedb8c..9434e5089 100644 --- a/mem0/embeddings/fastembed.py +++ b/mem0/embeddings/fastembed.py @@ -1,7 +1,7 @@ -from typing import Optional, Literal +from typing import Literal, Optional -from mem0.embeddings.base import EmbeddingBase from mem0.configs.embeddings.base import BaseEmbedderConfig +from mem0.embeddings.base import EmbeddingBase try: from fastembed import TextEmbedding @@ -29,4 +29,4 @@ class FastEmbedEmbedding(EmbeddingBase): """ text = text.replace("\n", " ") embeddings = list(self.dense_model.embed(text)) - return embeddings[0] + return embeddings[0].tolist() diff --git a/mem0/embeddings/huggingface.py b/mem0/embeddings/huggingface.py index 230fb7e20..7e175f36e 100644 --- a/mem0/embeddings/huggingface.py +++ b/mem0/embeddings/huggingface.py @@ -17,7 +17,7 @@ class HuggingFaceEmbedding(EmbeddingBase): super().__init__(config) if self.config.huggingface_base_url: - self.client = OpenAI(base_url=self.config.huggingface_base_url) + self.client = OpenAI(base_url=self.config.huggingface_base_url, api_key=self.config.api_key) self.config.model = self.config.model or "tei" else: self.config.model = self.config.model or "multi-qa-MiniLM-L6-cos-v1" diff --git a/mem0/embeddings/ollama.py b/mem0/embeddings/ollama.py index c5a74e36e..55c164f2c 100644 --- a/mem0/embeddings/ollama.py +++ b/mem0/embeddings/ollama.py @@ -1,5 +1,3 @@ -import subprocess -import sys from typing import Literal, Optional from mem0.configs.embeddings.base import BaseEmbedderConfig @@ -8,17 +6,7 @@ from mem0.embeddings.base import EmbeddingBase try: from ollama import Client except ImportError: - user_input = input("The 'ollama' library is required. Install it now? [y/N]: ") - if user_input.lower() == "y": - try: - subprocess.check_call([sys.executable, "-m", "pip", "install", "ollama"]) - from ollama import Client - except subprocess.CalledProcessError: - print("Failed to install 'ollama'. Please install it manually using 'pip install ollama'.") - sys.exit(1) - else: - print("The required 'ollama' library is not installed.") - sys.exit(1) + raise ImportError("The 'ollama' library is required. Please install it using 'pip install ollama'.") class OllamaEmbedding(EmbeddingBase): diff --git a/mem0/exceptions.py b/mem0/exceptions.py index 7289544fa..0ff1ba2b2 100644 --- a/mem0/exceptions.py +++ b/mem0/exceptions.py @@ -353,7 +353,7 @@ class LLMError(MemoryError): raise LLMError( message="LLM operation failed", error_code="LLM_001", - details={"model": "gpt-4", "prompt_length": 500}, + details={"model": "gpt-5-mini", "prompt_length": 500}, suggestion="Please check your LLM configuration and API key" ) """ diff --git a/mem0/llms/anthropic.py b/mem0/llms/anthropic.py index 179f1b4ff..e0e083464 100644 --- a/mem0/llms/anthropic.py +++ b/mem0/llms/anthropic.py @@ -44,6 +44,30 @@ class AnthropicLLM(LLMBase): client_kwargs["base_url"] = base_url self.client = anthropic.Anthropic(**client_kwargs) + def _enable_sampling_parameters(self): + """Return whether the configured model supports sampling parameters.""" + explicit = getattr(self.config, "enable_sampling_parameters", None) + if explicit is not None: + return explicit + + model_name = self.config.model.lower() + model_name_parts = model_name.rsplit("[", 1)[0].split("-") + _, family, major, minor, *_ = (*model_name_parts, "", "", "", "") + + # Use model_name if only provided a family name, e.g., opus + family = family or model_name + major = int(major) if major.isdigit() else None + minor = int(minor) if minor.isdigit() else None + + if family == "haiku": + return True + if family == "sonnet" and major is not None: + return major < 5 + if family == "opus" and major is not None and minor is not None: + return (major, minor) < (4, 7) + + return False + def _get_common_params(self, **kwargs) -> Dict: """Get common parameters, avoiding sending both temperature and top_p together. @@ -58,13 +82,14 @@ class AnthropicLLM(LLMBase): has_temperature = self.config.temperature is not None has_top_p = self.config.top_p is not None - if has_temperature and has_top_p: - # Anthropic forbids both; prefer temperature - params["temperature"] = self.config.temperature - elif has_temperature: - params["temperature"] = self.config.temperature - elif has_top_p: - params["top_p"] = self.config.top_p + if self._enable_sampling_parameters(): + if has_temperature and has_top_p: + # Anthropic forbids both; prefer temperature + params["temperature"] = self.config.temperature + elif has_temperature: + params["temperature"] = self.config.temperature + elif has_top_p: + params["top_p"] = self.config.top_p params.update(kwargs) return params @@ -122,8 +147,13 @@ class AnthropicLLM(LLMBase): if block.type == "text": result["content"] = block.text elif block.type == "tool_use": - result["tool_calls"].append( - {"name": block.name, "arguments": block.input} - ) + result["tool_calls"].append({"name": block.name, "arguments": block.input}) return result - return response.content[0].text + + # Thinking-enabled responses put a thinking block before the text + # block, and a response can carry no text block at all, so find the + # text block like the tools branch above instead of indexing content[0]. + for block in response.content: + if block.type == "text": + return block.text + return "" diff --git a/mem0/llms/aws_bedrock.py b/mem0/llms/aws_bedrock.py index d749ebb27..20b7f3883 100644 --- a/mem0/llms/aws_bedrock.py +++ b/mem0/llms/aws_bedrock.py @@ -9,8 +9,8 @@ try: except ImportError: raise ImportError("The 'boto3' library is required. Please install it using 'pip install boto3'.") -from mem0.configs.llms.base import BaseLlmConfig from mem0.configs.llms.aws_bedrock import AWSBedrockConfig +from mem0.configs.llms.base import BaseLlmConfig from mem0.llms.base import LLMBase from mem0.memory.utils import extract_json @@ -23,8 +23,14 @@ PROVIDERS = [ ] -def extract_provider(model: str) -> str: - """Extract provider from model identifier.""" +def extract_provider(model: str, explicit_provider: Optional[str] = None) -> str: + """Extract provider from model identifier, or return explicit_provider when set.""" + if explicit_provider: + if explicit_provider not in PROVIDERS: + raise ValueError( + f"Unknown provider_override '{explicit_provider}'. Valid providers: {', '.join(PROVIDERS)}" + ) + return explicit_provider for provider in PROVIDERS: if re.search(rf"\b{re.escape(provider)}\b", model): return provider @@ -69,7 +75,7 @@ class AWSBedrockLLM(LLMBase): # Get model configuration self.model_config = self.config.get_model_config() - self.provider = extract_provider(self.config.model) + self.provider = extract_provider(self.config.model, self.config.provider_override) # Initialize provider-specific settings self._initialize_provider_settings() diff --git a/mem0/llms/base.py b/mem0/llms/base.py index 817e4da2f..8f5c887e3 100644 --- a/mem0/llms/base.py +++ b/mem0/llms/base.py @@ -35,11 +35,6 @@ class LLMBase(ABC): if not hasattr(self.config, "model"): raise ValueError("Configuration must have a 'model' attribute") - if not hasattr(self.config, "api_key") and not hasattr(self.config, "api_key"): - # Check if API key is available via environment variable - # This will be handled by individual providers - pass - 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. diff --git a/mem0/llms/openai_structured.py b/mem0/llms/openai_structured.py index 2d5104039..5642da302 100644 --- a/mem0/llms/openai_structured.py +++ b/mem0/llms/openai_structured.py @@ -15,7 +15,7 @@ class OpenAIStructuredLLM(LLMBase): self.config.model = "gpt-5-mini" api_key = self.config.api_key or os.getenv("OPENAI_API_KEY") - base_url = self.config.openai_base_url or os.getenv("OPENAI_API_BASE") or "https://api.openai.com/v1" + base_url = self.config.openai_base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1" self.client = OpenAI(api_key=api_key, base_url=base_url) def generate_response( diff --git a/mem0/llms/vllm.py b/mem0/llms/vllm.py index 525006bff..a70398d74 100644 --- a/mem0/llms/vllm.py +++ b/mem0/llms/vllm.py @@ -37,8 +37,7 @@ class VllmLLM(LLMBase): self.config.model = "Qwen/Qwen2.5-32B-Instruct" self.config.api_key = self.config.api_key or os.getenv("VLLM_API_KEY") or "vllm-api-key" - base_url = self.config.vllm_base_url or os.getenv("VLLM_BASE_URL") - self.client = OpenAI(api_key=self.config.api_key, base_url=base_url) + self.client = OpenAI(api_key=self.config.api_key, base_url=self.config.vllm_base_url) def _parse_response(self, response, tools): """ diff --git a/mem0/memory/main.py b/mem0/memory/main.py index fa3829e3d..e18dee1d2 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -133,6 +133,33 @@ _SENSITIVE_SUFFIXES = ( # Entity parameters that must be passed via filters, not top-level kwargs ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"}) +DELETE_ALL_BATCH_SIZE = 1000 + +# Tenant-scoping fields that caller-supplied metadata must never set, on either the +# creation or the update path (issues #4490, #6277, #6655). +_IDENTITY_KEYS = ENTITY_PARAMS | {"actor_id"} + + +def _strip_identity_keys( + metadata: Dict[str, Any], + existing_payload: Dict[str, Any], + *, + context: str = "update()", +) -> Dict[str, Any]: + """Drop identity keys from caller metadata; scope is set by the entity params, not metadata. + + On the update path `existing_payload` carries the memory's current scope, so + re-sending an identical value is silently accepted; only a changed value warns. + On the creation path there is no prior payload, so pass an empty dict and every + identity key present in `metadata` is dropped with a warning. + """ + clean = {} + for key, value in metadata.items(): + if key not in _IDENTITY_KEYS: + clean[key] = value + elif value != existing_payload.get(key): + logger.warning(f"{context}: ignoring metadata['{key}'] - identity fields cannot be set through metadata") + return clean def _reject_top_level_entity_params(kwargs: Dict[str, Any], method_name: str) -> None: @@ -145,9 +172,10 @@ def _reject_top_level_entity_params(kwargs: Dict[str, Any], method_name: str) -> ) -def _validate_and_trim_entity_id(value: Optional[str], name: str) -> Optional[str]: +def _validate_and_trim_entity_id(value: Optional[Any], name: str) -> Optional[str]: """ Validates and normalizes an entity ID. + - Coerces non-string values (e.g. integer ids) to str - Trims leading/trailing whitespace - Rejects empty or whitespace-only strings - Rejects strings containing internal whitespace @@ -164,6 +192,11 @@ def _validate_and_trim_entity_id(value: Optional[str], name: str) -> Optional[st """ if value is None: return None + # Callers commonly pass integer ids (e.g. a database primary key). Coerce + # to str at this single validation point so scoping stays consistent across + # add/search/get_all/delete_all instead of crashing on `.strip()`. + if not isinstance(value, str): + value = str(value) trimmed = value.strip() if trimmed == "": raise ValueError( @@ -294,7 +327,9 @@ def _build_filters_and_metadata( for flexible session scoping and optionally narrows queries to a specific `actor_id`. It returns two dicts: 1. `base_metadata_template`: Used as a template for metadata when storing new memories. - It includes all provided session identifier(s) and any `input_metadata`. + It includes all provided session identifier(s) and any `input_metadata`. Identity + scope is set from the entity params only; identity keys in `input_metadata` are + dropped, so freeform metadata cannot place a memory into an unrequested scope. 2. `effective_query_filters`: Used for querying existing memories. It includes all provided session identifier(s), any `input_filters`, and a resolved actor identifier for targeted filtering if specified by any actor-related inputs. @@ -322,7 +357,12 @@ def _build_filters_and_metadata( scoped to the provided session(s) and potentially a resolved actor. """ - base_metadata_template = deepcopy(input_metadata) if input_metadata else {} + # Identity scope is set below from the entity params only. Stripping the keys here + # stops caller metadata from placing a memory into a scope the caller did not pass, + # which the re-pins below cannot prevent for a param that was left unset (issue #6655). + base_metadata_template = ( + _strip_identity_keys(deepcopy(input_metadata), {}, context="add()") if input_metadata else {} + ) effective_query_filters = deepcopy(input_filters) if input_filters else {} # ---------- validate and add all provided session ids ---------- @@ -364,13 +404,18 @@ def _build_filters_and_metadata( return base_metadata_template, effective_query_filters +def _escape_scope_value(val: Any) -> str: + """Escape the structural delimiters of the session scope key.""" + return str(val).replace("%", "%25").replace("&", "%26").replace("=", "%3D") + + def _build_session_scope(filters): """Build deterministic session scope string from entity IDs.""" parts = [] for key in sorted(["user_id", "agent_id", "run_id"]): val = filters.get(key) if val: - parts.append(f"{key}={val}") + parts.append(f"{key}={_escape_scope_value(val)}") return "&".join(parts) @@ -418,7 +463,6 @@ class _OSSProject: self, custom_instructions: Optional[str] = None, custom_categories: Optional[list] = None, - retrieval_criteria: Optional[list] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, ): @@ -432,7 +476,6 @@ class _AsyncOSSProject: self, custom_instructions: Optional[str] = None, custom_categories: Optional[list] = None, - retrieval_criteria: Optional[list] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, ): @@ -753,6 +796,11 @@ class Memory(MemoryBase): are treated as general conversational/factual memories. prompt (str, optional): Prompt to use for the memory creation. Defaults to None. + Note: + `search()` and `get_all()` scope queries via `filters={"user_id": "...", "agent_id": "...", "run_id": "..."}` β€” + they reject top-level `user_id`/`agent_id`/`run_id` arguments. `add()` accepts them top-level, but passing + the same arguments to `search()`/`get_all()` raises a `ValueError`; use the `filters` form there instead. + Returns: dict: A dictionary containing the result of the memory addition operation, typically @@ -1779,6 +1827,8 @@ class Memory(MemoryBase): memory_id (str): ID of the memory to update. text (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. + ``user_id``/``agent_id``/``run_id``/``actor_id`` are ignored here - they are + immutable after creation. expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. data (str, optional): Deprecated alias for ``text``. Will be removed in the next major release; use ``text`` instead. @@ -1865,14 +1915,28 @@ class Memory(MemoryBase): keys, encoded_ids = process_telemetry_filters(filters) capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"}) - # delete all vector memories and reset the collections - memories = self.vector_store.list(filters=filters)[0] - for memory in memories: - self._delete_memory(memory.id) + # Keep listing after each batch is deleted. Most vector stores cap + # list() at 100 results by default, which silently truncates deletes. + deleted_count = 0 + seen_batches = set() + while True: + memories = self.vector_store.list( + filters=filters, top_k=DELETE_ALL_BATCH_SIZE + )[0] + if not memories: + break + batch_ids = tuple(sorted(str(memory.id) for memory in memories)) + if batch_ids in seen_batches: + logger.warning("Stopping delete_all after a repeated memory batch") + break + seen_batches.add(batch_ids) + for memory in memories: + self._delete_memory(memory.id) + deleted_count += len(memories) - logger.info(f"Deleted {len(memories)} memories") + logger.info(f"Deleted {deleted_count} memories") - decay_usage_notice = detect_decay_usage_from_delete_all(len(memories)) + decay_usage_notice = detect_decay_usage_from_delete_all(deleted_count) if decay_usage_notice: display_decay_usage_notice(self, "sync", "delete_all", *decay_usage_notice) else: @@ -1987,7 +2051,7 @@ class Memory(MemoryBase): new_metadata = deepcopy(existing_memory.payload) if metadata is not None: - new_metadata.update(metadata) + new_metadata.update(_strip_identity_keys(metadata, existing_memory.payload)) new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() @@ -1995,10 +2059,6 @@ class Memory(MemoryBase): new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - # actor_id is immutable after creation (issue #4490) - if "actor_id" in existing_memory.payload: - new_metadata["actor_id"] = existing_memory.payload["actor_id"] - if data in existing_embeddings: embeddings = existing_embeddings[data] else: @@ -2397,6 +2457,12 @@ class AsyncMemory(MemoryBase): Pass "procedural_memory" to create procedural memories. prompt (str, optional): Prompt to use for the memory creation. Defaults to None. llm (BaseChatModel, optional): LLM class to use for generating procedural memories. Defaults to None. Useful when user is using LangChain ChatModel. + + Note: + `search()` and `get_all()` scope queries via `filters={"user_id": "...", "agent_id": "...", "run_id": "..."}` β€” + they reject top-level `user_id`/`agent_id`/`run_id` arguments. `add()` accepts them top-level, but passing + the same arguments to `search()`/`get_all()` raises a `ValueError`; use the `filters` form there instead. + Returns: dict: A dictionary containing the result of the memory addition operation. """ @@ -3410,6 +3476,8 @@ class AsyncMemory(MemoryBase): memory_id (str): ID of the memory to update. text (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. + ``user_id``/``agent_id``/``run_id``/``actor_id`` are ignored here - they are + immutable after creation. expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. data (str, optional): Deprecated alias for ``text``. Will be removed in the next major release; use ``text`` instead. @@ -3497,26 +3565,45 @@ class AsyncMemory(MemoryBase): keys, encoded_ids = process_telemetry_filters(filters) capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "async"}) - memories = await asyncio.to_thread(self.vector_store.list, filters=filters) - - delete_tasks = [] - for memory in memories[0]: - delete_tasks.append(self._delete_memory(memory.id, skip_entity_cleanup=True)) - - results = await asyncio.gather(*delete_tasks, return_exceptions=True) + deleted_count = 0 + errors = [] + seen_batches = set() + while True: + memories = await asyncio.to_thread( + self.vector_store.list, + filters=filters, + top_k=DELETE_ALL_BATCH_SIZE, + ) + batch = memories[0] if memories else [] + if not batch: + break + batch_ids = tuple(sorted(str(memory.id) for memory in batch)) + if batch_ids in seen_batches: + logger.warning("Stopping delete_all after a repeated memory batch") + break + seen_batches.add(batch_ids) + delete_tasks = [ + self._delete_memory(memory.id, skip_entity_cleanup=True) + for memory in batch + ] + results = await asyncio.gather(*delete_tasks, return_exceptions=True) + batch_errors = [ + result for result in results if isinstance(result, BaseException) + ] + errors.extend(batch_errors) + deleted_count += len(results) - len(batch_errors) if self._entity_store is not None: await self._bulk_clear_entity_store(filters) - errors = [r for r in results if isinstance(r, BaseException)] if errors: - logger.warning("Failed to delete %d out of %d memories", len(errors), len(results)) + logger.warning("Failed to delete %d memories", len(errors)) for err in errors: logger.warning("Delete error: %s", err) - logger.info(f"Deleted {len(results) - len(errors)} memories") + logger.info(f"Deleted {deleted_count} memories") - decay_usage_notice = detect_decay_usage_from_delete_all(len(memories[0])) + decay_usage_notice = detect_decay_usage_from_delete_all(deleted_count) if decay_usage_notice: await display_decay_usage_notice_async(self, "async", "delete_all", *decay_usage_notice) else: @@ -3585,16 +3672,6 @@ class AsyncMemory(MemoryBase): llm (llm, optional): LLM to use for the procedural memory creation. Defaults to None. prompt (str, optional): Prompt to use for the procedural memory creation. Defaults to None. """ - try: - from langchain_core.messages.utils import ( - convert_to_messages, # type: ignore - ) - except Exception: - logger.error( - "Import error while loading langchain-core. Please install 'langchain-core' to use procedural memory." - ) - raise - logger.info("Creating procedural memory") parsed_messages = [ @@ -3605,6 +3682,19 @@ class AsyncMemory(MemoryBase): try: if llm is not None: + # langchain-core is only needed to adapt messages for a custom + # LangChain LLM. The default path uses self.llm and must not + # require the optional dependency, mirroring the sync version. + try: + from langchain_core.messages.utils import ( + convert_to_messages, # type: ignore + ) + except ImportError as e: + raise ImportError( + "langchain-core is required to pass a custom LLM to procedural memory. " + "Install it with 'pip install langchain-core'." + ) from e + parsed_messages = convert_to_messages(parsed_messages) response = await asyncio.to_thread(llm.invoke, input=parsed_messages) procedural_memory = remove_code_blocks(response.content) @@ -3650,7 +3740,7 @@ class AsyncMemory(MemoryBase): new_metadata = deepcopy(existing_memory.payload) if metadata is not None: - new_metadata.update(metadata) + new_metadata.update(_strip_identity_keys(metadata, existing_memory.payload)) new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() @@ -3658,10 +3748,6 @@ class AsyncMemory(MemoryBase): new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - # actor_id is immutable after creation (issue #4490) - if "actor_id" in existing_memory.payload: - new_metadata["actor_id"] = existing_memory.payload["actor_id"] - if data in existing_embeddings: embeddings = existing_embeddings[data] else: diff --git a/mem0/memory/utils.py b/mem0/memory/utils.py index dd7b1e4cc..b22d60a27 100644 --- a/mem0/memory/utils.py +++ b/mem0/memory/utils.py @@ -121,6 +121,8 @@ def remove_code_blocks(content: str) -> str: - If a code block is detected, it returns only the inner content, stripping out the markers. - If no code block markers are found, the original content is returned as-is. """ + if content is None: + return "" pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$" match = re.match(pattern, content.strip()) match_res=match.group(1).strip() if match else content.strip() @@ -213,8 +215,8 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"): try: description = get_image_description(image_url, llm, vision_details) returned_messages.append({"role": role, "content": description}) - except Exception: - raise Exception(f"Error while downloading {image_url}.") + except Exception as e: + raise Exception(f"Error while downloading {image_url}.") from e else: # Regular text content returned_messages.append(msg) @@ -227,7 +229,7 @@ def process_telemetry_filters(filters): Process the telemetry filters """ if filters is None: - return {} + return [], {} encoded_ids = {} if "user_id" in filters: diff --git a/mem0/reranker/cohere_reranker.py b/mem0/reranker/cohere_reranker.py index 281fabcc6..6063ad0a7 100644 --- a/mem0/reranker/cohere_reranker.py +++ b/mem0/reranker/cohere_reranker.py @@ -1,6 +1,6 @@ import logging import os -from typing import List, Dict, Any +from typing import Any, Dict, List from mem0.reranker.base import BaseReranker @@ -84,7 +84,10 @@ class CohereReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("Cohere reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/reranker/huggingface_reranker.py b/mem0/reranker/huggingface_reranker.py index be87d3dba..6af1bee2e 100644 --- a/mem0/reranker/huggingface_reranker.py +++ b/mem0/reranker/huggingface_reranker.py @@ -1,14 +1,15 @@ import logging -from typing import List, Dict, Any, Union +from typing import Any, Dict, List, Union + import numpy as np -from mem0.reranker.base import BaseReranker from mem0.configs.rerankers.base import BaseRerankerConfig from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig +from mem0.reranker.base import BaseReranker try: - from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer TRANSFORMERS_AVAILABLE = True except ImportError: TRANSFORMERS_AVAILABLE = False @@ -163,7 +164,10 @@ class HuggingFaceReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("HuggingFace reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/reranker/llm_reranker.py b/mem0/reranker/llm_reranker.py index cef2dee66..c5fecb14f 100644 --- a/mem0/reranker/llm_reranker.py +++ b/mem0/reranker/llm_reranker.py @@ -27,7 +27,7 @@ class LLMReranker(BaseReranker): # Convert BaseRerankerConfig to LLMRerankerConfig with defaults config = LLMRerankerConfig( provider=getattr(config, 'provider', 'openai'), - model=getattr(config, 'model', 'gpt-4o-mini'), + model=getattr(config, 'model', 'gpt-5-mini'), api_key=getattr(config, 'api_key', None), top_k=getattr(config, 'top_k', None), temperature=0.0, # Default for reranking diff --git a/mem0/reranker/sentence_transformer_reranker.py b/mem0/reranker/sentence_transformer_reranker.py index d891294f1..2b92f3e80 100644 --- a/mem0/reranker/sentence_transformer_reranker.py +++ b/mem0/reranker/sentence_transformer_reranker.py @@ -1,10 +1,13 @@ import logging -from typing import List, Dict, Any, Union +from typing import Any, Dict, List, Union + import numpy as np -from mem0.reranker.base import BaseReranker from mem0.configs.rerankers.base import BaseRerankerConfig -from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig +from mem0.configs.rerankers.sentence_transformer import ( + SentenceTransformerRerankerConfig, +) +from mem0.reranker.base import BaseReranker try: from sentence_transformers import CrossEncoder @@ -108,7 +111,10 @@ class SentenceTransformerReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/reranker/zero_entropy_reranker.py b/mem0/reranker/zero_entropy_reranker.py index dcf71bfaf..68228c85d 100644 --- a/mem0/reranker/zero_entropy_reranker.py +++ b/mem0/reranker/zero_entropy_reranker.py @@ -1,6 +1,6 @@ import logging import os -from typing import List, Dict, Any +from typing import Any, Dict, List from mem0.reranker.base import BaseReranker @@ -95,7 +95,10 @@ class ZeroEntropyReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("Zero Entropy reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/utils/factory.py b/mem0/utils/factory.py index cf01ccafc..30a1079bb 100644 --- a/mem0/utils/factory.py +++ b/mem0/utils/factory.py @@ -19,7 +19,9 @@ 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.sentence_transformer import ( + SentenceTransformerRerankerConfig, +) from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig from mem0.embeddings.mock import MockEmbeddings @@ -86,7 +88,7 @@ class LlmFactory: config = config_class(**kwargs) elif isinstance(config, dict): # Merge dict config with kwargs - config.update(kwargs) + config = {**config, **kwargs} config = config_class(**config) elif isinstance(config, BaseLlmConfig): # Convert base config to provider-specific config if needed @@ -201,6 +203,7 @@ class VectorStoreFactory: "cassandra": "mem0.vector_stores.cassandra.CassandraDB", "neptune": "mem0.vector_stores.neptune_analytics.NeptuneAnalyticsVector", "turbopuffer": "mem0.vector_stores.turbopuffer.TurbopufferDB", + "oracledb": "mem0.vector_stores.oracledb.OracleAIVectorSearch", } @classmethod diff --git a/mem0/vector_stores/baidu.py b/mem0/vector_stores/baidu.py index e9437232d..2831fcaf7 100644 --- a/mem0/vector_stores/baidu.py +++ b/mem0/vector_stores/baidu.py @@ -224,8 +224,15 @@ class BaiduDB(VectorStoreBase): output = [] for row in res.rows: row_data = row.get("row", {}) + # Mochow returns the raw L2 distance (lower = closer). Convert it to a + # similarity score (higher = better) to satisfy the VectorStoreBase + # contract, mirroring the milvus provider. Non-L2 metrics already + # return a higher-is-better score. + raw_score = row.get("score", 0.0) + if self.metric_type in (MetricType.L2, "L2"): + raw_score = 1.0 / (1.0 + raw_score) output_data = OutputData( - id=row_data.get("id"), score=row.get("score", 0.0), payload=row_data.get("metadata", {}) + id=row_data.get("id"), score=raw_score, payload=row_data.get("metadata", {}) ) output.append(output_data) diff --git a/mem0/vector_stores/chroma.py b/mem0/vector_stores/chroma.py index b37872643..950c2d049 100644 --- a/mem0/vector_stores/chroma.py +++ b/mem0/vector_stores/chroma.py @@ -254,110 +254,111 @@ class ChromaDB(VectorStoreBase): def _generate_where_clause(where: dict[str, any]) -> dict[str, any]: """ Generate a properly formatted where clause for ChromaDB. - + + ChromaDB's where grammar allows exactly one field or one logical + operator per dict level, so multiple operators on the same field and + multi-field conditions must be combined with an explicit ``$and``. + Args: where (dict[str, any]): The filter conditions. - + Returns: dict[str, any]: Properly formatted where clause for ChromaDB. """ if where is None: return None - - def convert_condition(key: str, value: any) -> dict: - """Convert universal filter format to ChromaDB format.""" + + op_map = { + "eq": "$eq", + "ne": "$ne", + "gt": "$gt", + "gte": "$gte", + "lt": "$lt", + "lte": "$lte", + "in": "$in", + "nin": "$nin", + } + # Negation of each operator. contains/icontains fall back to equality + # on the positive path (ChromaDB has no substring match), so their + # negation falls back to inequality for consistency. + negate_map = { + "eq": "$ne", + "ne": "$eq", + "gt": "$lte", + "gte": "$lt", + "lt": "$gte", + "lte": "$gt", + "in": "$nin", + "nin": "$in", + } + + def convert_condition(key: str, value: any) -> list: + """Convert one field condition to a list of single-field ChromaDB clauses.""" if value == "*": # Wildcard - match any value (ChromaDB doesn't have direct wildcard, so we skip this filter) + return [] + if isinstance(value, dict): + # One clause per operator: ChromaDB rejects field expressions + # with more than one operator, so a range like + # {"gte": 18, "lte": 65} must become two clauses combined + # with $and by the caller (previously each operator + # overwrote the last, silently dropping bounds). + # contains/icontains and unknown operators fall back to equality. + return [{key: {op_map.get(op, "$eq"): val}} for op, val in value.items()] + # Simple equality + return [{key: {"$eq": value}}] + + def combine(clauses: list, operator: str): + """Combine clauses under a logical operator, unwrapping singletons.""" + if not clauses: return None - elif isinstance(value, dict): - # Handle comparison operators - chroma_condition = {} - for op, val in value.items(): - if op == "eq": - chroma_condition[key] = {"$eq": val} - elif op == "ne": - chroma_condition[key] = {"$ne": val} - elif op == "gt": - chroma_condition[key] = {"$gt": val} - elif op == "gte": - chroma_condition[key] = {"$gte": val} - elif op == "lt": - chroma_condition[key] = {"$lt": val} - elif op == "lte": - chroma_condition[key] = {"$lte": val} - elif op == "in": - chroma_condition[key] = {"$in": val} - elif op == "nin": - chroma_condition[key] = {"$nin": val} - elif op in ["contains", "icontains"]: - # ChromaDB doesn't support contains, fallback to equality - chroma_condition[key] = {"$eq": val} - else: - # Unknown operator, treat as equality - chroma_condition[key] = {"$eq": val} - return chroma_condition - else: - # Simple equality - return {key: {"$eq": value}} - + if len(clauses) == 1: + return clauses[0] + return {operator: clauses} + processed_filters = [] - + for key, value in where.items(): if key == "$or": - # Handle OR conditions or_conditions = [] for condition in value: - or_condition = {} + sub_clauses = [] for sub_key, sub_value in condition.items(): - converted = convert_condition(sub_key, sub_value) - if converted: - or_condition.update(converted) - if or_condition: - or_conditions.append(or_condition) - - if len(or_conditions) > 1: - processed_filters.append({"$or": or_conditions}) - elif len(or_conditions) == 1: - processed_filters.append(or_conditions[0]) - + sub_clauses.extend(convert_condition(sub_key, sub_value)) + combined = combine(sub_clauses, "$and") + if combined: + or_conditions.append(combined) + combined_or = combine(or_conditions, "$or") + if combined_or: + processed_filters.append(combined_or) + elif key == "$not": - negate_op = { - "eq": "$ne", "ne": "$eq", - "gt": "$lte", "gte": "$lt", - "lt": "$gte", "lte": "$gt", - "in": "$nin", "nin": "$in", - } negated_per_group = [] for condition in value: negated_fields = [] for sub_key, sub_value in condition.items(): if isinstance(sub_value, dict): for op, val in sub_value.items(): - neg = negate_op.get(op) - if neg: - negated_fields.append({sub_key: {neg: val}}) + # Unknown operators mirror the positive-path + # equality fallback as inequality (previously + # they were silently dropped, which could + # erase the entire NOT clause). + negated_fields.append({sub_key: {negate_map.get(op, "$ne"): val}}) else: negated_fields.append({sub_key: {"$ne": sub_value}}) - if len(negated_fields) > 1: - negated_per_group.append({"$or": negated_fields}) - elif len(negated_fields) == 1: - negated_per_group.append(negated_fields[0]) + # NOT(a AND b) == (NOT a) OR (NOT b) + combined = combine(negated_fields, "$or") + if combined: + negated_per_group.append(combined) + combined_not = combine(negated_per_group, "$and") + if combined_not: + processed_filters.append(combined_not) - if len(negated_per_group) > 1: - processed_filters.append({"$and": negated_per_group}) - elif len(negated_per_group) == 1: - processed_filters.append(negated_per_group[0]) - else: # Regular condition - converted = convert_condition(key, value) - if converted: - processed_filters.append(converted) - + combined = combine(convert_condition(key, value), "$and") + if combined: + processed_filters.append(combined) + # Return appropriate format based on number of conditions - if len(processed_filters) == 0: - return None - elif len(processed_filters) == 1: - return processed_filters[0] - else: - return {"$and": processed_filters} + return combine(processed_filters, "$and") diff --git a/mem0/vector_stores/configs.py b/mem0/vector_stores/configs.py index 32459dddc..990086c7d 100644 --- a/mem0/vector_stores/configs.py +++ b/mem0/vector_stores/configs.py @@ -35,6 +35,7 @@ class VectorStoreConfig(BaseModel): "langchain": "LangchainConfig", "s3_vectors": "S3VectorsConfig", "turbopuffer": "TurbopufferConfig", + "oracledb": "OracleAIVectorSearchConfig", } @model_validator(mode="after") diff --git a/mem0/vector_stores/elasticsearch.py b/mem0/vector_stores/elasticsearch.py index 96e520236..6578e9863 100644 --- a/mem0/vector_stores/elasticsearch.py +++ b/mem0/vector_stores/elasticsearch.py @@ -163,7 +163,10 @@ class ElasticsearchDB(VectorStoreBase): search_query = self.custom_search_query(vectors, top_k, filters) else: search_query = { - "knn": {"field": "vector", "query_vector": vectors, "k": top_k, "num_candidates": top_k * 2} + # Without `size`, Elasticsearch caps the response at its default of 10 hits + # regardless of `knn.k`, silently truncating results when top_k > 10. + "size": top_k, + "knn": {"field": "vector", "query_vector": vectors, "k": top_k, "num_candidates": top_k * 2}, } if filters: filter_conditions = [] diff --git a/mem0/vector_stores/milvus.py b/mem0/vector_stores/milvus.py index 748553ccc..c415722f4 100644 --- a/mem0/vector_stores/milvus.py +++ b/mem0/vector_stores/milvus.py @@ -159,7 +159,10 @@ class MilvusDB(VectorStoreBase): for key, value in filters.items(): if not self._SAFE_FILTER_KEY.match(key): raise ValueError(f"Invalid filter key: {key!r}") - if isinstance(value, str): + if value == "*": + # Wildcard - match any value (MilvusDB doesn't have direct wildcard, so we skip this filter) + continue + elif isinstance(value, str): escaped = value.replace("\\", "\\\\").replace('"', '\\"') operands.append(f'(metadata["{key}"] == "{escaped}")') elif isinstance(value, (int, float, bool)): @@ -295,10 +298,12 @@ class MilvusDB(VectorStoreBase): if payload is None: payload = existing[0].get("metadata") - text = "" - if payload: - text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535] - schema = {"id": vector_id, "vectors": vector, "metadata": payload, "text": text} + schema = {"id": vector_id, "vectors": vector, "metadata": payload} + if self._has_bm25_schema: + text = "" + if payload: + text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535] + schema["text"] = text self.client.upsert(collection_name=self.collection_name, data=schema) def get(self, vector_id) -> Optional[OutputData]: diff --git a/mem0/vector_stores/mongodb.py b/mem0/vector_stores/mongodb.py index a7738c6f2..53c0b7de6 100644 --- a/mem0/vector_stores/mongodb.py +++ b/mem0/vector_stores/mongodb.py @@ -15,7 +15,6 @@ except ImportError: from mem0.vector_stores.base import VectorStoreBase logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) _DRIVER_METADATA = DriverInfo(name="Mem0", version=version("mem0ai")) diff --git a/mem0/vector_stores/neptune_analytics.py b/mem0/vector_stores/neptune_analytics.py index 905b5de57..4c9868beb 100644 --- a/mem0/vector_stores/neptune_analytics.py +++ b/mem0/vector_stores/neptune_analytics.py @@ -232,6 +232,20 @@ class NeptuneAnalyticsVector(VectorStoreBase): vector (Optional[List[float]]): New embedding vector. payload (Optional[Dict]): New metadata to replace existing payload. """ + # ponytail: a combined update writes the payload before the embedding, and Neptune's + # vector index isn't transactional -- if the upsert below fails, the new payload would + # otherwise be left committed against the stale embedding. Capture the prior properties + # so a failed upsert can be restored; this is best-effort compensation, not a rollback. + # Only needed when both writes happen -- a payload-only or vector-only update can't desync. + # The restore assumes a single writer per vector_id -- concurrent updates to the same node + # can interleave and clobber each other's compensation. AWS advises against concurrent + # same-vertex writes to the Neptune Analytics vector index for exactly this reason. + prior_properties = None + if payload and vector: + prior = self.get(vector_id) + if prior is not None: + prior_properties = dict(prior.payload or {}) + prior_properties[self._FIELD_LABEL] = self.collection_name if payload: # Replace payload @@ -242,9 +256,9 @@ class NeptuneAnalyticsVector(VectorStoreBase): "vector_id": vector_id } query_string_embedding = f""" - MATCH (n :{self.collection_name}) - WHERE id(n) = $vector_id - SET n = $properties + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + SET n = $properties """ self.execute_query(query_string_embedding, para_payload) @@ -254,14 +268,40 @@ class NeptuneAnalyticsVector(VectorStoreBase): "vector_id": vector_id } query_string_embedding = f""" - MATCH (n :{self.collection_name}) - WHERE id(n) = $vector_id - WITH $embedding as embedding, n as n - CALL neptune.algo.vectors.upsert(n, embedding) - YIELD success - RETURN success + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + WITH $embedding as embedding, n as n + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success """ - self.execute_query(query_string_embedding, para_embedding) + try: + result = self.execute_query(query_string_embedding, para_embedding) + # A soft {"success": False} row desyncs the payload from the embedding just as + # much as a thrown error, so treat it as a failure and let the rollback below fire. + # Mirrors the TS store's assertSuccessfulResults() check (Python's + # _process_success_message only logs, so it cannot drive the rollback). + for row in result or []: + if "success" in row and row["success"] is not True: + raise RuntimeError(f"Neptune Analytics update upsert reported failure for {vector_id}") + except Exception: + if prior_properties is not None: + try: + restore_query = f""" + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + SET n = $properties + """ + self.execute_query( + restore_query, + {"properties": prior_properties, "vector_id": vector_id}, + ) + except Exception: + logger.error( + f"Neptune Analytics: failed to restore prior payload for {vector_id} " + "after a failed vector upsert" + ) + raise diff --git a/mem0/vector_stores/opensearch.py b/mem0/vector_stores/opensearch.py index 8b8b85377..73246858a 100644 --- a/mem0/vector_stores/opensearch.py +++ b/mem0/vector_stores/opensearch.py @@ -16,6 +16,7 @@ from mem0.vector_stores.base import VectorStoreBase logger = logging.getLogger(__name__) _SAFE_FILTER_KEY = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]*$") +_IDENTITY_FILTER_KEYS = ("user_id", "agent_id", "run_id") def _validate_filter(key: str, value) -> None: @@ -28,6 +29,29 @@ def _validate_filter(key: str, value) -> None: ) +def _build_filter_clauses(filters): + """Build term clauses from every filter key, not just the identity keys.""" + filter_clauses = [] + for key, value in (filters or {}).items(): + if value is None: + continue + if value == "*": + # "Any value" wildcard (a documented Platform pattern): match + # documents where the field exists β€” as opensearch.ts already + # does for every key β€” instead of a literal, near-always-empty + # term match on the string "*". + _validate_filter(key, value) + filter_clauses.append({"exists": {"field": f"payload.{key}"}}) + continue + if key not in _IDENTITY_FILTER_KEYS and not isinstance(value, (str, int, float, bool)): + logger.debug(f"Ignoring non-scalar filter value for key {key!r}") + continue + _validate_filter(key, value) + field = f"payload.{key}.keyword" if isinstance(value, str) else f"payload.{key}" + filter_clauses.append({"term": {field: value}}) + return filter_clauses + + class OutputData(BaseModel): id: str score: float @@ -204,13 +228,7 @@ class OpenSearchDB(VectorStoreBase): query_body = {"size": top_k * 2, "query": None} # Prepare filter conditions if applicable - filter_clauses = [] - if filters: - for key in ["user_id", "run_id", "agent_id"]: - value = filters.get(key) - if value: - _validate_filter(key, value) - filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + filter_clauses = _build_filter_clauses(filters) # Combine knn with filters if needed if filter_clauses: @@ -230,7 +248,7 @@ class OpenSearchDB(VectorStoreBase): return results except Exception as e: logger.error(f"Error during search: {e}", exc_info=True) - return [] + raise def keyword_search(self, query, top_k=5, filters=None): """Search for memories using BM25 keyword matching. @@ -255,13 +273,7 @@ class OpenSearchDB(VectorStoreBase): } # Apply filters consistently with the existing search() method - filter_clauses = [] - if filters: - for key in ["user_id", "run_id", "agent_id"]: - value = filters.get(key) - if value: - _validate_filter(key, value) - filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + filter_clauses = _build_filter_clauses(filters) if filter_clauses: bool_query["filter"] = filter_clauses @@ -281,8 +293,12 @@ class OpenSearchDB(VectorStoreBase): ] return results except Exception as e: - logger.error(f"Error during keyword search: {e}") - return [] + # Do NOT re-raise here: keyword_search() is a best-effort helper that + # search() may call to augment semantic results. Raising would crash + # the whole search() call on a keyword-only failure (regression per + # maintainer review on #6519). Log with exc_info and degrade to None. + logger.error(f"Error during keyword search: {e}", exc_info=True) + return None def delete(self, vector_id: str) -> None: """Delete a vector by custom ID.""" @@ -370,13 +386,7 @@ class OpenSearchDB(VectorStoreBase): """List all memories with optional filters.""" query: Dict = {"query": {"match_all": {}}} - filter_clauses = [] - if filters: - for key in ["user_id", "run_id", "agent_id"]: - value = filters.get(key) - if value: - _validate_filter(key, value) - filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + filter_clauses = _build_filter_clauses(filters) if filter_clauses: query["query"] = {"bool": {"filter": filter_clauses}} diff --git a/mem0/vector_stores/oracledb.py b/mem0/vector_stores/oracledb.py new file mode 100644 index 000000000..9cf7dd83c --- /dev/null +++ b/mem0/vector_stores/oracledb.py @@ -0,0 +1,602 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. +"""Oracle AI Vector Search vector store integration for mem0.""" + +import array +import json +import logging +import math +import re +import uuid +from contextlib import contextmanager +from typing import Any, Dict, List, Optional + +try: + import oracledb +except ImportError as exc: # pragma: no cover - dependency guard + raise ImportError("Oracle AI Vector Search requires the 'oracledb' package.") from exc + +from pydantic import BaseModel + +from mem0.configs.vector_stores.oracledb import OracleAIVectorSearchConfig +from mem0.vector_stores.base import VectorStoreBase + +logger = logging.getLogger(__name__) + + +class OutputData(BaseModel): + """Standard output structure returned from vector operations.""" + + id: Optional[str] + score: Optional[float] + payload: Optional[Dict[str, Any]] + + +# Allow letters, digits, underscore, dot, brackets, comma, *, space (for 'to') +METADATA_PATTERN = re.compile(r"[a-zA-Z0-9_\.\[\],\s\*]+") + + +def _validate_metadata_key(metadata_key: str) -> None: + if not METADATA_PATTERN.fullmatch(metadata_key): + raise ValueError( + f"Invalid metadata key '{metadata_key}'. " + "Only letters, numbers, underscores, nesting via '.', " + "and array wildcards '[*]' are allowed." + ) + + +_SCORE_FROM_DISTANCE = { + "COSINE": lambda d: max(0.0, min(1.0, 1.0 - d)), + "EUCLIDEAN": lambda d: 1.0 / (1.0 + max(0.0, d)), + "EUCLIDEAN_SQUARED": lambda d: 1.0 / (1.0 + math.sqrt(max(0.0, d))), + "HAMMING": lambda d: 1.0 / (1.0 + max(0.0, d)), + "MANHATTAN": lambda d: 1.0 / (1.0 + max(0.0, d)), + "DOT": lambda d: -d, +} + + +def _convert_distance_to_score(distance: float, metric: str) -> float: + try: + return _SCORE_FROM_DISTANCE[metric.upper()](distance) + except KeyError: + raise ValueError(f"Unsupported distance metric: {metric}") from None + + +_FIELD_OPERATORS = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "icontains"} +_COMPARISON_OPERATORS = { + "eq": "==", + "ne": "!=", + "gt": ">", + "gte": ">=", + "lt": "<", + "lte": "<=", +} +_LOGICAL_OPERATORS = { + "$and": "and", + "$or": "or", + "$not": "not", + "AND": "and", + "OR": "or", + "NOT": "not", +} + + +def _json_path(metadata_key: str) -> str: + _validate_metadata_key(metadata_key) + + path_parts: List[str] = [] + for part in metadata_key.split("."): + if part.endswith("[*]"): + path_parts.append(f'."{part[:-3]}"[*]') + else: + path_parts.append(f'."{part}"') + return "".join(path_parts) + + +def _bind_filter_value(value: Any, params: Dict[str, Any]) -> tuple[str, str]: + param = f"f_{len(params)}" + params[param] = value + return f"${param}", f':{param} AS "{param}"' + + +def _json_exists(json_path: str, predicate: str, passings: List[str]) -> str: + passing_clause = f" PASSING {', '.join(passings)}" if passings else "" + return f"JSON_EXISTS(payload, '${json_path}?({predicate})'{passing_clause})" + + +def _validate_scalar_operand(operator: str, value: Any) -> None: + if isinstance(value, (dict, list, tuple, set)): + raise ValueError(f"Oracle filter operator {operator!r} requires a scalar value") + + +def _build_field_condition(metadata_key: str, value: Any, params: Dict[str, Any]) -> str: + json_path = _json_path(metadata_key) + + if value == "*": + return f"JSON_EXISTS(payload, '${json_path}')" + + if not isinstance(value, dict): + _validate_scalar_operand("eq", value) + if value is None: + return _json_exists(json_path, "@ == null", []) + variable, passing = _bind_filter_value(value, params) + return _json_exists(json_path, f"@ == {variable}", [passing]) + + if not value: + raise ValueError(f"Operator filter for field {metadata_key!r} must not be empty") + + unsupported = set(value) - _FIELD_OPERATORS + if unsupported: + raise ValueError( + f"Unsupported Oracle filter operator(s) for field {metadata_key!r}: " + f"{', '.join(sorted(map(str, unsupported)))}" + ) + + predicates: List[str] = [] + passings: List[str] = [] + additional_clauses: List[str] = [] + + for operator, operand in value.items(): + if operator in _COMPARISON_OPERATORS: + _validate_scalar_operand(operator, operand) + if operand is None: + if operator not in {"eq", "ne"}: + raise ValueError(f"Oracle filter operator {operator!r} does not support null") + predicates.append(f"@ {_COMPARISON_OPERATORS[operator]} null") + continue + variable, passing = _bind_filter_value(operand, params) + predicates.append(f"@ {_COMPARISON_OPERATORS[operator]} {variable}") + passings.append(passing) + continue + + if operator in {"in", "nin"}: + if not isinstance(operand, (list, tuple)) or not operand: + raise ValueError(f"Oracle filter operator {operator!r} requires a non-empty list") + + variables: List[str] = [] + list_passings: List[str] = [] + for item in operand: + _validate_scalar_operand(operator, item) + if item is None: + variables.append("null") + continue + variable, passing = _bind_filter_value(item, params) + variables.append(variable) + list_passings.append(passing) + + membership = _json_exists(json_path, f"@ in ({', '.join(variables)})", list_passings) + if operator == "in": + additional_clauses.append(membership) + else: + additional_clauses.append(f"NOT ({membership})") + continue + + if not isinstance(operand, str): + raise ValueError(f"Oracle filter operator {operator!r} requires a string value") + + if operator == "contains": + variable, passing = _bind_filter_value(operand, params) + predicates.append(f"@ has substring {variable}") + passings.append(passing) + else: + variable, passing = _bind_filter_value(operand.lower(), params) + predicates.append(f"@.lower() has substring {variable}") + passings.append(passing) + + clauses = list(additional_clauses) + if predicates: + clauses.insert(0, _json_exists(json_path, " && ".join(predicates), passings)) + + if len(clauses) == 1: + return clauses[0] + return "(" + " AND ".join(clauses) + ")" + + +def _build_filter_group(filters: Dict[str, Any], params: Dict[str, Any]) -> str: + if not isinstance(filters, dict) or not filters: + raise ValueError("Oracle filter groups must be non-empty dictionaries") + + clauses: List[str] = [] + for key, value in filters.items(): + if key in _LOGICAL_OPERATORS: + if not isinstance(value, list) or not value: + raise ValueError(f"Logical filter operator {key!r} requires a non-empty list") + + nested = [_build_filter_group(condition, params) for condition in value] + logical_operator = _LOGICAL_OPERATORS[key] + if logical_operator == "not": + clauses.append(f"NOT ({' OR '.join(nested)})") + else: + joiner = " AND " if logical_operator == "and" else " OR " + clauses.append("(" + joiner.join(nested) + ")") + continue + + if key.startswith("$"): + raise ValueError(f"Unsupported Oracle logical filter operator: {key}") + + clauses.append(_build_field_condition(key, value, params)) + + if len(clauses) == 1: + return clauses[0] + return "(" + " AND ".join(clauses) + ")" + + +class OracleAIVectorSearch(VectorStoreBase): + """Oracle AI Vector Search backend for mem0.""" + + def __init__(self, **kwargs: Any) -> None: + self.config = OracleAIVectorSearchConfig(**kwargs) + self.collection_name = self.config.collection_name + + if self.config.client: + logger.debug("Using Oracle connection pool: %s", self.config.client) + self.client = self.config.client + self._owns_client = False + elif self.config.use_connection_pool: + pool_kwargs = { + "min": 1, + "max": 4, + } + pool_kwargs.update(self.config.connection_params) + + logger.debug("Creating Oracle connection pool") + self.client = oracledb.create_pool(**pool_kwargs) + self._owns_client = True + else: + logger.debug("Creating Oracle connection") + self.client = oracledb.connect(**self.config.connection_params) + self._owns_client = True + + try: + if not (hasattr(self.client, "thin") and self.client.thin): + if oracledb.clientversion()[:2] < (23, 4): + raise RuntimeError( + f"Oracle DB client driver version {'.'.join(map(str, oracledb.clientversion()))} " + "not supported, must be >=23.4 for vector support" + ) + + if isinstance(self.client, oracledb.Connection): + db_version = tuple([int(v) for v in self.client.version.split(".")]) + else: + with self.client.acquire() as conn: + db_version = tuple([int(v) for v in conn.version.split(".")]) + + if db_version < (23, 4): + raise ValueError( + f"Oracle DB version {'.'.join(map(str, db_version))} not supported, " + "must be >=23.4 for vector support" + ) + + self.create_col() + except Exception: + if self._owns_client: + try: + self.client.close() + except Exception: + pass + raise + + @contextmanager + def _get_cursor(self, commit: bool = False): + if isinstance(self.client, oracledb.ConnectionPool): + with self.client.acquire() as connection: + with connection.cursor() as cursor: + try: + yield cursor + if commit: + connection.commit() + except Exception: + connection.rollback() + raise + else: + with self.client.cursor() as cursor: + try: + yield cursor + if commit: + self.client.commit() + except Exception: + self.client.rollback() + raise + + # Utility helpers -------------------------------------------------- + @staticmethod + def _load_payload(value: Any) -> Dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return value + if hasattr(value, "read"): + value = value.read() + if isinstance(value, bytes): + value = value.decode("utf-8") + try: + return json.loads(value) + except json.JSONDecodeError: + logger.debug("Failed to decode payload JSON") + raise + + @staticmethod + def _catalog_name(name: str) -> str: + return name.replace('"', "") + + def _create_index_ddl(self) -> str: + accuracy_str = "" + if self.config.index_accuracy: + accuracy_str = f"WITH TARGET ACCURACY {self.config.index_accuracy}" + + parameters = self._index_parameters() + parameters_str = f"PARAMETERS ({parameters})" if parameters else "" + + distance_metric = self.config.distance_metric + + create_index = ( + f"CREATE VECTOR INDEX IF NOT EXISTS {self.config.index_name} ON {self.collection_name} (vector) " + f"ORGANIZATION {'INMEMORY NEIGHBOR GRAPH' if self.config.index_type == 'HNSW' else 'NEIGHBOR PARTITIONS'}" + f" DISTANCE {distance_metric} {accuracy_str} {parameters_str}" + ) + + return create_index + + def _index_parameters(self) -> str: + index_parameters = self.config.index_parameters + if not index_parameters: + return "" + + parameters = [f"type {self.config.index_type}"] + parameters.extend(f"{key} {value}" for key, value in index_parameters.items()) + + return ", ".join(parameters) + + # Vector store API ------------------------------------------------- + def create_col(self) -> None: + """ + Create a new collection (table in Oracle). + Will also initialize vector search index if specified. + """ + with self._get_cursor(commit=True) as cursor: + cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.collection_name} ( + id VARCHAR2(36) PRIMARY KEY, + vector VECTOR({self.config.embedding_model_dims}), + payload JSON + ) + """ + ) + + if self.config.do_create_index: + ddl = self._create_index_ddl() + cursor.execute(ddl) + + def insert( + self, + vectors: List[List[float]], + payloads: Optional[List[Dict[str, Any]]] = None, + ids: Optional[List[str]] = None, + ) -> None: + logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}") + + if payloads is not None and len(payloads) != len(vectors): + raise ValueError(f"Payload count must match vector count. Expected {len(vectors)} got {len(payloads)}.") + if ids is not None and len(ids) != len(vectors): + raise ValueError(f"ID count must match vector count. Expected {len(vectors)} got {len(ids)}.") + + ids = ids or [str(uuid.uuid4()) for _ in vectors] + data = [ + {"id": _id, "vector": array.array("f", vector), "payload": payload} + for vector, payload, _id in zip(vectors, payloads or [{}] * len(vectors), ids) + ] + + with self._get_cursor(commit=True) as cursor: + cursor.setinputsizes( + vector=oracledb.DB_TYPE_VECTOR, + payload=oracledb.DB_TYPE_JSON, + ) + cursor.executemany( + f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES (:id, :vector, :payload)", data + ) + + def search( + self, + query: str, + vectors: List[float], + top_k: int = 5, + filters: Optional[Dict[str, Any]] = None, + ) -> List[OutputData]: + """ + Search for similar vectors using the vector search index. + + Args: + query (str): Query string + vectors (List[float]): Query vector. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (Dict, optional): Filters to apply to the search. + + Returns: + List[OutputData]: Search results. + """ + filter_clause, params = self._build_filters(filters) + + distance_metric = self.config.distance_metric + + sql = ( + f"SELECT id, payload, VECTOR_DISTANCE(vector, :query_vec, {distance_metric}) distance " + f"FROM {self.collection_name} {filter_clause} ORDER BY distance FETCH APPROX FIRST :limit ROWS ONLY" + ) + + with self._get_cursor() as cursor: + cursor.execute(sql, query_vec=array.array("f", vectors), limit=top_k, **params) + rows = cursor.fetchall() + + return [ + OutputData( + id=row[0], + payload=self._load_payload(row[1]), + score=_convert_distance_to_score(float(row[2]), distance_metric), + ) + for row in rows + ] + + def _build_filters(self, filters: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + if not filters: + return "", {} + + params: Dict[str, Any] = {} + return "WHERE " + _build_filter_group(filters, params), params + + def delete(self, vector_id: str) -> None: + """ + Delete a vector by ID. + + Args: + vector_id (str): ID of the vector to delete. + """ + with self._get_cursor(commit=True) as cursor: + cursor.execute(f"DELETE FROM {self.collection_name} WHERE id = :id", id=vector_id) + + def update( + self, + vector_id: str, + vector: Optional[List[float]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Update a vector and its payload. + + Args: + vector_id (str): ID of the vector to update. + vector (List[float], optional): Updated vector. + payload (Dict, optional): Updated payload. + """ + if vector is None and payload is None: + return + + with self._get_cursor(commit=True) as cursor: + sets, params = [], {"vector_id": vector_id} + if vector is not None: + sets.append("vector = :vector") + params["vector"] = array.array("f", vector) + cursor.setinputsizes(vector=oracledb.DB_TYPE_VECTOR) + if payload is not None: + sets.append("payload = :payload") + params["payload"] = payload + cursor.setinputsizes(payload=oracledb.DB_TYPE_JSON) + cursor.execute(f"UPDATE {self.collection_name} SET {', '.join(sets)} WHERE id = :vector_id", params) + + def get(self, vector_id: str) -> Optional[OutputData]: + """ + Retrieve a vector by ID. + + Args: + vector_id (str): ID of the vector to retrieve. + + Returns: + OutputData: Retrieved vector. + """ + with self._get_cursor() as cursor: + cursor.execute( + f"SELECT id, payload FROM {self.collection_name} WHERE id = :vector_id", + vector_id=vector_id, + ) + row = cursor.fetchone() + if row is None: + return None + return OutputData(id=row[0], score=None, payload=self._load_payload(row[1])) + + def list_cols(self) -> List[str]: + """ + List all collections. + + Returns: + List[str]: List of collection names. + """ + with self._get_cursor() as cursor: + cursor.execute("SELECT table_name FROM user_tables") + tables = [row[0] for row in cursor.fetchall()] + return tables + + def delete_col(self) -> None: + """Delete a collection.""" + with self._get_cursor(commit=True) as cursor: + cursor.execute(f"DROP TABLE {self.collection_name} PURGE") + + def col_info(self) -> Dict[str, Any]: + """ + Get information about a collection. + + Returns: + Dict[str, Any]: Collection information. + """ + owner, table_name = self._split_collection_name() + + sql = f""" + SELECT + table_name, + (SELECT COUNT(*) FROM {self.collection_name}) AS row_count, + (SELECT + ROUND(SUM(bytes) / 1024 / 1024, 2) || ' MB' + FROM user_segments + WHERE segment_name = :table_name + AND segment_type = 'TABLE' + ) AS total_size + FROM all_tables + WHERE table_name = :table_name + AND owner = NVL(:owner, USER) + """ + + with self._get_cursor() as cursor: + cursor.execute(sql, table_name=table_name, owner=owner) + result = cursor.fetchone() + + if result is None: + raise ValueError(f"Collection {self.collection_name} not found") + + return {"name": result[0], "count": result[1], "size": result[2]} + + def _split_collection_name(self) -> tuple[Optional[str], str]: + """Split the quoted collection name into its optional owner and table parts.""" + segments = re.findall(r'"([^"]+)"', self.collection_name) + if len(segments) > 1: + return segments[-2], segments[-1] + return None, segments[-1] + + def list(self, filters: Optional[Dict[str, Any]] = None, top_k: Optional[int] = 100) -> List[List[OutputData]]: + """ + List all vectors in a collection. + + Args: + filters (Dict, optional): Filters to apply to the list. + top_k (int, optional): Number of vectors to return. Defaults to 100. + + Returns: + List[List[OutputData]]: A single-element list holding the list of vectors. + """ + filter_clause, params = self._build_filters(filters) + + limit_clause = "" + if top_k is not None: + limit_clause = " FETCH FIRST :limit ROWS ONLY" + params["limit"] = top_k + + sql = f"SELECT id, payload FROM {self.collection_name} {filter_clause} {limit_clause}" + + with self._get_cursor() as cursor: + cursor.execute(sql, **params) + rows = cursor.fetchall() + + return [[OutputData(id=row[0], score=None, payload=self._load_payload(row[1])) for row in rows]] + + def reset(self) -> None: + """Reset the index by deleting and recreating it.""" + logger.warning("Resetting collection %s", self.collection_name) + self.delete_col() + self.create_col() + + def __del__(self) -> None: + """ + Close the database connection pool when the object is deleted. + """ + try: + if getattr(self, "_owns_client", False): + self.client.close() + except Exception: + pass diff --git a/mem0/vector_stores/pgvector.py b/mem0/vector_stores/pgvector.py index d36cab1cc..f55435572 100644 --- a/mem0/vector_stores/pgvector.py +++ b/mem0/vector_stores/pgvector.py @@ -89,6 +89,10 @@ def _build_filter_conditions(filters): raise ValueError(f"Unsupported filter operator: {op}") template, is_numeric = OPERATOR_SQL_MAP[op] if op in ("in", "nin"): + if not isinstance(op_value, list): + raise ValueError( + f"Filter operator {op!r} for key {key!r} requires a list value, got {type(op_value).__name__}" + ) str_list = [str(v) for v in op_value] conditions.append(template) params.extend([key, str_list]) @@ -459,13 +463,13 @@ class PGVector(VectorStoreBase): self._ensure_collection() with self._get_cursor() as cur: cur.execute( - sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()), + sql.SQL("SELECT id, payload FROM {} WHERE id = %s").format(self._col()), (vector_id,), ) result = cur.fetchone() if not result: return None - return OutputData(id=str(result[0]), score=None, payload=result[2]) + return OutputData(id=str(result[0]), score=None, payload=result[1]) def list_cols(self) -> List[str]: """ @@ -528,7 +532,7 @@ class PGVector(VectorStoreBase): with self._get_cursor() as cur: cur.execute( sql.SQL(""" - SELECT id, vector, payload + SELECT id, payload FROM {} {} LIMIT %s @@ -536,7 +540,7 @@ class PGVector(VectorStoreBase): (*filter_params, top_k), ) results = cur.fetchall() - return [[OutputData(id=str(r[0]), score=None, payload=r[2]) for r in results]] + return [[OutputData(id=str(r[0]), score=None, payload=r[1]) for r in results]] def __del__(self) -> None: """ diff --git a/mem0/vector_stores/pinecone.py b/mem0/vector_stores/pinecone.py index 8c6ded7d7..5cf520667 100644 --- a/mem0/vector_stores/pinecone.py +++ b/mem0/vector_stores/pinecone.py @@ -358,12 +358,16 @@ class PineconeDB(VectorStoreBase): return self.client.list_indexes() def delete_col(self): - """Delete an index/collection.""" + """Delete the index, or clear only the configured namespace if one is set.""" try: - self.client.delete_index(self.collection_name) - logger.info(f"Index {self.collection_name} deleted successfully") + if self.namespace is not None: + self.index.delete(delete_all=True, namespace=self.namespace) + logger.info(f"Namespace {self.namespace} in index {self.collection_name} cleared successfully") + else: + self.client.delete_index(self.collection_name) + logger.info(f"Index {self.collection_name} deleted successfully") except Exception as e: - logger.error(f"Error deleting index {self.collection_name}: {e}") + logger.error(f"Error deleting index {self.collection_name} (namespace={self.namespace}): {e}") def col_info(self) -> Dict: """ @@ -419,8 +423,7 @@ class PineconeDB(VectorStoreBase): int: Total number of vectors. """ stats = self.index.describe_index_stats() - if self.namespace: - # Safely get the namespace stats and return vector_count, defaulting to 0 if not found + if self.namespace is not None: namespace_summary = (stats.namespaces or {}).get(self.namespace) if namespace_summary: return namespace_summary.vector_count or 0 diff --git a/mem0/vector_stores/qdrant.py b/mem0/vector_stores/qdrant.py index 99cca10d1..b48e03c91 100644 --- a/mem0/vector_stores/qdrant.py +++ b/mem0/vector_stores/qdrant.py @@ -596,3 +596,11 @@ class Qdrant(VectorStoreBase): logger.warning(f"Resetting index {self.collection_name}...") self.delete_col() self.create_col(self.embedding_model_dims, self.on_disk) + if self.is_local: + # Local delete_collection() rmtree's with ignore_errors=True and leaves its + # sqlite handle open, so where an open file blocks unlink (Windows, NFS) the + # recreated collection re-adopts the old storage.sqlite. Drop what survived. + self.client.delete( + collection_name=self.collection_name, + points_selector=models.FilterSelector(filter=models.Filter(must=[])), + ) diff --git a/mem0/vector_stores/supabase.py b/mem0/vector_stores/supabase.py index 6faa09b09..7148f82bd 100644 --- a/mem0/vector_stores/supabase.py +++ b/mem0/vector_stores/supabase.py @@ -14,6 +14,8 @@ from mem0.vector_stores.base import VectorStoreBase logger = logging.getLogger(__name__) +VECS_MAX_QUERY_LIMIT = 1000 + class OutputData(BaseModel): id: Optional[str] @@ -131,11 +133,21 @@ class Supabase(VectorStoreBase): List[OutputData]: Search results """ filters = self._preprocess_filters(filters) + limit = top_k + if limit > VECS_MAX_QUERY_LIMIT: + logger.warning( + f"Requested top_k={top_k} exceeds the vecs query limit of {VECS_MAX_QUERY_LIMIT}; " + f"capping to {VECS_MAX_QUERY_LIMIT}." + ) + limit = VECS_MAX_QUERY_LIMIT results = self.collection.query( - data=vectors, limit=top_k, filters=filters, include_metadata=True, include_value=True + data=vectors, limit=limit, filters=filters, include_metadata=True, include_value=True ) - return [OutputData(id=str(result[0]), score=max(0.0, 1.0 - 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): """ @@ -201,12 +213,15 @@ class Supabase(VectorStoreBase): Returns: Dict: Collection information including name and configuration """ - info = self.collection.describe() return { - "name": info.name, - "count": info.vectors, - "dimension": info.dimension, - "index": {"method": info.index_method, "metric": info.distance_metric}, + "name": self.collection.name, + "count": len(self.collection), + "dimension": self.collection.dimension, + "index": { + "name": self.collection.index, + "measure": self.index_measure.value, + "is_indexed": self.collection.is_indexed_for_measure(self.index_measure), + }, } def list(self, filters: Optional[dict] = None, top_k: int = 100) -> List[OutputData]: @@ -222,8 +237,15 @@ class Supabase(VectorStoreBase): """ filters = self._preprocess_filters(filters) query = [0] * self.embedding_model_dims + limit = top_k + if limit > VECS_MAX_QUERY_LIMIT: + logger.warning( + f"Requested top_k={top_k} exceeds the vecs query limit of {VECS_MAX_QUERY_LIMIT}; " + f"capping to {VECS_MAX_QUERY_LIMIT}." + ) + limit = VECS_MAX_QUERY_LIMIT ids = self.collection.query( - data=query, limit=top_k, filters=filters, include_metadata=True, include_value=False + data=query, limit=limit, filters=filters, include_metadata=True, include_value=False ) ids = [id[0] for id in ids] records = self.collection.fetch(ids=ids) diff --git a/mem0/vector_stores/upstash_vector.py b/mem0/vector_stores/upstash_vector.py index 49c2bda79..62e7f108d 100644 --- a/mem0/vector_stores/upstash_vector.py +++ b/mem0/vector_stores/upstash_vector.py @@ -1,5 +1,6 @@ import logging -from typing import Dict, List, Optional +import re +from typing import Any, Dict, List, Optional from pydantic import BaseModel @@ -13,6 +14,23 @@ except ImportError: logger = logging.getLogger(__name__) +_SAFE_FILTER_KEY = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*\Z") + + +def _validate_filter(key: str, value: Any) -> None: + if not isinstance(key, str) or not _SAFE_FILTER_KEY.fullmatch(key): + raise ValueError(f"Invalid filter key: {key!r}") + if not isinstance(value, (str, int, float, bool)): + raise ValueError( + f"Filter value for {key!r} must be str, int, float, or bool, " + f"got {type(value).__name__}" + ) + if isinstance(value, str) and ('"' in value or "\\" in value): + raise ValueError( + f"Filter value for {key!r} contains prohibited characters " + f"(double quote or backslash): {value!r}" + ) + class OutputData(BaseModel): id: Optional[str] # memory id @@ -92,7 +110,9 @@ class UpstashVector(VectorStoreBase): ) def _stringify(self, x): - return f'"{x}"' if isinstance(x, str) else x + if isinstance(x, str): + return f'"{x}"' + return x def search( self, @@ -113,6 +133,9 @@ class UpstashVector(VectorStoreBase): List[OutputData]: Search results. """ + if filters: + for k, v in filters.items(): + _validate_filter(k, v) filters_str = " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) if filters else None response = [] @@ -160,13 +183,16 @@ class UpstashVector(VectorStoreBase): Returns: List[OutputData]: Search results, or None if sparse/BM25 search is not supported. """ - try: - filters_str = ( - " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) - if filters - else None - ) + if filters: + for k, v in filters.items(): + _validate_filter(k, v) + filters_str = ( + " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) + if filters + else None + ) + try: response = self.client.query( data=query, top_k=top_k, @@ -252,6 +278,9 @@ class UpstashVector(VectorStoreBase): Returns: List[OutputData]: Search results. """ + if filters: + for k, v in filters.items(): + _validate_filter(k, v) filters_str = " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) if filters else None info = self.client.info() diff --git a/mem0/vector_stores/vertex_ai_vector_search.py b/mem0/vector_stores/vertex_ai_vector_search.py index 8ba1ceea7..0954bf90c 100644 --- a/mem0/vector_stores/vertex_ai_vector_search.py +++ b/mem0/vector_stores/vertex_ai_vector_search.py @@ -22,7 +22,6 @@ from mem0.configs.vector_stores.vertex_ai_vector_search import ( from mem0.vector_stores.base import VectorStoreBase # Configure logging -logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) diff --git a/mem0/vector_stores/weaviate.py b/mem0/vector_stores/weaviate.py index c341b8a45..2c56567eb 100644 --- a/mem0/vector_stores/weaviate.py +++ b/mem0/vector_stores/weaviate.py @@ -1,6 +1,6 @@ import logging import uuid -from typing import Dict, List, Mapping, Optional +from typing import Dict, List, Optional from urllib.parse import urlparse from pydantic import BaseModel @@ -297,13 +297,7 @@ class Weaviate(VectorStoreBase): collection.data.update(uuid=vector_id, properties=payload) if vector: - existing_data = self.get(vector_id) - if existing_data: - existing_data = dict(existing_data) - if "id" in existing_data: - del existing_data["id"] - existing_payload: Mapping[str, str] = existing_data - collection.data.update(uuid=vector_id, properties=existing_payload, vector=vector) + collection.data.update(uuid=vector_id, vector=vector) def get(self, vector_id) -> Optional[OutputData]: """ diff --git a/openmemory/.gitignore b/openmemory/.gitignore deleted file mode 100644 index a8a361071..000000000 --- a/openmemory/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -*.db -.env* -!.env.example -!.env.dev -!ui/lib -.venv/ -__pycache__ -.DS_Store -node_modules/ -*.log -api/.openmemory* -**/.next -.openmemory/ -ui/package-lock.json \ No newline at end of file diff --git a/openmemory/CONTRIBUTING.md b/openmemory/CONTRIBUTING.md deleted file mode 100644 index 701334d47..000000000 --- a/openmemory/CONTRIBUTING.md +++ /dev/null @@ -1,70 +0,0 @@ -# Contributing to OpenMemory - -We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized. - -## Ways to Contribute - -We welcome all forms of contributions: -- Bug reports and feature requests through GitHub Issues -- Documentation improvements -- Code contributions -- Testing and feedback -- Community support and discussions - -## Development Workflow - -1. Fork the repository -2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin openmemory/feature/amazing-feature`) -5. Open a Pull Request - -## Development Setup - -### Backend Setup - -```bash -# Copy environment file and edit file to update OPENAI_API_KEY and other secrets -make env - -# Build the containers -make build - -# Start the services -make up -``` - -### Frontend Setup - -The frontend is a React application. To start the frontend: - -```bash -# Install dependencies and start the development server -make ui-dev -``` - -### Prerequisites -- Docker and Docker Compose -- Python 3.9+ (for backend development) -- Node.js (for frontend development) -- OpenAI API Key (for LLM interactions) - -### Getting Started -Follow the setup instructions in the README.md file to set up your development environment. - -## Code Standards - -We value: -- Clean, well-documented code -- Thoughtful discussions about features and improvements -- Respectful and constructive feedback -- A welcoming environment for all contributors - -## Pull Request Process - -1. Ensure your code follows the project's coding standards -2. Update documentation as needed -3. Include tests for new features -4. Make sure all tests pass before submitting - -Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone. diff --git a/openmemory/Makefile b/openmemory/Makefile deleted file mode 100644 index fdca300f9..000000000 --- a/openmemory/Makefile +++ /dev/null @@ -1,52 +0,0 @@ -.PHONY: help up down logs shell migrate test test-clean env ui-install ui-start ui-dev ui-build ui-dev-start - -NEXT_PUBLIC_USER_ID=$(USER) -NEXT_PUBLIC_API_URL=http://localhost:8765 - -# Default target -help: - @echo "Available commands:" - @echo " make env - Copy .env.example to .env" - @echo " make up - Start the containers" - @echo " make down - Stop the containers" - @echo " make logs - Show container logs" - @echo " make shell - Open a shell in the api container" - @echo " make migrate - Run database migrations" - @echo " make test - Run tests in a new container" - @echo " make test-clean - Run tests and clean up volumes" - @echo " make ui-install - Install frontend dependencies" - @echo " make ui-start - Start the frontend development server" - @echo " make ui-dev - Install dependencies and start the frontend in dev mode" - @echo " make ui - Install dependencies and start the frontend in production mode" - -env: - cd api && cp .env.example .env - cd ui && cp .env.example .env - -build: - docker compose build - -up: - NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) docker compose up - -down: - docker compose down -v - rm -f api/openmemory.db - -logs: - docker compose logs -f - -shell: - docker compose exec api bash - -upgrade: - docker compose exec api alembic upgrade head - -migrate: - docker compose exec api alembic upgrade head - -downgrade: - docker compose exec api alembic downgrade -1 - -ui-dev: - cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) pnpm install && pnpm dev diff --git a/openmemory/README.md b/openmemory/README.md deleted file mode 100644 index 9c886bc20..000000000 --- a/openmemory/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# OpenMemory - -> **⚠️ Sunsetting Notice:** OpenMemory is being sunset. For local self-hosted memory with a dashboard, please use the [Mem0 self-hosted server](https://docs.mem0.ai/open-source/overview) instead. Get started with `cd server && make bootstrap`. See the [self-hosted docs](https://docs.mem0.ai/open-source/setup) for configuration details. - -OpenMemory is your personal memory layer for LLMs - private, portable, and open-source. Your memories live locally, giving you complete control over your data. Build AI applications with personalized memories while keeping your data secure. - -![OpenMemory](https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b) - -## Easy Setup - -### Prerequisites -- Docker -- OpenAI API Key - -You can quickly run OpenMemory by running the following command: - -```bash -curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash -``` - -You should set the `OPENAI_API_KEY` as a global environment variable: - -```bash -export OPENAI_API_KEY=your_api_key -``` - -You can also set the `OPENAI_API_KEY` as a parameter to the script: - -```bash -curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash -``` - -## Prerequisites - -- Docker and Docker Compose -- Python 3.9+ (for backend development) -- Node.js (for frontend development) -- OpenAI API Key (required for LLM interactions, run `cp api/.env.example api/.env` then change **OPENAI_API_KEY** to yours) - -## Quickstart - -### 1. Set Up Environment Variables - -Before running the project, you need to configure environment variables for both the API and the UI. - -You can do this in one of the following ways: - -- **Manually**: - Create a `.env` file in each of the following directories: - - `/api/.env` - - `/ui/.env` - -- **Using `.env.example` files**: - Copy and rename the example files: - - ```bash - cp api/.env.example api/.env - cp ui/.env.example ui/.env - ``` - - - **Using Makefile** (if supported): - Run: - - ```bash - make env - ``` -- #### Example `/api/.env` - -```env -OPENAI_API_KEY=sk-xxx -USER= # The User Id you want to associate the memories with -``` - -- #### LLM Configuration (optional) - -By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider using these environment variables in `/api/.env`: - -| Variable | Description | Default | -|---|---|---| -| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` | -| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) | -| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var | -| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default | -| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` | -| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` | -| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) | -| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var | -| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default | - -**Example: Using Ollama (fully local)** -```env -LLM_PROVIDER=ollama -LLM_MODEL=llama3.1:latest -EMBEDDER_PROVIDER=ollama -EMBEDDER_MODEL=nomic-embed-text -OLLAMA_BASE_URL=http://localhost:11434 -``` - -**Example: Using Anthropic** -```env -LLM_PROVIDER=anthropic -LLM_MODEL=claude-sonnet-4-20250514 -LLM_API_KEY=sk-ant-xxx -``` -- #### Example `/ui/.env` - -```env -NEXT_PUBLIC_API_URL=http://localhost:8765 -NEXT_PUBLIC_USER_ID= # Same as the user id for environment variable in api -``` - -### 2. Build and Run the Project -You can run the project using the following two commands: -```bash -make build # builds the mcp server and ui -make up # runs openmemory mcp server and ui -``` - -After running these commands, you will have: -- OpenMemory MCP server running at: http://localhost:8765 (API documentation available at http://localhost:8765/docs) -- OpenMemory UI running at: http://localhost:3000 - -#### UI not working on `localhost:3000`? - -If the UI does not start properly on [http://localhost:3000](http://localhost:3000), try running it manually: - -```bash -cd ui -pnpm install -pnpm dev -``` - -### MCP Client Setup - -Use the following one step command to configure OpenMemory Local MCP to a client. The general command format is as follows: - -```bash -npx @openmemory/install local http://localhost:8765/mcp//sse/ --client -``` - -Replace `` with the desired client name and `` with the value specified in your environment variables. - - -## Project Structure - -- `api/` - Backend APIs + MCP server -- `ui/` - Frontend React application - -## Contributing - -We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized. - -We welcome all forms of contributions: -- Bug reports and feature requests -- Documentation improvements -- Code contributions -- Testing and feedback -- Community support - -How to contribute: - -1. Fork the repository -2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin openmemory/feature/amazing-feature`) -5. Open a Pull Request - -Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone. diff --git a/openmemory/api/.dockerignore b/openmemory/api/.dockerignore deleted file mode 100644 index 7909beb9c..000000000 --- a/openmemory/api/.dockerignore +++ /dev/null @@ -1,23 +0,0 @@ -# Ignore all .env files -**/.env -**/.env.* - -# Ignore all database files -**/*.db -**/*.sqlite -**/*.sqlite3 - -# Ignore logs -**/*.log - -# Ignore runtime data -**/node_modules -**/__pycache__ -**/.pytest_cache -**/.coverage -**/coverage - -# Ignore Docker runtime files -**/.dockerignore -**/Dockerfile -**/docker-compose*.yml \ No newline at end of file diff --git a/openmemory/api/.env.example b/openmemory/api/.env.example deleted file mode 100644 index 21e32b7af..000000000 --- a/openmemory/api/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -OPENAI_API_KEY=sk-xxx -USER=user - -# LLM Configuration (optional - defaults to openai/gpt-4o-mini) -# LLM_PROVIDER=ollama -# LLM_MODEL=llama3.1:latest -# LLM_API_KEY= -# LLM_BASE_URL= -# OLLAMA_BASE_URL=http://localhost:11434 - -# Embedder Configuration (optional - defaults to openai/text-embedding-3-small) -# EMBEDDER_PROVIDER=ollama -# EMBEDDER_MODEL=nomic-embed-text -# EMBEDDER_API_KEY= -# EMBEDDER_BASE_URL= diff --git a/openmemory/api/.python-version b/openmemory/api/.python-version deleted file mode 100644 index fdcfcfdfc..000000000 --- a/openmemory/api/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 \ No newline at end of file diff --git a/openmemory/api/Dockerfile b/openmemory/api/Dockerfile deleted file mode 100644 index 6e1afef83..000000000 --- a/openmemory/api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM python:3.12-slim - -LABEL org.opencontainers.image.name="mem0/openmemory-mcp" - -WORKDIR /usr/src/openmemory - -COPY requirements.txt . -RUN pip install -r requirements.txt - -COPY config.json . -COPY . . - -EXPOSE 8765 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"] diff --git a/openmemory/api/README.md b/openmemory/api/README.md deleted file mode 100644 index bf8418c52..000000000 --- a/openmemory/api/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# OpenMemory API - -This directory contains the backend API for OpenMemory, built with FastAPI and SQLAlchemy. This also runs the Mem0 MCP Server that you can use with MCP clients to remember things. - -## Quick Start with Docker (Recommended) - -The easiest way to get started is using Docker. Make sure you have Docker and Docker Compose installed. - -1. Build the containers: -```bash -make build -``` - -2. Create `.env` file: -```bash -make env -``` - -Once you run this command, edit the file `api/.env` and enter the `OPENAI_API_KEY`. - -3. Start the services: -```bash -make up -``` - -The API will be available at `http://localhost:8765` - -### Common Docker Commands - -- View logs: `make logs` -- Open shell in container: `make shell` -- Run database migrations: `make migrate` -- Run tests: `make test` -- Run tests and clean up: `make test-clean` -- Stop containers: `make down` - -## API Documentation - -Once the server is running, you can access the API documentation at: -- Swagger UI: `http://localhost:8765/docs` -- ReDoc: `http://localhost:8765/redoc` - -## Project Structure - -- `app/`: Main application code - - `models.py`: Database models - - `database.py`: Database configuration - - `routers/`: API route handlers -- `migrations/`: Database migration files -- `tests/`: Test files -- `alembic/`: Alembic migration configuration -- `main.py`: Application entry point - -## Development Guidelines - -- Follow PEP 8 style guide -- Use type hints -- Write tests for new features -- Update documentation when making changes -- Run migrations for database changes diff --git a/openmemory/api/alembic.ini b/openmemory/api/alembic.ini deleted file mode 100644 index 8cddf4fe7..000000000 --- a/openmemory/api/alembic.ini +++ /dev/null @@ -1,114 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -# Use forward slashes (/) also on windows to provide an os agnostic path -script_location = alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python-dateutil library that can be -# installed by adding `alembic[tz]` to the pip requirements -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = sqlite:///./openmemory.db - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/openmemory/api/alembic/README b/openmemory/api/alembic/README deleted file mode 100644 index 98e4f9c44..000000000 --- a/openmemory/api/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/openmemory/api/alembic/env.py b/openmemory/api/alembic/env.py deleted file mode 100644 index 278cc65fd..000000000 --- a/openmemory/api/alembic/env.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import sys -from logging.config import fileConfig - -from alembic import context -from dotenv import load_dotenv -from sqlalchemy import engine_from_config, pool - -# Add the parent directory to the Python path -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -# Load environment variables -load_dotenv() - -# Import your models here - moved after path setup -from app.database import Base # noqa: E402 - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - configuration = config.get_section(config.config_ini_section) - configuration["sqlalchemy.url"] = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") - connectable = engine_from_config( - configuration, - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/openmemory/api/alembic/script.py.mako b/openmemory/api/alembic/script.py.mako deleted file mode 100644 index 480b130d6..000000000 --- a/openmemory/api/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/openmemory/api/alembic/versions/0b53c747049a_initial_migration.py b/openmemory/api/alembic/versions/0b53c747049a_initial_migration.py deleted file mode 100644 index 6bbfbccab..000000000 --- a/openmemory/api/alembic/versions/0b53c747049a_initial_migration.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Initial migration - -Revision ID: 0b53c747049a -Revises: -Create Date: 2025-04-19 00:59:56.244203 - -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = '0b53c747049a' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('access_controls', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('subject_type', sa.String(), nullable=False), - sa.Column('subject_id', sa.UUID(), nullable=True), - sa.Column('object_type', sa.String(), nullable=False), - sa.Column('object_id', sa.UUID(), nullable=True), - sa.Column('effect', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_access_object', 'access_controls', ['object_type', 'object_id'], unique=False) - op.create_index('idx_access_subject', 'access_controls', ['subject_type', 'subject_id'], unique=False) - op.create_index(op.f('ix_access_controls_created_at'), 'access_controls', ['created_at'], unique=False) - op.create_index(op.f('ix_access_controls_effect'), 'access_controls', ['effect'], unique=False) - op.create_index(op.f('ix_access_controls_object_id'), 'access_controls', ['object_id'], unique=False) - op.create_index(op.f('ix_access_controls_object_type'), 'access_controls', ['object_type'], unique=False) - op.create_index(op.f('ix_access_controls_subject_id'), 'access_controls', ['subject_id'], unique=False) - op.create_index(op.f('ix_access_controls_subject_type'), 'access_controls', ['subject_type'], unique=False) - op.create_table('archive_policies', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('criteria_type', sa.String(), nullable=False), - sa.Column('criteria_id', sa.UUID(), nullable=True), - sa.Column('days_to_archive', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_policy_criteria', 'archive_policies', ['criteria_type', 'criteria_id'], unique=False) - op.create_index(op.f('ix_archive_policies_created_at'), 'archive_policies', ['created_at'], unique=False) - op.create_index(op.f('ix_archive_policies_criteria_id'), 'archive_policies', ['criteria_id'], unique=False) - op.create_index(op.f('ix_archive_policies_criteria_type'), 'archive_policies', ['criteria_type'], unique=False) - op.create_table('categories', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_categories_created_at'), 'categories', ['created_at'], unique=False) - op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) - op.create_table('users', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.String(), nullable=False), - sa.Column('name', sa.String(), nullable=True), - sa.Column('email', sa.String(), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False) - op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=True) - op.create_table('apps', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('owner_id', sa.UUID(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.String(), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False) - op.create_index(op.f('ix_apps_is_active'), 'apps', ['is_active'], unique=False) - op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=True) - op.create_index(op.f('ix_apps_owner_id'), 'apps', ['owner_id'], unique=False) - op.create_table('memories', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('app_id', sa.UUID(), nullable=False), - sa.Column('content', sa.String(), nullable=False), - sa.Column('vector', sa.String(), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.Column('state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('archived_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_memory_app_state', 'memories', ['app_id', 'state'], unique=False) - op.create_index('idx_memory_user_app', 'memories', ['user_id', 'app_id'], unique=False) - op.create_index('idx_memory_user_state', 'memories', ['user_id', 'state'], unique=False) - op.create_index(op.f('ix_memories_app_id'), 'memories', ['app_id'], unique=False) - op.create_index(op.f('ix_memories_archived_at'), 'memories', ['archived_at'], unique=False) - op.create_index(op.f('ix_memories_created_at'), 'memories', ['created_at'], unique=False) - op.create_index(op.f('ix_memories_deleted_at'), 'memories', ['deleted_at'], unique=False) - op.create_index(op.f('ix_memories_state'), 'memories', ['state'], unique=False) - op.create_index(op.f('ix_memories_user_id'), 'memories', ['user_id'], unique=False) - op.create_table('memory_access_logs', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('memory_id', sa.UUID(), nullable=False), - sa.Column('app_id', sa.UUID(), nullable=False), - sa.Column('accessed_at', sa.DateTime(), nullable=True), - sa.Column('access_type', sa.String(), nullable=False), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ), - sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_access_app_time', 'memory_access_logs', ['app_id', 'accessed_at'], unique=False) - op.create_index('idx_access_memory_time', 'memory_access_logs', ['memory_id', 'accessed_at'], unique=False) - op.create_index(op.f('ix_memory_access_logs_access_type'), 'memory_access_logs', ['access_type'], unique=False) - op.create_index(op.f('ix_memory_access_logs_accessed_at'), 'memory_access_logs', ['accessed_at'], unique=False) - op.create_index(op.f('ix_memory_access_logs_app_id'), 'memory_access_logs', ['app_id'], unique=False) - op.create_index(op.f('ix_memory_access_logs_memory_id'), 'memory_access_logs', ['memory_id'], unique=False) - op.create_table('memory_categories', - sa.Column('memory_id', sa.UUID(), nullable=False), - sa.Column('category_id', sa.UUID(), nullable=False), - sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), - sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ), - sa.PrimaryKeyConstraint('memory_id', 'category_id') - ) - op.create_index('idx_memory_category', 'memory_categories', ['memory_id', 'category_id'], unique=False) - op.create_index(op.f('ix_memory_categories_category_id'), 'memory_categories', ['category_id'], unique=False) - op.create_index(op.f('ix_memory_categories_memory_id'), 'memory_categories', ['memory_id'], unique=False) - op.create_table('memory_status_history', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('memory_id', sa.UUID(), nullable=False), - sa.Column('changed_by', sa.UUID(), nullable=False), - sa.Column('old_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False), - sa.Column('new_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False), - sa.Column('changed_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ), - sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_history_memory_state', 'memory_status_history', ['memory_id', 'new_state'], unique=False) - op.create_index('idx_history_user_time', 'memory_status_history', ['changed_by', 'changed_at'], unique=False) - op.create_index(op.f('ix_memory_status_history_changed_at'), 'memory_status_history', ['changed_at'], unique=False) - op.create_index(op.f('ix_memory_status_history_changed_by'), 'memory_status_history', ['changed_by'], unique=False) - op.create_index(op.f('ix_memory_status_history_memory_id'), 'memory_status_history', ['memory_id'], unique=False) - op.create_index(op.f('ix_memory_status_history_new_state'), 'memory_status_history', ['new_state'], unique=False) - op.create_index(op.f('ix_memory_status_history_old_state'), 'memory_status_history', ['old_state'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_memory_status_history_old_state'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_new_state'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_memory_id'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_changed_by'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_changed_at'), table_name='memory_status_history') - op.drop_index('idx_history_user_time', table_name='memory_status_history') - op.drop_index('idx_history_memory_state', table_name='memory_status_history') - op.drop_table('memory_status_history') - op.drop_index(op.f('ix_memory_categories_memory_id'), table_name='memory_categories') - op.drop_index(op.f('ix_memory_categories_category_id'), table_name='memory_categories') - op.drop_index('idx_memory_category', table_name='memory_categories') - op.drop_table('memory_categories') - op.drop_index(op.f('ix_memory_access_logs_memory_id'), table_name='memory_access_logs') - op.drop_index(op.f('ix_memory_access_logs_app_id'), table_name='memory_access_logs') - op.drop_index(op.f('ix_memory_access_logs_accessed_at'), table_name='memory_access_logs') - op.drop_index(op.f('ix_memory_access_logs_access_type'), table_name='memory_access_logs') - op.drop_index('idx_access_memory_time', table_name='memory_access_logs') - op.drop_index('idx_access_app_time', table_name='memory_access_logs') - op.drop_table('memory_access_logs') - op.drop_index(op.f('ix_memories_user_id'), table_name='memories') - op.drop_index(op.f('ix_memories_state'), table_name='memories') - op.drop_index(op.f('ix_memories_deleted_at'), table_name='memories') - op.drop_index(op.f('ix_memories_created_at'), table_name='memories') - op.drop_index(op.f('ix_memories_archived_at'), table_name='memories') - op.drop_index(op.f('ix_memories_app_id'), table_name='memories') - op.drop_index('idx_memory_user_state', table_name='memories') - op.drop_index('idx_memory_user_app', table_name='memories') - op.drop_index('idx_memory_app_state', table_name='memories') - op.drop_table('memories') - op.drop_index(op.f('ix_apps_owner_id'), table_name='apps') - op.drop_index(op.f('ix_apps_name'), table_name='apps') - op.drop_index(op.f('ix_apps_is_active'), table_name='apps') - op.drop_index(op.f('ix_apps_created_at'), table_name='apps') - op.drop_table('apps') - op.drop_index(op.f('ix_users_user_id'), table_name='users') - op.drop_index(op.f('ix_users_name'), table_name='users') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_index(op.f('ix_users_created_at'), table_name='users') - op.drop_table('users') - op.drop_index(op.f('ix_categories_name'), table_name='categories') - op.drop_index(op.f('ix_categories_created_at'), table_name='categories') - op.drop_table('categories') - op.drop_index(op.f('ix_archive_policies_criteria_type'), table_name='archive_policies') - op.drop_index(op.f('ix_archive_policies_criteria_id'), table_name='archive_policies') - op.drop_index(op.f('ix_archive_policies_created_at'), table_name='archive_policies') - op.drop_index('idx_policy_criteria', table_name='archive_policies') - op.drop_table('archive_policies') - op.drop_index(op.f('ix_access_controls_subject_type'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_subject_id'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_object_type'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_object_id'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_effect'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_created_at'), table_name='access_controls') - op.drop_index('idx_access_subject', table_name='access_controls') - op.drop_index('idx_access_object', table_name='access_controls') - op.drop_table('access_controls') - # ### end Alembic commands ### diff --git a/openmemory/api/alembic/versions/add_config_table.py b/openmemory/api/alembic/versions/add_config_table.py deleted file mode 100644 index b53488f9b..000000000 --- a/openmemory/api/alembic/versions/add_config_table.py +++ /dev/null @@ -1,40 +0,0 @@ -"""add_config_table - -Revision ID: add_config_table -Revises: 0b53c747049a -Create Date: 2023-06-01 10:00:00.000000 - -""" -import uuid - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = 'add_config_table' -down_revision = '0b53c747049a' -branch_labels = None -depends_on = None - - -def upgrade(): - # Create configs table if it doesn't exist - op.create_table( - 'configs', - sa.Column('id', sa.UUID(), nullable=False, default=lambda: uuid.uuid4()), - sa.Column('key', sa.String(), nullable=False), - sa.Column('value', sa.JSON(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('key') - ) - - # Create index for key lookups - op.create_index('idx_configs_key', 'configs', ['key']) - - -def downgrade(): - # Drop the configs table - op.drop_index('idx_configs_key', 'configs') - op.drop_table('configs') \ No newline at end of file diff --git a/openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py b/openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py deleted file mode 100644 index bec325c3b..000000000 --- a/openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py +++ /dev/null @@ -1,34 +0,0 @@ -"""remove_global_unique_constraint_on_app_name_add_composite_unique - -Revision ID: afd00efbd06b -Revises: add_config_table -Create Date: 2025-06-04 01:59:41.637440 - -""" -from typing import Sequence, Union - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'afd00efbd06b' -down_revision: Union[str, None] = 'add_config_table' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('ix_apps_name', table_name='apps') - op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=False) - op.create_index('idx_app_owner_name', 'apps', ['owner_id', 'name'], unique=True) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('idx_app_owner_name', table_name='apps') - op.drop_index(op.f('ix_apps_name'), table_name='apps') - op.create_index('ix_apps_name', 'apps', ['name'], unique=True) - # ### end Alembic commands ### \ No newline at end of file diff --git a/openmemory/api/app/__init__.py b/openmemory/api/app/__init__.py deleted file mode 100644 index 4c4d1566e..000000000 --- a/openmemory/api/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file makes the app directory a Python package \ No newline at end of file diff --git a/openmemory/api/app/config.py b/openmemory/api/app/config.py deleted file mode 100644 index 5da454235..000000000 --- a/openmemory/api/app/config.py +++ /dev/null @@ -1,4 +0,0 @@ -import os - -USER_ID = os.getenv("USER", "default_user") -DEFAULT_APP_ID = "openmemory" \ No newline at end of file diff --git a/openmemory/api/app/database.py b/openmemory/api/app/database.py deleted file mode 100644 index 4ab4eaaa5..000000000 --- a/openmemory/api/app/database.py +++ /dev/null @@ -1,30 +0,0 @@ -import os - -from dotenv import load_dotenv -from sqlalchemy import create_engine -from sqlalchemy.orm import declarative_base, sessionmaker - -# load .env file (make sure you have DATABASE_URL set) -load_dotenv() - -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") -if not DATABASE_URL: - raise RuntimeError("DATABASE_URL is not set in environment") - -# SQLAlchemy engine & session -engine = create_engine( - DATABASE_URL, - connect_args={"check_same_thread": False} # Needed for SQLite -) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -# Base class for models -Base = declarative_base() - -# Dependency for FastAPI -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/openmemory/api/app/mcp_server.py b/openmemory/api/app/mcp_server.py deleted file mode 100644 index 70aa523c4..000000000 --- a/openmemory/api/app/mcp_server.py +++ /dev/null @@ -1,574 +0,0 @@ -""" -MCP Server for OpenMemory with resilient memory client handling. - -This module implements an MCP (Model Context Protocol) server that provides -memory operations for OpenMemory. The memory client is initialized lazily -to prevent server crashes when external dependencies (like Ollama) are -unavailable. If the memory client cannot be initialized, the server will -continue running with limited functionality and appropriate error messages. - -Key features: -- Lazy memory client initialization -- Graceful error handling for unavailable dependencies -- Fallback to database-only mode when vector store is unavailable -- Proper logging for debugging connection issues -- Environment variable parsing for API keys -""" - -import contextvars -import datetime -import json -import logging -import uuid - -import anyio - -from app.database import SessionLocal -from app.models import Memory, MemoryAccessLog, MemoryState, MemoryStatusHistory -from app.utils.db import get_user_and_app -from app.utils.memory import get_memory_client -from app.utils.permissions import check_memory_access_permissions -from dotenv import load_dotenv -from fastapi import FastAPI, Request -from fastapi.routing import APIRouter -from mcp.server.fastmcp import FastMCP -from mcp.server.sse import SseServerTransport -from mcp.server.streamable_http import StreamableHTTPServerTransport -from starlette.responses import Response - -# Load environment variables -load_dotenv() - -# Initialize MCP -mcp = FastMCP("mem0-mcp-server") - -# Don't initialize memory client at import time - do it lazily when needed -def get_memory_client_safe(): - """Get memory client with error handling. Returns None if client cannot be initialized.""" - try: - return get_memory_client() - except Exception as e: - logging.warning(f"Failed to get memory client: {e}") - return None - -# Context variables for user_id and client_name -user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id") -client_name_var: contextvars.ContextVar[str] = contextvars.ContextVar("client_name") - -# Create a router for MCP endpoints -mcp_router = APIRouter(prefix="/mcp") - -# Initialize SSE transport -sse = SseServerTransport("/mcp/messages/") - -@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something. Set infer to False to store the memory verbatim without LLM fact extraction.") -async def add_memories(text: str, infer: bool = True) -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Check if app is active - if not app.is_active: - return f"Error: App {app.name} is currently paused on OpenMemory. Cannot create new memories." - - response = memory_client.add(text, - user_id=uid, - metadata={ - "source_app": "openmemory", - "mcp_client": client_name, - }, - infer=infer) - - # Process the response and update database - if isinstance(response, dict) and 'results' in response: - for result in response['results']: - memory_id = uuid.UUID(result['id']) - memory = db.query(Memory).filter(Memory.id == memory_id).first() - - if result['event'] == 'ADD': - if not memory: - memory = Memory( - id=memory_id, - user_id=user.id, - app_id=app.id, - content=result['memory'], - state=MemoryState.active - ) - db.add(memory) - else: - memory.state = MemoryState.active - memory.content = result['memory'] - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.deleted if memory else None, - new_state=MemoryState.active - ) - db.add(history) - - elif result['event'] == 'DELETE': - if memory: - memory.state = MemoryState.deleted - memory.deleted_at = datetime.datetime.now(datetime.UTC) - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.active, - new_state=MemoryState.deleted - ) - db.add(history) - - db.commit() - - return json.dumps(response) - finally: - db.close() - except Exception as e: - logging.exception(f"Error adding to memory: {e}") - return f"Error adding to memory: {e}" - - -@mcp.tool(description="Search through stored memories. This method is called EVERYTIME the user asks anything.") -async def search_memory(query: str) -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Get accessible memory IDs based on ACL - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - - filters = { - "user_id": uid - } - - embeddings = memory_client.embedding_model.embed(query, "search") - - hits = memory_client.vector_store.search( - query=query, - vectors=embeddings, - limit=10, - filters=filters, - ) - - allowed = set(str(mid) for mid in accessible_memory_ids) if accessible_memory_ids else None - - results = [] - for h in hits: - # All vector db search functions return OutputData class - id, score, payload = h.id, h.score, h.payload - if allowed and (h.id is None or h.id not in allowed): - continue - - results.append({ - "id": id, - "memory": payload.get("data"), - "hash": payload.get("hash"), - "created_at": payload.get("created_at"), - "updated_at": payload.get("updated_at"), - "score": score, - }) - - for r in results: - if r.get("id"): - access_log = MemoryAccessLog( - memory_id=uuid.UUID(r["id"]), - app_id=app.id, - access_type="search", - metadata_={ - "query": query, - "score": r.get("score"), - "hash": r.get("hash"), - }, - ) - db.add(access_log) - db.commit() - - return json.dumps({"results": results}, indent=2) - finally: - db.close() - except Exception as e: - logging.exception(e) - return f"Error searching memory: {e}" - - -@mcp.tool(description="List all memories in the user's memory") -async def list_memories() -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Get all memories - memories = memory_client.get_all(user_id=uid) - filtered_memories = [] - - # Filter memories based on permissions - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - if isinstance(memories, dict) and 'results' in memories: - for memory_data in memories['results']: - if 'id' in memory_data: - memory_id = uuid.UUID(memory_data['id']) - if memory_id in accessible_memory_ids: - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="list", - metadata_={ - "hash": memory_data.get('hash') - } - ) - db.add(access_log) - filtered_memories.append(memory_data) - db.commit() - else: - for memory in memories: - memory_id = uuid.UUID(memory['id']) - memory_obj = db.query(Memory).filter(Memory.id == memory_id).first() - if memory_obj and check_memory_access_permissions(db, memory_obj, app.id): - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="list", - metadata_={ - "hash": memory.get('hash') - } - ) - db.add(access_log) - filtered_memories.append(memory) - db.commit() - return json.dumps(filtered_memories, indent=2) - finally: - db.close() - except Exception as e: - logging.exception(f"Error getting memories: {e}") - return f"Error getting memories: {e}" - - -@mcp.tool(description="Delete specific memories by their IDs") -async def delete_memories(memory_ids: list[str]) -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Convert string IDs to UUIDs and filter accessible ones - requested_ids = [uuid.UUID(mid) for mid in memory_ids] - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - - # Only delete memories that are both requested and accessible - ids_to_delete = [mid for mid in requested_ids if mid in accessible_memory_ids] - - if not ids_to_delete: - return "Error: No accessible memories found with provided IDs" - - # Delete from vector store - for memory_id in ids_to_delete: - try: - memory_client.delete(str(memory_id)) - except Exception as delete_error: - logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}") - - # Update each memory's state and create history entries - now = datetime.datetime.now(datetime.UTC) - for memory_id in ids_to_delete: - memory = db.query(Memory).filter(Memory.id == memory_id).first() - if memory: - # Update memory state - memory.state = MemoryState.deleted - memory.deleted_at = now - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.active, - new_state=MemoryState.deleted - ) - db.add(history) - - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="delete", - metadata_={"operation": "delete_by_id"} - ) - db.add(access_log) - - db.commit() - return f"Successfully deleted {len(ids_to_delete)} memories" - finally: - db.close() - except Exception as e: - logging.exception(f"Error deleting memories: {e}") - return f"Error deleting memories: {e}" - - -@mcp.tool(description="Delete all memories in the user's memory") -async def delete_all_memories() -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - - # delete the accessible memories only - for memory_id in accessible_memory_ids: - try: - memory_client.delete(str(memory_id)) - except Exception as delete_error: - logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}") - - # Update each memory's state and create history entries - now = datetime.datetime.now(datetime.UTC) - for memory_id in accessible_memory_ids: - memory = db.query(Memory).filter(Memory.id == memory_id).first() - # Update memory state - memory.state = MemoryState.deleted - memory.deleted_at = now - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.active, - new_state=MemoryState.deleted - ) - db.add(history) - - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="delete_all", - metadata_={"operation": "bulk_delete"} - ) - db.add(access_log) - - db.commit() - return "Successfully deleted all memories" - finally: - db.close() - except Exception as e: - logging.exception(f"Error deleting memories: {e}") - return f"Error deleting memories: {e}" - - -@mcp_router.get("/{client_name}/sse/{user_id}") -async def handle_sse(request: Request): - """Handle SSE connections for a specific user and client""" - # Extract user_id and client_name from path parameters - uid = request.path_params.get("user_id") - user_token = user_id_var.set(uid or "") - client_name = request.path_params.get("client_name") - client_token = client_name_var.set(client_name or "") - - try: - # NOTE: request._send is the raw ASGI `send` callable. Starlette does not - # expose it publicly, but the MCP SDK transports require the raw ASGI - # interface (scope, receive, send). This is the standard pattern from the - # MCP Python SDK examples. - async with sse.connect_sse( - request.scope, - request.receive, - request._send, - ) as (read_stream, write_stream): - await mcp._mcp_server.run( - read_stream, - write_stream, - mcp._mcp_server.create_initialization_options(), - ) - finally: - # Clean up context variables - user_id_var.reset(user_token) - client_name_var.reset(client_token) - - -@mcp_router.post("/messages/") -async def handle_get_message(request: Request): - return await handle_post_message(request) - - -@mcp_router.post("/{client_name}/sse/{user_id}/messages/") -async def handle_post_message(request: Request): - return await handle_post_message(request) - -async def handle_post_message(request: Request): - """Handle POST messages for SSE""" - try: - body = await request.body() - - # Create a simple receive function that returns the body - async def receive(): - return {"type": "http.request", "body": body, "more_body": False} - - # Create a simple send function that does nothing - async def send(message): - return {} - - # Call handle_post_message with the correct arguments - await sse.handle_post_message(request.scope, receive, send) - - # Return a success response - return {"status": "ok"} - finally: - pass - - -@mcp_router.api_route("/{client_name}/http/{user_id}", methods=["POST", "GET", "DELETE"]) -async def handle_streamable_http(request: Request): - """Handle Streamable HTTP connections for a specific user and client. - - Uses the Streamable HTTP transport (MCP spec 2025-03-26+) which replaces - the deprecated SSE transport. Runs in stateless mode β€” each request is - handled independently with no persistent session. - - The transport writes its response directly to the ASGI ``send`` callable. - We intercept it via ``capture_send`` so we can return a proper ``Response`` - to FastAPI β€” otherwise FastAPI would also try to send its own response, - causing a "double-response" bug. - """ - uid = request.path_params.get("user_id") - user_token = user_id_var.set(uid or "") - client_name = request.path_params.get("client_name") - client_token = client_name_var.set(client_name or "") - - # Intercept the ASGI messages the transport sends so we can return them - # as a single Response to FastAPI. Without this, FastAPI would attempt to - # write its own response after the transport already wrote one. - response_started = False - response_status = 200 - response_headers: list[tuple[bytes, bytes]] = [] - response_body = bytearray() - - async def capture_send(message): - nonlocal response_started, response_status - if message["type"] == "http.response.start": - response_started = True - response_status = message["status"] - response_headers.extend(message.get("headers", [])) - elif message["type"] == "http.response.body": - response_body.extend(message.get("body", b"")) - - try: - transport = StreamableHTTPServerTransport( - mcp_session_id=None, - is_json_response_enabled=True, - ) - - async with anyio.create_task_group() as tg: - - async def run_server(*, task_status=anyio.TASK_STATUS_IGNORED): - async with transport.connect() as (read_stream, write_stream): - task_status.started() - await mcp._mcp_server.run( - read_stream, - write_stream, - mcp._mcp_server.create_initialization_options(), - stateless=True, - ) - - await tg.start(run_server) - await transport.handle_request(request.scope, request.receive, capture_send) - await transport.terminate() - tg.cancel_scope.cancel() - finally: - user_id_var.reset(user_token) - client_name_var.reset(client_token) - - if not response_started: - return Response(status_code=500, content=b"Transport did not produce a response") - - # Header dict conversion is safe here: the MCP transport in stateless JSON - # mode only emits single-valued headers (Content-Type, Content-Length). - return Response( - content=bytes(response_body), - status_code=response_status, - headers={k.decode(): v.decode() for k, v in response_headers}, - ) - - -def setup_mcp_server(app: FastAPI): - """Setup MCP server with the FastAPI application""" - mcp._mcp_server.name = "mem0-mcp-server" - - # Include MCP router in the FastAPI app - app.include_router(mcp_router) diff --git a/openmemory/api/app/models.py b/openmemory/api/app/models.py deleted file mode 100644 index 66541013b..000000000 --- a/openmemory/api/app/models.py +++ /dev/null @@ -1,243 +0,0 @@ -import datetime -import enum -import uuid - -import sqlalchemy as sa -from app.database import Base -from app.utils.categorization import get_categories_for_memory -from sqlalchemy import ( - JSON, - UUID, - Boolean, - Column, - DateTime, - Enum, - ForeignKey, - Index, - Integer, - String, - Table, - event, -) -from sqlalchemy.orm import Session, relationship - - -def get_current_utc_time(): - """Get current UTC time""" - return datetime.datetime.now(datetime.UTC) - - -class MemoryState(enum.Enum): - active = "active" - paused = "paused" - archived = "archived" - deleted = "deleted" - - -class User(Base): - __tablename__ = "users" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - user_id = Column(String, nullable=False, unique=True, index=True) - name = Column(String, nullable=True, index=True) - email = Column(String, unique=True, nullable=True, index=True) - metadata_ = Column('metadata', JSON, default=dict) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - apps = relationship("App", back_populates="owner") - memories = relationship("Memory", back_populates="user") - - -class App(Base): - __tablename__ = "apps" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - owner_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True) - name = Column(String, nullable=False, index=True) - description = Column(String) - metadata_ = Column('metadata', JSON, default=dict) - is_active = Column(Boolean, default=True, index=True) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - owner = relationship("User", back_populates="apps") - memories = relationship("Memory", back_populates="app") - - __table_args__ = ( - sa.UniqueConstraint('owner_id', 'name', name='idx_app_owner_name'), - ) - - -class Config(Base): - __tablename__ = "configs" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - key = Column(String, unique=True, nullable=False, index=True) - value = Column(JSON, nullable=False) - created_at = Column(DateTime, default=get_current_utc_time) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - -class Memory(Base): - __tablename__ = "memories" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - user_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True) - app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True) - content = Column(String, nullable=False) - vector = Column(String) - metadata_ = Column('metadata', JSON, default=dict) - state = Column(Enum(MemoryState), default=MemoryState.active, index=True) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - archived_at = Column(DateTime, nullable=True, index=True) - deleted_at = Column(DateTime, nullable=True, index=True) - - user = relationship("User", back_populates="memories") - app = relationship("App", back_populates="memories") - categories = relationship("Category", secondary="memory_categories", back_populates="memories") - - __table_args__ = ( - Index('idx_memory_user_state', 'user_id', 'state'), - Index('idx_memory_app_state', 'app_id', 'state'), - Index('idx_memory_user_app', 'user_id', 'app_id'), - ) - - -class Category(Base): - __tablename__ = "categories" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - name = Column(String, unique=True, nullable=False, index=True) - description = Column(String) - created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC), index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - memories = relationship("Memory", secondary="memory_categories", back_populates="categories") - -memory_categories = Table( - "memory_categories", Base.metadata, - Column("memory_id", UUID, ForeignKey("memories.id"), primary_key=True, index=True), - Column("category_id", UUID, ForeignKey("categories.id"), primary_key=True, index=True), - Index('idx_memory_category', 'memory_id', 'category_id') -) - - -class AccessControl(Base): - __tablename__ = "access_controls" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - subject_type = Column(String, nullable=False, index=True) - subject_id = Column(UUID, nullable=True, index=True) - object_type = Column(String, nullable=False, index=True) - object_id = Column(UUID, nullable=True, index=True) - effect = Column(String, nullable=False, index=True) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - - __table_args__ = ( - Index('idx_access_subject', 'subject_type', 'subject_id'), - Index('idx_access_object', 'object_type', 'object_id'), - ) - - -class ArchivePolicy(Base): - __tablename__ = "archive_policies" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - criteria_type = Column(String, nullable=False, index=True) - criteria_id = Column(UUID, nullable=True, index=True) - days_to_archive = Column(Integer, nullable=False) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - - __table_args__ = ( - Index('idx_policy_criteria', 'criteria_type', 'criteria_id'), - ) - - -class MemoryStatusHistory(Base): - __tablename__ = "memory_status_history" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True) - changed_by = Column(UUID, ForeignKey("users.id"), nullable=False, index=True) - old_state = Column(Enum(MemoryState), nullable=False, index=True) - new_state = Column(Enum(MemoryState), nullable=False, index=True) - changed_at = Column(DateTime, default=get_current_utc_time, index=True) - - __table_args__ = ( - Index('idx_history_memory_state', 'memory_id', 'new_state'), - Index('idx_history_user_time', 'changed_by', 'changed_at'), - ) - - -class MemoryAccessLog(Base): - __tablename__ = "memory_access_logs" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True) - app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True) - accessed_at = Column(DateTime, default=get_current_utc_time, index=True) - access_type = Column(String, nullable=False, index=True) - metadata_ = Column('metadata', JSON, default=dict) - - __table_args__ = ( - Index('idx_access_memory_time', 'memory_id', 'accessed_at'), - Index('idx_access_app_time', 'app_id', 'accessed_at'), - ) - -def categorize_memory(memory: Memory, db: Session) -> None: - """Categorize a memory using OpenAI and store the categories in the database.""" - try: - # Get categories from OpenAI - categories = get_categories_for_memory(memory.content) - - # Get or create categories in the database - for category_name in categories: - category = db.query(Category).filter(Category.name == category_name).first() - if not category: - category = Category( - name=category_name, - description=f"Automatically created category for {category_name}" - ) - db.add(category) - db.flush() # Flush to get the category ID - - # Check if the memory-category association already exists - existing = db.execute( - memory_categories.select().where( - (memory_categories.c.memory_id == memory.id) & - (memory_categories.c.category_id == category.id) - ) - ).first() - - if not existing: - # Create the association - db.execute( - memory_categories.insert().values( - memory_id=memory.id, - category_id=category.id - ) - ) - - db.commit() - except Exception as e: - db.rollback() - print(f"Error categorizing memory: {e}") - - -@event.listens_for(Memory, 'after_insert') -def after_memory_insert(mapper, connection, target): - """Trigger categorization after a memory is inserted.""" - db = Session(bind=connection) - categorize_memory(target, db) - db.close() - - -@event.listens_for(Memory, 'after_update') -def after_memory_update(mapper, connection, target): - """Trigger categorization after a memory is updated.""" - db = Session(bind=connection) - categorize_memory(target, db) - db.close() diff --git a/openmemory/api/app/routers/__init__.py b/openmemory/api/app/routers/__init__.py deleted file mode 100644 index 8454f81e3..000000000 --- a/openmemory/api/app/routers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .apps import router as apps_router -from .backup import router as backup_router -from .config import router as config_router -from .memories import router as memories_router -from .stats import router as stats_router - -__all__ = ["memories_router", "apps_router", "stats_router", "config_router", "backup_router"] diff --git a/openmemory/api/app/routers/apps.py b/openmemory/api/app/routers/apps.py deleted file mode 100644 index 97f0dc898..000000000 --- a/openmemory/api/app/routers/apps.py +++ /dev/null @@ -1,223 +0,0 @@ -from typing import Optional -from uuid import UUID - -from app.database import get_db -from app.models import App, Memory, MemoryAccessLog, MemoryState -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import desc, func -from sqlalchemy.orm import Session, joinedload - -router = APIRouter(prefix="/api/v1/apps", tags=["apps"]) - -# Helper functions -def get_app_or_404(db: Session, app_id: UUID) -> App: - app = db.query(App).filter(App.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="App not found") - return app - -# List all apps with filtering -@router.get("/") -async def list_apps( - name: Optional[str] = None, - is_active: Optional[bool] = None, - sort_by: str = 'name', - sort_direction: str = 'asc', - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - # Create a subquery for memory counts - memory_counts = db.query( - Memory.app_id, - func.count(Memory.id).label('memory_count') - ).filter( - Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived]) - ).group_by(Memory.app_id).subquery() - - # Create a subquery for access counts - access_counts = db.query( - MemoryAccessLog.app_id, - func.count(func.distinct(MemoryAccessLog.memory_id)).label('access_count') - ).group_by(MemoryAccessLog.app_id).subquery() - - # Base query - query = db.query( - App, - func.coalesce(memory_counts.c.memory_count, 0).label('total_memories_created'), - func.coalesce(access_counts.c.access_count, 0).label('total_memories_accessed') - ) - - # Join with subqueries - query = query.outerjoin( - memory_counts, - App.id == memory_counts.c.app_id - ).outerjoin( - access_counts, - App.id == access_counts.c.app_id - ) - - if name: - query = query.filter(App.name.ilike(f"%{name}%")) - - if is_active is not None: - query = query.filter(App.is_active == is_active) - - # Apply sorting - if sort_by == 'name': - sort_field = App.name - elif sort_by == 'memories': - sort_field = func.coalesce(memory_counts.c.memory_count, 0) - elif sort_by == 'memories_accessed': - sort_field = func.coalesce(access_counts.c.access_count, 0) - else: - sort_field = App.name # default sort - - if sort_direction == 'desc': - query = query.order_by(desc(sort_field)) - else: - query = query.order_by(sort_field) - - total = query.count() - apps = query.offset((page - 1) * page_size).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "apps": [ - { - "id": app[0].id, - "name": app[0].name, - "is_active": app[0].is_active, - "total_memories_created": app[1], - "total_memories_accessed": app[2] - } - for app in apps - ] - } - -# Get app details -@router.get("/{app_id}") -async def get_app_details( - app_id: UUID, - db: Session = Depends(get_db) -): - app = get_app_or_404(db, app_id) - - # Get memory access statistics - access_stats = db.query( - func.count(MemoryAccessLog.id).label("total_memories_accessed"), - func.min(MemoryAccessLog.accessed_at).label("first_accessed"), - func.max(MemoryAccessLog.accessed_at).label("last_accessed") - ).filter(MemoryAccessLog.app_id == app_id).first() - - return { - "is_active": app.is_active, - "total_memories_created": db.query(Memory) - .filter(Memory.app_id == app_id) - .count(), - "total_memories_accessed": access_stats.total_memories_accessed or 0, - "first_accessed": access_stats.first_accessed, - "last_accessed": access_stats.last_accessed - } - -# List memories created by app -@router.get("/{app_id}/memories") -async def list_app_memories( - app_id: UUID, - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - get_app_or_404(db, app_id) - query = db.query(Memory).filter( - Memory.app_id == app_id, - Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived]) - ) - # Add eager loading for categories - query = query.options(joinedload(Memory.categories)) - total = query.count() - memories = query.order_by(Memory.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "memories": [ - { - "id": memory.id, - "content": memory.content, - "created_at": memory.created_at, - "state": memory.state.value, - "app_id": memory.app_id, - "categories": [category.name for category in memory.categories], - "metadata_": memory.metadata_ - } - for memory in memories - ] - } - -# List memories accessed by app -@router.get("/{app_id}/accessed") -async def list_app_accessed_memories( - app_id: UUID, - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - - # Get memories with access counts - query = db.query( - Memory, - func.count(MemoryAccessLog.id).label("access_count") - ).join( - MemoryAccessLog, - Memory.id == MemoryAccessLog.memory_id - ).filter( - MemoryAccessLog.app_id == app_id - ).group_by( - Memory.id - ).order_by( - desc("access_count") - ) - - # Add eager loading for categories - query = query.options(joinedload(Memory.categories)) - - total = query.count() - results = query.offset((page - 1) * page_size).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "memories": [ - { - "memory": { - "id": memory.id, - "content": memory.content, - "created_at": memory.created_at, - "state": memory.state.value, - "app_id": memory.app_id, - "app_name": memory.app.name if memory.app else None, - "categories": [category.name for category in memory.categories], - "metadata_": memory.metadata_ - }, - "access_count": count - } - for memory, count in results - ] - } - - -@router.put("/{app_id}") -async def update_app_details( - app_id: UUID, - is_active: bool, - db: Session = Depends(get_db) -): - app = get_app_or_404(db, app_id) - app.is_active = is_active - db.commit() - return {"status": "success", "message": "Updated app details successfully"} diff --git a/openmemory/api/app/routers/backup.py b/openmemory/api/app/routers/backup.py deleted file mode 100644 index 243224c5d..000000000 --- a/openmemory/api/app/routers/backup.py +++ /dev/null @@ -1,499 +0,0 @@ -from datetime import UTC, datetime -import io -import json -import gzip -import zipfile -from typing import Optional, List, Dict, Any -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy.orm import Session, joinedload -from sqlalchemy import and_ - -from app.database import get_db -from app.models import ( - User, App, Memory, MemoryState, Category, memory_categories, - MemoryStatusHistory, AccessControl -) -from app.utils.memory import get_memory_client - -from uuid import uuid4 - -router = APIRouter(prefix="/api/v1/backup", tags=["backup"]) - -class ExportRequest(BaseModel): - user_id: str - app_id: Optional[UUID] = None - from_date: Optional[int] = None - to_date: Optional[int] = None - include_vectors: bool = True - -def _iso(dt: Optional[datetime]) -> Optional[str]: - if isinstance(dt, datetime): - try: - return dt.astimezone(UTC).isoformat() - except: - return dt.replace(tzinfo=UTC).isoformat() - return None - -def _parse_iso(dt: Optional[str]) -> Optional[datetime]: - if not dt: - return None - try: - return datetime.fromisoformat(dt) - except Exception: - try: - return datetime.fromisoformat(dt.replace("Z", "+00:00")) - except Exception: - return None - -def _export_sqlite(db: Session, req: ExportRequest) -> Dict[str, Any]: - user = db.query(User).filter(User.user_id == req.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - time_filters = [] - if req.from_date: - time_filters.append(Memory.created_at >= datetime.fromtimestamp(req.from_date, tz=UTC)) - if req.to_date: - time_filters.append(Memory.created_at <= datetime.fromtimestamp(req.to_date, tz=UTC)) - - mem_q = ( - db.query(Memory) - .options(joinedload(Memory.categories), joinedload(Memory.app)) - .filter( - Memory.user_id == user.id, - *(time_filters or []), - * ( [Memory.app_id == req.app_id] if req.app_id else [] ), - ) - ) - - memories = mem_q.all() - memory_ids = [m.id for m in memories] - - app_ids = sorted({m.app_id for m in memories if m.app_id}) - apps = db.query(App).filter(App.id.in_(app_ids)).all() if app_ids else [] - - cats = sorted({c for m in memories for c in m.categories}, key = lambda c: str(c.id)) - - mc_rows = db.execute( - memory_categories.select().where(memory_categories.c.memory_id.in_(memory_ids)) - ).fetchall() if memory_ids else [] - - history = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.memory_id.in_(memory_ids)).all() if memory_ids else [] - - acls = db.query(AccessControl).filter( - AccessControl.subject_type == "app", - AccessControl.subject_id.in_(app_ids) if app_ids else False - ).all() if app_ids else [] - - return { - "user": { - "id": str(user.id), - "user_id": user.user_id, - "name": user.name, - "email": user.email, - "metadata": user.metadata_, - "created_at": _iso(user.created_at), - "updated_at": _iso(user.updated_at) - }, - "apps": [ - { - "id": str(a.id), - "owner_id": str(a.owner_id), - "name": a.name, - "description": a.description, - "metadata": a.metadata_, - "is_active": a.is_active, - "created_at": _iso(a.created_at), - "updated_at": _iso(a.updated_at), - } - for a in apps - ], - "categories": [ - { - "id": str(c.id), - "name": c.name, - "description": c.description, - "created_at": _iso(c.created_at), - "updated_at": _iso(c.updated_at), - } - for c in cats - ], - "memories": [ - { - "id": str(m.id), - "user_id": str(m.user_id), - "app_id": str(m.app_id) if m.app_id else None, - "content": m.content, - "metadata": m.metadata_, - "state": m.state.value, - "created_at": _iso(m.created_at), - "updated_at": _iso(m.updated_at), - "archived_at": _iso(m.archived_at), - "deleted_at": _iso(m.deleted_at), - "category_ids": [str(c.id) for c in m.categories], #TODO: figure out a way to add category names simply to this - } - for m in memories - ], - "memory_categories": [ - {"memory_id": str(r.memory_id), "category_id": str(r.category_id)} - for r in mc_rows - ], - "status_history": [ - { - "id": str(h.id), - "memory_id": str(h.memory_id), - "changed_by": str(h.changed_by), - "old_state": h.old_state.value, - "new_state": h.new_state.value, - "changed_at": _iso(h.changed_at), - } - for h in history - ], - "access_controls": [ - { - "id": str(ac.id), - "subject_type": ac.subject_type, - "subject_id": str(ac.subject_id) if ac.subject_id else None, - "object_type": ac.object_type, - "object_id": str(ac.object_id) if ac.object_id else None, - "effect": ac.effect, - "created_at": _iso(ac.created_at), - } - for ac in acls - ], - "export_meta": { - "app_id_filter": str(req.app_id) if req.app_id else None, - "from_date": req.from_date, - "to_date": req.to_date, - "version": "1", - "generated_at": datetime.now(UTC).isoformat(), - }, - } - -def _export_logical_memories_gz( - db: Session, - *, - user_id: str, - app_id: Optional[UUID] = None, - from_date: Optional[int] = None, - to_date: Optional[int] = None -) -> bytes: - """ - Export a provider-agnostic backup of memories so they can be restored to any vector DB - by re-embedding content. One JSON object per line, gzip-compressed. - - Schema (per line): - { - "id": "", - "content": "", - "metadata": {...}, - "created_at": "", - "updated_at": "", - "state": "active|paused|archived|deleted", - "app": "", - "categories": ["catA", "catB", ...] - } - """ - - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - time_filters = [] - if from_date: - time_filters.append(Memory.created_at >= datetime.fromtimestamp(from_date, tz=UTC)) - if to_date: - time_filters.append(Memory.created_at <= datetime.fromtimestamp(to_date, tz=UTC)) - - q = ( - db.query(Memory) - .options(joinedload(Memory.categories), joinedload(Memory.app)) - .filter( - Memory.user_id == user.id, - *(time_filters or []), - ) - ) - if app_id: - q = q.filter(Memory.app_id == app_id) - - buf = io.BytesIO() - with gzip.GzipFile(fileobj=buf, mode="wb") as gz: - for m in q.all(): - record = { - "id": str(m.id), - "content": m.content, - "metadata": m.metadata_ or {}, - "created_at": _iso(m.created_at), - "updated_at": _iso(m.updated_at), - "state": m.state.value, - "app": m.app.name if m.app else None, - "categories": [c.name for c in m.categories], - } - gz.write((json.dumps(record) + "\n").encode("utf-8")) - return buf.getvalue() - -@router.post("/export") -async def export_backup(req: ExportRequest, db: Session = Depends(get_db)): - sqlite_payload = _export_sqlite(db=db, req=req) - memories_blob = _export_logical_memories_gz( - db=db, - user_id=req.user_id, - app_id=req.app_id, - from_date=req.from_date, - to_date=req.to_date, - - ) - - #TODO: add vector store specific exports in future for speed - - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: - zf.writestr("memories.json", json.dumps(sqlite_payload, indent=2)) - zf.writestr("memories.jsonl.gz", memories_blob) - - zip_buf.seek(0) - return StreamingResponse( - zip_buf, - media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="memories_export_{req.user_id}.zip"'}, - ) - -@router.post("/import") -async def import_backup( - file: UploadFile = File(..., description="Zip with memories.json and memories.jsonl.gz"), - user_id: str = Form(..., description="Import memories into this user_id"), - mode: str = Query("overwrite"), - db: Session = Depends(get_db) -): - if not file.filename.endswith(".zip"): - raise HTTPException(status_code=400, detail="Expected a zip file.") - - if mode not in {"skip", "overwrite"}: - raise HTTPException(status_code=400, detail="Invalid mode. Must be 'skip' or 'overwrite'.") - - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - content = await file.read() - try: - with zipfile.ZipFile(io.BytesIO(content), "r") as zf: - names = zf.namelist() - - def find_member(filename: str) -> Optional[str]: - for name in names: - # Skip directory entries - if name.endswith('/'): - continue - if name.rsplit('/', 1)[-1] == filename: - return name - return None - - sqlite_member = find_member("memories.json") - if not sqlite_member: - raise HTTPException(status_code=400, detail="memories.json missing in zip") - - memories_member = find_member("memories.jsonl.gz") - - sqlite_data = json.loads(zf.read(sqlite_member)) - memories_blob = zf.read(memories_member) if memories_member else None - except Exception: - raise HTTPException(status_code=400, detail="Invalid zip file") - - default_app = db.query(App).filter(App.owner_id == user.id, App.name == "openmemory").first() - if not default_app: - default_app = App(owner_id=user.id, name="openmemory", is_active=True, metadata_={}) - db.add(default_app) - db.commit() - db.refresh(default_app) - - cat_id_map: Dict[str, UUID] = {} - for c in sqlite_data.get("categories", []): - cat = db.query(Category).filter(Category.name == c["name"]).first() - if not cat: - cat = Category(name=c["name"], description=c.get("description")) - db.add(cat) - db.commit() - db.refresh(cat) - cat_id_map[c["id"]] = cat.id - - old_to_new_id: Dict[str, UUID] = {} - for m in sqlite_data.get("memories", []): - incoming_id = UUID(m["id"]) - existing = db.query(Memory).filter(Memory.id == incoming_id).first() - - # Cross-user collision: always mint a new UUID and import as a new memory - if existing and existing.user_id != user.id: - target_id = uuid4() - else: - target_id = incoming_id - - old_to_new_id[m["id"]] = target_id - - # Same-user collision + skip mode: leave existing row untouched - if existing and (existing.user_id == user.id) and mode == "skip": - continue - - # Same-user collision + overwrite mode: treat import as ground truth - if existing and (existing.user_id == user.id) and mode == "overwrite": - incoming_state = m.get("state", "active") - existing.user_id = user.id - existing.app_id = default_app.id - existing.content = m.get("content") or "" - existing.metadata_ = m.get("metadata") or {} - try: - existing.state = MemoryState(incoming_state) - except Exception: - existing.state = MemoryState.active - # Update state-related timestamps from import (ground truth) - existing.archived_at = _parse_iso(m.get("archived_at")) - existing.deleted_at = _parse_iso(m.get("deleted_at")) - existing.created_at = _parse_iso(m.get("created_at")) or existing.created_at - existing.updated_at = _parse_iso(m.get("updated_at")) or existing.updated_at - db.add(existing) - db.commit() - continue - - new_mem = Memory( - id=target_id, - user_id=user.id, - app_id=default_app.id, - content=m.get("content") or "", - metadata_=m.get("metadata") or {}, - state=MemoryState(m.get("state", "active")) if m.get("state") else MemoryState.active, - created_at=_parse_iso(m.get("created_at")) or datetime.now(UTC), - updated_at=_parse_iso(m.get("updated_at")) or datetime.now(UTC), - archived_at=_parse_iso(m.get("archived_at")), - deleted_at=_parse_iso(m.get("deleted_at")), - ) - db.add(new_mem) - db.commit() - - for link in sqlite_data.get("memory_categories", []): - mid = old_to_new_id.get(link["memory_id"]) - cid = cat_id_map.get(link["category_id"]) - if not (mid and cid): - continue - exists = db.execute( - memory_categories.select().where( - (memory_categories.c.memory_id == mid) & (memory_categories.c.category_id == cid) - ) - ).first() - - if not exists: - db.execute(memory_categories.insert().values(memory_id=mid, category_id=cid)) - db.commit() - - for h in sqlite_data.get("status_history", []): - hid = UUID(h["id"]) - mem_id = old_to_new_id.get(h["memory_id"], UUID(h["memory_id"])) - exists = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.id == hid).first() - if exists and mode == "skip": - continue - rec = exists if exists else MemoryStatusHistory(id=hid) - rec.memory_id = mem_id - rec.changed_by = user.id - try: - rec.old_state = MemoryState(h.get("old_state", "active")) - rec.new_state = MemoryState(h.get("new_state", "active")) - except Exception: - rec.old_state = MemoryState.active - rec.new_state = MemoryState.active - rec.changed_at = _parse_iso(h.get("changed_at")) or datetime.now(UTC) - db.add(rec) - db.commit() - - memory_client = get_memory_client() - vector_store = getattr(memory_client, "vector_store", None) if memory_client else None - - if vector_store and memory_client and hasattr(memory_client, "embedding_model"): - def iter_logical_records(): - if memories_blob: - gz_buf = io.BytesIO(memories_blob) - with gzip.GzipFile(fileobj=gz_buf, mode="rb") as gz: - for raw in gz: - yield json.loads(raw.decode("utf-8")) - else: - for m in sqlite_data.get("memories", []): - yield { - "id": m["id"], - "content": m.get("content"), - "metadata": m.get("metadata") or {}, - "created_at": m.get("created_at"), - "updated_at": m.get("updated_at"), - } - - for rec in iter_logical_records(): - old_id = rec["id"] - new_id = old_to_new_id.get(old_id, UUID(old_id)) - content = rec.get("content") or "" - metadata = rec.get("metadata") or {} - created_at = rec.get("created_at") - updated_at = rec.get("updated_at") - - if mode == "skip": - try: - get_fn = getattr(vector_store, "get", None) - if callable(get_fn) and vector_store.get(str(new_id)): - continue - except Exception: - pass - - payload = dict(metadata) - payload["data"] = content - if created_at: - payload["created_at"] = created_at - if updated_at: - payload["updated_at"] = updated_at - payload["user_id"] = user_id - payload.setdefault("source_app", "openmemory") - - try: - vec = memory_client.embedding_model.embed(content, "add") - vector_store.insert(vectors=[vec], payloads=[payload], ids=[str(new_id)]) - except Exception as e: - print(f"Vector upsert failed for memory {new_id}: {e}") - continue - - return {"message": f'Import completed into user "{user_id}"'} - - return {"message": f'Import completed into user "{user_id}"'} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openmemory/api/app/routers/config.py b/openmemory/api/app/routers/config.py deleted file mode 100644 index d55cf7829..000000000 --- a/openmemory/api/app/routers/config.py +++ /dev/null @@ -1,291 +0,0 @@ -from typing import Any, Dict, Optional - -from app.database import get_db -from app.models import Config as ConfigModel -from app.utils.memory import reset_memory_client -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -router = APIRouter(prefix="/api/v1/config", tags=["config"]) - -class LLMConfig(BaseModel): - model: str = Field(..., description="LLM model name") - temperature: float = Field(..., description="Temperature setting for the model") - max_tokens: int = Field(..., description="Maximum tokens to generate") - api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable") - ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)") - -class LLMProvider(BaseModel): - provider: str = Field(..., description="LLM provider name") - config: LLMConfig - -class EmbedderConfig(BaseModel): - model: str = Field(..., description="Embedder model name") - api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable") - ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)") - -class EmbedderProvider(BaseModel): - provider: str = Field(..., description="Embedder provider name") - config: EmbedderConfig - -class VectorStoreProvider(BaseModel): - provider: str = Field(..., description="Vector store provider name") - # Below config can vary widely based on the vector store used. Refer https://docs.mem0.ai/components/vectordbs/config - config: Dict[str, Any] = Field(..., description="Vector store-specific configuration") - -class OpenMemoryConfig(BaseModel): - custom_instructions: Optional[str] = Field(None, description="Custom instructions for memory management and fact extraction") - -class Mem0Config(BaseModel): - llm: Optional[LLMProvider] = None - embedder: Optional[EmbedderProvider] = None - vector_store: Optional[VectorStoreProvider] = None - -class ConfigSchema(BaseModel): - openmemory: Optional[OpenMemoryConfig] = None - mem0: Optional[Mem0Config] = None - -def get_default_configuration(): - """Get the default configuration with sensible defaults for LLM and embedder.""" - return { - "openmemory": { - "custom_instructions": None - }, - "mem0": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:OPENAI_API_KEY" - } - }, - "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:OPENAI_API_KEY" - } - }, - "vector_store": None - } - } - -def get_config_from_db(db: Session, key: str = "main"): - """Get configuration from database.""" - config = db.query(ConfigModel).filter(ConfigModel.key == key).first() - - if not config: - # Create default config with proper provider configurations - default_config = get_default_configuration() - db_config = ConfigModel(key=key, value=default_config) - db.add(db_config) - db.commit() - db.refresh(db_config) - return default_config - - # Ensure the config has all required sections with defaults - config_value = config.value - default_config = get_default_configuration() - - # Merge with defaults to ensure all required fields exist - if "openmemory" not in config_value: - config_value["openmemory"] = default_config["openmemory"] - - if "mem0" not in config_value: - config_value["mem0"] = default_config["mem0"] - else: - # Ensure LLM config exists with defaults - if "llm" not in config_value["mem0"] or config_value["mem0"]["llm"] is None: - config_value["mem0"]["llm"] = default_config["mem0"]["llm"] - - # Ensure embedder config exists with defaults - if "embedder" not in config_value["mem0"] or config_value["mem0"]["embedder"] is None: - config_value["mem0"]["embedder"] = default_config["mem0"]["embedder"] - - # Ensure vector_store config exists with defaults - if "vector_store" not in config_value["mem0"]: - config_value["mem0"]["vector_store"] = default_config["mem0"]["vector_store"] - - # Save the updated config back to database if it was modified - if config_value != config.value: - config.value = config_value - db.commit() - db.refresh(config) - - return config_value - -def save_config_to_db(db: Session, config: Dict[str, Any], key: str = "main"): - """Save configuration to database.""" - db_config = db.query(ConfigModel).filter(ConfigModel.key == key).first() - - if db_config: - db_config.value = config - db_config.updated_at = None # Will trigger the onupdate to set current time - else: - db_config = ConfigModel(key=key, value=config) - db.add(db_config) - - db.commit() - db.refresh(db_config) - return db_config.value - -@router.get("/", response_model=ConfigSchema) -async def get_configuration(db: Session = Depends(get_db)): - """Get the current configuration.""" - config = get_config_from_db(db) - return config - -@router.put("/", response_model=ConfigSchema) -async def update_configuration(config: ConfigSchema, db: Session = Depends(get_db)): - """Update the configuration.""" - current_config = get_config_from_db(db) - - # Convert to dict for processing - updated_config = current_config.copy() - - # Update openmemory settings if provided - if config.openmemory is not None: - if "openmemory" not in updated_config: - updated_config["openmemory"] = {} - updated_config["openmemory"].update(config.openmemory.dict(exclude_none=True)) - - # Update mem0 settings - updated_config["mem0"] = config.mem0.dict(exclude_none=True) - - -@router.patch("/", response_model=ConfigSchema) -async def patch_configuration(config_update: ConfigSchema, db: Session = Depends(get_db)): - """Update parts of the configuration.""" - current_config = get_config_from_db(db) - - def deep_update(source, overrides): - for key, value in overrides.items(): - if isinstance(value, dict) and key in source and isinstance(source[key], dict): - source[key] = deep_update(source[key], value) - else: - source[key] = value - return source - - update_data = config_update.dict(exclude_unset=True) - updated_config = deep_update(current_config, update_data) - - save_config_to_db(db, updated_config) - reset_memory_client() - return updated_config - - -@router.post("/reset", response_model=ConfigSchema) -async def reset_configuration(db: Session = Depends(get_db)): - """Reset the configuration to default values.""" - try: - # Get the default configuration with proper provider setups - default_config = get_default_configuration() - - # Save it as the current configuration in the database - save_config_to_db(db, default_config) - reset_memory_client() - return default_config - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Failed to reset configuration: {str(e)}" - ) - -@router.get("/mem0/llm", response_model=LLMProvider) -async def get_llm_configuration(db: Session = Depends(get_db)): - """Get only the LLM configuration.""" - config = get_config_from_db(db) - llm_config = config.get("mem0", {}).get("llm", {}) - return llm_config - -@router.put("/mem0/llm", response_model=LLMProvider) -async def update_llm_configuration(llm_config: LLMProvider, db: Session = Depends(get_db)): - """Update only the LLM configuration.""" - current_config = get_config_from_db(db) - - # Ensure mem0 key exists - if "mem0" not in current_config: - current_config["mem0"] = {} - - # Update the LLM configuration - current_config["mem0"]["llm"] = llm_config.dict(exclude_none=True) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["mem0"]["llm"] - -@router.get("/mem0/embedder", response_model=EmbedderProvider) -async def get_embedder_configuration(db: Session = Depends(get_db)): - """Get only the Embedder configuration.""" - config = get_config_from_db(db) - embedder_config = config.get("mem0", {}).get("embedder", {}) - return embedder_config - -@router.put("/mem0/embedder", response_model=EmbedderProvider) -async def update_embedder_configuration(embedder_config: EmbedderProvider, db: Session = Depends(get_db)): - """Update only the Embedder configuration.""" - current_config = get_config_from_db(db) - - # Ensure mem0 key exists - if "mem0" not in current_config: - current_config["mem0"] = {} - - # Update the Embedder configuration - current_config["mem0"]["embedder"] = embedder_config.dict(exclude_none=True) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["mem0"]["embedder"] - -@router.get("/mem0/vector_store", response_model=Optional[VectorStoreProvider]) -async def get_vector_store_configuration(db: Session = Depends(get_db)): - """Get only the Vector Store configuration.""" - config = get_config_from_db(db) - vector_store_config = config.get("mem0", {}).get("vector_store", None) - return vector_store_config - -@router.put("/mem0/vector_store", response_model=VectorStoreProvider) -async def update_vector_store_configuration(vector_store_config: VectorStoreProvider, db: Session = Depends(get_db)): - """Update only the Vector Store configuration.""" - current_config = get_config_from_db(db) - - # Ensure mem0 key exists - if "mem0" not in current_config: - current_config["mem0"] = {} - - # Update the Vector Store configuration - current_config["mem0"]["vector_store"] = vector_store_config.dict(exclude_none=True) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["mem0"]["vector_store"] - -@router.get("/openmemory", response_model=OpenMemoryConfig) -async def get_openmemory_configuration(db: Session = Depends(get_db)): - """Get only the OpenMemory configuration.""" - config = get_config_from_db(db) - openmemory_config = config.get("openmemory", {}) - return openmemory_config - -@router.put("/openmemory", response_model=OpenMemoryConfig) -async def update_openmemory_configuration(openmemory_config: OpenMemoryConfig, db: Session = Depends(get_db)): - """Update only the OpenMemory configuration.""" - current_config = get_config_from_db(db) - - # Ensure openmemory key exists - if "openmemory" not in current_config: - current_config["openmemory"] = {} - - # Update the OpenMemory configuration - current_config["openmemory"].update(openmemory_config.dict(exclude_none=True)) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["openmemory"] diff --git a/openmemory/api/app/routers/memories.py b/openmemory/api/app/routers/memories.py deleted file mode 100644 index 3af7bbe13..000000000 --- a/openmemory/api/app/routers/memories.py +++ /dev/null @@ -1,694 +0,0 @@ -import logging -from datetime import UTC, datetime -from typing import List, Optional, Set -from uuid import UUID - -from app.database import get_db -from app.models import ( - AccessControl, - App, - Category, - Memory, - MemoryAccessLog, - MemoryState, - MemoryStatusHistory, - User, -) -from app.schemas import MemoryResponse -from app.utils.memory import get_memory_client -from app.utils.permissions import check_memory_access_permissions -from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi_pagination import Page, Params -from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate -from pydantic import BaseModel -from sqlalchemy import func -from sqlalchemy.orm import Session, joinedload - -router = APIRouter(prefix="/api/v1/memories", tags=["memories"]) - - -def get_memory_or_404(db: Session, memory_id: UUID) -> Memory: - memory = db.query(Memory).filter(Memory.id == memory_id).first() - if not memory: - raise HTTPException(status_code=404, detail="Memory not found") - return memory - - -def update_memory_state(db: Session, memory_id: UUID, new_state: MemoryState, user_id: UUID): - memory = get_memory_or_404(db, memory_id) - old_state = memory.state - - # Update memory state - memory.state = new_state - if new_state == MemoryState.archived: - memory.archived_at = datetime.now(UTC) - elif new_state == MemoryState.deleted: - memory.deleted_at = datetime.now(UTC) - - # Record state change - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user_id, - old_state=old_state, - new_state=new_state - ) - db.add(history) - db.commit() - return memory - - -def get_accessible_memory_ids(db: Session, app_id: UUID) -> Set[UUID]: - """ - Get the set of memory IDs that the app has access to based on app-level ACL rules. - Returns all memory IDs if no specific restrictions are found. - """ - # Get app-level access controls - app_access = db.query(AccessControl).filter( - AccessControl.subject_type == "app", - AccessControl.subject_id == app_id, - AccessControl.object_type == "memory" - ).all() - - # If no app-level rules exist, return None to indicate all memories are accessible - if not app_access: - return None - - # Initialize sets for allowed and denied memory IDs - allowed_memory_ids = set() - denied_memory_ids = set() - - # Process app-level rules - for rule in app_access: - if rule.effect == "allow": - if rule.object_id: # Specific memory access - allowed_memory_ids.add(rule.object_id) - else: # All memories access - return None # All memories allowed - elif rule.effect == "deny": - if rule.object_id: # Specific memory denied - denied_memory_ids.add(rule.object_id) - else: # All memories denied - return set() # No memories accessible - - # Remove denied memories from allowed set - if allowed_memory_ids: - allowed_memory_ids -= denied_memory_ids - - return allowed_memory_ids - - -# List all memories with filtering -@router.get("/", response_model=Page[MemoryResponse]) -async def list_memories( - user_id: str, - app_id: Optional[UUID] = None, - from_date: Optional[int] = Query( - None, - description="Filter memories created after this date (timestamp)", - examples=[1718505600] - ), - to_date: Optional[int] = Query( - None, - description="Filter memories created before this date (timestamp)", - examples=[1718505600] - ), - categories: Optional[str] = None, - params: Params = Depends(), - search_query: Optional[str] = None, - sort_column: Optional[str] = Query(None, description="Column to sort by (memory, categories, app_name, created_at)"), - sort_direction: Optional[str] = Query(None, description="Sort direction (asc or desc)"), - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Build base query - query = db.query(Memory).filter( - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived, - Memory.content.ilike(f"%{search_query}%") if search_query else True - ) - - # Apply filters - if app_id: - query = query.filter(Memory.app_id == app_id) - - if from_date: - from_datetime = datetime.fromtimestamp(from_date, tz=UTC) - query = query.filter(Memory.created_at >= from_datetime) - - if to_date: - to_datetime = datetime.fromtimestamp(to_date, tz=UTC) - query = query.filter(Memory.created_at <= to_datetime) - - # Add joins for app and categories after filtering - query = query.outerjoin(App, Memory.app_id == App.id) - query = query.outerjoin(Memory.categories) - - # Apply category filter if provided - if categories: - category_list = [c.strip() for c in categories.split(",")] - query = query.filter(Category.name.in_(category_list)) - - # Apply sorting if specified - if sort_column: - sort_field = getattr(Memory, sort_column, None) - if sort_field: - query = query.order_by(sort_field.desc()) if sort_direction == "desc" else query.order_by(sort_field.asc()) - - # Add eager loading for app and categories - query = query.options( - joinedload(Memory.app), - joinedload(Memory.categories) - ).distinct(Memory.id) - - # Get paginated results with transformer - return sqlalchemy_paginate( - query, - params, - transformer=lambda items: [ - MemoryResponse( - id=memory.id, - content=memory.content, - created_at=memory.created_at, - state=memory.state.value, - app_id=memory.app_id, - app_name=memory.app.name if memory.app else None, - categories=[category.name for category in memory.categories], - metadata_=memory.metadata_ - ) - for memory in items - if check_memory_access_permissions(db, memory, app_id) - ] - ) - - -# Get all categories -@router.get("/categories") -async def get_categories( - user_id: str, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get unique categories associated with the user's memories - # Get all memories - memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted, Memory.state != MemoryState.archived).all() - # Get all categories from memories - categories = [category for memory in memories for category in memory.categories] - # Get unique categories - unique_categories = list(set(categories)) - - return { - "categories": unique_categories, - "total": len(unique_categories) - } - - -class CreateMemoryRequest(BaseModel): - user_id: str - text: str - metadata: dict = {} - infer: bool = True - app: str = "openmemory" - - -# Create new memory -@router.post("/") -async def create_memory( - request: CreateMemoryRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - # Get or create app - app_obj = db.query(App).filter(App.name == request.app, - App.owner_id == user.id).first() - if not app_obj: - app_obj = App(name=request.app, owner_id=user.id) - db.add(app_obj) - db.commit() - db.refresh(app_obj) - - # Check if app is active - if not app_obj.is_active: - raise HTTPException(status_code=403, detail=f"App {request.app} is currently paused on OpenMemory. Cannot create new memories.") - - # Log what we're about to do - logging.info(f"Creating memory for user_id: {request.user_id} with app: {request.app}") - - # Try to get memory client safely - try: - memory_client = get_memory_client() - if not memory_client: - raise Exception("Memory client is not available") - except Exception as client_error: - logging.warning(f"Memory client unavailable: {client_error}. Creating memory in database only.") - # Return a json response with the error - return { - "error": str(client_error) - } - - # Try to save to Qdrant via memory_client - try: - qdrant_response = memory_client.add( - request.text, - user_id=request.user_id, # Use string user_id to match search - metadata={ - "source_app": "openmemory", - "mcp_client": request.app, - }, - infer=request.infer - ) - - # Log the response for debugging - logging.info(f"Qdrant response: {qdrant_response}") - - # Process Qdrant response - if isinstance(qdrant_response, dict) and 'results' in qdrant_response: - created_memories = [] - - for result in qdrant_response['results']: - if result['event'] == 'ADD': - # Get the Qdrant-generated ID - memory_id = UUID(result['id']) - - # Check if memory already exists - existing_memory = db.query(Memory).filter(Memory.id == memory_id).first() - - if existing_memory: - # Update existing memory - existing_memory.state = MemoryState.active - existing_memory.content = result['memory'] - memory = existing_memory - else: - # Create memory with the EXACT SAME ID from Qdrant - memory = Memory( - id=memory_id, # Use the same ID that Qdrant generated - user_id=user.id, - app_id=app_obj.id, - content=result['memory'], - metadata_=request.metadata, - state=MemoryState.active - ) - db.add(memory) - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.deleted if existing_memory else MemoryState.deleted, - new_state=MemoryState.active - ) - db.add(history) - - created_memories.append(memory) - - # Commit all changes at once - if created_memories: - db.commit() - for memory in created_memories: - db.refresh(memory) - - # Return the first memory (for API compatibility) - # but all memories are now saved to the database - return created_memories[0] - except Exception as qdrant_error: - logging.warning(f"Qdrant operation failed: {qdrant_error}.") - # Return a json response with the error - return { - "error": str(qdrant_error) - } - - - - -# Get memory by ID -@router.get("/{memory_id}") -async def get_memory( - memory_id: UUID, - db: Session = Depends(get_db) -): - memory = get_memory_or_404(db, memory_id) - return { - "id": memory.id, - "text": memory.content, - "created_at": int(memory.created_at.timestamp()), - "state": memory.state.value, - "app_id": memory.app_id, - "app_name": memory.app.name if memory.app else None, - "categories": [category.name for category in memory.categories], - "metadata_": memory.metadata_ - } - - -class DeleteMemoriesRequest(BaseModel): - memory_ids: List[UUID] - user_id: str - -# Delete multiple memories -@router.delete("/") -async def delete_memories( - request: DeleteMemoriesRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get memory client to delete from vector store - try: - memory_client = get_memory_client() - if not memory_client: - raise HTTPException( - status_code=503, - detail="Memory client is not available" - ) - except HTTPException: - raise - except Exception as client_error: - logging.error(f"Memory client initialization failed: {client_error}") - raise HTTPException( - status_code=503, - detail=f"Memory service unavailable: {str(client_error)}" - ) - - # Delete from vector store then mark as deleted in database - for memory_id in request.memory_ids: - try: - memory_client.delete(str(memory_id)) - except Exception as delete_error: - logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}") - - update_memory_state(db, memory_id, MemoryState.deleted, user.id) - - return {"message": f"Successfully deleted {len(request.memory_ids)} memories"} - - -# Archive memories -@router.post("/actions/archive") -async def archive_memories( - memory_ids: List[UUID], - user_id: UUID, - db: Session = Depends(get_db) -): - for memory_id in memory_ids: - update_memory_state(db, memory_id, MemoryState.archived, user_id) - return {"message": f"Successfully archived {len(memory_ids)} memories"} - - -class PauseMemoriesRequest(BaseModel): - memory_ids: Optional[List[UUID]] = None - category_ids: Optional[List[UUID]] = None - app_id: Optional[UUID] = None - all_for_app: bool = False - global_pause: bool = False - state: Optional[MemoryState] = None - user_id: str - -# Pause access to memories -@router.post("/actions/pause") -async def pause_memories( - request: PauseMemoriesRequest, - db: Session = Depends(get_db) -): - - global_pause = request.global_pause - all_for_app = request.all_for_app - app_id = request.app_id - memory_ids = request.memory_ids - category_ids = request.category_ids - state = request.state or MemoryState.paused - - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - user_id = user.id - - if global_pause: - # Pause all memories - memories = db.query(Memory).filter( - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": "Successfully paused all memories"} - - if app_id: - # Pause all memories for an app - memories = db.query(Memory).filter( - Memory.app_id == app_id, - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": f"Successfully paused all memories for app {app_id}"} - - if all_for_app and memory_ids: - # Pause all memories for an app - memories = db.query(Memory).filter( - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - Memory.id.in_(memory_ids) - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": "Successfully paused all memories"} - - if memory_ids: - # Pause specific memories - for memory_id in memory_ids: - update_memory_state(db, memory_id, state, user_id) - return {"message": f"Successfully paused {len(memory_ids)} memories"} - - if category_ids: - # Pause memories by category - memories = db.query(Memory).join(Memory.categories).filter( - Category.id.in_(category_ids), - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": f"Successfully paused memories in {len(category_ids)} categories"} - - raise HTTPException(status_code=400, detail="Invalid pause request parameters") - - -# Get memory access logs -@router.get("/{memory_id}/access-log") -async def get_memory_access_log( - memory_id: UUID, - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - query = db.query(MemoryAccessLog).filter(MemoryAccessLog.memory_id == memory_id) - total = query.count() - logs = query.order_by(MemoryAccessLog.accessed_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - - # Get app name - for log in logs: - app = db.query(App).filter(App.id == log.app_id).first() - log.app_name = app.name if app else None - - return { - "total": total, - "page": page, - "page_size": page_size, - "logs": logs - } - - -class UpdateMemoryRequest(BaseModel): - memory_content: str - user_id: str - -# Update a memory -@router.put("/{memory_id}") -async def update_memory( - memory_id: UUID, - request: UpdateMemoryRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - memory = get_memory_or_404(db, memory_id) - memory.content = request.memory_content - db.commit() - db.refresh(memory) - return memory - -class FilterMemoriesRequest(BaseModel): - user_id: str - page: int = 1 - size: int = 10 - search_query: Optional[str] = None - app_ids: Optional[List[UUID]] = None - category_ids: Optional[List[UUID]] = None - sort_column: Optional[str] = None - sort_direction: Optional[str] = None - from_date: Optional[int] = None - to_date: Optional[int] = None - show_archived: Optional[bool] = False - -@router.post("/filter", response_model=Page[MemoryResponse]) -async def filter_memories( - request: FilterMemoriesRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Build base query - query = db.query(Memory).filter( - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - ) - - # Filter archived memories based on show_archived parameter - if not request.show_archived: - query = query.filter(Memory.state != MemoryState.archived) - - # Apply search filter - if request.search_query: - query = query.filter(Memory.content.ilike(f"%{request.search_query}%")) - - # Apply app filter - if request.app_ids: - query = query.filter(Memory.app_id.in_(request.app_ids)) - - # Add joins for app and categories - query = query.outerjoin(App, Memory.app_id == App.id) - - # Apply category filter - if request.category_ids: - query = query.join(Memory.categories).filter(Category.id.in_(request.category_ids)) - else: - query = query.outerjoin(Memory.categories) - - # Apply date filters - if request.from_date: - from_datetime = datetime.fromtimestamp(request.from_date, tz=UTC) - query = query.filter(Memory.created_at >= from_datetime) - - if request.to_date: - to_datetime = datetime.fromtimestamp(request.to_date, tz=UTC) - query = query.filter(Memory.created_at <= to_datetime) - - # Apply sorting - if request.sort_column and request.sort_direction: - sort_direction = request.sort_direction.lower() - if sort_direction not in ['asc', 'desc']: - raise HTTPException(status_code=400, detail="Invalid sort direction") - - sort_mapping = { - 'memory': Memory.content, - 'app_name': App.name, - 'created_at': Memory.created_at - } - - if request.sort_column not in sort_mapping: - raise HTTPException(status_code=400, detail="Invalid sort column") - - sort_field = sort_mapping[request.sort_column] - if sort_direction == 'desc': - query = query.order_by(sort_field.desc()) - else: - query = query.order_by(sort_field.asc()) - else: - # Default sorting - query = query.order_by(Memory.created_at.desc()) - - # Add eager loading for categories and make the query distinct - query = query.options( - joinedload(Memory.categories) - ).distinct(Memory.id) - - # Use fastapi-pagination's paginate function - return sqlalchemy_paginate( - query, - Params(page=request.page, size=request.size), - transformer=lambda items: [ - MemoryResponse( - id=memory.id, - content=memory.content, - created_at=memory.created_at, - state=memory.state.value, - app_id=memory.app_id, - app_name=memory.app.name if memory.app else None, - categories=[category.name for category in memory.categories], - metadata_=memory.metadata_ - ) - for memory in items - ] - ) - - -@router.get("/{memory_id}/related", response_model=Page[MemoryResponse]) -async def get_related_memories( - memory_id: UUID, - user_id: str, - params: Params = Depends(), - db: Session = Depends(get_db) -): - # Validate user - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get the source memory - memory = get_memory_or_404(db, memory_id) - - # Extract category IDs from the source memory - category_ids = [category.id for category in memory.categories] - - if not category_ids: - return Page.create([], total=0, params=params) - - # Build query for related memories - query = db.query(Memory).distinct(Memory.id).filter( - Memory.user_id == user.id, - Memory.id != memory_id, - Memory.state != MemoryState.deleted - ).join(Memory.categories).filter( - Category.id.in_(category_ids) - ).options( - joinedload(Memory.categories), - joinedload(Memory.app) - ).order_by( - func.count(Category.id).desc(), - Memory.created_at.desc() - ).group_by(Memory.id) - - # ⚑ Force page size to be 5 - params = Params(page=params.page, size=5) - - return sqlalchemy_paginate( - query, - params, - transformer=lambda items: [ - MemoryResponse( - id=memory.id, - content=memory.content, - created_at=memory.created_at, - state=memory.state.value, - app_id=memory.app_id, - app_name=memory.app.name if memory.app else None, - categories=[category.name for category in memory.categories], - metadata_=memory.metadata_ - ) - for memory in items - ] - ) \ No newline at end of file diff --git a/openmemory/api/app/routers/stats.py b/openmemory/api/app/routers/stats.py deleted file mode 100644 index c609d3726..000000000 --- a/openmemory/api/app/routers/stats.py +++ /dev/null @@ -1,29 +0,0 @@ -from app.database import get_db -from app.models import App, Memory, MemoryState, User -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session - -router = APIRouter(prefix="/api/v1/stats", tags=["stats"]) - -@router.get("/") -async def get_profile( - user_id: str, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get total number of memories - total_memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted).count() - - # Get total number of apps - apps = db.query(App).filter(App.owner == user) - total_apps = apps.count() - - return { - "total_memories": total_memories, - "total_apps": total_apps, - "apps": apps.all() - } - diff --git a/openmemory/api/app/schemas.py b/openmemory/api/app/schemas.py deleted file mode 100644 index fd47b643d..000000000 --- a/openmemory/api/app/schemas.py +++ /dev/null @@ -1,65 +0,0 @@ -from datetime import datetime -from typing import List, Optional -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, validator - - -class MemoryBase(BaseModel): - content: str - metadata_: Optional[dict] = Field(default_factory=dict) - -class MemoryCreate(MemoryBase): - user_id: UUID - app_id: UUID - - -class Category(BaseModel): - name: str - - -class App(BaseModel): - id: UUID - name: str - - -class Memory(MemoryBase): - id: UUID - user_id: UUID - app_id: UUID - created_at: datetime - updated_at: Optional[datetime] = None - state: str - categories: Optional[List[Category]] = None - app: App - - model_config = ConfigDict(from_attributes=True) - -class MemoryUpdate(BaseModel): - content: Optional[str] = None - metadata_: Optional[dict] = None - state: Optional[str] = None - - -class MemoryResponse(BaseModel): - id: UUID - content: str - created_at: int - state: str - app_id: UUID - app_name: str - categories: List[str] - metadata_: Optional[dict] = None - - @validator('created_at', pre=True) - def convert_to_epoch(cls, v): - if isinstance(v, datetime): - return int(v.timestamp()) - return v - -class PaginatedMemoryResponse(BaseModel): - items: List[MemoryResponse] - total: int - page: int - size: int - pages: int diff --git a/openmemory/api/app/utils/__init__.py b/openmemory/api/app/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/openmemory/api/app/utils/categorization.py b/openmemory/api/app/utils/categorization.py deleted file mode 100644 index e20c40052..000000000 --- a/openmemory/api/app/utils/categorization.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging -from typing import List - -from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT -from dotenv import load_dotenv -from openai import OpenAI -from pydantic import BaseModel -from tenacity import retry, stop_after_attempt, wait_exponential - -load_dotenv() -openai_client = OpenAI() - - -class MemoryCategories(BaseModel): - categories: List[str] - - -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15)) -def get_categories_for_memory(memory: str) -> List[str]: - try: - messages = [ - {"role": "system", "content": MEMORY_CATEGORIZATION_PROMPT}, - {"role": "user", "content": memory} - ] - - # Let OpenAI handle the pydantic parsing directly - completion = openai_client.beta.chat.completions.parse( - model="gpt-4o-mini", - messages=messages, - response_format=MemoryCategories, - temperature=0 - ) - - parsed: MemoryCategories = completion.choices[0].message.parsed - return [cat.strip().lower() for cat in parsed.categories] - - except Exception as e: - logging.error(f"[ERROR] Failed to get categories: {e}") - try: - logging.debug(f"[DEBUG] Raw response: {completion.choices[0].message.content}") - except Exception as debug_e: - logging.debug(f"[DEBUG] Could not extract raw response: {debug_e}") - raise diff --git a/openmemory/api/app/utils/db.py b/openmemory/api/app/utils/db.py deleted file mode 100644 index 50a90f6a9..000000000 --- a/openmemory/api/app/utils/db.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Tuple - -from app.models import App, User -from sqlalchemy.orm import Session - - -def get_or_create_user(db: Session, user_id: str) -> User: - """Get or create a user with the given user_id""" - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - user = User(user_id=user_id) - db.add(user) - db.commit() - db.refresh(user) - return user - - -def get_or_create_app(db: Session, user: User, app_id: str) -> App: - """Get or create an app for the given user""" - app = db.query(App).filter(App.owner_id == user.id, App.name == app_id).first() - if not app: - app = App(owner_id=user.id, name=app_id) - db.add(app) - db.commit() - db.refresh(app) - return app - - -def get_user_and_app(db: Session, user_id: str, app_id: str) -> Tuple[User, App]: - """Get or create both user and their app""" - user = get_or_create_user(db, user_id) - app = get_or_create_app(db, user, app_id) - return user, app diff --git a/openmemory/api/app/utils/memory.py b/openmemory/api/app/utils/memory.py deleted file mode 100644 index 7afce595b..000000000 --- a/openmemory/api/app/utils/memory.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -Memory client utilities for OpenMemory. - -This module provides functionality to initialize and manage the Mem0 memory client -with automatic configuration management and Docker environment support. - -Docker Ollama Configuration: -When running inside a Docker container and using Ollama as the LLM or embedder provider, -the system automatically detects the Docker environment and adjusts localhost URLs -to properly reach the host machine where Ollama is running. - -Supported Docker host resolution (in order of preference): -1. OLLAMA_HOST environment variable (if set) -2. host.docker.internal (Docker Desktop for Mac/Windows) -3. Docker bridge gateway IP (typically 172.17.0.1 on Linux) -4. Fallback to 172.17.0.1 - -Example configuration that will be automatically adjusted: -{ - "llm": { - "provider": "ollama", - "config": { - "model": "llama3.1:latest", - "ollama_base_url": "http://localhost:11434" # Auto-adjusted in Docker - } - } -} -""" - -import hashlib -import json -import os -import socket - -from app.database import SessionLocal -from app.models import Config as ConfigModel - -from mem0 import Memory - -_memory_client = None -_config_hash = None - - -def _get_config_hash(config_dict): - """Generate a hash of the config to detect changes.""" - config_str = json.dumps(config_dict, sort_keys=True) - return hashlib.md5(config_str.encode()).hexdigest() - - -def _get_docker_host_url(): - """ - Determine the appropriate host URL to reach host machine from inside Docker container. - Returns the best available option for reaching the host from inside a container. - """ - # Check for custom environment variable first - custom_host = os.environ.get('OLLAMA_HOST') - if custom_host: - print(f"Using custom Ollama host from OLLAMA_HOST: {custom_host}") - return custom_host.replace('http://', '').replace('https://', '').split(':')[0] - - # Check if we're running inside Docker - if not os.path.exists('/.dockerenv'): - # Not in Docker, return localhost as-is - return "localhost" - - print("Detected Docker environment, adjusting host URL for Ollama...") - - # Try different host resolution strategies - host_candidates = [] - - # 1. host.docker.internal (works on Docker Desktop for Mac/Windows) - try: - socket.gethostbyname('host.docker.internal') - host_candidates.append('host.docker.internal') - print("Found host.docker.internal") - except socket.gaierror: - pass - - # 2. Docker bridge gateway (typically 172.17.0.1 on Linux) - try: - with open('/proc/net/route', 'r') as f: - for line in f: - fields = line.strip().split() - if fields[1] == '00000000': # Default route - gateway_hex = fields[2] - gateway_ip = socket.inet_ntoa(bytes.fromhex(gateway_hex)[::-1]) - host_candidates.append(gateway_ip) - print(f"Found Docker gateway: {gateway_ip}") - break - except (FileNotFoundError, IndexError, ValueError): - pass - - # 3. Fallback to common Docker bridge IP - if not host_candidates: - host_candidates.append('172.17.0.1') - print("Using fallback Docker bridge IP: 172.17.0.1") - - # Return the first available candidate - return host_candidates[0] - - -def _fix_ollama_urls(config_section): - """ - Fix Ollama URLs for Docker environment. - Replaces localhost URLs with appropriate Docker host URLs. - Sets default ollama_base_url if not provided. - """ - if not config_section or "config" not in config_section: - return config_section - - ollama_config = config_section["config"] - - # Set default ollama_base_url if not provided - if "ollama_base_url" not in ollama_config: - ollama_config["ollama_base_url"] = "http://host.docker.internal:11434" - else: - # Check for ollama_base_url and fix if it's localhost - url = ollama_config["ollama_base_url"] - if "localhost" in url or "127.0.0.1" in url: - docker_host = _get_docker_host_url() - if docker_host != "localhost": - new_url = url.replace("localhost", docker_host).replace("127.0.0.1", docker_host) - ollama_config["ollama_base_url"] = new_url - print(f"Adjusted Ollama URL from {url} to {new_url}") - - return config_section - - -def reset_memory_client(): - """Reset the global memory client to force reinitialization with new config.""" - global _memory_client, _config_hash - _memory_client = None - _config_hash = None - - -# --- LLM provider config factories --- - -def _build_ollama_llm_config(model, api_key, base_url, ollama_base_url): - config = {"model": model or "llama3.1:latest"} - # OLLAMA_BASE_URL takes precedence, then LLM_BASE_URL, then default - config["ollama_base_url"] = ollama_base_url or base_url or "http://localhost:11434" - return config - - -def _build_openai_llm_config(model, api_key, base_url, ollama_base_url): - config = { - "model": model or "gpt-4o-mini", - "api_key": api_key or "env:OPENAI_API_KEY", - } - if base_url: - config["openai_base_url"] = base_url - return config - - -_LLM_CONFIG_FACTORIES = { - "ollama": _build_ollama_llm_config, - "openai": _build_openai_llm_config, -} - - -def _create_llm_config(provider, model, api_key, base_url, ollama_base_url): - """Build LLM config using registered provider factory or generic fallback.""" - base_config = { - "temperature": 0.1, - "max_tokens": 2000, - } - - factory = _LLM_CONFIG_FACTORIES.get(provider) - if factory: - base_config.update(factory(model, api_key, base_url, ollama_base_url)) - else: - # Generic provider (anthropic, groq, together, deepseek, etc.) - if not model: - raise ValueError( - f"LLM_MODEL environment variable is required when using LLM_PROVIDER='{provider}'. " - f"Set LLM_MODEL to a valid model name for the '{provider}' provider." - ) - base_config["model"] = model - if api_key: - base_config["api_key"] = api_key - - return base_config - - -# --- Embedder provider config factories --- - -def _build_ollama_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url): - config = {"model": model or "nomic-embed-text"} - config["ollama_base_url"] = base_url or ollama_base_url or llm_base_url or "http://localhost:11434" - return config - - -def _build_openai_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url): - config = { - "model": model or "text-embedding-3-small", - "api_key": api_key or "env:OPENAI_API_KEY", - } - if base_url: - config["openai_base_url"] = base_url - return config - - -_EMBEDDER_CONFIG_FACTORIES = { - "ollama": _build_ollama_embedder_config, - "openai": _build_openai_embedder_config, -} - - -def _create_embedder_config(provider, model, api_key, base_url, ollama_base_url, llm_base_url): - """Build embedder config using registered provider factory or generic fallback.""" - factory = _EMBEDDER_CONFIG_FACTORIES.get(provider) - if factory: - config = factory(model, api_key, base_url, ollama_base_url, llm_base_url) - else: - if not model: - raise ValueError( - f"EMBEDDER_MODEL environment variable is required when using EMBEDDER_PROVIDER='{provider}'. " - f"Set EMBEDDER_MODEL to a valid model name for the '{provider}' provider." - ) - config = {"model": model} - if api_key: - config["api_key"] = api_key - - return config - - -def get_default_memory_config(): - """Get default memory client configuration with sensible defaults.""" - # Detect vector store based on environment variables - vector_store_config = { - "collection_name": "openmemory", - "host": "mem0_store", - } - - # Check for different vector store configurations based on environment variables - if os.environ.get('CHROMA_HOST') and os.environ.get('CHROMA_PORT'): - vector_store_provider = "chroma" - vector_store_config.update({ - "host": os.environ.get('CHROMA_HOST'), - "port": int(os.environ.get('CHROMA_PORT')) - }) - elif os.environ.get('QDRANT_HOST') and os.environ.get('QDRANT_PORT'): - vector_store_provider = "qdrant" - vector_store_config.update({ - "host": os.environ.get('QDRANT_HOST'), - "port": int(os.environ.get('QDRANT_PORT')) - }) - elif os.environ.get('WEAVIATE_CLUSTER_URL') or (os.environ.get('WEAVIATE_HOST') and os.environ.get('WEAVIATE_PORT')): - vector_store_provider = "weaviate" - # Prefer an explicit cluster URL if provided; otherwise build from host/port - cluster_url = os.environ.get('WEAVIATE_CLUSTER_URL') - if not cluster_url: - weaviate_host = os.environ.get('WEAVIATE_HOST') - weaviate_port = int(os.environ.get('WEAVIATE_PORT')) - cluster_url = f"http://{weaviate_host}:{weaviate_port}" - vector_store_config = { - "collection_name": "openmemory", - "cluster_url": cluster_url - } - elif os.environ.get('REDIS_URL'): - vector_store_provider = "redis" - vector_store_config = { - "collection_name": "openmemory", - "redis_url": os.environ.get('REDIS_URL') - } - elif os.environ.get('PG_HOST') and os.environ.get('PG_PORT'): - vector_store_provider = "pgvector" - vector_store_config.update({ - "host": os.environ.get('PG_HOST'), - "port": int(os.environ.get('PG_PORT')), - "dbname": os.environ.get('PG_DB', 'mem0'), - "user": os.environ.get('PG_USER', 'mem0'), - "password": os.environ.get('PG_PASSWORD', 'mem0') - }) - elif os.environ.get('MILVUS_HOST') and os.environ.get('MILVUS_PORT'): - vector_store_provider = "milvus" - # Construct the full URL as expected by MilvusDBConfig - milvus_host = os.environ.get('MILVUS_HOST') - milvus_port = int(os.environ.get('MILVUS_PORT')) - milvus_url = f"http://{milvus_host}:{milvus_port}" - - vector_store_config = { - "collection_name": "openmemory", - "url": milvus_url, - "token": os.environ.get('MILVUS_TOKEN', ''), # Always include, empty string for local setup - "db_name": os.environ.get('MILVUS_DB_NAME', ''), - "embedding_model_dims": 1536, - "metric_type": "COSINE" # Using COSINE for better semantic similarity - } - elif os.environ.get('ELASTICSEARCH_HOST') and os.environ.get('ELASTICSEARCH_PORT'): - vector_store_provider = "elasticsearch" - # Construct the full URL with scheme since Elasticsearch client expects it - elasticsearch_host = os.environ.get('ELASTICSEARCH_HOST') - elasticsearch_port = int(os.environ.get('ELASTICSEARCH_PORT')) - # Use http:// scheme since we're not using SSL - full_host = f"http://{elasticsearch_host}" - - vector_store_config.update({ - "host": full_host, - "port": elasticsearch_port, - "user": os.environ.get('ELASTICSEARCH_USER', 'elastic'), - "password": os.environ.get('ELASTICSEARCH_PASSWORD', 'changeme'), - "verify_certs": False, - "use_ssl": False, - "embedding_model_dims": 1536 - }) - elif os.environ.get('OPENSEARCH_HOST') and os.environ.get('OPENSEARCH_PORT'): - vector_store_provider = "opensearch" - vector_store_config.update({ - "host": os.environ.get('OPENSEARCH_HOST'), - "port": int(os.environ.get('OPENSEARCH_PORT')) - }) - elif os.environ.get('FAISS_PATH'): - vector_store_provider = "faiss" - vector_store_config = { - "collection_name": "openmemory", - "path": os.environ.get('FAISS_PATH'), - "embedding_model_dims": 1536, - "distance_strategy": "cosine" - } - else: - # Default fallback to Qdrant - vector_store_provider = "qdrant" - vector_store_config.update({ - "port": 6333, - }) - - print(f"Auto-detected vector store: {vector_store_provider} with config: {vector_store_config}") - - # Detect LLM provider from environment variables - llm_provider = os.environ.get('LLM_PROVIDER', 'openai').lower() - llm_model = os.environ.get('LLM_MODEL') - llm_api_key = os.environ.get('LLM_API_KEY') - llm_base_url = os.environ.get('LLM_BASE_URL') - ollama_base_url = os.environ.get('OLLAMA_BASE_URL') - - llm_config = _create_llm_config( - provider=llm_provider, - model=llm_model, - api_key=llm_api_key, - base_url=llm_base_url, - ollama_base_url=ollama_base_url, - ) - print(f"Auto-detected LLM provider: {llm_provider}") - - # Detect embedder provider from environment variables - embedder_provider = os.environ.get('EMBEDDER_PROVIDER', llm_provider if llm_provider == 'ollama' else 'openai').lower() - embedder_model = os.environ.get('EMBEDDER_MODEL') - embedder_api_key = os.environ.get('EMBEDDER_API_KEY') - embedder_base_url = os.environ.get('EMBEDDER_BASE_URL') - - embedder_config = _create_embedder_config( - provider=embedder_provider, - model=embedder_model, - api_key=embedder_api_key, - base_url=embedder_base_url, - ollama_base_url=ollama_base_url, - llm_base_url=llm_base_url, - ) - print(f"Auto-detected embedder provider: {embedder_provider}") - - return { - "vector_store": { - "provider": vector_store_provider, - "config": vector_store_config - }, - "llm": { - "provider": llm_provider, - "config": llm_config - }, - "embedder": { - "provider": embedder_provider, - "config": embedder_config - }, - "version": "v1.1" - } - - -def _parse_environment_variables(config_dict): - """ - Parse environment variables in config values. - Converts 'env:VARIABLE_NAME' to actual environment variable values. - """ - if isinstance(config_dict, dict): - parsed_config = {} - for key, value in config_dict.items(): - if isinstance(value, str) and value.startswith("env:"): - env_var = value.split(":", 1)[1] - env_value = os.environ.get(env_var) - if env_value: - parsed_config[key] = env_value - print(f"Loaded {env_var} from environment for {key}") - else: - print(f"Warning: Environment variable {env_var} not found, keeping original value") - parsed_config[key] = value - elif isinstance(value, dict): - parsed_config[key] = _parse_environment_variables(value) - else: - parsed_config[key] = value - return parsed_config - return config_dict - - -def get_memory_client(custom_instructions: str = None): - """ - Get or initialize the Mem0 client. - - Args: - custom_instructions: Optional instructions for the memory project. - - Returns: - Initialized Mem0 client instance or None if initialization fails. - - Raises: - Exception: If required API keys are not set or critical configuration is missing. - """ - global _memory_client, _config_hash - - try: - # Start with default configuration - config = get_default_memory_config() - - # Variable to track custom instructions - db_custom_instructions = None - - # Load configuration from database - try: - db = SessionLocal() - db_config = db.query(ConfigModel).filter(ConfigModel.key == "main").first() - - if db_config: - json_config = db_config.value - - # Extract custom instructions from openmemory settings - if "openmemory" in json_config and "custom_instructions" in json_config["openmemory"]: - db_custom_instructions = json_config["openmemory"]["custom_instructions"] - - # Override defaults with configurations from the database - if "mem0" in json_config: - mem0_config = json_config["mem0"] - - # Update LLM configuration if available - if "llm" in mem0_config and mem0_config["llm"] is not None: - config["llm"] = mem0_config["llm"] - - # Update Embedder configuration if available - if "embedder" in mem0_config and mem0_config["embedder"] is not None: - config["embedder"] = mem0_config["embedder"] - - if "vector_store" in mem0_config and mem0_config["vector_store"] is not None: - config["vector_store"] = mem0_config["vector_store"] - else: - print("No configuration found in database, using defaults") - - db.close() - - except Exception as e: - print(f"Warning: Error loading configuration from database: {e}") - print("Using default configuration") - # Continue with default configuration if database config can't be loaded - - # Use custom_instructions parameter first, then fall back to database value - instructions_to_use = custom_instructions or db_custom_instructions - if instructions_to_use: - config["custom_fact_extraction_prompt"] = instructions_to_use - - # Fix Ollama URLs for Docker environment (applies to both env-var defaults and DB overrides) - if config.get("llm", {}).get("provider") == "ollama": - config["llm"] = _fix_ollama_urls(config["llm"]) - if config.get("embedder", {}).get("provider") == "ollama": - config["embedder"] = _fix_ollama_urls(config["embedder"]) - - # ALWAYS parse environment variables in the final config - # This ensures that even default config values like "env:OPENAI_API_KEY" get parsed - print("Parsing environment variables in final config...") - config = _parse_environment_variables(config) - - # Check if config has changed by comparing hashes - current_config_hash = _get_config_hash(config) - - # Only reinitialize if config changed or client doesn't exist - if _memory_client is None or _config_hash != current_config_hash: - print(f"Initializing memory client with config hash: {current_config_hash}") - try: - _memory_client = Memory.from_config(config_dict=config) - _config_hash = current_config_hash - print("Memory client initialized successfully") - except Exception as init_error: - print(f"Warning: Failed to initialize memory client: {init_error}") - print("Server will continue running with limited memory functionality") - _memory_client = None - _config_hash = None - return None - - return _memory_client - - except Exception as e: - print(f"Warning: Exception occurred while initializing memory client: {e}") - print("Server will continue running with limited memory functionality") - return None - - -def get_default_user_id(): - return "default_user" diff --git a/openmemory/api/app/utils/permissions.py b/openmemory/api/app/utils/permissions.py deleted file mode 100644 index 060caf962..000000000 --- a/openmemory/api/app/utils/permissions.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Optional -from uuid import UUID - -from app.models import App, Memory, MemoryState -from sqlalchemy.orm import Session - - -def check_memory_access_permissions( - db: Session, - memory: Memory, - app_id: Optional[UUID] = None -) -> bool: - """ - Check if the given app has permission to access a memory based on: - 1. Memory state (must be active) - 2. App state (must not be paused) - 3. App-specific access controls - - Args: - db: Database session - memory: Memory object to check access for - app_id: Optional app ID to check permissions for - - Returns: - bool: True if access is allowed, False otherwise - """ - # Check if memory is active - if memory.state != MemoryState.active: - return False - - # If no app_id provided, only check memory state - if not app_id: - return True - - # Check if app exists and is active - app = db.query(App).filter(App.id == app_id).first() - if not app: - return False - - # Check if app is paused/inactive - if not app.is_active: - return False - - # Check app-specific access controls - from app.routers.memories import get_accessible_memory_ids - accessible_memory_ids = get_accessible_memory_ids(db, app_id) - - # If accessible_memory_ids is None, all memories are accessible - if accessible_memory_ids is None: - return True - - # Check if memory is in the accessible set - return memory.id in accessible_memory_ids diff --git a/openmemory/api/app/utils/prompts.py b/openmemory/api/app/utils/prompts.py deleted file mode 100644 index 669f2e600..000000000 --- a/openmemory/api/app/utils/prompts.py +++ /dev/null @@ -1,28 +0,0 @@ -MEMORY_CATEGORIZATION_PROMPT = """Your task is to assign each piece of information (or β€œmemory”) to one or more of the following categories. Feel free to use multiple categories per item when appropriate. - -- Personal: family, friends, home, hobbies, lifestyle -- Relationships: social network, significant others, colleagues -- Preferences: likes, dislikes, habits, favorite media -- Health: physical fitness, mental health, diet, sleep -- Travel: trips, commutes, favorite places, itineraries -- Work: job roles, companies, projects, promotions -- Education: courses, degrees, certifications, skills development -- Projects: to‑dos, milestones, deadlines, status updates -- AI, ML & Technology: infrastructure, algorithms, tools, research -- Technical Support: bug reports, error logs, fixes -- Finance: income, expenses, investments, billing -- Shopping: purchases, wishlists, returns, deliveries -- Legal: contracts, policies, regulations, privacy -- Entertainment: movies, music, games, books, events -- Messages: emails, SMS, alerts, reminders -- Customer Support: tickets, inquiries, resolutions -- Product Feedback: ratings, bug reports, feature requests -- News: articles, headlines, trending topics -- Organization: meetings, appointments, calendars -- Goals: ambitions, KPIs, long‑term objectives - -Guidelines: -- Return only the categories under 'categories' key in the JSON format. -- If you cannot categorize the memory, return an empty list with key 'categories'. -- Don't limit yourself to the categories listed above only. Feel free to create new categories based on the memory. Make sure that it is a single phrase. -""" diff --git a/openmemory/api/config.json b/openmemory/api/config.json deleted file mode 100644 index 15defa011..000000000 --- a/openmemory/api/config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "mem0": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:API_KEY" - } - }, - "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:API_KEY" - } - } - } -} \ No newline at end of file diff --git a/openmemory/api/default_config.json b/openmemory/api/default_config.json deleted file mode 100644 index 256e350f8..000000000 --- a/openmemory/api/default_config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "mem0": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:OPENAI_API_KEY" - } - }, - "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:OPENAI_API_KEY" - } - } - } -} \ No newline at end of file diff --git a/openmemory/api/main.py b/openmemory/api/main.py deleted file mode 100644 index 593abd413..000000000 --- a/openmemory/api/main.py +++ /dev/null @@ -1,89 +0,0 @@ -import datetime -from uuid import uuid4 - -from app.config import DEFAULT_APP_ID, USER_ID -from app.database import Base, SessionLocal, engine -from app.mcp_server import setup_mcp_server -from app.models import App, User -from app.routers import apps_router, backup_router, config_router, memories_router, stats_router -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi_pagination import add_pagination - -app = FastAPI(title="OpenMemory API") - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Create all tables -Base.metadata.create_all(bind=engine) - -# Check for USER_ID and create default user if needed -def create_default_user(): - db = SessionLocal() - try: - # Check if user exists - user = db.query(User).filter(User.user_id == USER_ID).first() - if not user: - # Create default user - user = User( - id=uuid4(), - user_id=USER_ID, - name="Default User", - created_at=datetime.datetime.now(datetime.UTC) - ) - db.add(user) - db.commit() - finally: - db.close() - - -def create_default_app(): - db = SessionLocal() - try: - user = db.query(User).filter(User.user_id == USER_ID).first() - if not user: - return - - # Check if app already exists - existing_app = db.query(App).filter( - App.name == DEFAULT_APP_ID, - App.owner_id == user.id - ).first() - - if existing_app: - return - - app = App( - id=uuid4(), - name=DEFAULT_APP_ID, - owner_id=user.id, - created_at=datetime.datetime.now(datetime.UTC), - updated_at=datetime.datetime.now(datetime.UTC), - ) - db.add(app) - db.commit() - finally: - db.close() - -# Create default user on startup -create_default_user() -create_default_app() - -# Setup MCP server -setup_mcp_server(app) - -# Include routers -app.include_router(memories_router) -app.include_router(apps_router) -app.include_router(stats_router) -app.include_router(config_router) -app.include_router(backup_router) - -# Add pagination support -add_pagination(app) diff --git a/openmemory/api/requirements.txt b/openmemory/api/requirements.txt deleted file mode 100644 index d30e43b03..000000000 --- a/openmemory/api/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -fastapi>=0.68.0 -uvicorn>=0.15.0 -sqlalchemy>=1.4.0 -python-dotenv>=1.2.2 -alembic>=1.7.0 -psycopg2-binary>=2.9.0 -python-multipart>=0.0.27 -urllib3>=2.7.0 -fastapi-pagination>=0.12.0 -mem0ai>=0.1.92 -openai>=1.40.0 -mcp[cli]>=1.25.4 -starlette>=0.40.0 -pytest>=7.0.0 -pytest-asyncio>=0.21.0 -httpx>=0.24.0 -pytest-cov>=4.0.0 -tenacity==9.1.2 -anthropic==0.51.0 -ollama==0.4.8 \ No newline at end of file diff --git a/openmemory/api/tests/__init__.py b/openmemory/api/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/openmemory/api/tests/test_mcp_server.py b/openmemory/api/tests/test_mcp_server.py deleted file mode 100644 index 6c63c79cc..000000000 --- a/openmemory/api/tests/test_mcp_server.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Tests for the MCP server endpoints (SSE and Streamable HTTP transports). - -Covers the Streamable HTTP transport (MCP spec 2025-03-26+) and the legacy SSE -transport. Tests exercise the full JSON-RPC flow β€” initialize, tools/list, -tools/call β€” as well as error handling and context-variable isolation. -""" - -import os - -# Set dummy keys before any imports that trigger client initialization -os.environ.setdefault("OPENAI_API_KEY", "test-key") - -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient - -from app.mcp_server import client_name_var, mcp, mcp_router, user_id_var - -# MCP Streamable HTTP requires the Accept header to include application/json. -# Including text/event-stream as well satisfies GET (SSE) requests. -MCP_HEADERS = {"Accept": "application/json, text/event-stream"} - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture -def test_app(): - """Create a minimal FastAPI app with just the MCP router for testing.""" - from fastapi import FastAPI - - app = FastAPI() - app.include_router(mcp_router) - return app - - -@pytest_asyncio.fixture -async def client(test_app): - """Async HTTP client wired to the test app via ASGI transport.""" - transport = ASGITransport(app=test_app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _jsonrpc(method: str, params: dict | None = None, req_id: int = 1) -> dict: - """Build a JSON-RPC 2.0 request envelope.""" - return { - "jsonrpc": "2.0", - "id": req_id, - "method": method, - "params": params or {}, - } - - -def _initialize_payload(req_id: int = 1) -> dict: - return _jsonrpc( - "initialize", - { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "0.1.0"}, - }, - req_id=req_id, - ) - - -# --------------------------------------------------------------------------- -# Streamable HTTP β€” route existence & basic protocol -# --------------------------------------------------------------------------- - -class TestStreamableHTTPBasic: - """Verify the Streamable HTTP route is registered and responds.""" - - @pytest.mark.asyncio - async def test_post_initialize(self, client): - """POST initialize should return a valid JSON-RPC result.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["jsonrpc"] == "2.0" - assert data["id"] == 1 - assert "result" in data - result = data["result"] - assert "serverInfo" in result - assert "capabilities" in result - assert result["protocolVersion"] == "2025-03-26" - - @pytest.mark.asyncio - async def test_delete_returns_method_not_allowed(self, client): - """DELETE in stateless mode should return 405 (no session to terminate).""" - resp = await client.delete( - "/mcp/testclient/http/user1", - headers=MCP_HEADERS, - ) - assert resp.status_code == 405 - - @pytest.mark.asyncio - async def test_missing_accept_header_returns_406(self, client): - """POST without the required Accept header should return 406.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - ) - assert resp.status_code == 406 - - @pytest.mark.asyncio - async def test_invalid_json_returns_400(self, client): - """POST with unparseable body should return 400.""" - resp = await client.post( - "/mcp/testclient/http/user1", - content=b"not json", - headers={**MCP_HEADERS, "Content-Type": "application/json"}, - ) - assert resp.status_code == 400 - - @pytest.mark.asyncio - async def test_route_not_found_for_wrong_path(self, client): - """Requests to a non-existent path should 404.""" - resp = await client.post( - "/mcp/testclient/nonexistent/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# Streamable HTTP β€” full protocol flow -# --------------------------------------------------------------------------- - -class TestStreamableHTTPProtocol: - """End-to-end JSON-RPC flows over Streamable HTTP.""" - - @pytest.mark.asyncio - async def test_tools_list(self, client): - """tools/list should return all registered MCP tools.""" - init_resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - assert init_resp.status_code == 200 - - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/list", req_id=2), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert "result" in data - tool_names = {t["name"] for t in data["result"]["tools"]} - expected = {"add_memories", "search_memory", "list_memories", - "delete_memories", "delete_all_memories"} - assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}" - - @pytest.mark.asyncio - async def test_tools_list_has_descriptions(self, client): - """Every tool returned by tools/list should have a non-empty description.""" - await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/list", req_id=2), - headers=MCP_HEADERS, - ) - for tool in resp.json()["result"]["tools"]: - assert tool.get("description"), f"Tool {tool['name']} has no description" - - @pytest.mark.asyncio - async def test_tools_list_has_input_schemas(self, client): - """Every tool should declare an inputSchema.""" - await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/list", req_id=2), - headers=MCP_HEADERS, - ) - for tool in resp.json()["result"]["tools"]: - assert "inputSchema" in tool, f"Tool {tool['name']} missing inputSchema" - - @pytest.mark.asyncio - async def test_call_unknown_tool_returns_error(self, client): - """Calling a non-existent tool should return a JSON-RPC error.""" - await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/call", {"name": "no_such_tool", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert "error" in data or ( - "result" in data and data["result"].get("isError") - ) - - @pytest.mark.asyncio - async def test_unknown_jsonrpc_method(self, client): - """An unknown JSON-RPC method should return an error.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("nonexistent/method"), - headers=MCP_HEADERS, - ) - assert resp.status_code in (200, 400) - - @pytest.mark.asyncio - async def test_response_content_type_is_json(self, client): - """Responses should have Content-Type: application/json.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - ct = resp.headers.get("content-type", "") - assert "application/json" in ct - - -# --------------------------------------------------------------------------- -# Streamable HTTP β€” context variable handling -# --------------------------------------------------------------------------- - -class TestStreamableHTTPContext: - """Verify that user_id and client_name context variables are set correctly.""" - - @pytest.mark.asyncio - async def test_context_vars_set_during_tool_call(self, client): - """Context vars should reflect the path parameters during tool execution.""" - captured = {} - - @mcp.tool(name="__test_ctx", description="test only") - async def _capture(query: str = "") -> str: - captured["user_id"] = user_id_var.get(None) - captured["client_name"] = client_name_var.get(None) - return "ok" - - try: - await client.post( - "/mcp/my-app/http/alice", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/my-app/http/alice", - json=_jsonrpc("tools/call", {"name": "__test_ctx", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - assert captured.get("user_id") == "alice" - assert captured.get("client_name") == "my-app" - finally: - mcp._tool_manager._tools.pop("__test_ctx", None) - - @pytest.mark.asyncio - async def test_different_users_are_isolated(self, client): - """Sequential requests with different user_ids must not leak state.""" - results = [] - - @mcp.tool(name="__test_uid_iso", description="test only") - async def _capture_uid(query: str = "") -> str: - results.append(user_id_var.get(None)) - return "ok" - - try: - for uid in ("userA", "userB", "userC"): - await client.post( - f"/mcp/app1/http/{uid}", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - await client.post( - f"/mcp/app1/http/{uid}", - json=_jsonrpc("tools/call", {"name": "__test_uid_iso", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - - assert results == ["userA", "userB", "userC"] - finally: - mcp._tool_manager._tools.pop("__test_uid_iso", None) - - @pytest.mark.asyncio - async def test_different_clients_are_isolated(self, client): - """Sequential requests with different client_names must not leak state.""" - results = [] - - @mcp.tool(name="__test_cn_iso", description="test only") - async def _capture_cn(query: str = "") -> str: - results.append(client_name_var.get(None)) - return "ok" - - try: - for cn in ("cursor", "windsurf", "claude"): - await client.post( - f"/mcp/{cn}/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - await client.post( - f"/mcp/{cn}/http/user1", - json=_jsonrpc("tools/call", {"name": "__test_cn_iso", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - - assert results == ["cursor", "windsurf", "claude"] - finally: - mcp._tool_manager._tools.pop("__test_cn_iso", None) - - -# --------------------------------------------------------------------------- -# Streamable HTTP β€” response correctness -# --------------------------------------------------------------------------- - -class TestStreamableHTTPResponses: - """Verify that captured responses are returned correctly to the caller.""" - - @pytest.mark.asyncio - async def test_error_status_codes_are_preserved(self, client): - """Transport error codes (e.g. 406) must be forwarded, not masked as 200.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - ) - assert resp.status_code == 406 - - @pytest.mark.asyncio - async def test_delete_status_code_preserved(self, client): - """DELETE 405 from stateless transport must not be masked.""" - resp = await client.delete( - "/mcp/testclient/http/user1", - headers=MCP_HEADERS, - ) - assert resp.status_code == 405 - - @pytest.mark.asyncio - async def test_multiple_sequential_requests(self, client): - """Multiple requests in sequence should each get independent responses.""" - for i in range(5): - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(req_id=i + 1), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == i + 1 - assert "result" in data - - @pytest.mark.asyncio - async def test_wrong_content_type_returns_error(self, client): - """POST with wrong Content-Type should return an error status.""" - resp = await client.post( - "/mcp/testclient/http/user1", - content=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', - headers={**MCP_HEADERS, "Content-Type": "text/plain"}, - ) - assert resp.status_code in (400, 415) - - -# --------------------------------------------------------------------------- -# Route registration -# --------------------------------------------------------------------------- - -class TestRouteRegistration: - """Verify all expected routes are registered in the router.""" - - def test_sse_route_is_registered(self, test_app): - routes = [r.path for r in test_app.routes if hasattr(r, "path")] - assert "/mcp/{client_name}/sse/{user_id}" in routes - - def test_sse_post_messages_route_is_registered(self, test_app): - routes = [r.path for r in test_app.routes if hasattr(r, "path")] - assert "/mcp/messages/" in routes or "/mcp/{client_name}/sse/{user_id}/messages/" in routes - - def test_streamable_http_route_is_registered(self, test_app): - routes = [r.path for r in test_app.routes if hasattr(r, "path")] - assert "/mcp/{client_name}/http/{user_id}" in routes diff --git a/openmemory/backup-scripts/export_openmemory.sh b/openmemory/backup-scripts/export_openmemory.sh deleted file mode 100644 index cbe7b3040..000000000 --- a/openmemory/backup-scripts/export_openmemory.sh +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Export OpenMemory data from a running Docker container without relying on API endpoints. -# Produces: memories.json + memories.jsonl.gz zipped as memories_export_.zip -# -# Requirements: -# - docker available locally -# - The target container has Python + SQLAlchemy and access to the same DATABASE_URL it uses in prod -# -# Usage: -# ./export_openmemory.sh --user-id [--container ] [--app-id ] [--from-date ] [--to-date ] -# -# Notes: -# - USER_ID is the external user identifier (e.g., "vikramiyer"), not the internal UUID. -# - If --container is omitted, the script uses container name "openmemory-openmemory-mcp-1". -# - The script writes intermediate files to /tmp inside the container, then docker cp's them out and zips locally. - -usage() { - echo "Usage: $0 --user-id [--container ] [--app-id ] [--from-date ] [--to-date ]" - exit 1 -} - -USER_ID="" -CONTAINER="" -APP_ID="" -FROM_DATE="" -TO_DATE="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --user-id) USER_ID="${2:-}"; shift 2 ;; - --container) CONTAINER="${2:-}"; shift 2 ;; - --app-id) APP_ID="${2:-}"; shift 2 ;; - --from-date) FROM_DATE="${2:-}"; shift 2 ;; - --to-date) TO_DATE="${2:-}"; shift 2 ;; - -h|--help) usage ;; - *) echo "Unknown arg: $1"; usage ;; - esac -done - -if [[ -z "${USER_ID}" ]]; then - echo "ERROR: --user-id is required" - usage -fi - -if [[ -z "${CONTAINER}" ]]; then - CONTAINER="openmemory-openmemory-mcp-1" -fi - -# Verify the container exists and is running -if ! docker ps --format '{{.Names}}' | grep -qx "${CONTAINER}"; then - echo "ERROR: Container '${CONTAINER}' not found/running. Pass --container if different." - exit 1 -fi - -# Verify python is available inside the container -if ! docker exec "${CONTAINER}" sh -lc 'command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1'; then - echo "ERROR: Python is not available in container ${CONTAINER}" - exit 1 -fi - -PY_BIN="python3" -if ! docker exec "${CONTAINER}" sh -lc 'command -v python3 >/dev/null 2>&1'; then - PY_BIN="python" -fi - -echo "Using container: ${CONTAINER}" -echo "Exporting data for user_id: ${USER_ID}" - -# Run Python inside the container to generate memories.json and memories.jsonl.gz in /tmp -set +e -cat <<'PYCODE' | docker exec -i \ - -e EXPORT_USER_ID="${USER_ID}" \ - -e EXPORT_APP_ID="${APP_ID}" \ - -e EXPORT_FROM_DATE="${FROM_DATE}" \ - -e EXPORT_TO_DATE="${TO_DATE}" \ - "${CONTAINER}" "${PY_BIN}" - -import os -import sys -import json -import gzip -import uuid -import datetime -from typing import Any, Dict, List - -try: - from sqlalchemy import create_engine, text -except Exception as e: - print(f"ERROR: SQLAlchemy not available inside the container: {e}", file=sys.stderr) - sys.exit(3) - -def _iso(dt): - if dt is None: - return None - try: - if isinstance(dt, str): - try: - dt_obj = datetime.datetime.fromisoformat(dt.replace("Z", "+00:00")) - except Exception: - return dt - else: - dt_obj = dt - if dt_obj.tzinfo is None: - dt_obj = dt_obj.replace(tzinfo=datetime.timezone.utc) - else: - dt_obj = dt_obj.astimezone(datetime.timezone.utc) - return dt_obj.isoformat() - except Exception: - return None - -def _json_load_maybe(val): - if isinstance(val, (dict, list)) or val is None: - return val - if isinstance(val, (bytes, bytearray)): - try: - return json.loads(val.decode("utf-8")) - except Exception: - try: - return val.decode("utf-8", "ignore") - except Exception: - return None - if isinstance(val, str): - try: - return json.loads(val) - except Exception: - return val - return val - -def _named_in_clause(prefix: str, items: List[Any]): - names = [f":{prefix}{i}" for i in range(len(items))] - params = {f"{prefix}{i}": items[i] for i in range(len(items))} - return ", ".join(names), params - -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") -user_id_str = os.getenv("EXPORT_USER_ID") -app_id_filter = os.getenv("EXPORT_APP_ID") or None -from_date = os.getenv("EXPORT_FROM_DATE") -to_date = os.getenv("EXPORT_TO_DATE") - -if not user_id_str: - print("Missing EXPORT_USER_ID", file=sys.stderr) - sys.exit(2) - -from_ts = None -to_ts = None -try: - if from_date: - from_ts = int(from_date) - if to_date: - to_ts = int(to_date) -except Exception: - pass - -engine = create_engine(DATABASE_URL) - -with engine.connect() as conn: - user_row = conn.execute( - text("SELECT id, user_id, name, email, metadata, created_at, updated_at FROM users WHERE user_id = :uid"), - {"uid": user_id_str} - ).mappings().first() - if not user_row: - print(f'User not found for user_id "{user_id_str}"', file=sys.stderr) - sys.exit(1) - - user_uuid = user_row["id"] - - # Build memories filter - params = {"user_id": user_uuid} - conditions = ["user_id = :user_id"] - if from_ts is not None: - params["from_dt"] = datetime.datetime.fromtimestamp(from_ts, tz=datetime.timezone.utc) - conditions.append("created_at >= :from_dt") - if to_ts is not None: - params["to_dt"] = datetime.datetime.fromtimestamp(to_ts, tz=datetime.timezone.utc) - conditions.append("created_at <= :to_dt") - if app_id_filter: - try: - # Accept UUID or raw DB value - app_uuid = uuid.UUID(app_id_filter) - params["app_id"] = str(app_uuid) - except Exception: - params["app_id"] = app_id_filter - conditions.append("app_id = :app_id") - - mem_sql = f""" - SELECT id, user_id, app_id, content, metadata, state, created_at, updated_at, archived_at, deleted_at - FROM memories - WHERE {' AND '.join(conditions)} - """ - mem_rows = list(conn.execute(text(mem_sql), params).mappings()) - memory_ids = [r["id"] for r in mem_rows] - app_ids = sorted({r["app_id"] for r in mem_rows if r["app_id"] is not None}) - - # memory_categories - mc_rows = [] - if memory_ids: - names, in_params = _named_in_clause("mid", memory_ids) - mc_rows = list(conn.execute( - text(f"SELECT memory_id, category_id FROM memory_categories WHERE memory_id IN ({names})"), - in_params - ).mappings()) - - # categories for referenced category_ids - cats = [] - cat_ids = sorted({r["category_id"] for r in mc_rows}) - if cat_ids: - names, in_params = _named_in_clause("cid", cat_ids) - cats = list(conn.execute( - text(f"SELECT id, name, description, created_at, updated_at FROM categories WHERE id IN ({names})"), - in_params - ).mappings()) - - # apps for referenced app_ids - apps = [] - if app_ids: - names, in_params = _named_in_clause("aid", app_ids) - apps = list(conn.execute( - text(f"SELECT id, owner_id, name, description, metadata, is_active, created_at, updated_at FROM apps WHERE id IN ({names})"), - in_params - ).mappings()) - - # status history for selected memories - history = [] - if memory_ids: - names, in_params = _named_in_clause("hid", memory_ids) - history = list(conn.execute( - text(f"SELECT id, memory_id, changed_by, old_state, new_state, changed_at FROM memory_status_history WHERE memory_id IN ({names})"), - in_params - ).mappings()) - - # access_controls for the apps - acls = [] - if app_ids: - names, in_params = _named_in_clause("sid", app_ids) - acls = list(conn.execute( - text(f"""SELECT id, subject_type, subject_id, object_type, object_id, effect, created_at - FROM access_controls - WHERE subject_type = 'app' AND subject_id IN ({names})"""), - in_params - ).mappings()) - - # Build helper maps - app_name_by_id = {r["id"]: r["name"] for r in apps} - app_rec_by_id = {r["id"]: r for r in apps} - cat_name_by_id = {r["id"]: r["name"] for r in cats} - mem_cat_ids_map: Dict[Any, List[Any]] = {} - mem_cat_names_map: Dict[Any, List[str]] = {} - for r in mc_rows: - mem_cat_ids_map.setdefault(r["memory_id"], []).append(r["category_id"]) - mem_cat_names_map.setdefault(r["memory_id"], []).append(cat_name_by_id.get(r["category_id"], "")) - - # Build sqlite-like payload - sqlite_payload = { - "user": { - "id": str(user_row["id"]), - "user_id": user_row["user_id"], - "name": user_row.get("name"), - "email": user_row.get("email"), - "metadata": _json_load_maybe(user_row.get("metadata")), - "created_at": _iso(user_row.get("created_at")), - "updated_at": _iso(user_row.get("updated_at")), - }, - "apps": [ - { - "id": str(a["id"]), - "owner_id": str(a["owner_id"]) if a.get("owner_id") else None, - "name": a["name"], - "description": a.get("description"), - "metadata": _json_load_maybe(a.get("metadata")), - "is_active": bool(a.get("is_active")), - "created_at": _iso(a.get("created_at")), - "updated_at": _iso(a.get("updated_at")), - } - for a in apps - ], - "categories": [ - { - "id": str(c["id"]), - "name": c["name"], - "description": c.get("description"), - "created_at": _iso(c.get("created_at")), - "updated_at": _iso(c.get("updated_at")), - } - for c in cats - ], - "memories": [ - { - "id": str(m["id"]), - "user_id": str(m["user_id"]), - "app_id": str(m["app_id"]) if m.get("app_id") else None, - "content": m.get("content") or "", - "metadata": _json_load_maybe(m.get("metadata")) or {}, - "state": m.get("state"), - "created_at": _iso(m.get("created_at")), - "updated_at": _iso(m.get("updated_at")), - "archived_at": _iso(m.get("archived_at")), - "deleted_at": _iso(m.get("deleted_at")), - "category_ids": [str(cid) for cid in mem_cat_ids_map.get(m["id"], [])], - } - for m in mem_rows - ], - "memory_categories": [ - {"memory_id": str(r["memory_id"]), "category_id": str(r["category_id"])} - for r in mc_rows - ], - "status_history": [ - { - "id": str(h["id"]), - "memory_id": str(h["memory_id"]), - "changed_by": str(h["changed_by"]), - "old_state": h.get("old_state"), - "new_state": h.get("new_state"), - "changed_at": _iso(h.get("changed_at")), - } - for h in history - ], - "access_controls": [ - { - "id": str(ac["id"]), - "subject_type": ac.get("subject_type"), - "subject_id": str(ac["subject_id"]) if ac.get("subject_id") else None, - "object_type": ac.get("object_type"), - "object_id": str(ac["object_id"]) if ac.get("object_id") else None, - "effect": ac.get("effect"), - "created_at": _iso(ac.get("created_at")), - } - for ac in acls - ], - "export_meta": { - "app_id_filter": str(app_id_filter) if app_id_filter else None, - "from_date": from_ts, - "to_date": to_ts, - "version": "1", - "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), - }, - } - - # Write memories.json - out_json = "/tmp/memories.json" - with open(out_json, "w", encoding="utf-8") as f: - json.dump(sqlite_payload, f, indent=2, ensure_ascii=False) - - # Write logical jsonl.gz - out_jsonl_gz = "/tmp/memories.jsonl.gz" - with gzip.open(out_jsonl_gz, "wb") as gz: - for m in mem_rows: - record = { - "id": str(m["id"]), - "content": m.get("content") or "", - "metadata": _json_load_maybe(m.get("metadata")) or {}, - "created_at": _iso(m.get("created_at")), - "updated_at": _iso(m.get("updated_at")), - "state": m.get("state"), - "app": app_name_by_id.get(m.get("app_id")) if m.get("app_id") else None, - "categories": [c for c in mem_cat_names_map.get(m["id"], []) if c], - } - gz.write((json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")) - - print(out_json) - print(out_jsonl_gz) -PYCODE -PY_EXIT=$? -set -e -if [[ $PY_EXIT -ne 0 ]]; then - echo "ERROR: Export failed inside container (exit code $PY_EXIT)" - exit $PY_EXIT -fi - -# Copy files out of the container -TMPDIR="$(mktemp -d)" -docker cp "${CONTAINER}:/tmp/memories.json" "${TMPDIR}/memories.json" -docker cp "${CONTAINER}:/tmp/memories.jsonl.gz" "${TMPDIR}/memories.jsonl.gz" - -# Create zip on host -ZIP_NAME="memories_export_${USER_ID}.zip" -if command -v zip >/dev/null 2>&1; then - (cd "${TMPDIR}" && zip -q -r "../${ZIP_NAME}" "memories.json" "memories.jsonl.gz") - mv "${TMPDIR}/../${ZIP_NAME}" "./${ZIP_NAME}" -else - # Fallback: use Python zipfile - python3 - < - redis-stack-server - --appendonly yes - --appendfsync everysec - --save 900 1 300 10 60 10000 \ No newline at end of file diff --git a/openmemory/compose/weaviate.yml b/openmemory/compose/weaviate.yml deleted file mode 100644 index 6eab1b8bc..000000000 --- a/openmemory/compose/weaviate.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - mem0_store: - image: semitechnologies/weaviate:latest - restart: unless-stopped - environment: - - QUERY_DEFAULTS_LIMIT=25 - - AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true - - PERSISTENCE_DATA_PATH=/var/lib/weaviate - - CLUSTER_HOSTNAME=node1 - - WEAVIATE_CLUSTER_URL=http://mem0_store:8080 - ports: - - "8080:8080" - volumes: - - mem0_storage:/var/lib/weaviate \ No newline at end of file diff --git a/openmemory/docker-compose.yml b/openmemory/docker-compose.yml deleted file mode 100644 index 7e89311fb..000000000 --- a/openmemory/docker-compose.yml +++ /dev/null @@ -1,36 +0,0 @@ -services: - mem0_store: - image: qdrant/qdrant - ports: - - "6333:6333" - volumes: - - mem0_storage:/mem0/storage - openmemory-mcp: - image: mem0/openmemory-mcp - build: api/ - environment: - - USER - - API_KEY - env_file: - - api/.env - depends_on: - - mem0_store - ports: - - "8765:8765" - volumes: - - ./api:/usr/src/openmemory - command: > - sh -c "uvicorn main:app --host 0.0.0.0 --port 8765 --reload --workers 4" - openmemory-ui: - build: - context: ui/ - dockerfile: Dockerfile - image: mem0/openmemory-ui:latest - ports: - - "3000:3000" - environment: - - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} - - NEXT_PUBLIC_USER_ID=${USER} - -volumes: - mem0_storage: diff --git a/openmemory/run.sh b/openmemory/run.sh deleted file mode 100644 index ca15322e7..000000000 --- a/openmemory/run.sh +++ /dev/null @@ -1,400 +0,0 @@ -#!/bin/bash - -set -e - -echo "πŸš€ Starting OpenMemory installation..." - -# Set environment variables -OPENAI_API_KEY="${OPENAI_API_KEY:-}" -USER="${USER:-$(whoami)}" -NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8765}" - -if [ -z "$OPENAI_API_KEY" ]; then - echo "❌ OPENAI_API_KEY not set. Please run with: curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash" - echo "❌ OPENAI_API_KEY not set. You can also set it as global environment variable: export OPENAI_API_KEY=your_api_key" - exit 1 -fi - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - echo "❌ Docker not found. Please install Docker first." - exit 1 -fi - -# Check if docker compose is available -if ! docker compose version &> /dev/null; then - echo "❌ Docker Compose not found. Please install Docker Compose V2." - exit 1 -fi - -# Check if the container "mem0_ui" already exists and remove it if necessary -if [ $(docker ps -aq -f name=mem0_ui) ]; then - echo "⚠️ Found existing container 'mem0_ui'. Removing it..." - docker rm -f mem0_ui -fi - -# Find an available port starting from 3000 -echo "πŸ” Looking for available port for frontend..." -for port in {3000..3010}; do - if ! lsof -i:$port >/dev/null 2>&1; then - FRONTEND_PORT=$port - break - fi -done - -if [ -z "$FRONTEND_PORT" ]; then - echo "❌ Could not find an available port between 3000 and 3010" - exit 1 -fi - -# Export required variables for Compose and frontend -export OPENAI_API_KEY -export USER -export NEXT_PUBLIC_API_URL -export NEXT_PUBLIC_USER_ID="$USER" -export FRONTEND_PORT - -# Parse vector store selection (env var or flag). Default: qdrant -VECTOR_STORE="${VECTOR_STORE:-qdrant}" -EMBEDDING_DIMS="${EMBEDDING_DIMS:-1536}" - -for arg in "$@"; do - case $arg in - --vector-store=*) - VECTOR_STORE="${arg#*=}" - shift - ;; - --vector-store) - VECTOR_STORE="$2" - shift 2 - ;; - *) - ;; - esac -done - -export VECTOR_STORE -echo "🧰 Using vector store: $VECTOR_STORE" - -# Function to create compose file by merging vector store config with openmemory-mcp service -create_compose_file() { - local vector_store=$1 - local compose_file="compose/${vector_store}.yml" - local volume_name="${vector_store}_data" # Vector-store-specific volume name - - # Check if the compose file exists - if [ ! -f "$compose_file" ]; then - echo "❌ Compose file not found: $compose_file" - echo "Available vector stores: $(ls compose/*.yml | sed 's/compose\///g' | sed 's/\.yml//g' | tr '\n' ' ')" - exit 1 - fi - - echo "πŸ“ Creating docker-compose.yml using $compose_file..." - echo "πŸ’Ύ Using volume: $volume_name" - - # Start the compose file with services section - echo "services:" > docker-compose.yml - - # Extract services from the compose file and replace volume name - # First get everything except the last volumes section - tail -n +2 "$compose_file" | sed '/^volumes:/,$d' | sed "s/mem0_storage/${volume_name}/g" >> docker-compose.yml - - # Add a newline to ensure proper YAML formatting - echo "" >> docker-compose.yml - - # Add the openmemory-mcp service - cat >> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <=1.9.1" || echo "⚠️ Failed to install qdrant packages" - ;; - chroma) - docker exec openmemory-openmemory-mcp-1 pip install "chromadb>=0.4.24" || echo "⚠️ Failed to install chroma packages" - ;; - weaviate) - docker exec openmemory-openmemory-mcp-1 pip install "weaviate-client>=4.4.0,<4.15.0" || echo "⚠️ Failed to install weaviate packages" - ;; - faiss) - docker exec openmemory-openmemory-mcp-1 pip install "faiss-cpu>=1.7.4" || echo "⚠️ Failed to install faiss packages" - ;; - pgvector) - docker exec openmemory-openmemory-mcp-1 pip install "vecs>=0.4.0" "psycopg>=3.2.8" || echo "⚠️ Failed to install pgvector packages" - ;; - redis) - docker exec openmemory-openmemory-mcp-1 pip install "redis>=5.0.0,<6.0.0" "redisvl>=0.1.0,<1.0.0" || echo "⚠️ Failed to install redis packages" - ;; - elasticsearch) - docker exec openmemory-openmemory-mcp-1 pip install "elasticsearch>=8.0.0,<9.0.0" || echo "⚠️ Failed to install elasticsearch packages" - ;; - milvus) - docker exec openmemory-openmemory-mcp-1 pip install "pymilvus>=2.4.0,<2.6.0" || echo "⚠️ Failed to install milvus packages" - ;; - *) - echo "⚠️ Unknown vector store: $vector_store. Installing default qdrant packages." - docker exec openmemory-openmemory-mcp-1 pip install "qdrant-client>=1.9.1" || echo "⚠️ Failed to install qdrant packages" - ;; - esac -} - -# Start services -echo "πŸš€ Starting backend services..." -docker compose up -d - -# Wait for container to be ready before installing packages -echo "⏳ Waiting for container to be ready..." -for i in {1..30}; do - if docker exec openmemory-openmemory-mcp-1 python -c "import sys; print('ready')" >/dev/null 2>&1; then - break - fi - sleep 1 -done - -# Install vector store specific packages -install_vector_store_packages "$VECTOR_STORE" - -# If a specific vector store is selected, seed the backend config accordingly -if [ "$VECTOR_STORE" = "milvus" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (milvus) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"milvus\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"url\":\"http://mem0_store:19530\",\"token\":\"\",\"db_name\":\"\",\"metric_type\":\"COSINE\"}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "weaviate" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (weaviate) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"weaviate\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"cluster_url\":\"http://mem0_store:8080\"}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "redis" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (redis) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"redis\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"redis_url\":\"redis://mem0_store:6379\"}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "pgvector" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (pgvector) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"pgvector\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"dbname\":\"mem0\",\"user\":\"mem0\",\"password\":\"mem0\",\"host\":\"mem0_store\",\"port\":5432,\"diskann\":false,\"hnsw\":true}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "qdrant" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (qdrant) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"qdrant\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"host\":\"mem0_store\",\"port\":6333}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "chroma" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (chroma) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"chroma\",\"config\":{\"collection_name\":\"openmemory\",\"host\":\"mem0_store\",\"port\":8000}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "elasticsearch" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (elasticsearch) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"elasticsearch\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"host\":\"http://mem0_store\",\"port\":9200,\"user\":\"elastic\",\"password\":\"changeme\",\"verify_certs\":false,\"use_ssl\":false}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "faiss" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (faiss) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"faiss\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"path\":\"/tmp/faiss\",\"distance_strategy\":\"cosine\"}}" >/dev/null || true -fi - -# Start the frontend -echo "πŸš€ Starting frontend on port $FRONTEND_PORT..." -docker run -d \ - --name mem0_ui \ - -p ${FRONTEND_PORT}:3000 \ - -e NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \ - -e NEXT_PUBLIC_USER_ID="$USER" \ - mem0/openmemory-ui:latest - -echo "βœ… Backend: http://localhost:8765" -echo "βœ… Frontend: http://localhost:$FRONTEND_PORT" - -# Open the frontend URL in the default web browser -echo "🌐 Opening frontend in the default browser..." -URL="http://localhost:$FRONTEND_PORT" - -if command -v xdg-open > /dev/null; then - xdg-open "$URL" # Linux -elif command -v open > /dev/null; then - open "$URL" # macOS -elif command -v start > /dev/null; then - start "$URL" # Windows (if run via Git Bash or similar) -else - echo "⚠️ Could not detect a method to open the browser. Please open $URL manually." -fi \ No newline at end of file diff --git a/openmemory/ui/.dockerignore b/openmemory/ui/.dockerignore deleted file mode 100644 index dc61851a3..000000000 --- a/openmemory/ui/.dockerignore +++ /dev/null @@ -1,23 +0,0 @@ -# Ignore all .env files -**/.env - - -# Ignore all database files -**/*.db -**/*.sqlite -**/*.sqlite3 - -# Ignore logs -**/*.log - -# Ignore runtime data -**/node_modules -**/__pycache__ -**/.pytest_cache -**/.coverage -**/coverage - -# Ignore Docker runtime files -**/.dockerignore -**/Dockerfile -**/docker-compose*.yml \ No newline at end of file diff --git a/openmemory/ui/.env.example b/openmemory/ui/.env.example deleted file mode 100644 index 3768f3e59..000000000 --- a/openmemory/ui/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL -NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID diff --git a/openmemory/ui/Dockerfile b/openmemory/ui/Dockerfile deleted file mode 100644 index 7fd1dc982..000000000 --- a/openmemory/ui/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -# syntax=docker.io/docker/dockerfile:1 - -# Base stage for common setup -FROM node:18-alpine AS base - -# Install dependencies for pnpm -RUN apk add --no-cache libc6-compat curl && \ - corepack enable && \ - corepack prepare pnpm@latest --activate - -WORKDIR /app - -FROM base AS deps - -COPY package.json pnpm-lock.yaml ./ - -RUN pnpm install --frozen-lockfile - -FROM base AS builder -WORKDIR /app - -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml -COPY . . - -RUN cp next.config.dev.mjs next.config.mjs -RUN cp .env.example .env -RUN pnpm build - -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV=production - -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs - -COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh -RUN chmod +x /home/nextjs/entrypoint.sh - -USER nextjs - -EXPOSE 3000 -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - -ENTRYPOINT ["/home/nextjs/entrypoint.sh"] -CMD ["node", "server.js"] diff --git a/openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx b/openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx deleted file mode 100644 index 7f3f3c4fa..000000000 --- a/openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { PauseIcon, Loader2, PlayIcon } from "lucide-react"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import Image from "next/image"; -import { useDispatch, useSelector } from "react-redux"; -import { setAppDetails } from "@/store/appsSlice"; -import { BiEdit } from "react-icons/bi"; -import { constants } from "@/components/shared/source-app"; -import { RootState } from "@/store/store"; - -const capitalize = (str: string) => { - return str.charAt(0).toUpperCase() + str.slice(1); -}; - -const AppDetailCard = ({ - appId, - selectedApp, -}: { - appId: string; - selectedApp: any; -}) => { - const { updateAppDetails } = useAppsApi(); - const [isLoading, setIsLoading] = useState(false); - const dispatch = useDispatch(); - const apps = useSelector((state: RootState) => state.apps.apps); - const currentApp = apps.find((app: any) => app.id === appId); - const appConfig = currentApp - ? constants[currentApp.name as keyof typeof constants] || constants.default - : constants.default; - - const handlePauseAccess = async () => { - setIsLoading(true); - try { - await updateAppDetails(appId, { - is_active: !selectedApp.details.is_active, - }); - dispatch( - setAppDetails({ appId, isActive: !selectedApp.details.is_active }) - ); - } catch (error) { - console.error("Failed to toggle app pause state:", error); - } finally { - setIsLoading(false); - } - }; - - const buttonText = selectedApp.details.is_active - ? "Pause Access" - : "Unpause Access"; - - return ( -
-
-
-
- {appConfig.iconImage ? ( -
-
- {appConfig.name} -
-
- ) : ( -
- -
- )} -
-

{appConfig.name}

-
- -
-
-

Access Status

-

- {capitalize( - selectedApp.details.is_active ? "active" : "inactive" - )} -

-
- -
-

Total Memories Created

-

- {selectedApp.details.total_memories_created} Memories -

-
- -
-

Total Memories Accessed

-

- {selectedApp.details.total_memories_accessed} Memories -

-
- -
-

First Accessed

-

- {selectedApp.details.first_accessed - ? new Date( - selectedApp.details.first_accessed - ).toLocaleDateString("en-US", { - day: "numeric", - month: "short", - year: "numeric", - hour: "numeric", - minute: "numeric", - }) - : "Never"} -

-
- -
-

Last Accessed

-

- {selectedApp.details.last_accessed - ? new Date( - selectedApp.details.last_accessed - ).toLocaleDateString("en-US", { - day: "numeric", - month: "short", - year: "numeric", - hour: "numeric", - minute: "numeric", - }) - : "Never"} -

-
- -
- -
- -
-
-
-
- ); -}; - -export default AppDetailCard; diff --git a/openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx b/openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx deleted file mode 100644 index dfb35cb11..000000000 --- a/openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { ArrowRight } from "lucide-react"; -import Categories from "@/components/shared/categories"; -import Link from "next/link"; -import { constants } from "@/components/shared/source-app"; -import Image from "next/image"; -interface MemoryCardProps { - id: string; - content: string; - created_at: string; - metadata?: Record; - categories?: string[]; - access_count?: number; - app_name: string; - state: string; -} - -export function MemoryCard({ - id, - content, - created_at, - metadata, - categories, - access_count, - app_name, - state, -}: MemoryCardProps) { - return ( -
-
-
-

- {content} -

-
- - {metadata && Object.keys(metadata).length > 0 && ( -
-

METADATA

-
-
-                {JSON.stringify(metadata, null, 2)}
-              
-
-
- )} - -
- -
- -
-
- - {access_count ? ( - - Accessed {access_count} times - - ) : ( - new Date(created_at + "Z").toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - }) - )} - - - {state !== "active" && ( - - {state === "paused" ? "Paused" : "Archived"} - - )} -
- - {!app_name && ( - - View Details - - - )} - {app_name && ( -
-
- Created by: -
- OpenMemory -
-

- {constants[app_name as keyof typeof constants]?.name} -

-
-
- )} -
-
-
- ); -} diff --git a/openmemory/ui/app/apps/[appId]/page.tsx b/openmemory/ui/app/apps/[appId]/page.tsx deleted file mode 100644 index ecd90313d..000000000 --- a/openmemory/ui/app/apps/[appId]/page.tsx +++ /dev/null @@ -1,219 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { useSelector } from "react-redux"; -import { RootState } from "@/store/store"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { MemoryCard } from "./components/MemoryCard"; -import AppDetailCard from "./components/AppDetailCard"; -import "@/styles/animation.css"; -import NotFound from "@/app/not-found"; -import { AppDetailCardSkeleton } from "@/skeleton/AppDetailCardSkeleton"; -import { MemoryCardSkeleton } from "@/skeleton/MemoryCardSkeleton"; - -export default function AppDetailsPage() { - const params = useParams(); - const appId = params.appId as string; - const [activeTab, setActiveTab] = useState("created"); - - const { - fetchAppDetails, - fetchAppMemories, - fetchAppAccessedMemories, - fetchApps, - } = useAppsApi(); - const selectedApp = useSelector((state: RootState) => state.apps.selectedApp); - - useEffect(() => { - fetchApps({}); - }, [fetchApps]); - - useEffect(() => { - const loadData = async () => { - if (appId) { - try { - // Load all data in parallel - await Promise.all([ - fetchAppDetails(appId), - fetchAppMemories(appId), - fetchAppAccessedMemories(appId), - ]); - } catch (error) { - console.error("Error loading app data:", error); - } - } - }; - - loadData(); - }, [appId, fetchAppDetails, fetchAppMemories, fetchAppAccessedMemories]); - - if (selectedApp.error) { - return ( - - ); - } - - if (!selectedApp.details) { - return ( -
-
-
-
-
-
- {[...Array(3)].map((_, i) => ( - - ))} -
-
-
-
- -
-
-
- ); - } - - const renderCreatedMemories = () => { - const memories = selectedApp.memories.created; - - if (memories.loading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (memories.error) { - return ( - - ); - } - - if (memories.items.length === 0) { - return ( -
No memories found
- ); - } - - return memories.items.map((memory) => ( - - )); - }; - - const renderAccessedMemories = () => { - const memories = selectedApp.memories.accessed; - - if (memories.loading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (memories.error) { - return ( -
- Error loading memories: {memories.error} -
- ); - } - - if (memories.items.length === 0) { - return ( -
- No accessed memories found -
- ); - } - - return memories.items.map((accessedMemory) => ( -
- -
- )); - }; - - return ( -
-
- {/* Main content area */} -
- - - - Created ({selectedApp.memories.created.total}) - - - Accessed ({selectedApp.memories.accessed.total}) - - - - - {renderCreatedMemories()} - - - - {renderAccessedMemories()} - - -
- - {/* Sidebar */} -
- -
-
-
- ); -} diff --git a/openmemory/ui/app/apps/components/AppCard.tsx b/openmemory/ui/app/apps/components/AppCard.tsx deleted file mode 100644 index 13a4fe0cb..000000000 --- a/openmemory/ui/app/apps/components/AppCard.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type React from "react"; -import { ArrowRight } from "lucide-react"; -import { - Card, - CardContent, - CardFooter, - CardHeader, -} from "@/components/ui/card"; - -import { constants } from "@/components/shared/source-app"; -import { App } from "@/store/appsSlice"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; - -interface AppCardProps { - app: App; -} - -export function AppCard({ app }: AppCardProps) { - const router = useRouter(); - const appConfig = - constants[app.name as keyof typeof constants] || constants.default; - const isActive = app.is_active; - - return ( - - -
-
- {appConfig.iconImage ? ( -
- {appConfig.name} -
- ) : ( -
- {appConfig.icon} -
- )} -
-

{appConfig.name}

-
-
- -
-
-

Memories Created

-

- {app.total_memories_created.toLocaleString()} Memories -

-
-
-

Memories Accessed

-

- {app.total_memories_accessed.toLocaleString()} Memories -

-
-
-
- -
- - {isActive ? "Active" : "Inactive"} -
-
router.push(`/apps/${app.id}`)} - className="border hover:cursor-pointer border-zinc-700 bg-zinc-950 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:bg-zinc-950/50 hover:text-white" - > - View Details -
-
-
- ); -} diff --git a/openmemory/ui/app/apps/components/AppFilters.tsx b/openmemory/ui/app/apps/components/AppFilters.tsx deleted file mode 100644 index 25aad06d7..000000000 --- a/openmemory/ui/app/apps/components/AppFilters.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; -import { Search, ChevronDown, SortAsc, SortDesc } from "lucide-react"; -import { useDispatch, useSelector } from "react-redux"; -import { - setSearchQuery, - setActiveFilter, - setSortBy, - setSortDirection, -} from "@/store/appsSlice"; -import { RootState } from "@/store/store"; -import { useCallback } from "react"; -import debounce from "lodash/debounce"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import { AppFiltersSkeleton } from "@/skeleton/AppFiltersSkeleton"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuGroup, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; - -const sortOptions = [ - { value: "name", label: "Name" }, - { value: "memories", label: "Memories Created" }, - { value: "memories_accessed", label: "Memories Accessed" }, -]; - -export function AppFilters() { - const dispatch = useDispatch(); - const filters = useSelector((state: RootState) => state.apps.filters); - const [localSearch, setLocalSearch] = useState(filters.searchQuery); - const { isLoading } = useAppsApi(); - - const debouncedSearch = useCallback( - debounce((query: string) => { - dispatch(setSearchQuery(query)); - }, 300), - [dispatch] - ); - - const handleSearchChange = (e: React.ChangeEvent) => { - const query = e.target.value; - setLocalSearch(query); - debouncedSearch(query); - }; - - const handleActiveFilterChange = (value: string) => { - dispatch(setActiveFilter(value === "all" ? "all" : value === "true")); - }; - - const setSorting = (sortBy: "name" | "memories" | "memories_accessed") => { - const newDirection = - filters.sortBy === sortBy && filters.sortDirection === "asc" - ? "desc" - : "asc"; - dispatch(setSortBy(sortBy)); - dispatch(setSortDirection(newDirection)); - }; - - useEffect(() => { - setLocalSearch(filters.searchQuery); - }, [filters.searchQuery]); - - if (isLoading) { - return ; - } - - return ( -
-
- - -
- - - - - - - - - Sort by - - - {sortOptions.map((option) => ( - - setSorting( - option.value as "name" | "memories" | "memories_accessed" - ) - } - className="cursor-pointer flex justify-between items-center" - > - {option.label} - {filters.sortBy === option.value && - (filters.sortDirection === "asc" ? ( - - ) : ( - - ))} - - ))} - - - -
- ); -} diff --git a/openmemory/ui/app/apps/components/AppGrid.tsx b/openmemory/ui/app/apps/components/AppGrid.tsx deleted file mode 100644 index b075e1209..000000000 --- a/openmemory/ui/app/apps/components/AppGrid.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import { useEffect } from "react"; -import { useSelector } from "react-redux"; -import { RootState } from "@/store/store"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import { AppCard } from "./AppCard"; -import { AppCardSkeleton } from "@/skeleton/AppCardSkeleton"; - -export function AppGrid() { - const { fetchApps, isLoading } = useAppsApi(); - const apps = useSelector((state: RootState) => state.apps.apps); - const filters = useSelector((state: RootState) => state.apps.filters); - - useEffect(() => { - fetchApps({ - name: filters.searchQuery, - is_active: filters.isActive === "all" ? undefined : filters.isActive, - sort_by: filters.sortBy, - sort_direction: filters.sortDirection, - }); - }, [fetchApps, filters]); - - if (isLoading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (apps.length === 0) { - return ( -
- No apps found matching your filters -
- ); - } - - return ( -
- {apps.map((app) => ( - - ))} -
- ); -} diff --git a/openmemory/ui/app/apps/page.tsx b/openmemory/ui/app/apps/page.tsx deleted file mode 100644 index 9b255fe12..000000000 --- a/openmemory/ui/app/apps/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { AppFilters } from "./components/AppFilters"; -import { AppGrid } from "./components/AppGrid"; -import "@/styles/animation.css"; - -export default function AppsPage() { - return ( -
-
-
- -
-
- -
-
-
- ); -} diff --git a/openmemory/ui/app/globals.css b/openmemory/ui/app/globals.css deleted file mode 100644 index 91d1eb5a1..000000000 --- a/openmemory/ui/app/globals.css +++ /dev/null @@ -1,59 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 260 94% 59%; - --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 260 94% 59%; - --radius: 0.5rem; - } - - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 260 94% 59%; - --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 260 94% 59%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/openmemory/ui/app/layout.tsx b/openmemory/ui/app/layout.tsx deleted file mode 100644 index 0096136c8..000000000 --- a/openmemory/ui/app/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type React from "react"; -import "@/app/globals.css"; -import { ThemeProvider } from "@/components/theme-provider"; -import { Navbar } from "@/components/Navbar"; -import { Toaster } from "@/components/ui/toaster"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Providers } from "./providers"; - -export const metadata = { - title: "OpenMemory - Developer Dashboard", - description: "Manage your OpenMemory integration and stored memories", - generator: "v0.dev", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - - - - {children} - - - - - - ); -} diff --git a/openmemory/ui/app/loading.tsx b/openmemory/ui/app/loading.tsx deleted file mode 100644 index 4349ac3a6..000000000 --- a/openmemory/ui/app/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Loading() { - return null; -} diff --git a/openmemory/ui/app/memories/components/CreateMemoryDialog.tsx b/openmemory/ui/app/memories/components/CreateMemoryDialog.tsx deleted file mode 100644 index 7eb4c8212..000000000 --- a/openmemory/ui/app/memories/components/CreateMemoryDialog.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { useState, useRef } from "react"; -import { GoPlus } from "react-icons/go"; -import { Loader2 } from "lucide-react"; -import { useMemoriesApi } from "@/hooks/useMemoriesApi"; -import { toast } from "sonner"; -import { Textarea } from "@/components/ui/textarea"; - -export function CreateMemoryDialog() { - const { createMemory, isLoading, fetchMemories } = useMemoriesApi(); - const [open, setOpen] = useState(false); - const textRef = useRef(null); - - const handleCreateMemory = async (text: string) => { - try { - await createMemory(text); - toast.success("Memory created successfully"); - // close the dialog - setOpen(false); - // refetch memories - await fetchMemories(); - } catch (error) { - console.error(error); - toast.error("Failed to create memory"); - } - }; - - return ( - - - - - - - Create New Memory - - Add a new memory to your OpenMemory instance - - -
-
- -