From 6f160141c9de11b7f2a969929408990d97904638 Mon Sep 17 00:00:00 2001 From: rishibalanagu0108 Date: Thu, 15 Oct 2026 13:45:29 +0530 Subject: [PATCH] feat(tools): add LangChain integration Port the store and retriever to the v5 namespace API. Closes #1743 --- bun.lock | 41 ++- packages/tools/README.md | 30 +- packages/tools/package.json | 17 +- packages/tools/src/langchain/index.ts | 9 + .../tools/src/langchain/namespace.test.ts | 22 ++ packages/tools/src/langchain/namespace.ts | 28 ++ .../tools/src/langchain/retriever.test.ts | 70 ++++ packages/tools/src/langchain/retriever.ts | 62 ++++ packages/tools/src/langchain/store.test.ts | 202 +++++++++++ packages/tools/src/langchain/store.ts | 323 ++++++++++++++++++ packages/tools/tsdown.config.ts | 1 + 11 files changed, 798 insertions(+), 7 deletions(-) create mode 100644 packages/tools/src/langchain/index.ts create mode 100644 packages/tools/src/langchain/namespace.test.ts create mode 100644 packages/tools/src/langchain/namespace.ts create mode 100644 packages/tools/src/langchain/retriever.test.ts create mode 100644 packages/tools/src/langchain/retriever.ts create mode 100644 packages/tools/src/langchain/store.test.ts create mode 100644 packages/tools/src/langchain/store.ts diff --git a/bun.lock b/bun.lock index a58dfc2f..c1601ff0 100644 --- a/bun.lock +++ b/bun.lock @@ -227,7 +227,7 @@ }, "packages/tools": { "name": "@supermemory/tools", - "version": "3.1.0", + "version": "3.1.2", "dependencies": { "lru-cache": "^11.2.6", "openai": "^4.104.0", @@ -239,6 +239,8 @@ "@ai-sdk/openai": "^2.0.23", "@ai-sdk/provider": "^3.0.0", "@anthropic-ai/sdk": "^0.65.0", + "@langchain/core": "^1.2.14", + "@langchain/langgraph": "^1.4.18", "@mastra/core": "^1.0.0", "@total-typescript/tsconfig": "^1.0.4", "@types/bun": "^1.2.21", @@ -251,10 +253,15 @@ }, "peerDependencies": { "@ai-sdk/provider": "^2.0.0 || ^3.0.0 || ^4.0.0", + "@langchain/core": "^1.2.14", + "@langchain/langgraph": "^1.4.18", "@voltagent/core": "^2.6.12", "ai": "^5.0.0 || ^6.0.0 || ^7.0.0", }, "optionalPeers": [ + "@ai-sdk/provider", + "@langchain/core", + "@langchain/langgraph", "@voltagent/core", "ai", ], @@ -887,6 +894,16 @@ "@jsep-plugin/ternary": ["@jsep-plugin/ternary@1.1.4", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg=="], + "@langchain/core": ["@langchain/core@1.2.17", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "zod": "^3.25.76 || ^4" } }, "sha512-aLQLSed6oLCMLtJHrINhnFmsu79G2UiDlRslbOx+TSN9NmU+WuDv1OtVxrLpYglhXNy53elSduMVk3XBPG9ulw=="], + + "@langchain/langgraph": ["@langchain/langgraph@1.5.0", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.2.0", "@langchain/langgraph-sdk": "~1.12.4", "@langchain/protocol": "^0.0.19", "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@langchain/core": "^1.1.48", "zod": "^3.25.32 || ^4.2.0" } }, "sha512-nYBV1vkWtAdU9GFDzdnPKiEMZVXerqCadRcyRtjTnd6Z4nIPmyB4SGSKioQFjSWBZK6nd/RIoyQVhjnLpw9oxQ=="], + + "@langchain/langgraph-checkpoint": ["@langchain/langgraph-checkpoint@1.2.0", "", { "peerDependencies": { "@langchain/core": "^1.1.48" } }, "sha512-a9F1tO/bKGS4AlLR0FVa859oImD0iQiQLAAPkP66Nnp7a5dXPgys2IpzdJQ9slKiVznsSAFjxxNCmM25dBOvWA=="], + + "@langchain/langgraph-sdk": ["@langchain/langgraph-sdk@1.12.4", "", { "dependencies": { "@langchain/protocol": "^0.0.19", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["react", "react-dom"] }, "sha512-DuYk7HbajphOzBBtZYm+4LpCZuHj1/Csqepej5Ro1PJvchotA+bjg9XLh9LRsbz5j49Tl3owNoUp9ezj0lMCXA=="], + + "@langchain/protocol": ["@langchain/protocol@0.0.19", "", {}, "sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], @@ -2689,6 +2706,8 @@ "kysely": ["kysely@0.29.6", "", {}, "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg=="], + "langsmith": ["langsmith@0.10.13", "", { "dependencies": { "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*", "ws": ">=7" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai", "ws"] }, "sha512-v5500fr7rVzQSfQphTBJV2/OrMRDzb2QfLfoO+gvPBY/kER99QF2+wbG6QQUydm0N7ueyJfBOYhq6tqYacXVAA=="], + "lcm": ["lcm@0.0.3", "", { "dependencies": { "gcd": "^0.0.1" } }, "sha512-TB+ZjoillV6B26Vspf9l2L/vKaRY/4ep3hahcyVkCGFgsTNRUQdc24bQeNFiZeoxH0vr5+7SfNRMQuPHv/1IrQ=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -2943,6 +2962,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -3051,13 +3072,17 @@ "p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "p-some": ["p-some@6.0.0", "", { "dependencies": { "aggregate-error": "^4.0.0", "p-cancelable": "^3.0.0" } }, "sha512-CJbQCKdfSX3fIh8/QKgS+9rjm7OBNUTmwWswAFQAhc8j1NR1dsEDETUEuVUtQHZpV+J03LqWBEwvu0g1Yn+TYg=="], - "p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -4019,6 +4044,12 @@ "@jridgewell/source-map/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@langchain/core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.5", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ=="], + + "@langchain/langgraph-sdk/p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], + "@mastra/core/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], "@mastra/core/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], @@ -4451,6 +4482,8 @@ "is-online/got": ["got@12.6.1", "", { "dependencies": { "@sindresorhus/is": "^5.2.0", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^10.2.8", "decompress-response": "^6.0.0", "form-data-encoder": "^2.1.2", "get-stream": "^6.0.1", "http2-wrapper": "^2.1.10", "lowercase-keys": "^3.0.0", "p-cancelable": "^3.0.0", "responselike": "^3.0.0" } }, "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ=="], + "is-online/p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "is-regex/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "jsonwebtoken/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -4485,6 +4518,8 @@ "openid-client/jose": ["jose@6.2.0", "", {}, "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ=="], + "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -4813,6 +4848,8 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@langchain/langgraph-sdk/p-queue/p-timeout": ["p-timeout@7.0.2", "", {}, "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw=="], + "@mastra/core/@modelcontextprotocol/sdk/jose": ["jose@6.2.0", "", {}, "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ=="], "@mastra/core/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/packages/tools/README.md b/packages/tools/README.md index 1bc677d2..fb39a4e2 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -1,6 +1,6 @@ # @supermemory/tools -Memory tools and middleware for Vercel AI SDK, OpenAI, Mastra, VoltAgent and Claude, backed by the [Supermemory](https://supermemory.ai) v5 API. +Memory tools and middleware for Vercel AI SDK, OpenAI, LangChain, LangGraph, Mastra, VoltAgent and Claude, backed by the [Supermemory](https://supermemory.ai) v5 API. Pick the entry point for your stack: @@ -8,6 +8,7 @@ Pick the entry point for your stack: | --- | --- | | `@supermemory/tools/ai-sdk` | Vercel AI SDK: 7 memory tools for `generateText`/`streamText`, plus `withSupermemory` model middleware | | `@supermemory/tools/openai` | OpenAI SDK: function-calling tool definitions and executor, plus `withSupermemory` client middleware | +| `@supermemory/tools/langchain` | LangGraph: persistent `BaseStore`; LangChain: hybrid-search `BaseRetriever` | | `@supermemory/tools/mastra` | Mastra agents: input/output processors or the `withSupermemory` config wrapper | | `@supermemory/tools/voltagent` | VoltAgent agents: `withSupermemory` config wrapper built on hooks | | `@supermemory/tools/claude-memory` | Claude's native memory tool (`memory_20250818`), stored as Supermemory documents | @@ -40,7 +41,32 @@ The `ai-sdk` entry point works with AI SDK 5, 6 and 7. It uses the `ai` package ## Usage -Each section below covers one entry point: AI SDK, OpenAI, Mastra, VoltAgent, then the Claude memory tool. The `withSupermemory` middleware takes the same options everywhere; see [withSupermemory Middleware Options](#withsupermemory-middleware-options). +Each section below covers one entry point: AI SDK, OpenAI, LangChain/LangGraph, Mastra, VoltAgent, then the Claude memory tool. The `withSupermemory` middleware takes the same options everywhere; see [withSupermemory Middleware Options](#withsupermemory-middleware-options). + +### LangChain and LangGraph + +Install the optional peer dependencies, then use Supermemory as a persistent LangGraph store or a LangChain retriever: + +```bash +npm install @supermemory/tools @langchain/core @langchain/langgraph +``` + +```typescript +import { + SupermemoryRetriever, + SupermemoryStore, +} from "@supermemory/tools/langchain" + +const store = new SupermemoryStore(process.env.SUPERMEMORY_API_KEY!) +await store.put(["users", "ada"], "preferences", { theme: "dark" }) +const preferences = await store.get(["users", "ada"], "preferences") + +const retriever = new SupermemoryRetriever( + process.env.SUPERMEMORY_API_KEY!, + { namespace: "user_ada", limit: 5 }, +) +const context = await retriever.invoke("What editor does Ada use?") +``` ### AI SDK Usage diff --git a/packages/tools/package.json b/packages/tools/package.json index 63d6de35..2ffab4ef 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -2,13 +2,13 @@ "name": "@supermemory/tools", "type": "module", "version": "3.1.2", - "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", + "description": "Memory tools for AI SDK, OpenAI, LangChain, LangGraph, VoltAgent and Mastra with supermemory", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts src/langchain/*.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000", "test:compat": "bun test/ai-sdk-compat/run.ts" }, @@ -23,10 +23,12 @@ "@ai-sdk/openai": "^2.0.23", "@ai-sdk/provider": "^3.0.0", "@anthropic-ai/sdk": "^0.65.0", - "@voltagent/core": "^2.6.12", + "@langchain/core": "^1.2.14", + "@langchain/langgraph": "^1.4.18", "@mastra/core": "^1.0.0", "@total-typescript/tsconfig": "^1.0.4", "@types/bun": "^1.2.21", + "@voltagent/core": "^2.6.12", "ai": "^5.0.29", "dotenv": "^16.6.1", "tsdown": "^0.14.2", @@ -35,6 +37,8 @@ }, "peerDependencies": { "@ai-sdk/provider": "^2.0.0 || ^3.0.0 || ^4.0.0", + "@langchain/core": "^1.2.14", + "@langchain/langgraph": "^1.4.18", "@voltagent/core": "^2.6.12", "ai": "^5.0.0 || ^6.0.0 || ^7.0.0" }, @@ -42,6 +46,12 @@ "@ai-sdk/provider": { "optional": true }, + "@langchain/core": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, "@voltagent/core": { "optional": true }, @@ -59,6 +69,7 @@ ".": "./dist/index.js", "./ai-sdk": "./dist/ai-sdk.js", "./claude-memory": "./dist/claude-memory.js", + "./langchain": "./dist/langchain/index.js", "./mastra": "./dist/mastra.js", "./openai": "./dist/openai/index.js", "./voltagent": "./dist/voltagent/index.js", diff --git a/packages/tools/src/langchain/index.ts b/packages/tools/src/langchain/index.ts new file mode 100644 index 00000000..488def5b --- /dev/null +++ b/packages/tools/src/langchain/index.ts @@ -0,0 +1,9 @@ +/** LangChain and LangGraph integrations backed by Supermemory v5. */ + +export { SupermemoryRetriever } from "./retriever" +export type { SupermemoryRetrieverOptions } from "./retriever" +export { SupermemoryStore } from "./store" +export { + fromSupermemoryNamespace, + toSupermemoryNamespace, +} from "./namespace" diff --git a/packages/tools/src/langchain/namespace.test.ts b/packages/tools/src/langchain/namespace.test.ts new file mode 100644 index 00000000..50e6f03b --- /dev/null +++ b/packages/tools/src/langchain/namespace.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" +import { fromSupermemoryNamespace, toSupermemoryNamespace } from "./namespace" + +describe("LangGraph namespace mapping", () => { + it("round-trips segments without collisions", () => { + const namespaces = [ + ["users", "ada"], + ["a:b"], + ["a", "b"], + ["spaces and/slashes", "under_score", "日本語"], + ] + + const encoded = namespaces.map(toSupermemoryNamespace) + expect(new Set(encoded).size).toBe(namespaces.length) + expect(encoded.map(fromSupermemoryNamespace)).toEqual(namespaces) + }) + + it("ignores namespaces not owned by the store", () => { + expect(fromSupermemoryNamespace("user_alex")).toBeNull() + expect(fromSupermemoryNamespace("langgraph:bad_encoding_")).toBeNull() + }) +}) diff --git a/packages/tools/src/langchain/namespace.ts b/packages/tools/src/langchain/namespace.ts new file mode 100644 index 00000000..aded95da --- /dev/null +++ b/packages/tools/src/langchain/namespace.ts @@ -0,0 +1,28 @@ +const PREFIX = "langgraph:" +const SEPARATOR = ":" + +function encodeSegment(segment: string): string { + return encodeURIComponent(segment) + .replace(/[^A-Za-z0-9%-]/g, (char) => `%${char.charCodeAt(0).toString(16)}`) + .replaceAll("%", "_") +} + +function decodeSegment(segment: string): string { + return decodeURIComponent(segment.replaceAll("_", "%")) +} + +/** Maps a hierarchical LangGraph namespace to one isolated v5 namespace. */ +export function toSupermemoryNamespace(namespace: string[]): string { + return `${PREFIX}${namespace.map(encodeSegment).join(SEPARATOR)}` +} + +/** Returns null for namespaces not owned by this LangGraph store. */ +export function fromSupermemoryNamespace(namespace: string): string[] | null { + if (!namespace.startsWith(PREFIX)) return null + const encoded = namespace.slice(PREFIX.length) + try { + return encoded ? encoded.split(SEPARATOR).map(decodeSegment) : [] + } catch { + return null + } +} diff --git a/packages/tools/src/langchain/retriever.test.ts b/packages/tools/src/langchain/retriever.test.ts new file mode 100644 index 00000000..23af7d6d --- /dev/null +++ b/packages/tools/src/langchain/retriever.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const search = vi.fn() + +vi.mock("supermemory", () => ({ + NotFoundError: class NotFoundError extends Error {}, + Supermemory: class MockSupermemory { + search = search + }, +})) + +import { SupermemoryRetriever } from "./retriever" + +describe("SupermemoryRetriever", () => { + beforeEach(() => vi.clearAllMocks()) + + it("searches its v5 namespace in hybrid mode", async () => { + search.mockResolvedValue({ results: [] }) + const retriever = new SupermemoryRetriever("sm_test", { + namespace: "user_1", + limit: 5, + }) + + await retriever.invoke("favorite editor") + + expect(search).toHaveBeenCalledWith("user_1", { + query: "favorite editor", + limit: 5, + threshold: 0.6, + searchMode: "hybrid", + }) + }) + + it("maps memory and chunk results to LangChain documents", async () => { + search.mockResolvedValue({ + results: [ + { + id: "mem_1", + memory: "Uses Neovim", + metadata: { source: "profile" }, + similarity: 0.9, + system: { createdAt: "c", updatedAt: "u" }, + }, + { + id: "chunk_1", + chunk: "Editor notes", + metadata: {}, + similarity: 0.8, + system: { updatedAt: "u" }, + }, + ], + }) + const retriever = new SupermemoryRetriever("sm_test", { + namespace: "user_1", + }) + + const documents = await retriever.invoke("editor") + + expect(documents.map((document) => document.pageContent)).toEqual([ + "Uses Neovim", + "Editor notes", + ]) + expect(documents[0]?.metadata).toMatchObject({ + id: "mem_1", + namespace: "user_1", + similarity: 0.9, + source: "profile", + }) + }) +}) diff --git a/packages/tools/src/langchain/retriever.ts b/packages/tools/src/langchain/retriever.ts new file mode 100644 index 00000000..bfce3cb1 --- /dev/null +++ b/packages/tools/src/langchain/retriever.ts @@ -0,0 +1,62 @@ +import { Document } from "@langchain/core/documents" +import { + BaseRetriever, + type BaseRetrieverInput, +} from "@langchain/core/retrievers" +import { + DEFAULT_VALUES, + clampSearchLimit, + createToolsClient, +} from "../tools-shared" +import type { SupermemoryToolsConfig } from "../types" + +export interface SupermemoryRetrieverOptions extends BaseRetrieverInput { + /** Supermemory v5 namespace to search. */ + namespace: string + /** Maximum number of documents to return. */ + limit?: number + config?: SupermemoryToolsConfig +} + +/** LangChain retriever backed by Supermemory's hybrid memory search. */ +export class SupermemoryRetriever extends BaseRetriever { + lc_namespace = ["supermemory", "retrievers"] + + private client + private namespace: string + private limit: number + + constructor(apiKey: string, options: SupermemoryRetrieverOptions) { + super(options) + this.client = createToolsClient(apiKey, options.config) + this.namespace = options.namespace + this.limit = clampSearchLimit(options.limit ?? DEFAULT_VALUES.limit) + } + + override async _getRelevantDocuments(query: string): Promise { + const response = await this.client.search(this.namespace, { + query, + limit: this.limit, + threshold: DEFAULT_VALUES.searchThreshold, + searchMode: "hybrid", + }) + + return response.results.flatMap((result) => { + const pageContent = result.memory ?? result.chunk + if (!pageContent) return [] + return [ + new Document({ + pageContent, + metadata: { + ...result.metadata, + id: result.id, + namespace: this.namespace, + similarity: result.similarity, + createdAt: result.system.createdAt, + updatedAt: result.system.updatedAt, + }, + }), + ] + }) + } +} diff --git a/packages/tools/src/langchain/store.test.ts b/packages/tools/src/langchain/store.test.ts new file mode 100644 index 00000000..5d5d03c5 --- /dev/null +++ b/packages/tools/src/langchain/store.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { + add, + documentsDelete, + documentsGet, + documentsUpdate, + list, + namespacesList, + search, + MockNotFoundError, +} = vi.hoisted(() => ({ + add: vi.fn(), + documentsDelete: vi.fn(), + documentsGet: vi.fn(), + documentsUpdate: vi.fn(), + list: vi.fn(), + namespacesList: vi.fn(), + search: vi.fn(), + MockNotFoundError: class MockNotFoundError extends Error {}, +})) + +vi.mock("supermemory", () => ({ + NotFoundError: MockNotFoundError, + Supermemory: class MockSupermemory { + add = add + documents = { + delete: documentsDelete, + get: documentsGet, + update: documentsUpdate, + } + list = list + namespaces = { list: namespacesList } + search = search + }, +})) + +import { SupermemoryStore } from "./store" + +const timestamps = { + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", +} + +function document(id: string, key: string, value: Record) { + return { + id, + content: JSON.stringify(value), + metadata: { langgraphKey: key }, + system: { status: "done", ...timestamps }, + } +} + +describe("SupermemoryStore", () => { + let store: SupermemoryStore + + beforeEach(() => { + vi.clearAllMocks() + documentsGet.mockRejectedValue(new MockNotFoundError()) + namespacesList.mockResolvedValue({ + namespaces: [], + pagination: { currentPage: 1, totalPages: 1, totalItems: 0 }, + }) + store = new SupermemoryStore("sm_test") + }) + + it("creates an item with a scoped v5 namespace and caller ID", async () => { + await store.put(["users", "ada"], "profile", { theme: "dark" }) + + expect(add).toHaveBeenCalledWith("langgraph:users:ada", { + content: JSON.stringify({ theme: "dark" }), + id: "profile", + metadata: { langgraphKey: "profile" }, + taskType: "superrag", + }) + }) + + it("reads and replaces an existing item directly by namespace and key", async () => { + documentsGet.mockResolvedValue( + document("doc_1", "profile", { theme: "light" }), + ) + + const item = await store.get(["users", "ada"], "profile") + await store.put(["users", "ada"], "profile", { theme: "dark" }) + + expect(item).toMatchObject({ + key: "profile", + namespace: ["users", "ada"], + value: { theme: "light" }, + }) + expect(documentsUpdate).toHaveBeenCalledWith( + "langgraph:users:ada", + "doc_1", + { content: JSON.stringify({ theme: "dark" }) }, + ) + }) + + it("deletes an existing item", async () => { + documentsGet.mockResolvedValue(document("doc_1", "profile", {})) + documentsDelete.mockResolvedValue({ count: 1, errors: [] }) + + await store.delete(["users", "ada"], "profile") + + expect(documentsDelete).toHaveBeenCalledWith("langgraph:users:ada", { + ids: ["doc_1"], + }) + }) + + it("searches every managed namespace under the requested prefix", async () => { + namespacesList.mockResolvedValue({ + namespaces: [ + { namespace: "langgraph:users:ada" }, + { namespace: "langgraph:users:grace" }, + { namespace: "unrelated" }, + ], + pagination: { currentPage: 1, totalPages: 1, totalItems: 3 }, + }) + search + .mockResolvedValueOnce({ + results: [ + { + metadata: { langgraphKey: "profile" }, + similarity: 0.7, + }, + ], + }) + .mockResolvedValueOnce({ + results: [ + { + metadata: { langgraphKey: "profile" }, + similarity: 0.9, + }, + ], + }) + documentsGet + .mockResolvedValueOnce(document("ada", "profile", { active: true })) + .mockResolvedValueOnce(document("grace", "profile", { active: true })) + + const results = await store.search(["users"], { + query: "active profile", + filter: { active: true }, + }) + + expect(search).toHaveBeenCalledTimes(2) + expect(search).toHaveBeenCalledWith("langgraph:users:ada", { + query: "active profile", + searchMode: "chunks", + limit: 50, + threshold: 0, + }) + expect(results.map((item) => item.namespace)).toEqual([ + ["users", "grace"], + ["users", "ada"], + ]) + }) + + it("lists and filters items when search has no semantic query", async () => { + namespacesList.mockResolvedValue({ + namespaces: [{ namespace: "langgraph:users:ada" }], + pagination: { currentPage: 1, totalPages: 1, totalItems: 1 }, + }) + list.mockResolvedValue({ + documents: [{ id: "profile" }], + pagination: { currentPage: 1, totalPages: 1, totalItems: 1 }, + }) + documentsGet.mockResolvedValue( + document("doc_1", "profile", { theme: "dark" }), + ) + + const results = await store.search(["users"], { + filter: { theme: "dark" }, + }) + + expect(list).toHaveBeenCalledWith("langgraph:users:ada", "documents", { + page: 1, + limit: 100, + }) + expect(results).toHaveLength(1) + expect(results[0]?.value).toEqual({ theme: "dark" }) + }) + + it("lists only store-owned namespaces with LangGraph matching", async () => { + namespacesList.mockResolvedValue({ + namespaces: [ + { namespace: "langgraph:users:ada" }, + { namespace: "langgraph:users:grace" }, + { namespace: "langgraph:projects:demo" }, + { namespace: "user_alex" }, + ], + pagination: { currentPage: 1, totalPages: 1, totalItems: 4 }, + }) + + const namespaces = await store.listNamespaces({ + prefix: ["users"], + }) + + expect(namespaces).toEqual([ + ["users", "ada"], + ["users", "grace"], + ]) + }) +}) diff --git a/packages/tools/src/langchain/store.ts b/packages/tools/src/langchain/store.ts new file mode 100644 index 00000000..4859da25 --- /dev/null +++ b/packages/tools/src/langchain/store.ts @@ -0,0 +1,323 @@ +import { BaseStore } from "@langchain/langgraph" +import type { + GetOperation, + Item, + ListNamespacesOperation, + MatchCondition, + Operation, + OperationResults, + PutOperation, + SearchOperation, +} from "@langchain/langgraph" +import type { Supermemory } from "supermemory" +import { + DEFAULT_VALUES, + SEARCH_LIMIT_BOUNDS, + createToolsClient, + isNotFoundError, +} from "../tools-shared" +import type { SupermemoryToolsConfig } from "../types" +import { fromSupermemoryNamespace, toSupermemoryNamespace } from "./namespace" + +const KEY_FIELD = "langgraphKey" +const PAGE_SIZE = 100 + +type StoredDocument = Awaited> + +/** LangGraph's persistent store interface backed by Supermemory v5. */ +export class SupermemoryStore extends BaseStore { + private client + + constructor(apiKey: string, config?: SupermemoryToolsConfig) { + super() + this.client = createToolsClient(apiKey, config) + } + + async batch( + operations: Op, + ): Promise> { + const results = [] + for (const operation of operations) { + if ("namespacePrefix" in operation) { + results.push(await this.runSearch(operation)) + } else if ("value" in operation) { + results.push(await this.runPut(operation)) + } else if ("key" in operation) { + results.push(await this.runGet(operation)) + } else { + results.push(await this.runListNamespaces(operation)) + } + } + return results as OperationResults + } + + private async runPut(operation: PutOperation): Promise { + const namespace = toSupermemoryNamespace(operation.namespace) + const existing = await this.getDocument(namespace, operation.key) + + if (operation.value === null) { + if (!existing) return + const document = await this.settle(namespace, existing) + const response = await this.client.documents.delete(namespace, { + ids: [document.id], + }) + if (response.count !== 1) { + throw new Error( + response.errors[0]?.error ?? + `Failed to delete LangGraph item ${operation.key}`, + ) + } + return + } + + const content = JSON.stringify(operation.value) + if (existing) { + const document = await this.settle(namespace, existing) + await this.client.documents.update(namespace, document.id, { content }) + return + } + + await this.client.add(namespace, { + content, + id: operation.key, + metadata: { [KEY_FIELD]: operation.key }, + taskType: "superrag", + }) + } + + private async runGet(operation: GetOperation): Promise { + const namespace = toSupermemoryNamespace(operation.namespace) + const found = await this.getDocument(namespace, operation.key) + if (!found) return null + return this.toItem( + await this.settle(namespace, found), + operation.namespace, + operation.key, + ) + } + + private async runSearch(operation: SearchOperation) { + const limit = operation.limit ?? DEFAULT_VALUES.limit + const offset = operation.offset ?? 0 + const namespaces = await this.namespacesUnder(operation.namespacePrefix) + const pages = await Promise.all( + namespaces.map((namespace) => + operation.query + ? this.searchNamespace(namespace, operation.query) + : this.listNamespace(namespace), + ), + ) + const items = pages.flat() + + if (operation.query) { + items.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + } + const filter = operation.filter + const matching = filter + ? items.filter((item) => matchesFilter(item.value, filter)) + : items + return matching.slice(offset, offset + limit) + } + + private async searchNamespace(namespace: string[], query: string) { + const encoded = toSupermemoryNamespace(namespace) + // ponytail: one maximum API page per namespace; add server-side paging if + // search gains it and stores need more than the top 50 candidates. + const response = await this.client.search(encoded, { + query, + searchMode: "chunks", + limit: SEARCH_LIMIT_BOUNDS.max, + threshold: 0, + }) + + const items = await Promise.all( + response.results.map(async (result) => { + const key = result.metadata[KEY_FIELD] + if (typeof key !== "string") return null + const document = await this.getDocument(encoded, key) + if (!document) return null + return { + ...this.toItem(document, namespace, key), + score: result.similarity, + } + }), + ) + return items.filter((item) => item !== null) + } + + private async listNamespace(namespace: string[]) { + const encoded = toSupermemoryNamespace(namespace) + const items: Array = [] + let page = 1 + while (true) { + const response = await this.client.list(encoded, "documents", { + page, + limit: PAGE_SIZE, + }) + const documents = await Promise.all( + response.documents.map(async (listed) => { + const document = await this.getDocument(encoded, listed.id) + return document ? this.settle(encoded, document) : null + }), + ) + for (const document of documents) { + if (!document) continue + const key = document.metadata[KEY_FIELD] + if (typeof key !== "string") continue + items.push(this.toItem(document, namespace, key)) + } + if (page >= response.pagination.totalPages) return items + page += 1 + } + } + + private async runListNamespaces(operation: ListNamespacesOperation) { + const seen = new Map() + const conditions = operation.matchConditions ?? [] + for (const namespace of await this.listManagedNamespaces()) { + let candidate = namespace + if (operation.maxDepth !== undefined) { + candidate = candidate.slice(0, operation.maxDepth) + } + if (!conditions.every((condition) => matches(candidate, condition))) { + continue + } + seen.set(candidate.join("\u0000"), candidate) + } + + return [...seen.values()] + .sort((a, b) => a.join("\u0000").localeCompare(b.join("\u0000"))) + .slice(operation.offset, operation.offset + operation.limit) + } + + private async namespacesUnder(prefix: string[]): Promise { + const namespaces = await this.listManagedNamespaces() + return namespaces.filter( + (namespace) => + namespace.length >= prefix.length && + prefix.every((segment, index) => namespace[index] === segment), + ) + } + + private async listManagedNamespaces(): Promise { + const namespaces: string[][] = [] + let page = 1 + while (true) { + const response = await this.client.namespaces.list({ + page, + limit: PAGE_SIZE, + }) + for (const entry of response.namespaces) { + const decoded = fromSupermemoryNamespace(entry.namespace) + if (decoded) namespaces.push(decoded) + } + if (page >= response.pagination.totalPages) return namespaces + page += 1 + } + } + + private async getDocument( + namespace: string, + id: string, + ): Promise { + try { + return await this.client.documents.get(namespace, id) + } catch (error) { + if (isNotFoundError(error)) return null + throw error + } + } + + private async settle( + namespace: string, + document: StoredDocument, + ): Promise { + let current = document + for (let attempt = 0; !isSettled(current.system.status); attempt++) { + if (attempt === 60) { + throw new Error(`Supermemory document ${document.id} still processing`) + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + current = await this.client.documents.get(namespace, document.id) + } + return current + } + + private toItem( + document: StoredDocument, + namespace: string[], + key: string, + ): Item { + return { + value: parseValue(document.content), + key, + namespace, + createdAt: new Date(document.system.createdAt), + updatedAt: new Date(document.system.updatedAt), + } + } +} + +function isSettled(status: string): boolean { + return status === "done" || status === "failed" +} + +function parseValue(content: string | null): Record { + if (!content) return {} + try { + const parsed: unknown = JSON.parse(content) + return parsed !== null && typeof parsed === "object" + ? (parsed as Record) + : { value: parsed } + } catch { + return { content } + } +} + +function matchesFilter( + value: Record, + filter: Record, +): boolean { + return Object.entries(filter).every(([field, expected]) => + compareValues(value[field], expected), + ) +} + +function compareValues(actual: unknown, expected: unknown): boolean { + if (expected === null || typeof expected !== "object") { + return actual === expected + } + return Object.entries(expected).every(([operator, value]) => { + switch (operator) { + case "$eq": + return actual === value + case "$ne": + return actual !== value + case "$gt": + return Number(actual) > Number(value) + case "$gte": + return Number(actual) >= Number(value) + case "$lt": + return Number(actual) < Number(value) + case "$lte": + return Number(actual) <= Number(value) + case "$in": + return Array.isArray(value) && value.includes(actual) + case "$nin": + return !Array.isArray(value) || !value.includes(actual) + default: + return false + } + }) +} + +function matches(namespace: string[], condition: MatchCondition): boolean { + if (condition.path.length > namespace.length) return false + const start = + condition.matchType === "suffix" + ? namespace.length - condition.path.length + : 0 + return condition.path.every( + (pattern, index) => pattern === "*" || namespace[start + index] === pattern, + ) +} diff --git a/packages/tools/tsdown.config.ts b/packages/tools/tsdown.config.ts index 139dd7df..7caaf77b 100644 --- a/packages/tools/tsdown.config.ts +++ b/packages/tools/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ "src/index.ts", "src/ai-sdk.ts", "src/claude-memory.ts", + "src/langchain/index.ts", "src/openai/index.ts", "src/mastra.ts", "src/voltagent/index.ts",