fix(dsh): support DeepSeek Harness 0.1.7-rc.2 (#571)

* fix(dsh): support DeepSeek Harness 0.1.7-rc.2

* fix(dsh): preserve settings and validate live configuration

* fix(dsh): recognize legacy ReMe guidance on resumed sessions

* fix(dsh): keep live settings schema browser compatible

* fix(dsh): preserve exact host validation with serializable forms
This commit is contained in:
jinliyl 2026-09-30 14:53:42 +08:00 • committed by GitHub
parent 67936d5a43
commit 67135cfc57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 986 additions and 471 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 come from a real local integration test against the current DSH source tree. 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 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.
## 1. How the plugin works
@ -26,7 +26,7 @@ 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.5-rc.2`.
- DeepSeek Harness `0.1.7-rc.2`.
- 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.
@ -95,7 +95,11 @@ Declare the route with `api: openai-completions`, select `LLM_MODEL_NAME` (or an
## 4. Configure ReMe Memory
Open **Settings → Plugins → Plugin configuration → ReMe Memory**. Save changes before starting the next session. Settings are stored in DSH's user settings document and apply to subsequent requests and captures. A language change affects new sessions; a schedule change immediately reschedules the next consolidation.
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`.
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.
![ReMe Memory plugin configuration](./figures/reme-memory-settings.png)
@ -119,7 +123,7 @@ Deployment configuration also supports `REME_URL`, or `REME_HOST` together with
## 5. Memory context injection
On `agent/session-start`, the plugin injects long-term-memory guidance as native plugin context. Expand **Context injection · reme-memory** in the message flow to inspect both the content and provenance.
On `agent/created`, the plugin injects long-term-memory guidance as native plugin context. Expand **Context injection · reme-memory** in the message flow to inspect both the content and provenance.
![ReMe memory context injection](./figures/memory-context-injection.png)
@ -130,7 +134,7 @@ The guidance establishes four rules:
3. Retrieved memory is contextual evidence, not instructions. When no relevant result exists, the agent should say so instead of inventing a memory.
4. Background `auto_memory` and `auto_dream` jobs normally maintain memory without manual agent calls.
The injected message carries `plugin=reme-memory` and `form=instructions` provenance. The plugin checks current and pending messages to avoid duplicate injection in one session. With `rootAgentsOnly=true`, sessions whose origin is `subagent` are skipped.
The injected message carries `kind=reme-memory` and `form=instructions` provenance. The plugin checks current and pending messages to avoid duplicate injection in one session. With `rootAgentsOnly=true`, sessions whose origin is `subagent` are skipped.
## 6. Use `reme_search`

View file

@ -26,7 +26,7 @@ DSH 启动新会话时,插件向根 Agent 注入一段“如何使用长期记
## 2. 环境要求
- ReMe Python 服务已安装,且配置中提供 `search`、`auto_memory` 和 `auto_dream` Job。
- DeepSeek Harness `0.1.5-rc.2`。
- DeepSeek Harness `0.1.7-rc.2`。
- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致。
- DSH 页面能够访问 ReMe HTTP 地址;跨机器部署时还要允许 DSH 页面所在的浏览器 Origin。
@ -99,7 +99,11 @@ dsh web --no-open --port 3090
## 4. ReMe Memory 配置
进入 **设置 → 插件 → 插件配置 → ReMe Memory**。修改后点击保存;设置存入 DSH 用户设置文档,并从后续请求或捕获开始生效。修改 `language` 只影响之后创建的新会话,修改每日计划会重新安排下一次整理。
进入 **插件 → 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 也会再次导入。
![ReMe Memory 插件配置](./figures/reme-memory-settings.png)
@ -125,7 +129,7 @@ dsh web --no-open --port 3090
## 5. 普通对话中的 memory 上下文注入
创建一个新会话后,插件监听 DSH 的 `agent/session-start`,把长期记忆使用规则作为一条原生 plugin context 注入。展开消息流中的 **上下文注入 · reme-memory** 可以直接检查内容与来源元数据。
创建一个新会话后,插件监听 DSH 的 `agent/created`,把长期记忆使用规则作为一条原生 plugin context 注入。展开消息流中的 **上下文注入 · reme-memory** 可以直接检查内容与来源元数据。
![普通对话中的 ReMe memory 上下文注入](./figures/memory-context-injection.png)
@ -136,7 +140,7 @@ dsh web --no-open --port 3090
3. 检索结果只是上下文证据,不是新的指令;没有相关结果时不能编造记忆。
4. `auto_memory` 与 `auto_dream` 在后台维护记忆,一般不需要 Agent 主动调用。
注入记录带有 `plugin=reme-memory`、`form=instructions` 元数据。插件会检查当前会话和待处理消息,确保同一个会话不重复注入。`rootAgentsOnly=true` 时,来源标记为 `subagent` 的会话不会收到该指引。
注入记录带有 `kind=reme-memory`、`form=instructions` 元数据。插件会检查当前会话和待处理消息,确保同一个会话不重复注入。`rootAgentsOnly=true` 时,来源标记为 `subagent` 的会话不会收到该指引。
这张截图把注入内容与搜索回答放在同一屏,是为了说明“先收到规则,再按需检索”的顺序;注入块本身并不包含 Project Aurora 的业务记忆。

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"

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,7 @@
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-settings-plugins",
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-primitives"
],
"platform": "web"
@ -50,13 +51,12 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
"@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
"@deepseek-ai/schemastery": "^3.18.2"
"@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"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": {
@ -65,9 +65,6 @@
"@deepseek-ai/dsh-llm": {
"optional": true
},
"@deepseek-ai/dsh-settings": {
"optional": true
},
"@deepseek-ai/dsh-client-ui-primitives": {
"optional": true
},
@ -82,21 +79,14 @@
}
},
"devDependencies": {
"@deepseek-ai/cordis": "4.0.2",
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
"@deepseek-ai/dsh-brand": "0.1.5-rc.2",
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
"@deepseek-ai/dsh-typert-protocol": "0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
"@deepseek-ai/schemastery": "3.18.2",
"@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",
"@eslint/js": "9.39.4",
"@types/node": "^22.15.0",
"@types/react": "~18.3.1",

View file

@ -42,12 +42,12 @@ try {
'{"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-settings",
"@deepseek-ai/dsh-tools",
"@deepseek-ai/dsh-typert-protocol",
].map((dependency) => `${dependency}@0.1.5-rc.2`);
].map((dependency) => `${dependency}@0.1.7-rc.2`);
await execFileAsync(
"npm",
[

View file

@ -62,9 +62,12 @@ interface ClientContext {
},
): () => void;
};
settingsScope: { bind<T>(spec: { namespace: string }): SettingsScope<T> };
configForms: {
get<T>(namespace: string): SettingsScope<T>;
whileServed(namespaces: string[], register: () => () => void): () => void;
};
slots: {
inject(name: string, factory: () => unknown): void;
inject(name: string, factory: () => unknown): () => void;
register<Props>(
options: Record<string, unknown>,
component: (props: Props) => JSX.Element | null,
@ -91,6 +94,7 @@ interface Draft {
interface ReMeCardProps {
scope: SettingsScope<ReMeSettings>;
t: Translator;
view?: "summary" | "page";
}
const en = {
@ -244,7 +248,7 @@ const zh: typeof en = {
unhealthy: "异常",
};
export const inject = ["slots", "locale", "settingsScope", "connection"];
export const inject = ["slots", "locale", "configForms", "connection"];
export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind(NS);
@ -257,19 +261,24 @@ export function apply(ctx: ClientContext): void {
"remeMemory.statusLocale()",
);
ctx.effect(() => installStyles(), "remeMemory.settingsStyles()");
const scope = ctx.settingsScope.bind<ReMeSettings>({
namespace: SETTINGS_NS,
});
ctx.slots.inject("settings.plugin.item", () =>
ctx.slots.register(
{
name: "settings.plugin.item",
key: SETTINGS_NS,
locale: NS,
inject: () => ({ scope, t }),
},
ReMeSettingsCard,
),
const scope = ctx.configForms.get<ReMeSettings>(SETTINGS_NS);
ctx.effect(
() =>
ctx.configForms.whileServed([SETTINGS_NS], () =>
ctx.slots.inject("plugins.item", () =>
ctx.slots.register(
{
name: "plugins.item",
id: SETTINGS_NS,
label: () => t("title"),
locale: NS,
inject: () => ({ scope, t }),
},
ReMeSettingsCard,
),
),
),
"remeMemory.settingsCard()",
);
const statusT = ctx.locale.bind(STATUS_NS);
const { rpc } = ctx.get("connection");
@ -289,7 +298,11 @@ export function apply(ctx: ClientContext): void {
);
}
function ReMeSettingsCard({ scope, t }: ReMeCardProps): JSX.Element | null {
function ReMeSettingsCard({
scope,
t,
view,
}: ReMeCardProps): JSX.Element | null {
const snapshot = useSyncExternalStore(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
@ -303,6 +316,7 @@ function ReMeSettingsCard({ scope, t }: ReMeCardProps): JSX.Element | null {
const draft =
draftOverride ?? (value === undefined ? undefined : draftFrom(value));
if (view === "summary") return <>{t("description")}</>;
if (snapshot.status === "unavailable") return null;
const dirty =
resetAll ||
@ -361,7 +375,7 @@ function ReMeSettingsCard({ scope, t }: ReMeCardProps): JSX.Element | null {
};
return (
<li className={`reme-settings-card${open ? " open" : ""}`}>
<div className={`reme-settings-card${open ? " open" : ""}`}>
<button
type="button"
className="reme-settings-header"
@ -456,7 +470,7 @@ function ReMeSettingsCard({ scope, t }: ReMeCardProps): JSX.Element | null {
)}
</div>
) : null}
</li>
</div>
);
}

View file

@ -1,53 +1,74 @@
import z from "@deepseek-ai/schemastery";
import { nextDailyRun, validTimezone } from "./scheduling.js";
import type { ReMeConfig, ReMeConfigInput, ReMeSettings } from "./types.js";
import { validTimezone, validateDailyCron } from "./scheduling.js";
import type { ReMeConfig, ReMeConfigInput } from "./types.js";
/** Durable DSH settings section owned by the ReMe integration. */
export const REME_SETTINGS_NAMESPACE = "reme-memory";
const DEFAULT_ENDPOINT =
process.env.REME_URL ||
`http://${process.env.REME_HOST || "127.0.0.1"}:${
process.env.REME_PORT || "2333"
}`;
const DEFAULT_DREAM_CRON = process.env.REME_DSH_DREAM_CRON || "0 23 * * *";
export const Config = z.object({
endpoint: z.string().description("ReMe HTTP service URL"),
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000),
backgroundTimeoutMs: z.natural().min(1000).max(3600000).default(3600000),
shutdownTimeoutMs: z.natural().min(100).max(60000).default(5000),
autoMemoryEnabled: z.boolean().default(true),
autoMemoryInterval: z.natural().min(1).max(1000).default(5),
autoDreamEnabled: z.boolean().default(true),
dreamCron: z.string().description("Daily cron in the workspace timezone"),
dreamHint: z.string().default(""),
endpoint: checkedString(
DEFAULT_ENDPOINT,
"ReMe HTTP service URL",
assertEndpoint,
),
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(),
backgroundTimeoutMs: z
.natural()
.min(1000)
.max(3600000)
.default(3600000)
.volatile(),
shutdownTimeoutMs: z.natural().min(100).max(60000).default(5000).volatile(),
autoMemoryEnabled: z.boolean().default(true).volatile(),
autoMemoryInterval: z.natural().min(1).max(1000).default(5).volatile(),
autoDreamEnabled: z.boolean().default(true).volatile(),
dreamCron: checkedString(
DEFAULT_DREAM_CRON,
"Daily cron in the workspace timezone",
validateDailyCron,
),
dreamHint: z.string().default("").volatile(),
dreamIntervalMs: z.natural().max(2147483647).default(0),
rootAgentsOnly: z.boolean().default(true),
language: z.union(["en", "zh"]).default("en"),
searchLimit: z.natural().min(1).max(50).default(5),
timezone: z
.string()
.default("Asia/Shanghai")
.description("IANA timezone matching the ReMe workspace"),
rootAgentsOnly: z.boolean().default(true).volatile(),
language: z.union(["en", "zh"]).default("en").volatile(),
searchLimit: z.natural().min(1).max(50).default(5).volatile(),
timezone: checkedString(
"Asia/Shanghai",
"IANA timezone matching the ReMe workspace",
(value) => {
if (!validTimezone(value))
throw new TypeError(`Invalid ReMe timezone: ${value}`);
},
),
});
/** User-editable subset of the DSH integration configuration. */
export const SettingsConfig: z<ReMeSettings> = z.object({
endpoint: z.string().required().description("ReMe HTTP service URL"),
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000),
backgroundTimeoutMs: z.natural().min(1000).max(3600000).default(3600000),
shutdownTimeoutMs: z.natural().min(100).max(60000).default(5000),
autoMemoryEnabled: z.boolean().default(true),
autoMemoryInterval: z.natural().min(1).max(1000).default(5),
autoDreamEnabled: z.boolean().default(true),
dreamCron: z
function checkedString(
defaultValue: string,
description: string,
check: (value: string) => void,
) {
const form = z
.string()
.required()
.description("Daily cron in the workspace timezone"),
dreamHint: z.string().default(""),
rootAgentsOnly: z.boolean().default(true),
language: z.union(["en", "zh"]).default("en"),
searchLimit: z.natural().min(1).max(50).default(5),
timezone: z
.string()
.default("Asia/Shanghai")
.description("IANA timezone matching the ReMe workspace"),
});
.description(description)
.default(defaultValue)
.volatile();
const host = z
.transform(z.string(), (value) => {
check(value);
return value;
})
.description(description)
.default(defaultValue)
.volatile();
// DSH sends Config.toJSON() to the browser; keep Host validation while serializing a plain form field.
host.toJSON = () => form.toJSON();
return host;
}
const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
endpoint: "http://127.0.0.1:2333",
@ -70,6 +91,17 @@ export function resolveConfig(
input: ReMeConfigInput = {},
env: Record<string, string | undefined> = process.env,
): ReMeConfig {
input = Object.fromEntries(
Object.entries(input).map(([key, value]) => [
key,
value !== null &&
typeof value === "object" &&
"get" in value &&
typeof value.get === "function"
? value.get()
: value,
]),
) as ReMeConfigInput;
const unknownKeys = Object.keys(input).filter(
(key) => !(key in DEFAULT_CONFIG),
);
@ -126,33 +158,10 @@ 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;
}
/** Project the full plugin configuration into its user-editable settings section. */
export function settingsFrom(config: ReMeConfig): ReMeSettings {
const { dreamIntervalMs: _dreamIntervalMs, ...settings } = config;
return settings;
}
/** Layer current DSH user settings over fixed deployment-only values. */
export function mergeSettings(
base: ReMeConfig,
settings: ReMeSettings,
): ReMeConfig {
return { ...base, ...settings };
}
/** Reject a settings section the integration cannot use. */
export function validateSettings(settings: ReMeSettings): void {
assertEndpoint(settings.endpoint);
if (!validTimezone(settings.timezone)) {
throw new TypeError(`Invalid ReMe timezone: ${String(settings.timezone)}`);
}
nextDailyRun(settings.dreamCron, settings.timezone);
}
function assertEndpoint(value: string): void {
let endpoint: URL;
try {

View file

@ -25,6 +25,12 @@ export function memoryGuidance(language: "en" | "zh" = "en"): string {
export const REME_PLUGIN_SOURCE = "reme-memory";
declare module "@deepseek-ai/dsh-llm" {
interface MessageSourceMap {
"reme-memory": { kind: "reme-memory"; form: "instructions" };
}
}
export function hasGuidance(
session: DshSession,
pendingMessages: readonly unknown[] = [],
@ -40,8 +46,8 @@ function isGuidance(value: unknown): boolean {
const source = isRecord(value) ? value.source : undefined;
return (
isRecord(source) &&
source.kind === "plugin" &&
source.plugin === REME_PLUGIN_SOURCE &&
(source.kind === REME_PLUGIN_SOURCE ||
(source.kind === "plugin" && source.plugin === REME_PLUGIN_SOURCE)) &&
source.form === "instructions"
);
}

View file

@ -1,47 +1,23 @@
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import type { Context } from "@deepseek-ai/cordis";
import type {} from "@deepseek-ai/dsh-settings";
import type {} from "@deepseek-ai/cordis-plugin-loader";
import { ReMeClient } from "./reme/client.js";
import {
mergeSettings,
REME_SETTINGS_NAMESPACE,
resolveConfig,
SettingsConfig,
settingsFrom,
validateSettings,
} from "./config.js";
import { resolveConfig } from "./config.js";
import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js";
import { ReMeRuntime } from "./runtime.js";
import { ReMeStatusGateway } from "./status-gateway.js";
import { registerReMeTools } from "./tools.js";
import type { ReMeConfigInput, ReMeSettings } from "./types.js";
import type { ReMeConfigInput } from "./types.js";
export const name = "reme-memory";
export const inject = ["agents", "sessions", "tools"];
export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
const base = resolveConfig(input);
const defaultSettings = settingsFrom(base);
let settingsSource: () => ReMeSettings = () => defaultSettings;
const current = () => mergeSettings(base, settingsSource());
let config = resolveConfig(input);
const current = () => config;
const client = new ReMeClient(current);
const runtime = new ReMeRuntime(client, current, ctx.logger);
ctx.inject(["settings"], (settingsCtx) => {
settingsCtx.settings.installSection(
ctx,
REME_SETTINGS_NAMESPACE,
SettingsConfig,
defaultSettings,
{
setSource(source) {
settingsSource = source;
},
onChange: () => runtime.reconfigure(),
validate: validateSettings,
},
);
});
ctx.provide("remeMemory", runtime);
void ctx.plugin(ReMeStatusGateway);
ctx.effect(
@ -54,7 +30,12 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
return () => runtime.disposeAll();
}, "remeMemory.lifecycle()");
ctx.on("agent/session-start", ({ agent }) => {
ctx.on("loader/volatile-update", () => {
config = resolveConfig(input);
runtime.reconfigure();
});
ctx.on("agent/created", ({ agent }): undefined => {
const config = current();
if (config.rootAgentsOnly && agent.session.header?.origin === "subagent")
return;
@ -71,8 +52,7 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
createUserMessage({
content: [{ type: "text", text: memoryGuidance(config.language) }],
source: {
kind: "plugin",
plugin: REME_PLUGIN_SOURCE,
kind: REME_PLUGIN_SOURCE,
form: "instructions",
},
}),
@ -92,4 +72,4 @@ export type {
ReMeRuntimeTask,
ReMeRuntimeTaskPhase,
} from "./runtime-status.js";
export { Config, REME_SETTINGS_NAMESPACE, SettingsConfig } from "./config.js";
export { Config } from "./config.js";

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(
@ -19,15 +21,16 @@ test("declares one installable DeepSeek Harness plugin", async () => {
for (const dependency of [
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-llm",
"@deepseek-ai/dsh-settings",
"@deepseek-ai/dsh-tools",
"@deepseek-ai/dsh-typert-protocol",
]) {
assert.equal(manifest.peerDependencies[dependency], "^0.1.5-rc.2");
assert.equal(manifest.peerDependencies[dependency], "0.1.7-rc.2");
assert.equal(manifest.peerDependenciesMeta[dependency]?.optional, true);
}
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/);
@ -44,9 +47,75 @@ 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, /settings\.plugin\.item/);
assert.match(bundle, /plugins\.item/);
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 () => {
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,13 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
Config,
mergeSettings,
resolveConfig,
SettingsConfig,
settingsFrom,
validateSettings,
} from "../dist/config.js";
import { Context } from "@deepseek-ai/cordis";
import Loader from "@deepseek-ai/cordis-plugin-loader";
import z from "@deepseek-ai/schemastery";
import { Config, resolveConfig } from "../dist/config.js";
test("resolves the established ReMe host and port environment", () => {
const config = resolveConfig(
@ -27,8 +23,53 @@ test("exports a Cordis schema that rejects invalid configuration", async () => {
const valid = await Config["~standard"].validate({ language: "zh" });
assert.equal(valid.issues, undefined);
assert.equal(valid.value.autoMemoryInterval, 5);
assert.equal(valid.value.shutdownTimeoutMs, 5000);
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" },
{ endpoint: "http://localhost:bad-port" },
{ endpoint: "http://localhost:65536" },
{ endpoint: "http://[:::]" },
{ endpoint: "http://999.0.0.1" },
{ dreamCron: "every night" },
{ timezone: "Mars/Olympus" },
]) {
assert.throws(() => Config["~standard"].validate(input));
}
for (const timezone of [
"US/Pacific",
"US/Eastern",
"Etc/GMT+8",
"Etc/GMT-8",
]) {
const result = Config["~standard"].validate({ timezone });
assert.equal(result.issues, undefined);
assert.equal(result.value.timezone.get(), timezone);
}
});
test("volatile settings survive Host projection and browser schema rehydration", () => {
const fields = Object.entries(Config.dict).flatMap(([name, schema]) => {
if (!schema.meta.volatile) return [];
const plain = new z(schema.toJSON());
delete plain.meta.volatile;
return [[name, plain]];
});
const hostForm = z.object(Object.fromEntries(fields));
const browserForm = new z(JSON.parse(JSON.stringify(hostForm.toJSON())));
const defaults = Config["~standard"].validate({}).value;
const values = Object.fromEntries(
fields.map(([name]) => [name, defaults[name].get()]),
);
assert.equal(browserForm["~standard"].validate(values).issues, undefined);
assert.equal(
browserForm["~standard"].validate({ ...values, timezone: "US/Pacific" })
.issues,
undefined,
);
});
test("rejects unknown options and invalid IANA timezones", () => {
@ -64,27 +105,72 @@ test("normalizes bounded plugin configuration", () => {
assert.equal(config.rootAgentsOnly, false);
});
test("projects editable DSH settings without test-only intervals", async () => {
const base = resolveConfig({ dreamIntervalMs: 5000 }, {});
const settings = settingsFrom(base);
assert.equal("dreamIntervalMs" in settings, false);
const validated = await SettingsConfig["~standard"].validate({
...settings,
searchLimit: 8,
});
assert.equal(validated.issues, undefined);
const merged = mergeSettings(base, validated.value);
assert.equal(merged.searchLimit, 8);
});
test("rejects settings that cannot be scheduled or reached", () => {
const settings = settingsFrom(resolveConfig({}, {}));
assert.throws(
() => validateSettings({ ...settings, endpoint: "file:///tmp/reme" }),
() => resolveConfig({ endpoint: "file:///tmp/reme" }),
/absolute http/,
);
assert.throws(
() => validateSettings({ ...settings, dreamCron: "every night" }),
() => resolveConfig({ dreamCron: "every night" }),
/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",
autoMemoryEnabled: false,
autoDreamEnabled: false,
timezone: "US/Pacific",
},
});
const entry = ctx.loader.resolve(id);
await entry.fiber.await();
for (const patch of [
{ endpoint: "not-a-url" },
{ endpoint: "http://localhost:65536" },
{ endpoint: "http://999.0.0.1" },
{ dreamCron: "every night" },
{ timezone: "Mars/Olympus" },
]) {
await entry.update({
config: {
endpoint: "http://valid.test",
autoMemoryEnabled: true,
autoDreamEnabled: false,
timezone: "US/Pacific",
...patch,
},
});
assert.equal(live.endpoint.get(), "http://valid.test");
assert.equal(live.autoMemoryEnabled.get(), false);
assert.equal(live.dreamCron.get(), "0 23 * * *");
assert.equal(live.timezone.get(), "US/Pacific");
}
await entry.update({
config: {
endpoint: "http://updated.test",
autoMemoryEnabled: false,
autoDreamEnabled: false,
timezone: "US/Pacific",
},
});
assert.equal(live.endpoint.get(), "http://updated.test");
} finally {
await ctx.fiber.dispose();
}
});

View file

@ -1,6 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { apply } from "../dist/index.js";
import { apply, Config } from "../dist/index.js";
import { hasGuidance } from "../dist/guidance.js";
test("recognizes guidance already stored by the previous DSH plugin", () => {
const legacy = {
role: "user",
source: { kind: "plugin", plugin: "reme-memory", form: "instructions" },
};
assert.equal(
hasGuidance({ events: [{ type: "user/message", data: legacy }] }),
true,
);
assert.equal(hasGuidance({ events: [] }, [legacy]), true);
assert.equal(
hasGuidance({
events: [
{
type: "user/message",
data: {
source: { kind: "plugin", plugin: "other", form: "instructions" },
},
},
],
}),
false,
);
});
test("composes root-agent guidance and reme_search on supported DSH releases", async () => {
const handlers = new Map();
@ -59,13 +85,12 @@ test("composes root-agent guidance and reme_search on supported DSH releases", a
},
},
};
handlers.get("agent/session-start")({ agent, source: "startup" });
handlers.get("agent/created")({ agent, source: "startup" });
assert.equal(injected.length, 1);
assert.equal(injected[0].source.kind, "plugin");
assert.equal(injected[0].source.plugin, "reme-memory");
assert.equal(injected[0].source.kind, "reme-memory");
assert.match(injected[0].content[0].text, /长期记忆/);
handlers.get("agent/session-start")({ agent, source: "resume" });
handlers.get("agent/created")({ agent, source: "resume" });
assert.equal(injected.length, 1);
await Promise.all(agentCleanups.map((cleanup) => cleanup()));
@ -95,7 +120,7 @@ test("keeps prompt injection and capture out of subagents by default", async ()
};
apply(ctx, { autoDreamEnabled: false });
let injected = false;
handlers.get("agent/session-start")({
handlers.get("agent/created")({
agent: {
status: "idle",
session: { id: "child", header: { origin: "subagent" }, events: [] },
@ -113,10 +138,8 @@ test("keeps prompt injection and capture out of subagents by default", async ()
assert.equal(injected, false);
});
test("registers a ReMe settings namespace and reads changed values for new sessions", () => {
test("reads live DSH configuration for new sessions", async () => {
const handlers = new Map();
let section;
let notify;
const ctx = {
fiber: { state: 0 },
logger: { debug() {}, warn() {}, log() {} },
@ -135,31 +158,16 @@ test("registers a ReMe settings namespace and reads changed values for new sessi
on(name, handler) {
handlers.set(name, handler);
},
inject(names, callback) {
if (!names.includes("settings")) return;
const settingsCtx = {
settings: {
installSection(owner, ns, _schema, base, hooks) {
assert.equal(owner, ctx);
section = base;
assert.equal(String(ns), "reme-memory");
hooks.setSource(() => section);
notify = hooks.onChange;
},
},
};
callback(settingsCtx);
},
};
apply(ctx, {
const config = await Config["~standard"].validate({
autoMemoryEnabled: false,
autoDreamEnabled: false,
language: "en",
language: "zh",
});
section = { ...section, language: "zh" };
notify();
assert.equal(config.issues, undefined);
apply(ctx, config.value);
const injected = [];
handlers.get("agent/session-start")({
handlers.get("agent/created")({
agent: {
status: "idle",
session: { id: "settings-session", header: {}, events: [] },
@ -175,4 +183,63 @@ test("registers a ReMe settings namespace and reads changed values for new sessi
},
});
assert.match(injected[0].content[0].text, /长期记忆/);
config.value.language[Symbol.for("cosmokit.volatile.write")]("en");
handlers.get("loader/volatile-update")([["language"]]);
handlers.get("agent/created")({
agent: {
status: "idle",
session: { id: "updated-session", header: {}, events: [] },
inbox: { nextStep: [] },
inject(message) {
injected.push(message);
},
ctx: {
effect() {
return () => {};
},
},
},
});
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;
}
});

View file

@ -51,7 +51,7 @@ test("does not launder plugin context into memory", () => {
data: {
role: "user",
content: [{ type: "text", text: "recalled content" }],
source: { kind: "plugin", plugin: "reme-memory" },
source: { kind: "reme-memory", form: "instructions" },
},
},
"session-a",