diff --git a/README.md b/README.md index 22e61944..06f07d91 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,7 @@ Debugging no longer means probing an opaque database — it becomes a determinis | Document | Contents | | :--- | :--- | +| [`docs/DIFY-ADAPTER.md`](./docs/DIFY-ADAPTER.md) | Dify workflow adapter guide | | [`scripts/README.memory-tencentdb-ctl.md`](./scripts/README.memory-tencentdb-ctl.md) | Operations & management tooling | | [`CHANGELOG.md`](./CHANGELOG.md) | Release notes and version history | | [`openclaw.plugin.json`](./openclaw.plugin.json) | OpenClaw plugin manifest and configuration schema | diff --git a/docs/DIFY-ADAPTER.md b/docs/DIFY-ADAPTER.md new file mode 100644 index 00000000..e260aa3b --- /dev/null +++ b/docs/DIFY-ADAPTER.md @@ -0,0 +1,143 @@ +# Dify Adapter Guide + +This guide documents the Dify-specific follow-up adapter for TencentDB Agent +Memory. It intentionally stays narrower than the shared Gateway client baseline: +the Dify adapter maps workflow variables to the existing Gateway `/recall` and +`/capture` routes, while reusable cross-platform client boundaries should live +in the dedicated Gateway adapter kit. + +## Scope + +Included: + +- Dify workflow input mapping for recall before the LLM node. +- Dify answer mapping for capture after the LLM node. +- Stable Dify `session_key` construction from platform, user, conversation, and + optional session IDs. +- HTTP-only Dify node examples for users who do not run TypeScript. + +Not included: + +- A general-purpose Gateway SDK. +- MCP server, Python SDK, Codex or Claude hooks. +- Core memory runtime changes. +- CI or packaging changes beyond the Dify adapter export. + +## Gateway Setup + +Start the Gateway in the plugin checkout: + +```bash +npx tsx src/gateway/server.ts +``` + +Verify it: + +```bash +curl http://127.0.0.1:8420/health +``` + +If the Gateway is protected, configure the same token on both sides: + +```bash +export TDAI_GATEWAY_API_KEY="replace-me" +``` + +The Dify adapter sends: + +```http +Authorization: Bearer replace-me +``` + +## TypeScript Adapter + +Use the TypeScript adapter when your Dify workflow can call a small Node.js +bridge service. The adapter accepts either an injected Dify memory port or +Dify-scoped HTTP Gateway options. + +```ts +import { createDifyWorkflowMemoryAdapter } from "@tencentdb-agent-memory/memory-tencentdb"; + +const memory = createDifyWorkflowMemoryAdapter({ + gateway: { + baseUrl: "http://127.0.0.1:8420", + apiKey: process.env.TDAI_GATEWAY_API_KEY, + }, +}); + +const recalled = await memory.recall({ + query: inputs.query, + conversation_id: conversationId, + user: userId, +}); + +// Inject recalled.memory_context into the Dify prompt template. + +await memory.capture({ + query: inputs.query, + answer: llmAnswer, + conversation_id: conversationId, + user: userId, +}); +``` + +## HTTP-Only Dify Nodes + +If you do not run TypeScript, use two HTTP request nodes. + +Before the LLM node: + +```http +POST http://127.0.0.1:8420/recall +Content-Type: application/json +Authorization: Bearer ${TDAI_GATEWAY_API_KEY} + +{ + "query": "{{query}}", + "session_key": "dify:{{user}}:{{conversation_id}}", + "user_id": "{{user}}" +} +``` + +Use `context` from the response as `memory_context`. + +After the LLM node: + +```http +POST http://127.0.0.1:8420/capture +Content-Type: application/json +Authorization: Bearer ${TDAI_GATEWAY_API_KEY} + +{ + "user_content": "{{query}}", + "assistant_content": "{{answer}}", + "session_key": "dify:{{user}}:{{conversation_id}}", + "user_id": "{{user}}" +} +``` + +## Field Mapping + +| Dify field | Gateway field | Notes | +| --- | --- | --- | +| `query`, `inputs.query`, `user_content`, `prompt`, `message` | `query` / `user_content` | Recall and capture accept common Dify variable names. | +| `answer`, `assistant_content`, `response`, `output` | `assistant_content` | Required for capture. | +| `user`, `user_id` | `user_id` and `session_key` part | Falls back to `default_user`. | +| `conversation_id`, `session_id` | `session_key` part | Falls back to `default_conversation`. | +| `messages` | `messages` | Forwarded to `/capture` when present. | + +## Validation + +Run adapter-focused tests: + +```bash +npm test -- src/adapters/dify/index.test.ts +``` + +Run the full suite before opening or updating a PR: + +```bash +npm test +npm run build +git diff --check +``` diff --git a/index.ts b/index.ts index 868a7701..d0b6e787 100644 --- a/index.ts +++ b/index.ts @@ -45,6 +45,22 @@ import { } from "./src/utils/ensure-hook-policy.js"; import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.js"; +export { + DifyWorkflowMemoryAdapter, + createDifyWorkflowMemoryAdapter, +} from "./src/adapters/index.js"; +export type { + DifyCaptureResult, + DifyGatewayHttpFetch, + DifyGatewayHttpOptions, + DifyGatewayHttpRequestInit, + DifyGatewayHttpResponse, + DifyGatewayMemoryPort, + DifyRecallResult, + DifyWorkflowInput, + DifyWorkflowMemoryAdapterOptions, +} from "./src/adapters/index.js"; + const TAG = "[memory-tdai]"; /** diff --git a/src/adapters/dify/index.test.ts b/src/adapters/dify/index.test.ts new file mode 100644 index 00000000..02781e4b --- /dev/null +++ b/src/adapters/dify/index.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + DifyWorkflowMemoryAdapter, + type DifyGatewayHttpFetch, + type DifyGatewayHttpRequestInit, + type DifyGatewayMemoryPort, +} from "./index.js"; + +interface RecordedCall { + url: string; + init?: DifyGatewayHttpRequestInit; +} + +function makeClient(calls: unknown[]): DifyGatewayMemoryPort { + return { + async recall(body) { + calls.push({ method: "recall", body }); + return { context: "memory context", memory_count: 2, strategy: "hybrid" }; + }, + async capture(body) { + calls.push({ method: "capture", body }); + return { l0_recorded: 2, scheduler_notified: true }; + }, + }; +} + +describe("DifyWorkflowMemoryAdapter", () => { + it("maps Dify workflow input to Gateway recall", async () => { + const calls: unknown[] = []; + const adapter = new DifyWorkflowMemoryAdapter({ client: makeClient(calls) }); + + const result = await adapter.recall({ + inputs: { query: "project preference" }, + conversation_id: "conv 1", + user: "user/42", + }); + + expect(result).toEqual({ + session_key: "dify:user_42:conv_1", + memory_context: "memory context", + memory_count: 2, + strategy: "hybrid", + }); + expect(calls).toEqual([ + { + method: "recall", + body: { + query: "project preference", + session_key: "dify:user_42:conv_1", + user_id: "user/42", + }, + }, + ]); + }); + + it("maps Dify answer output to Gateway capture", async () => { + const calls: unknown[] = []; + const adapter = new DifyWorkflowMemoryAdapter({ + client: makeClient(calls), + platform: "dify-cloud", + }); + + const result = await adapter.capture({ + query: "remember this", + answer: "stored", + conversation_id: "conv", + session_id: "run-1", + user_id: "u", + messages: [{ role: "user", content: "remember this" }], + }); + + expect(result).toEqual({ + session_key: "dify-cloud:u:conv:run-1", + l0_recorded: 2, + scheduler_notified: true, + }); + expect(calls).toEqual([ + { + method: "capture", + body: { + user_content: "remember this", + assistant_content: "stored", + session_key: "dify-cloud:u:conv:run-1", + session_id: "run-1", + user_id: "u", + messages: [{ role: "user", content: "remember this" }], + }, + }, + ]); + }); + + it("rejects incomplete capture payloads", async () => { + const adapter = new DifyWorkflowMemoryAdapter({ client: makeClient([]) }); + + await expect(adapter.capture({ query: "only user text" })).rejects.toThrow( + "Dify capture requires `assistant_content` or `answer`", + ); + }); + + it("posts recall to the Gateway when Dify uses HTTP-only integration", async () => { + const calls: RecordedCall[] = []; + const fetchFn: DifyGatewayHttpFetch = async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ context: "from gateway", memory_count: 1 }), + }; + }; + const adapter = new DifyWorkflowMemoryAdapter({ + gateway: { + baseUrl: "http://127.0.0.1:8420/", + apiKey: "token", + fetch: fetchFn, + }, + }); + + await expect(adapter.recall({ + query: "latest project memory", + conversation_id: "conv", + user_id: "u", + })).resolves.toMatchObject({ + memory_context: "from gateway", + session_key: "dify:u:conv", + }); + expect(calls).toEqual([ + { + url: "http://127.0.0.1:8420/recall", + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + }, + body: JSON.stringify({ + query: "latest project memory", + session_key: "dify:u:conv", + user_id: "u", + }), + }, + }, + ]); + }); + + it("keeps the HTTP helper scoped to Dify recall and capture routes", async () => { + const calls: RecordedCall[] = []; + const fetchFn: DifyGatewayHttpFetch = async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ l0_recorded: 1, scheduler_notified: true }), + }; + }; + const adapter = new DifyWorkflowMemoryAdapter({ + gateway: { baseUrl: "http://gateway.local", fetch: fetchFn }, + }); + + await adapter.capture({ + query: "remember", + answer: "done", + conversation_id: "conv", + user: "user", + }); + + expect(calls.map((call) => call.url)).toEqual(["http://gateway.local/capture"]); + expect(JSON.parse(calls[0].init?.body ?? "{}")).toMatchObject({ + user_content: "remember", + assistant_content: "done", + session_key: "dify:user:conv", + user_id: "user", + }); + }); + + it("requires either an injected Dify port or Dify Gateway HTTP options", () => { + expect(() => new DifyWorkflowMemoryAdapter({})).toThrow( + "DifyWorkflowMemoryAdapter requires either `client` or `gateway` options", + ); + }); +}); diff --git a/src/adapters/dify/index.ts b/src/adapters/dify/index.ts new file mode 100644 index 00000000..1377f671 --- /dev/null +++ b/src/adapters/dify/index.ts @@ -0,0 +1,261 @@ +import type { CaptureRequest, CaptureResponse, RecallRequest, RecallResponse } from "../../gateway/types.js"; + +export interface DifyGatewayHttpRequestInit { + method?: string; + headers?: Record; + body?: string; +} + +export interface DifyGatewayHttpResponse { + ok: boolean; + status: number; + statusText?: string; + text(): Promise; +} + +export type DifyGatewayHttpFetch = ( + url: string, + init?: DifyGatewayHttpRequestInit, +) => Promise; + +export interface DifyGatewayHttpOptions { + /** Gateway base URL, for example `http://127.0.0.1:8420`. */ + baseUrl: string; + /** Optional Bearer token matching `TDAI_GATEWAY_API_KEY`. */ + apiKey?: string; + /** Custom fetch implementation for Dify-side runtimes or tests. */ + fetch?: DifyGatewayHttpFetch; +} + +export interface DifyGatewayMemoryPort { + recall(body: RecallRequest): Promise; + capture(body: CaptureRequest): Promise; +} + +export interface DifyWorkflowMemoryAdapterOptions { + gateway?: DifyGatewayHttpOptions; + client?: DifyGatewayMemoryPort; + platform?: string; + defaultUserId?: string; +} + +export interface DifyWorkflowInput { + query?: string; + user_content?: string; + assistant_content?: string; + answer?: string; + conversation_id?: string; + session_id?: string; + user?: string; + user_id?: string; + inputs?: Record; + messages?: unknown[]; +} + +export interface DifyRecallResult { + session_key: string; + memory_context: string; + memory_count: number; + strategy?: string; +} + +export interface DifyCaptureResult extends CaptureResponse { + session_key: string; +} + +export class DifyWorkflowMemoryAdapter { + private readonly client: DifyGatewayMemoryPort; + private readonly platform: string; + private readonly defaultUserId: string; + + constructor(opts: DifyWorkflowMemoryAdapterOptions) { + if (!opts.client && !opts.gateway) { + throw new Error("DifyWorkflowMemoryAdapter requires either `client` or `gateway` options"); + } + this.client = opts.client ?? createDifyGatewayHttpPort(opts.gateway!); + this.platform = opts.platform ?? "dify"; + this.defaultUserId = opts.defaultUserId ?? "default_user"; + } + + /** + * Call before the Dify LLM node. Return `memory_context` as a workflow + * variable and inject it into the system prompt or user prompt template. + */ + async recall(input: DifyWorkflowInput): Promise { + const query = readString(input, "query", "user_content", "prompt", "message"); + if (!query) throw new Error("Dify recall requires `query` or `inputs.query`"); + + const identity = this.resolveIdentity(input); + const result = await this.client.recall({ + query, + session_key: identity.sessionKey, + user_id: identity.userId, + }); + + return { + session_key: identity.sessionKey, + memory_context: result.context, + memory_count: result.memory_count ?? 0, + strategy: result.strategy, + }; + } + + /** + * Call after the Dify answer node. This stores the completed turn in L0 and + * lets the Gateway schedule L1/L2/L3 processing. + */ + async capture(input: DifyWorkflowInput): Promise { + const userContent = readString(input, "user_content", "query", "prompt", "message"); + const assistantContent = readString(input, "assistant_content", "answer", "response", "output"); + if (!userContent) throw new Error("Dify capture requires `user_content` or `query`"); + if (!assistantContent) throw new Error("Dify capture requires `assistant_content` or `answer`"); + + const identity = this.resolveIdentity(input); + const result = await this.client.capture({ + user_content: userContent, + assistant_content: assistantContent, + session_key: identity.sessionKey, + session_id: identity.sessionId, + user_id: identity.userId, + messages: input.messages, + }); + + return { + ...result, + session_key: identity.sessionKey, + }; + } + + buildSessionKey(input: DifyWorkflowInput): string { + return this.resolveIdentity(input).sessionKey; + } + + private resolveIdentity(input: DifyWorkflowInput): { + userId: string; + conversationId: string; + sessionId?: string; + sessionKey: string; + } { + const userId = readString(input, "user_id", "user") ?? this.defaultUserId; + const conversationId = + readString(input, "conversation_id", "conversationId") ?? + readString(input, "session_id", "sessionId") ?? + "default_conversation"; + const sessionId = readString(input, "session_id", "sessionId"); + return { + userId, + conversationId, + sessionId, + sessionKey: buildDifySessionKey({ + platform: this.platform, + userId, + conversationId, + sessionId, + }), + }; + } +} + +export function createDifyWorkflowMemoryAdapter( + opts: DifyWorkflowMemoryAdapterOptions, +): DifyWorkflowMemoryAdapter { + return new DifyWorkflowMemoryAdapter(opts); +} + +function readString(input: DifyWorkflowInput, ...keys: string[]): string | undefined { + for (const key of keys) { + const direct = (input as Record)[key]; + if (typeof direct === "string" && direct.trim()) return direct; + const nested = input.inputs?.[key]; + if (typeof nested === "string" && nested.trim()) return nested; + } + return undefined; +} + +function createDifyGatewayHttpPort(opts: DifyGatewayHttpOptions): DifyGatewayMemoryPort { + const baseUrl = normalizeGatewayBaseUrl(opts.baseUrl); + const fetchFn = opts.fetch ?? getGlobalFetch(); + return { + recall(body) { + return requestJson(fetchFn, baseUrl, opts.apiKey, "/recall", body); + }, + capture(body) { + return requestJson(fetchFn, baseUrl, opts.apiKey, "/capture", body); + }, + }; +} + +async function requestJson( + fetchFn: DifyGatewayHttpFetch, + baseUrl: string, + apiKey: string | undefined, + pathname: string, + body: unknown, +): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await fetchFn(`${baseUrl}${pathname}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + const text = await response.text(); + const parsed = parseJsonOrText(text); + + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error?: unknown }).error) + : `Dify Gateway request failed with HTTP ${response.status}`; + throw new Error(message); + } + + return parsed as T; +} + +function buildDifySessionKey(parts: { + platform: string; + userId: string; + conversationId: string; + sessionId?: string; +}): string { + const platform = sanitizeSessionKeyPart(parts.platform || "dify"); + const user = sanitizeSessionKeyPart(parts.userId || "default_user"); + const conversation = sanitizeSessionKeyPart(parts.conversationId || "default_conversation"); + const session = parts.sessionId ? `:${sanitizeSessionKeyPart(parts.sessionId)}` : ""; + return `${platform}:${user}:${conversation}${session}`; +} + +function normalizeGatewayBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + if (!trimmed) throw new Error("Dify Gateway baseUrl is required"); + return trimmed.replace(/\/+$/, ""); +} + +function sanitizeSessionKeyPart(value: string): string { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9_.:-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + return sanitized || "unknown"; +} + +function parseJsonOrText(text: string): unknown { + if (!text) return {}; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function getGlobalFetch(): DifyGatewayHttpFetch { + const fetchFn = globalThis.fetch; + if (typeof fetchFn !== "function") { + throw new Error("No fetch implementation available; pass `gateway.fetch` to DifyWorkflowMemoryAdapter"); + } + return (url, init) => fetchFn(url, init as RequestInit) as Promise; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4bf0b95b..e88ff7f2 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -17,3 +17,17 @@ export type { OpenClawHostAdapterOptions, OpenClawLLMRunnerFactoryOptions } from // Standalone adapter export { StandaloneHostAdapter, StandaloneLLMRunner, StandaloneLLMRunnerFactory } from "./standalone/index.js"; export type { StandaloneHostAdapterOptions, StandaloneLLMConfig, StandaloneLLMRunnerFactoryOptions } from "./standalone/index.js"; + +// Dify Workflow adapter +export { DifyWorkflowMemoryAdapter, createDifyWorkflowMemoryAdapter } from "./dify/index.js"; +export type { + DifyCaptureResult, + DifyGatewayHttpFetch, + DifyGatewayHttpOptions, + DifyGatewayHttpRequestInit, + DifyGatewayHttpResponse, + DifyGatewayMemoryPort, + DifyRecallResult, + DifyWorkflowInput, + DifyWorkflowMemoryAdapterOptions, +} from "./dify/index.js";