mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-12 03:50:59 +00:00
fix(dsh): preserve settings and validate live configuration
This commit is contained in:
parent
7360d40887
commit
5887b4a07d
10 changed files with 204 additions and 19 deletions
|
|
@ -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`.
|
||||
|
||||

|
||||
|
||||
| UI meaning | Configuration key | Default | Description |
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
||||

|
||||
|
||||
截图中的测试配置使用 `http://127.0.0.1:3457`、English 指引、默认搜索数量 5、搜索超时 10 秒,并启用了自动记忆和“Exclude subagents”。完整字段如下:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue