fix(dsh): support 0.2.0 RC and current Harness settings (#629)

This commit is contained in:
jinliyl 2026-10-16 11:32:38 +08:00 • committed by GitHub
parent 0b5bb64f26
commit e478b68085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 834 additions and 684 deletions

View file

@ -4,7 +4,7 @@
This guide explains how to install, configure, and use `@agentscope-ai/reme-dsh-plugin` with DeepSeek Harness (DSH), including memory guidance injection, the `reme_search` tool, automatic memory, daily consolidation, and the ReMe Status page.
The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the current compatibility target is `0.1.7-rc.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots.
The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, and `0.2.1-alpha.4`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots.
## 1. How the plugin works
@ -26,12 +26,14 @@ The DSH adapter injects **usage guidance**, not every historical memory. Relevan
## 2. Requirements
- ReMe is installed and its configuration exposes the `search`, `auto_memory`, and `auto_dream` jobs.
- DeepSeek Harness `0.1.7-rc.2`.
- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, or `0.2.1-alpha.4`.
- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range.
- The browser running DSH can reach the configured ReMe HTTP endpoint. Cross-machine deployments must also allow the DSH browser origin.
The default ReMe endpoint is `http://127.0.0.1:2333`. ReMe HTTP does not use API-key authentication, so do not expose it directly to an untrusted network.
Peer ranges cover the tested prerelease lines and compatible stable `0.1.x` / `0.2.x` releases. Other prerelease lines require validation before being added; `0.3.x` is excluded.
## 3. Install and start
This package replaces the former `@agentscope-ai/reme/dsh` entry. Remove the combined package before installing the new
@ -100,11 +102,11 @@ Declare the route with `api: openai-completions`, select `LLM_MODEL_NAME` (or an
## 4. Configure ReMe Memory
Open **Plugins → ReMe Memory**. Save changes before starting the next session. Settings are stored in the active DSH profile patch and apply to subsequent requests and captures. A language change affects new sessions; a schedule change immediately reschedules the next consolidation.
Open **Plugins → ReMe bundle → reme-memory → Configure**. Save submits all edits together as one revision-checked atomic update. Save changes before starting the next session. Settings are stored in the active DSH profile patch and apply to subsequent requests and captures. A language change affects new sessions; a schedule change immediately reschedules the next consolidation.
On upgrade from the old `settings.yaml`, DSH imports its `reme-memory` section into the ReMe runtime entry. If a profile patch explicitly targets the former `reme-memory-runtime` entry, change that entry ID to `reme-memory`; the enclosing group is now `reme-memory-scope`.
If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported` while ReMe's import failed, DSH will not retry that file. Open `settings.yaml.imported` in the DSH home directory, find its `reme-memory` section, and compare those fields with **Plugins → ReMe Memory** in each affected profile. Copy the old values you still want into the form and save; keep any newer profile values. In particular, check `endpoint`, `autoMemoryEnabled`, and `autoDreamEnabled` before using the plugin. Do not rename the backup back to `settings.yaml`, since that would retry imports for unrelated sections too.
If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported` while ReMe's import failed, DSH will not retry that file. Open `settings.yaml.imported` in the DSH home directory, find its `reme-memory` section, and compare those fields with **Plugins → ReMe bundle → reme-memory → Configure** in each affected profile. Copy the old values you still want into the form and save; keep any newer profile values. In particular, check `endpoint`, `autoMemoryEnabled`, and `autoDreamEnabled` before using the plugin. Do not rename the backup back to `settings.yaml`, since that would retry imports for unrelated sections too.
![ReMe Memory plugin configuration](./figures/reme-memory-settings.png)

View file

@ -26,12 +26,14 @@ DSH 启动新会话时,插件向根 Agent 注入一段“如何使用长期记
## 2. 环境要求
- ReMe Python 服务已安装,且配置中提供 `search`、`auto_memory` 和 `auto_dream` Job。
- DeepSeek Harness `0.1.7-rc.2`。
- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2` 或 `0.2.1-alpha.4`。
- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致。
- DSH 页面能够访问 ReMe HTTP 地址;跨机器部署时还要允许 DSH 页面所在的浏览器 Origin。
ReMe HTTP 服务默认监听 `http://127.0.0.1:2333`,不使用 API Key 认证。因此不建议未经网络隔离直接暴露到公网。
peer 范围覆盖已测试的预发布系列及兼容的 `0.1.x` / `0.2.x` 正式版本;其他预发布系列需验证后再加入,不包含 `0.3.x`。
## 3. 安装与启动
本包替代原来的 `@agentscope-ai/reme/dsh` 入口。安装新的宿主专用插件前,请先移除旧的组合包。
@ -103,11 +105,11 @@ dsh web --no-open --port 3090
## 4. ReMe Memory 配置
进入 **插件 → ReMe Memory**。修改后点击保存;设置存入当前 DSH profile patch,并从后续请求或捕获开始生效。修改 `language` 只影响之后创建的新会话,修改每日计划会重新安排下一次整理。
进入 **插件 → ReMe 插件包 → reme-memory → 配置**。修改后点击保存,全部字段会作为一次带版本校验的原子更新提交;设置存入当前 DSH profile patch,并从后续请求或捕获开始生效。修改 `language` 只影响之后创建的新会话,修改每日计划会重新安排下一次整理。
从旧版 `settings.yaml` 升级时,DSH 会将其中的 `reme-memory` 设置导入 ReMe 运行条目。如果 profile patch 显式指定了原来的 `reme-memory-runtime` 条目,需要将该条目 ID 改为 `reme-memory`;外层 group 现为 `reme-memory-scope`。
如果此前升级已将 `settings.yaml` 改名为 `settings.yaml.imported`,但当时 ReMe 设置导入失败,DSH 不会再次导入这个文件。请打开 DSH home 目录中的 `settings.yaml.imported`,找到 `reme-memory` 段,并与每个受影响 profile 的 **插件 → ReMe Memory** 当前设置逐项比较。只将仍需保留的旧值填入表单并保存,保留更新后的 profile 设置。使用插件前尤其要核对 `endpoint`、`autoMemoryEnabled` 和 `autoDreamEnabled`。不要把备份文件改回 `settings.yaml`,否则其他 section 也会再次导入。
如果此前升级已将 `settings.yaml` 改名为 `settings.yaml.imported`,但当时 ReMe 设置导入失败,DSH 不会再次导入这个文件。请打开 DSH home 目录中的 `settings.yaml.imported`,找到 `reme-memory` 段,并与每个受影响 profile 的 **插件 → ReMe 插件包 → reme-memory → 配置** 当前设置逐项比较。只将仍需保留的旧值填入表单并保存,保留更新后的 profile 设置。使用插件前尤其要核对 `endpoint`、`autoMemoryEnabled` 和 `autoDreamEnabled`。不要把备份文件改回 `settings.yaml`,否则其他 section 也会再次导入。
![ReMe Memory 插件配置](./figures/reme-memory-settings.png)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "@agentscope-ai/reme-dsh-plugin",
"version": "0.1.1",
"version": "0.1.2",
"description": "ReMe memory and context integration for DeepSeek Harness",
"type": "module",
"main": "./dist/index.js",
@ -51,12 +51,12 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"@deepseek-ai/cordis": "~4.0.4",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.2",
"@deepseek-ai/dsh-llm": "0.1.7-rc.2",
"@deepseek-ai/dsh-tools": "0.1.7-rc.2",
"@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
"@deepseek-ai/schemastery": "~3.18.4"
"@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2",
"@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": {
@ -79,14 +79,14 @@
}
},
"devDependencies": {
"@deepseek-ai/cordis": "4.0.4",
"@deepseek-ai/cordis-plugin-loader": "1.0.5",
"@deepseek-ai/dsh-agent": "0.1.7-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.2",
"@deepseek-ai/dsh-llm": "0.1.7-rc.2",
"@deepseek-ai/dsh-tools": "0.1.7-rc.2",
"@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
"@deepseek-ai/schemastery": "3.18.4",
"@deepseek-ai/cordis": "4.0.5-alpha.2",
"@deepseek-ai/cordis-plugin-loader": "1.0.6-alpha.2",
"@deepseek-ai/dsh-agent": "0.2.1-alpha.4",
"@deepseek-ai/dsh-client-ui-primitives": "0.2.1-alpha.4",
"@deepseek-ai/dsh-llm": "0.2.1-alpha.4",
"@deepseek-ai/dsh-tools": "0.2.1-alpha.4",
"@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.4",
"@deepseek-ai/schemastery": "3.18.5-alpha.2",
"@eslint/js": "9.39.4",
"@types/node": "^22.15.0",
"@types/react": "~18.3.1",
@ -97,7 +97,13 @@
"prettier": "3.0.0",
"react": "^18.2.0",
"typescript": "^5.9.2",
"typescript-eslint": "8.59.3"
"typescript-eslint": "8.59.3",
"@deepseek-ai/dsh-session": "0.2.1-alpha.4",
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.4",
"@deepseek-ai/dsh-scope": "0.2.1-alpha.4",
"@deepseek-ai/dsh-sandbox": "0.2.1-alpha.4",
"react-test-renderer": "^18.3.1",
"semver": "^7.8.5"
},
"engines": {
"node": "^22.19.0 || >=24.0.0"

View file

@ -1,17 +1,59 @@
import { execFile } from "node:child_process";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const root = fileURLToPath(new URL("..", import.meta.url));
const manifest = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "reme-dsh-package-"),
);
const releases = [
{ dsh: "0.1.7-rc.2", cordis: "4.0.4", loader: "1.0.5", schema: "3.18.4" },
{ dsh: "0.2.0-rc.2", cordis: "4.0.4", loader: "1.0.5", schema: "3.18.4" },
{
dsh: "0.2.1-alpha.2",
cordis: "4.0.5-alpha.1",
loader: "1.0.6-alpha.1",
schema: "3.18.5-alpha.1",
},
{
dsh: "0.2.1-alpha.4",
cordis: "4.0.5-alpha.2",
loader: "1.0.6-alpha.2",
schema: "3.18.5-alpha.2",
},
];
async function run(command, args, cwd) {
try {
return await execFileAsync(command, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
} catch (error) {
process.stderr.write(error.stdout || "");
process.stderr.write(error.stderr || "");
throw error;
}
}
try {
const { stdout } = await execFileAsync(
const { stdout } = await run(
"npm",
[
"pack",
@ -20,7 +62,7 @@ try {
"--pack-destination",
temporaryDirectory,
],
{ cwd: new URL("..", import.meta.url) },
root,
);
const [result] = JSON.parse(stdout);
const files = new Set(result.files.map(({ path: file }) => file));
@ -35,45 +77,91 @@ try {
}
assert.ok(![...files].some((file) => file.includes("openclaw")));
const consumerDirectory = path.join(temporaryDirectory, "consumer");
await mkdir(consumerDirectory);
await writeFile(
path.join(consumerDirectory, "package.json"),
'{"name":"reme-dsh-package-consumer","private":true}\n',
);
const hostDependencies = [
"@deepseek-ai/dsh-agent",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-llm",
"@deepseek-ai/dsh-tools",
"@deepseek-ai/dsh-typert-protocol",
].map((dependency) => `${dependency}@0.1.7-rc.2`);
await execFileAsync(
"npm",
[
"install",
"--ignore-scripts",
"--no-audit",
"--no-fund",
path.join(temporaryDirectory, result.filename),
...hostDependencies,
],
{ cwd: consumerDirectory },
);
await execFileAsync(
process.execPath,
[
"--input-type=module",
"--eval",
for (const release of releases) {
const consumer = path.join(temporaryDirectory, release.dsh);
await mkdir(consumer);
const dependencies = Object.fromEntries(
Object.entries(manifest.devDependencies)
.filter(
([name]) =>
name.startsWith("@deepseek-ai/") ||
[
"typescript",
"esbuild",
"react",
"react-test-renderer",
"semver",
"@types/react",
"@types/node",
].includes(name),
)
.map(([name, version]) => [
name,
name.startsWith("@deepseek-ai/dsh-") ? release.dsh : version,
]),
);
Object.assign(dependencies, {
"@deepseek-ai/cordis": release.cordis,
"@deepseek-ai/cordis-plugin-loader": release.loader,
"@deepseek-ai/schemastery": release.schema,
[manifest.name]: path.join(temporaryDirectory, result.filename),
});
await writeFile(
path.join(consumer, "package.json"),
JSON.stringify({ private: true, type: "module", dependencies }),
);
// Install as a consumer with normal peer resolution, then check the shipped artifact.
await run(
"npm",
[
'const plugin = await import("@agentscope-ai/reme-dsh-plugin");',
'if (typeof plugin.apply !== "function") throw new Error("missing apply export");',
'if (!Array.isArray(plugin.inject)) throw new Error("missing inject export");',
'if (plugin.Config === undefined) throw new Error("missing Config export");',
].join("\n"),
],
{ cwd: consumerDirectory },
);
"install",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--fetch-retries=0",
"--fetch-timeout=30000",
],
consumer,
);
await run(
process.execPath,
[
"--input-type=module",
"--eval",
`
const plugin = await import(${JSON.stringify(manifest.name)});
if (typeof plugin.apply !== "function" || !Array.isArray(plugin.inject) || plugin.Config === undefined)
throw new Error("missing Host plugin exports");
`,
],
consumer,
);
const installed = path.join(consumer, "node_modules", manifest.name);
for (const file of [
"src",
"tests",
"tsconfig.json",
"scripts/build-client.mjs",
]) {
await cp(path.join(root, file), path.join(installed, file), {
recursive: true,
});
}
const testFiles = (await readdir(path.join(installed, "tests")))
.filter((file) => file.endsWith(".test.mjs"))
.map((file) => path.join("tests", file));
const tested = await run(
process.execPath,
["--test", ...testFiles],
installed,
);
await run("npm", ["run", "build"], installed);
const count = /# tests (\d+)/.exec(tested.stdout)?.[1];
process.stdout.write(
`DSH ${release.dsh}: packed import, source build, and ${count} tests passed\n`,
);
}
} finally {
await rm(temporaryDirectory, { force: true, recursive: true });
}

View file

@ -1,4 +1,5 @@
import { useState, useSyncExternalStore } from "react";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import type { ReMeSettings } from "../types.js";
import {
@ -32,22 +33,6 @@ const FIELDS = [
type Field = (typeof FIELDS)[number];
type Translator = (key: keyof typeof en) => string;
interface SettingsSnapshot<T> {
status: "loading" | "ready" | "unavailable";
value: T | undefined;
base: unknown;
user: unknown;
revision: number | undefined;
writable: boolean;
}
interface SettingsScope<T> {
getSnapshot(): SettingsSnapshot<T>;
subscribe(listener: () => void): () => void;
set(field: string, value: unknown): Promise<void>;
unset(field: string): Promise<void>;
}
interface ClientContext {
get(name: string): { rpc: StatusRpc };
effect(factory: () => (() => void) | void, label: string): void;
@ -63,7 +48,7 @@ interface ClientContext {
): () => void;
};
configForms: {
get<T>(namespace: string): SettingsScope<T>;
get<T>(namespace: string): ConfigForm<T>;
whileServed(namespaces: string[], register: () => () => void): () => void;
};
slots: {
@ -92,7 +77,7 @@ interface Draft {
}
interface ReMeCardProps {
scope: SettingsScope<ReMeSettings>;
scope: ConfigForm<ReMeSettings>;
t: Translator;
view?: "summary" | "page";
}
@ -101,8 +86,6 @@ const en = {
title: "ReMe Memory",
description:
"Turn conversations into lasting memory and grow your personal knowledge base.",
expand: "Expand ReMe Memory settings",
collapse: "Collapse ReMe Memory settings",
loading: "Loading configuration…",
unavailable: "ReMe settings are unavailable.",
connected: "Connected",
@ -176,8 +159,6 @@ const en = {
const zh: typeof en = {
title: "ReMe Memory",
description: "让对话沉淀为长期记忆,持续构建你的个人知识库。",
expand: "展开 ReMe Memory 设置",
collapse: "收起 ReMe Memory 设置",
loading: "正在加载配置…",
unavailable: "ReMe 设置不可用。",
connected: "已连接",
@ -265,11 +246,11 @@ export function apply(ctx: ClientContext): void {
ctx.effect(
() =>
ctx.configForms.whileServed([SETTINGS_NS], () =>
ctx.slots.inject("plugins.item", () =>
ctx.slots.inject("plugins.row.config", () =>
ctx.slots.register(
{
name: "plugins.item",
id: SETTINGS_NS,
name: "plugins.row.config",
key: "@agentscope-ai/reme-dsh-plugin#reme-memory",
label: () => t("title"),
locale: NS,
inject: () => ({ scope, t }),
@ -307,14 +288,17 @@ function ReMeSettingsCard({
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
);
const [open, setOpen] = useState(false);
const [draftOverride, setDraft] = useState<Draft>();
const [draftOverride, setDraft] = useState<{
value: Draft;
revision: number | undefined;
}>();
const [resetAll, setResetAll] = useState(false);
const [saving, setSaving] = useState(false);
const [saveState, setSaveState] = useState<"" | "saved" | "failed">("");
const value = snapshot.value;
const draft =
draftOverride ?? (value === undefined ? undefined : draftFrom(value));
draftOverride?.value ??
(value === undefined ? undefined : draftFrom(value));
if (view === "summary") return <>{t("description")}</>;
if (snapshot.status === "unavailable") return null;
@ -327,11 +311,10 @@ function ReMeSettingsCard({
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
if (value === undefined) return;
setDraft((current) =>
current === undefined
? { ...draftFrom(value), [field]: next }
: { ...current, [field]: next },
);
setDraft((current) => ({
value: { ...(current?.value ?? draftFrom(value)), [field]: next },
revision: current ? current.revision : snapshot.revision,
}));
setResetAll(false);
setSaveState("");
};
@ -341,135 +324,122 @@ function ReMeSettingsCard({
value === undefined ||
draft === undefined ||
validation === undefined ||
!snapshot.writable ||
!dirty ||
saving
)
return;
setSaving(true);
setSaveState("");
try {
if (resetAll) {
await Promise.all(FIELDS.map((field) => scope.unset(field)));
} else {
await Promise.all(
FIELDS.flatMap((field) =>
const ops = resetAll
? FIELDS.map((field) => ({ op: "unset" as const, path: [field] }))
: FIELDS.flatMap((field) =>
Object.is(value[field], validation[field])
? []
: [scope.set(field, validation[field])],
),
);
}
const accepted = scope.getSnapshot().value;
const expected = resetAll ? scope.getSnapshot().base : validation;
const success =
isRecord(expected) &&
accepted !== undefined &&
FIELDS.every((field) => Object.is(accepted[field], expected[field]));
: [
{
op: "set" as const,
path: [field],
value: validation[field],
},
],
);
const success = await scope.mutate(
ops,
draftOverride?.revision ?? snapshot.revision,
);
setSaveState(success ? "saved" : "failed");
if (success) {
setDraft(draftFrom(accepted));
setDraft(undefined);
setResetAll(false);
}
} catch {
setSaveState("failed");
} finally {
setSaving(false);
}
};
return (
<div className={`reme-settings-card${open ? " open" : ""}`}>
<button
type="button"
className="reme-settings-header"
aria-expanded={open}
aria-label={t(open ? "collapse" : "expand")}
onClick={() => setOpen(!open)}
>
<span className="reme-settings-title">
<strong>{t("title")}</strong>
<span>{t("description")}</span>
</span>
{dirty ? (
<span className="reme-settings-pending">{t("unsaved")}</span>
) : null}
<svg
className={`reme-settings-chevron${open ? " open" : ""}`}
viewBox="0 0 14 14"
aria-hidden="true"
>
<path d="m3 5.25 4 4 4-4" />
</svg>
</button>
{open ? (
<div className="reme-settings-body">
{snapshot.status === "loading" ||
draft === undefined ||
value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
<div className="reme-settings-card">
<div className="reme-settings-body">
{dirty ? <p className="reme-settings-pending">{t("unsaved")}</p> : null}
{snapshot.status === "loading" ||
draft === undefined ||
value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
<fieldset
className="reme-settings-fields"
disabled={saving || !snapshot.writable}
>
<SettingsForm draft={draft} update={update} t={t} />
{!snapshot.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{validation === undefined ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!snapshot.writable || saving}
onClick={() => {
setDraft(draftFrom(baseSettings(snapshot.base, value)));
setResetAll(true);
setSaveState("");
}}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{saveState === "failed" ? (
<span className="reme-settings-error">
{t("saveFailed")}
</span>
) : null}
{saveState === "saved" ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!dirty || saving}
onClick={() => {
setDraft(draftFrom(value));
setResetAll(false);
setSaveState("");
}}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!snapshot.writable ||
!dirty ||
validation === undefined ||
saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
</>
)}
</div>
) : null}
</fieldset>
{!snapshot.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{validation === undefined ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!snapshot.writable || saving}
onClick={() => {
setDraft({
value: draftFrom(baseSettings(snapshot.base, value)),
revision: snapshot.revision,
});
setResetAll(true);
setSaveState("");
}}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{saveState === "failed" ? (
<span className="reme-settings-error">{t("saveFailed")}</span>
) : null}
{saveState === "saved" ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!dirty || saving}
onClick={() => {
setDraft(undefined);
setResetAll(false);
setSaveState("");
}}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!snapshot.writable ||
!dirty ||
validation === undefined ||
saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
</>
)}
</div>
</div>
);
}

View file

@ -1,11 +1,9 @@
export const styles = `
.reme-settings-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}
.reme-settings-card:hover{border-color:var(--dsw-alias-label-dimmed)}.reme-settings-card.open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
.reme-settings-header{width:100%;appearance:none;border:0;border-radius:12px;background:none;color:inherit;display:flex;align-items:center;gap:12px;padding:14px 16px;text-align:left;font:inherit;cursor:pointer}.reme-settings-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.reme-settings-title{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.reme-settings-title strong{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}.reme-settings-title span,.reme-settings-muted{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.reme-settings-fields{min-width:0;margin:0;padding:0;border:0}
.reme-settings-card{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-2)}
.reme-settings-muted{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.reme-settings-pending{flex:none;border-radius:999px;padding:1px 8px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;font-weight:500;white-space:nowrap}
.reme-settings-chevron{width:14px;height:14px;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.4}.reme-settings-chevron.open{transform:rotate(180deg)}
.reme-settings-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.reme-settings-toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
.reme-settings-body{padding:0 16px 8px}.reme-settings-toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
.reme-settings-status{flex:1 0 100%;min-width:0;display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}.reme-settings-status+.reme-settings-button{margin-left:auto}.reme-settings-status .endpoint{color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.reme-settings-dot{width:8px;height:8px;flex:none;border-radius:50%;background:var(--dsw-alias-label-tertiary)}.reme-settings-dot.ok{background:#21a366}.reme-settings-dot.bad{background:var(--dsw-alias-label-error)}
.reme-settings-button,.reme-settings-discard,.reme-settings-save{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 12px;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:1.5;text-decoration:none;cursor:pointer}.reme-settings-button,.reme-settings-discard{border-color:var(--dsw-alias-border-l2)}.reme-settings-button:hover:not(:disabled),.reme-settings-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.reme-settings-save{padding-inline:14px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.reme-settings-button:disabled,.reme-settings-discard:disabled,.reme-settings-save:disabled,.reme-settings-reset:disabled{opacity:.4;cursor:default}.reme-settings-button:focus-visible,.reme-settings-discard:focus-visible,.reme-settings-save:focus-visible,.reme-settings-reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
.reme-settings-section{padding:14px 0 0}.reme-settings-section h4{margin:0;padding-bottom:8px;font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}.reme-settings-grid{display:flex;flex-direction:column}

View file

@ -1,6 +1,8 @@
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import type { Context } from "@deepseek-ai/cordis";
import type {} from "@deepseek-ai/cordis-plugin-loader";
import type {} from "@deepseek-ai/dsh-agent";
import type {} from "@deepseek-ai/dsh-session";
import { ReMeClient } from "./reme/client.js";
import { resolveConfig } from "./config.js";

View file

@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import test from "node:test";
import vm from "node:vm";
import semver from "semver";
test("declares one installable DeepSeek Harness plugin", async () => {
const manifest = JSON.parse(
@ -24,7 +25,28 @@ test("declares one installable DeepSeek Harness plugin", async () => {
"@deepseek-ai/dsh-tools",
"@deepseek-ai/dsh-typert-protocol",
]) {
assert.equal(manifest.peerDependencies[dependency], "0.1.7-rc.2");
for (const version of [
"0.1.7-rc.2",
"0.2.0-rc.2",
"0.2.1-alpha.2",
"0.2.1-alpha.4",
]) {
assert.ok(
semver.satisfies(version, manifest.peerDependencies[dependency]),
);
}
for (const version of [
"0.1.6",
"0.2.0-rc.1",
"0.2.2-alpha.1",
"0.3.0-alpha.1",
"0.3.0",
]) {
assert.equal(
semver.satisfies(version, manifest.peerDependencies[dependency]),
false,
);
}
assert.equal(manifest.peerDependenciesMeta[dependency]?.optional, true);
}
assert.equal(manifest.peerDependencies.openclaw, undefined);
@ -47,14 +69,14 @@ test("builds a lazy DSH browser module for the ReMe settings card", async () =>
);
assert.match(bundle, /window\.__ModuleLoader__\.load/);
assert.match(bundle, /id: "@agentscope-ai\/reme-dsh-plugin"/);
assert.match(bundle, /plugins\.item/);
assert.match(bundle, /plugins\.row\.config/);
assert.match(bundle, /reme-status/);
assert.match(bundle, /Personal Knowledge Base/);
assert.match(statusPage, /个人知识库/);
assert.match(bundle, /health_check/);
});
test("registers a visible title for the Plugins page", async () => {
test("registers configuration on the ReMe bundle row", async () => {
const bundle = await readFile(
new URL("../dist/client.js", import.meta.url),
"utf8",
@ -115,7 +137,9 @@ test("registers a visible title for the Plugins page", async () => {
},
};
client.apply(ctx);
const item = registrations.find((options) => options.name === "plugins.item");
assert.equal(item.id, "reme-memory");
const item = registrations.find(
(options) => options.name === "plugins.row.config",
);
assert.equal(item.key, "@agentscope-ai/reme-dsh-plugin#reme-memory");
assert.equal(item.label(), "ReMe Memory");
});

View file

@ -0,0 +1,193 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import test from "node:test";
import vm from "node:vm";
import React from "react";
import { act, create } from "react-test-renderer";
import { resolveConfig } from "../dist/config.js";
async function editor(mutate) {
let module;
const bundle = await readFile(
new URL("../dist/client.js", import.meta.url),
"utf8",
);
vm.runInNewContext(bundle, {
URL,
document: {
head: { appendChild() {} },
createElement: () => ({ dataset: {}, remove() {} }),
},
window: {
__ModuleLoader__: {
load(value) {
module = value;
},
},
},
});
const nodeRequire = createRequire(import.meta.url);
const client = module.factory((name) =>
name === "@deepseek-ai/dsh-client-ui-primitives" ? {} : nodeRequire(name),
);
let Component;
let snapshot = {
status: "ready",
value: resolveConfig({}, {}),
base: resolveConfig({}, {}),
revision: 7,
writable: true,
};
const listeners = new Set();
const calls = [];
const scope = {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async mutate(ops, revision) {
calls.push({ ops: JSON.parse(JSON.stringify(ops)), revision });
const accepted = await mutate(ops, revision);
if (accepted) {
const value = { ...snapshot.value };
for (const op of ops)
value[op.path[0]] =
op.op === "unset" ? snapshot.base[op.path[0]] : op.value;
update({ value, revision: snapshot.revision + 1 });
}
return accepted;
},
};
function update(next) {
snapshot = { ...snapshot, ...next };
for (const listener of listeners) listener();
}
const disposers = [];
client.apply({
locale: { bind: () => (key) => key, register: () => () => {} },
configForms: {
get: () => scope,
whileServed: (_names, register) => register(),
},
slots: {
inject: (_name, register) => register(),
register(options, component) {
if (options.name === "plugins.row.config") Component = component;
return () => {};
},
},
get: () => ({ rpc: {} }),
effect(factory) {
disposers.push(factory());
},
});
let renderer;
act(() => {
renderer = create(
React.createElement(Component, { scope, t: (key) => key, view: "page" }),
);
});
return {
calls,
update,
change(index, value) {
act(() => {
const input = renderer.root.findAllByType("input")[index];
input.props.onChange({ target: { value } });
});
},
button(label) {
return renderer.root
.findAllByType("button")
.find((button) => button.children.includes(label));
},
text: () => JSON.stringify(renderer.toJSON()),
close() {
act(() => renderer.unmount());
for (const dispose of disposers) dispose?.();
},
};
}
test("saves all edited fields in one revision-fenced mutation", async () => {
const ui = await editor(async () => true);
try {
ui.change(0, "http://memory.test");
ui.change(1, "8");
await act(async () => {
await ui.button("save").props.onClick();
});
assert.deepEqual(ui.calls, [
{
ops: [
{ op: "set", path: ["endpoint"], value: "http://memory.test" },
{ op: "set", path: ["searchLimit"], value: 8 },
],
revision: 7,
},
]);
assert.match(ui.text(), /saved/);
assert.equal(ui.button("save").props.disabled, true);
} finally {
ui.close();
}
});
for (const outcome of ["refused", "transport failure"]) {
test(`preserves edits after ${outcome} and keeps their original revision`, async () => {
const ui = await editor(async () => {
if (outcome === "transport failure") throw new Error("offline");
return false;
});
try {
ui.change(0, "http://memory.test");
act(() => ui.update({ revision: 8 }));
await act(async () => {
await ui.button("save").props.onClick();
});
assert.equal(ui.calls[0].revision, 7);
assert.match(ui.text(), /saveFailed/);
assert.match(ui.text(), /http:\/\/memory.test/);
assert.equal(ui.button("save").props.disabled, false);
act(() => ui.button("discard").props.onClick());
assert.doesNotMatch(ui.text(), /http:\/\/memory.test/);
} finally {
ui.close();
}
});
}
test("resets overrides in one mutation only after Save", async () => {
const ui = await editor(async () => true);
try {
act(() => ui.button("reset").props.onClick());
assert.equal(ui.calls.length, 0);
await act(async () => {
await ui.button("save").props.onClick();
});
assert.equal(ui.calls.length, 1);
assert.equal(ui.calls[0].ops.length, 13);
assert.ok(ui.calls[0].ops.every((op) => op.op === "unset"));
assert.equal(ui.calls[0].revision, 7);
} finally {
ui.close();
}
});
test("read-only configuration cannot be saved", async () => {
const ui = await editor(async () => true);
try {
ui.change(0, "http://memory.test");
act(() => ui.update({ writable: false }));
await act(async () => {
await ui.button("save").props.onClick();
});
assert.equal(ui.calls.length, 0);
assert.equal(ui.button("save").props.disabled, true);
assert.match(ui.text(), /readOnly/);
} finally {
ui.close();
}
});

View file

@ -75,7 +75,7 @@ def test_typescript_host_plugins_are_independent_packages() -> None:
assert manifests["dsh"]["name"] == "@agentscope-ai/reme-dsh-plugin"
assert manifests["openclaw"]["name"] == "@agentscope-ai/reme-openclaw-plugin"
assert manifests["dsh"]["version"] == "0.1.1"
assert manifests["dsh"]["version"] == "0.1.2"
assert manifests["openclaw"]["version"] == "0.1.1"
assert manifests["dsh"].get("dependencies", {}) == {}
assert manifests["openclaw"].get("dependencies", {}) == {"typebox": "1.3.19"}