diff --git a/integrations/dsh/README.md b/integrations/dsh/README.md index 842b8017..6eba5411 100644 --- a/integrations/dsh/README.md +++ b/integrations/dsh/README.md @@ -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 | diff --git a/integrations/dsh/README_ZH.md b/integrations/dsh/README_ZH.md index a35c2192..bf9a8d92 100644 --- a/integrations/dsh/README_ZH.md +++ b/integrations/dsh/README_ZH.md @@ -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”。完整字段如下: diff --git a/integrations/dsh/cordis.patch.yml b/integrations/dsh/cordis.patch.yml index 20b6b168..998b5609 100644 --- a/integrations/dsh/cordis.patch.yml +++ b/integrations/dsh/cordis.patch.yml @@ -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" diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index 35983955..4b903237 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -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 }), }, diff --git a/integrations/dsh/src/config.ts b/integrations/dsh/src/config.ts index 56569fc7..a232b8d2 100644 --- a/integrations/dsh/src/config.ts +++ b/integrations/dsh/src/config.ts @@ -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; } diff --git a/integrations/dsh/src/index.ts b/integrations/dsh/src/index.ts index 59c5d797..5e0d98e7 100644 --- a/integrations/dsh/src/index.ts +++ b/integrations/dsh/src/index.ts @@ -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(); }); diff --git a/integrations/dsh/src/scheduling.ts b/integrations/dsh/src/scheduling.ts index c3b03f8c..dd71f066 100644 --- a/integrations/dsh/src/scheduling.ts +++ b/integrations/dsh/src/scheduling.ts @@ -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 ' * * *'", + ); + 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 ' * * *'", - ); - } - 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", diff --git a/integrations/dsh/tests/bundle.test.mjs b/integrations/dsh/tests/bundle.test.mjs index 1dcf5b3e..abe80059 100644 --- a/integrations/dsh/tests/bundle.test.mjs +++ b/integrations/dsh/tests/bundle.test.mjs @@ -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"); +}); diff --git a/integrations/dsh/tests/config.test.mjs b/integrations/dsh/tests/config.test.mjs index 5301b376..7e661b33 100644 --- a/integrations/dsh/tests/config.test.mjs +++ b/integrations/dsh/tests/config.test.mjs @@ -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(); + } +}); diff --git a/integrations/dsh/tests/dsh-index.test.mjs b/integrations/dsh/tests/dsh-index.test.mjs index a641ac71..114b4c11 100644 --- a/integrations/dsh/tests/dsh-index.test.mjs +++ b/integrations/dsh/tests/dsh-index.test.mjs @@ -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; + } +});