fix(dsh): preserve settings and validate live configuration

This commit is contained in:
jinli.yl 2026-09-30 12:39:11 +08:00
parent 7360d40887
commit 5887b4a07d
10 changed files with 204 additions and 19 deletions

View file

@ -97,6 +97,8 @@ Declare the route with `api: openai-completions`, select `LLM_MODEL_NAME` (or an
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.
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`.
![ReMe Memory plugin configuration](./figures/reme-memory-settings.png)
| UI meaning | Configuration key | Default | Description |

View file

@ -101,6 +101,8 @@ dsh web --no-open --port 3090
进入 **插件 → ReMe Memory**。修改后点击保存;设置存入当前 DSH profile patch,并从后续请求或捕获开始生效。修改 `language` 只影响之后创建的新会话,修改每日计划会重新安排下一次整理。
从旧版 `settings.yaml` 升级时,DSH 会将其中的 `reme-memory` 设置导入 ReMe 运行条目。如果 profile patch 显式指定了原来的 `reme-memory-runtime` 条目,需要将该条目 ID 改为 `reme-memory`;外层 group 现为 `reme-memory-scope`。
![ReMe Memory 插件配置](./figures/reme-memory-settings.png)
截图中的测试配置使用 `http://127.0.0.1:3457`、English 指引、默认搜索数量 5、搜索超时 10 秒,并启用了自动记忆和“Exclude subagents”。完整字段如下:

View file

@ -1,9 +1,9 @@
- insert:
- id: reme-memory
- id: reme-memory-scope
name: "@deepseek-ai/cordis-plugin-group"
group: true
isolate:
remeMemory: true
config:
- id: reme-memory-runtime
- id: reme-memory
name: "@agentscope-ai/reme-dsh-plugin"

View file

@ -12,7 +12,7 @@ import { styles } from "./styles.js";
const NS = "reme.settings";
const STATUS_NS = "reme.status";
const SETTINGS_NS = "reme-memory-runtime";
const SETTINGS_NS = "reme-memory";
const FIELDS = [
"endpoint",
"requestTimeoutMs",
@ -270,6 +270,7 @@ export function apply(ctx: ClientContext): void {
{
name: "plugins.item",
id: SETTINGS_NS,
label: () => t("title"),
locale: NS,
inject: () => ({ scope, t }),
},

View file

@ -1,11 +1,14 @@
import z from "@deepseek-ai/schemastery";
import { nextDailyRun, validTimezone } from "./scheduling.js";
import { validTimezone, validateDailyCron } from "./scheduling.js";
import type { ReMeConfig, ReMeConfigInput } from "./types.js";
export const Config = z.object({
endpoint: z
.string()
.transform(z.string(), (value) => {
assertEndpoint(value);
return value;
})
.description("ReMe HTTP service URL")
.default(
process.env.REME_URL ||
@ -26,7 +29,10 @@ export const Config = z.object({
autoMemoryInterval: z.natural().min(1).max(1000).default(5).volatile(),
autoDreamEnabled: z.boolean().default(true).volatile(),
dreamCron: z
.string()
.transform(z.string(), (value) => {
validateDailyCron(value);
return value;
})
.description("Daily cron in the workspace timezone")
.default(process.env.REME_DSH_DREAM_CRON || "0 23 * * *")
.volatile(),
@ -36,7 +42,11 @@ export const Config = z.object({
language: z.union(["en", "zh"]).default("en").volatile(),
searchLimit: z.natural().min(1).max(50).default(5).volatile(),
timezone: z
.string()
.transform(z.string(), (value) => {
if (!validTimezone(value))
throw new TypeError(`Invalid ReMe timezone: ${value}`);
return value;
})
.default("Asia/Shanghai")
.description("IANA timezone matching the ReMe workspace")
.volatile(),
@ -130,7 +140,7 @@ export function resolveConfig(
config.language = config.language === "zh" ? "zh" : "en";
if (!validTimezone(config.timezone))
throw new TypeError(`Invalid ReMe timezone: ${String(config.timezone)}`);
nextDailyRun(config.dreamCron, config.timezone);
validateDailyCron(config.dreamCron);
return config;
}

View file

@ -14,8 +14,8 @@ export const name = "reme-memory";
export const inject = ["agents", "sessions", "tools"];
export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
resolveConfig(input);
const current = () => resolveConfig(input);
let config = resolveConfig(input);
const current = () => config;
const client = new ReMeClient(current);
const runtime = new ReMeRuntime(client, current, ctx.logger);
ctx.provide("remeMemory", runtime);
@ -31,6 +31,7 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
}, "remeMemory.lifecycle()");
ctx.on("loader/volatile-update", () => {
config = resolveConfig(input);
runtime.reconfigure();
});

View file

@ -2,22 +2,27 @@ import type { ReMeMessage } from "./reme/types.js";
const DAILY_CRON = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/;
/** Check the daily cron syntax without searching for its next occurrence. */
export function validateDailyCron(cron: string): void {
const match = DAILY_CRON.exec(String(cron || "").trim());
if (!match)
throw new Error(
"dreamCron must use the daily form '<minute> <hour> * * *'",
);
if (Number(match[1]) > 59 || Number(match[2]) > 23)
throw new Error("dreamCron contains an invalid hour or minute");
}
/** Resolve the next occurrence of ReMe's deliberately narrow daily cron form. */
export function nextDailyRun(
cron: string,
timezone: string,
now = new Date(),
): Date {
validateDailyCron(cron);
const match = DAILY_CRON.exec(String(cron || "").trim());
if (!match) {
throw new Error(
"dreamCron must use the daily form '<minute> <hour> * * *'",
);
}
const minute = Number(match[1]);
const hour = Number(match[2]);
if (minute > 59 || hour > 23)
throw new Error("dreamCron contains an invalid hour or minute");
const minute = Number(match![1]);
const hour = Number(match![2]);
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",

View file

@ -1,6 +1,8 @@
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";
test("declares one installable DeepSeek Harness plugin", async () => {
const manifest = JSON.parse(
@ -27,6 +29,8 @@ test("declares one installable DeepSeek Harness plugin", async () => {
}
assert.equal(manifest.peerDependencies.openclaw, undefined);
assert.match(patch, /remeMemory: true/);
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\n/);
assert.doesNotMatch(patch, /id: reme-memory-runtime/);
assert.doesNotMatch(patch, /@agentscope-ai\/reme\/dsh/);
assert.equal(patch.match(/@agentscope-ai\/reme-dsh-plugin/g)?.length, 1);
assert.doesNotMatch(patch, /reme-memory-client/);
@ -49,3 +53,69 @@ test("builds a lazy DSH browser module for the ReMe settings card", async () =>
assert.match(statusPage, /个人知识库/);
assert.match(bundle, /health_check/);
});
test("registers a visible title for the Plugins page", async () => {
const bundle = await readFile(
new URL("../dist/client.js", import.meta.url),
"utf8",
);
let module;
const document = {
head: { appendChild() {} },
createElement() {
return { dataset: {}, remove() {} };
},
};
vm.runInNewContext(bundle, {
document,
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),
);
const registrations = [];
const ctx = {
locale: {
bind() {
return (key) => (key === "title" ? "ReMe Memory" : key);
},
register() {
return () => {};
},
},
configForms: {
get() {
return {};
},
whileServed(_namespaces, register) {
return register();
},
},
slots: {
inject(_name, register) {
return register();
},
register(options) {
registrations.push(options);
return () => {};
},
},
get() {
return { rpc: {} };
},
effect(execute) {
return execute();
},
};
client.apply(ctx);
const item = registrations.find((options) => options.name === "plugins.item");
assert.equal(item.id, "reme-memory");
assert.equal(item.label(), "ReMe Memory");
});

View file

@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Context } from "@deepseek-ai/cordis";
import Loader from "@deepseek-ai/cordis-plugin-loader";
import { Config, resolveConfig } from "../dist/config.js";
test("resolves the established ReMe host and port environment", () => {
@ -23,6 +25,14 @@ test("exports a Cordis schema that rejects invalid configuration", async () => {
assert.equal(valid.value.autoMemoryInterval.get(), 5);
assert.equal(valid.value.shutdownTimeoutMs.get(), 5000);
assert.equal(resolveConfig(valid.value).language, "zh");
for (const input of [
{ endpoint: "not-a-url" },
{ dreamCron: "every night" },
{ timezone: "Mars/Olympus" },
]) {
assert.throws(() => Config["~standard"].validate(input));
}
});
test("rejects unknown options and invalid IANA timezones", () => {
@ -68,3 +78,47 @@ test("rejects settings that cannot be scheduled or reached", () => {
/daily form/,
);
});
test("Loader keeps the previous live values when an update fails schema validation", async () => {
const ctx = new Context();
let live;
try {
await ctx.plugin(Loader);
ctx.loader.builtins.remeTest = {
name: "reme-test",
Config,
apply(_owner, input) {
live = input;
},
};
const id = await ctx.loader.create({
id: "reme-memory",
name: "cordis:remeTest",
config: { endpoint: "http://valid.test", autoDreamEnabled: false },
});
const entry = ctx.loader.resolve(id);
await entry.fiber.await();
for (const patch of [
{ endpoint: "not-a-url" },
{ dreamCron: "every night" },
{ timezone: "Mars/Olympus" },
]) {
await entry.update({
config: {
endpoint: "http://valid.test",
autoDreamEnabled: false,
...patch,
},
});
assert.equal(live.endpoint.get(), "http://valid.test");
assert.equal(live.dreamCron.get(), "0 23 * * *");
assert.equal(live.timezone.get(), "Asia/Shanghai");
}
await entry.update({
config: { endpoint: "http://updated.test", autoDreamEnabled: false },
});
assert.equal(live.endpoint.get(), "http://updated.test");
} finally {
await ctx.fiber.dispose();
}
});

View file

@ -177,3 +177,43 @@ test("reads live DSH configuration for new sessions", async () => {
});
assert.match(injected[1].content[0].text, /Long-term Memory/);
});
test("session events read a validated configuration snapshot", () => {
const handlers = new Map();
const ctx = {
logger: { debug() {}, warn() {}, log() {} },
provide() {},
plugin() {
return Promise.resolve();
},
effect(execute) {
return execute();
},
tools: {
register() {
return () => {};
},
},
on(name, handler) {
handlers.set(name, handler);
},
};
apply(ctx, { autoMemoryEnabled: false, autoDreamEnabled: false });
const original = Intl.DateTimeFormat.prototype.formatToParts;
let scans = 0;
Intl.DateTimeFormat.prototype.formatToParts = function (...args) {
scans += 1;
return original.apply(this, args);
};
try {
for (let index = 0; index < 100; index += 1) {
handlers.get("session/event")(
{ id: "quiet", header: {}, events: [] },
{ type: "unrelated" },
);
}
assert.equal(scans, 0);
} finally {
Intl.DateTimeFormat.prototype.formatToParts = original;
}
});