Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
143 changes: 143 additions & 0 deletions docs/DIFY-ADAPTER.md
Original file line number Diff line number Diff line change
@@ -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
```
16 changes: 16 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

/**
Expand Down
181 changes: 181 additions & 0 deletions src/adapters/dify/index.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading