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 MemoryCore/openclaw-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ openclaw gateway restart
| `recall.includePersona` | `true` | Include L3 core/profile |
| `recall.includeSceneNav` | `true` | Include L2 scene navigation |
| `capture.enabled` | `true` | Auto-capture completed turns |
| `storage.localDir` | _(empty)_ | Absolute path to a local scene-memory root. When set, `tdai_read_local` is registered instead of `tdai_read_cos` (COS-less deployments, issue #762) |
| `hooks.allowPromptInjection` | `true` | Only write on OpenClaw `>= 2026.4.24` |
| `hooks.allowConversationAccess` | `true` | Only write on OpenClaw `>= 2026.4.24`; required for L0 on non-bundled plugins |

Expand Down
3 changes: 2 additions & 1 deletion MemoryCore/openclaw-plugin/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ openclaw gateway restart
| `recall.includePersona` | `true` | 是否注入 L3 |
| `recall.includeSceneNav` | `true` | 是否注入 L2 场景导航 |
| `capture.enabled` | `true` | 是否自动捕获对话 |
| `storage.localDir` | (空) | 本地场景记忆根目录的绝对路径。设置时注册 `tdai_read_local` 替代 `tdai_read_cos`(无 COS 部署,见 issue #762) |
| `hooks.allowPromptInjection` | `true` | 仅在 OpenClaw `>= 2026.4.24` 写入 |
| `hooks.allowConversationAccess` | `true` | 仅在 OpenClaw `>= 2026.4.24` 写入;non-bundled 上 L0 必需 |

Expand Down Expand Up @@ -214,5 +215,5 @@ openclaw-plugin/
## 注意

- 仅客户端 adapter:不要在插件内启动 Memory Gateway 子进程,也不要在本地实现抽取逻辑。
- 无 COS/STS 的 standalone:`tdai_read_cos` 应返回可读错误;capture/recall 不得依赖它
- 无 COS/STS 的 standalone:设置 `storage.localDir` 后注册 `tdai_read_local`,直接从本地目录读取场景记忆;capture/recall 不依赖文件读取
- Gateway 启动与更广 SDK 示例见仓库根目录 README。
92 changes: 70 additions & 22 deletions MemoryCore/openclaw-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { performCapture } from "./src/hooks/capture.js";
import { handleMemorySearch } from "./src/tools/memory-search.js";
import { handleConversationSearch } from "./src/tools/conversation-search.js";
import { handleReadCos } from "./src/tools/read-cos.js";
import { handleReadLocal } from "./src/tools/read-local.js";
import type { ReadTool } from "./src/format.js";

const TAG = "[memory-client]";

Expand All @@ -37,10 +39,16 @@ interface RecallConfig {
interface CaptureConfig {
enabled?: boolean;
}
interface StorageConfig {
/** Absolute path to the local scene-memory root. When set, tdai_read_local
* is registered instead of tdai_read_cos (COS-less deployments, #762). */
localDir?: string;
}
interface PluginConfig {
server?: ServerConfig;
recall?: RecallConfig;
capture?: CaptureConfig;
storage?: StorageConfig;
}

// Matches OpenClaw plugin register() signature: export default function register(api)
Expand All @@ -50,6 +58,12 @@ export default function register(api: any) {
const server = cfg.server ?? {};
const recall = cfg.recall ?? {};
const capture = cfg.capture ?? {};
const storage = cfg.storage ?? {};

// Local scene-memory root. When set, the plugin reads scene detail from a
// local directory (tdai_read_local) instead of COS (tdai_read_cos).
const localDir = storage.localDir?.trim() ?? "";
const readTool: ReadTool = localDir ? "local" : "cos";

const serverUrl = server.url || "http://127.0.0.1:8420";
const apiKey = server.apiKey || "local";
Expand Down Expand Up @@ -88,7 +102,8 @@ export default function register(api: any) {
`${TAG} Initialized: server=${serverUrl}, instance=${instanceId}, ` +
`isolation(team=${teamId},agent=${agentId},user=${userId}), ` +
`recall(persona=${includePersona},sceneNav=${includeSceneNav},max=${recallMaxResults}), ` +
`capture=${captureEnabled}, cosRead=on, rejectUnauthorized=${rejectUnauthorized}`,
`capture=${captureEnabled}, read=${readTool}${localDir ? `(localDir=${localDir})` : ""}, ` +
`rejectUnauthorized=${rejectUnauthorized}`,
);

// ── Register Tools (same pattern as extensions/memory-tencentdb/index.ts) ──
Expand Down Expand Up @@ -138,31 +153,63 @@ export default function register(api: any) {
{ name: "tdai_conversation_search" },
);

api.registerTool(
{
name: "tdai_read_cos",
label: "Read Memory File",
description:
"Read a memory pipeline file from object storage by relative path " +
"(e.g. Scene Navigation paths like 'scene_blocks/xxx.md', or 'persona.md'). " +
"Uses STS credentials from the Memory Gateway.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Full relative storage key (e.g. 'scene_blocks/travel-plan.md' or 'persona.md').",
// Scene-detail read tool: register exactly one, depending on deployment.
// COS mode (default) → tdai_read_cos via STS; local mode → tdai_read_local
// reading a configured local directory. Never register a tool that is
// guaranteed to fail (issue #762).
if (localDir) {
api.registerTool(
{
name: "tdai_read_local",
label: "Read Local Memory File",
description:
"Read a local scene-memory file by relative path " +
"(e.g. Scene Navigation paths like 'scene_blocks/xxx.md', or 'persona.md'). " +
"Reads from the configured local storage directory.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Relative path under the local memory root (e.g. 'scene_blocks/travel-plan.md' or 'persona.md').",
},
},
required: ["path"],
},
async execute(_toolCallId: string, params: Record<string, unknown>) {
return handleReadLocal(localDir, params as any, api.logger);
},
required: ["path"],
},
async execute(_toolCallId: string, params: Record<string, unknown>) {
return handleReadCos(fileReader, params as any, api.logger);
{ name: "tdai_read_local" },
);
} else {
api.registerTool(
{
name: "tdai_read_cos",
label: "Read Memory File",
description:
"Read a memory pipeline file from object storage by relative path " +
"(e.g. Scene Navigation paths like 'scene_blocks/xxx.md', or 'persona.md'). " +
"Uses STS credentials from the Memory Gateway.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Full relative storage key (e.g. 'scene_blocks/travel-plan.md' or 'persona.md').",
},
},
required: ["path"],
},
async execute(_toolCallId: string, params: Record<string, unknown>) {
return handleReadCos(fileReader, params as any, api.logger);
},
},
},
{ name: "tdai_read_cos" },
);
{ name: "tdai_read_cos" },
);
}

// ── Register Hooks (api.on pattern, same as memory-tencentdb) ──

Expand Down Expand Up @@ -200,6 +247,7 @@ export default function register(api: any) {
maxResults: recallMaxResults,
includePersona,
includeSceneNav,
readTool,
}, api.logger);

// OpenClaw consumes the *return value* of before_prompt_build,
Expand Down
13 changes: 12 additions & 1 deletion MemoryCore/openclaw-plugin/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"tools": [
"tdai_memory_search",
"tdai_conversation_search",
"tdai_read_cos"
"tdai_read_cos",
"tdai_read_local"
]
},
"configSchema": {
Expand Down Expand Up @@ -89,6 +90,16 @@
"description": "是否启用自动对话捕获 (L0)"
}
}
},
"storage": {
"type": "object",
"description": "本地存储配置(无 COS 时从本地目录读取场景记忆,见 issue #762)",
"properties": {
"localDir": {
"type": "string",
"description": "本地场景记忆根目录的绝对路径。设置时注册 tdai_read_local 替代 tdai_read_cos;未设置时不注册本地读取工具"
}
}
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion MemoryCore/openclaw-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
"scripts": {
"clean": "rm -rf dist *.tgz",
"build": "tsc",
"test": "vitest run",
"prepack": "npm run clean && npm install --no-save --no-audit --no-fund && npm run build"
},
"dependencies": {
"@tencentdb-agent-memory/memory-sdk-ts-v2": "1.0.0-beta.2"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/node": "^22.0.0"
"@types/node": "^22.0.0",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22.0.0"
Expand Down
44 changes: 44 additions & 0 deletions MemoryCore/openclaw-plugin/src/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Tests for #762 — the memory-tools guide and Scene Navigation hint must
* reflect which scene-detail read tool is actually registered.
*/

import { describe, expect, it } from "vitest";
import { formatRecallResult, readToolName } from "./format.js";

const l1 = [{ id: "1", content: "prefers short replies", type: "preference" }];
const scenes = [{ path: "scene_blocks/career.md" }];

describe("readToolName (#762)", () => {
it("maps each deployment mode to the registered tool", () => {
expect(readToolName("cos")).toBe("tdai_read_cos");
expect(readToolName("local")).toBe("tdai_read_local");
expect(readToolName("none")).toBeNull();
});
});

describe("formatRecallResult readTool (#762)", () => {
it("guides tdai_read_local in local mode (never tdai_read_cos)", () => {
const out = formatRecallResult(l1, "Persona text", scenes, "local");
expect(out.appendSystemContext).toContain("tdai_read_local");
expect(out.appendSystemContext).not.toContain("tdai_read_cos");
// Scene Navigation hint points at the local tool.
expect(out.appendSystemContext).toContain("可使用 tdai_read_local 读取详细内容");
});

it("keeps tdai_read_cos guidance by default (COS mode)", () => {
const out = formatRecallResult(l1, null, scenes); // readTool defaults to "cos"
expect(out.appendSystemContext).toContain("tdai_read_cos");
expect(out.appendSystemContext).not.toContain("tdai_read_local");
expect(out.appendSystemContext).toContain("可使用 tdai_read_cos 读取详细内容");
});

it("omits read-tool guidance entirely when none is registered", () => {
const out = formatRecallResult(l1, null, scenes, "none");
expect(out.appendSystemContext).not.toContain("tdai_read_cos");
expect(out.appendSystemContext).not.toContain("tdai_read_local");
// Scene Navigation is still present but does not advertise a read tool.
expect(out.appendSystemContext).toContain("Scene Navigation");
expect(out.appendSystemContext).toContain("*以下是当前场景记忆索引。*");
});
});
36 changes: 29 additions & 7 deletions MemoryCore/openclaw-plugin/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,35 @@ interface SceneEntry {
}

// ── Memory Tools Guide ──
const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
/** Which scene-detail read tool is actually registered (see issue #762). */
export type ReadTool = "cos" | "local" | "none";

/** Tool name for the current deployment, or null when no read tool exists. */
export function readToolName(readTool: ReadTool): string | null {
if (readTool === "local") return "tdai_read_local";
if (readTool === "cos") return "tdai_read_cos";
return null;
}

function formatToolsGuide(readTool: ReadTool): string {
const tool = readToolName(readTool);
const readToolLine = tool
? `- **${tool}**:读取记忆文件详情(使用下方 Scene Navigation 中的完整相对路径,如 scene_blocks/xxx.md;也可读 persona.md)。\n`
: "";
return `<memory-tools-guide>
## 记忆工具调用指南

当上方注入的记忆片段不足以回答用户问题时,可主动调用以下工具获取更多信息:

- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件、规则等。
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节。
- **tdai_read_cos**:读取记忆文件详情(使用下方 Scene Navigation 中的完整相对路径,如 scene_blocks/xxx.md;也可读 persona.md)。

${readToolLine}
### ⚠️ 调用次数限制
每轮对话中,tdai_memory_search 和 tdai_conversation_search **合计最多调用 3 次**。
- 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户。
</memory-tools-guide>`;
}

/**
* Format L1 memories as prependContext.
Expand Down Expand Up @@ -65,6 +80,7 @@ function formatL1Memories(items: L1Item[]): string | undefined {
function formatSystemContext(
persona: string | null,
scenes: SceneEntry[],
readTool: ReadTool,
): string | undefined {
const parts: string[] = [];

Expand All @@ -79,16 +95,21 @@ function formatSystemContext(
if (scenes.length > 0 && (!persona || !persona.includes("Scene Navigation"))) {
parts.push("");
parts.push("## 🗺️ Scene Navigation");
parts.push("*以下是当前场景记忆索引,可使用 tdai_read_cos 读取详细内容。*");
const tool = readToolName(readTool);
parts.push(
tool
? `*以下是当前场景记忆索引,可使用 ${tool} 读取详细内容。*`
: "*以下是当前场景记忆索引。*",
);
parts.push("");
for (const scene of scenes) {
parts.push(`- \`${scene.path}\``);
}
}

// Tools guide (always append)
// Tools guide (always append; content depends on which tools are registered)
parts.push("");
parts.push(MEMORY_TOOLS_GUIDE);
parts.push(formatToolsGuide(readTool));

const result = parts.join("\n").trim();
return result || undefined;
Expand All @@ -101,9 +122,10 @@ export function formatRecallResult(
l1Items: L1Item[],
persona: string | null,
scenes: SceneEntry[],
readTool: ReadTool = "cos",
): RecallResult {
return {
prependContext: formatL1Memories(l1Items),
appendSystemContext: formatSystemContext(persona, scenes),
appendSystemContext: formatSystemContext(persona, scenes, readTool),
};
}
6 changes: 4 additions & 2 deletions MemoryCore/openclaw-plugin/src/hooks/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts-v2";
import { formatRecallResult } from "../format.js";
import { formatRecallResult, type ReadTool } from "../format.js";

const TAG = "[memory-client-v3][recall]";

Expand All @@ -19,6 +19,8 @@ export interface RecallOptions {
maxResults: number;
includePersona: boolean;
includeSceneNav: boolean;
/** Which scene-detail read tool is registered, guiding prompt injection. */
readTool?: ReadTool;
}

export interface RecallResult {
Expand Down Expand Up @@ -51,5 +53,5 @@ export async function performRecall(
`persona=${personaContent ? "yes" : "no"}, scenes=${sceneEntries.length}`,
);

return formatRecallResult(l1Items, personaContent, sceneEntries);
return formatRecallResult(l1Items, personaContent, sceneEntries, opts.readTool ?? "cos");
}
Loading