diff --git a/packages/typescript/README.md b/packages/typescript/README.md index bebbde68..1fcd51ae 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -12,8 +12,8 @@ selected adapter: reme start workspace_dir=/absolute/path/to/workspace ``` -The default endpoint is `http://127.0.0.1:2333`. All entries support `REME_URL`, or `REME_HOST` plus `REME_PORT`, and an -optional `REME_API_KEY` bearer token. +The default endpoint is `http://127.0.0.1:2333`. All entries support `REME_URL`, or `REME_HOST` plus `REME_PORT`. +ReMe's HTTP service does not use API-key authentication. ## DeepSeek Harness @@ -46,21 +46,21 @@ Configure the bundle by replacing its row in the profile's `cordis.patch.yml`: On the DSH Web profile, the same fields are available under **Settings → Plugins → Plugin configuration → ReMe Memory**. Changes are stored in DSH's user settings document and apply to subsequent requests and captures. Changing the daily dream controls reschedules the next run; changing the guidance language affects newly started sessions. -Deployment-only `apiKey` and `dreamIntervalMs` values remain outside the user-settings section. +The test-only `dreamIntervalMs` value remains outside the user-settings section. -| Option | Default | Meaning | -| --------------------- | ----------------------- | ------------------------------------------- | -| `endpoint` | `http://127.0.0.1:2333` | ReMe HTTP service URL | -| `language` | `en` | Memory guidance language: `en` or `zh` | -| `autoMemoryEnabled` | `true` | Capture completed main-agent turns | -| `autoMemoryInterval` | `5` | Submit after this many completed turns | -| `autoDreamEnabled` | `true` | Enable daily dream maintenance | -| `dreamCron` | `0 23 * * *` | Daily schedule in the DSH process timezone | -| `rootAgentsOnly` | `true` | Exclude subagents from guidance and capture | -| `requestTimeoutMs` | `10000` | Search request timeout | -| `backgroundTimeoutMs` | `3600000` | Automatic-memory and dream timeout | -| `shutdownTimeoutMs` | `5000` | Best-effort shutdown drain budget | -| `timezone` | `Asia/Shanghai` | IANA timezone used for daily batches | +| Option | Default | Meaning | +| --------------------- | ----------------------- | --------------------------------------------- | +| `endpoint` | `http://127.0.0.1:2333` | ReMe HTTP service URL | +| `language` | `en` | Memory guidance language: `en` or `zh` | +| `autoMemoryEnabled` | `true` | Capture completed main-agent turns | +| `autoMemoryInterval` | `5` | Submit after this many completed turns | +| `autoDreamEnabled` | `true` | Enable daily dream maintenance | +| `dreamCron` | `0 23 * * *` | Daily schedule in the workspace timezone | +| `rootAgentsOnly` | `true` | Exclude subagents from guidance and capture | +| `requestTimeoutMs` | `10000` | Search request timeout | +| `backgroundTimeoutMs` | `3600000` | Automatic-memory and dream timeout | +| `shutdownTimeoutMs` | `5000` | Best-effort shutdown drain budget | +| `timezone` | `Asia/Shanghai` | IANA timezone used for batches and scheduling | The ReMe card reads the service's `health_check` and `status` jobs on demand. It shows the ReMe version, component health, chunk/index counts, process RSS, and estimated component memory; it can also display the redacted `app_config` @@ -68,10 +68,6 @@ response and trigger one `auto_dream` run. Diagnostics are refreshed when the ca not polled continuously. The page calls the configured ReMe HTTP endpoint from the local browser, so that service must remain browser-reachable and allow the DSH origin. -The current ReMe HTTP service does not authenticate its job routes. `apiKey` only adds an `Authorization: Bearer ...` -header for a deployment whose reverse proxy requires it; it is intentionally not exposed or returned by the DSH -settings page. - ## OpenClaw OpenClaw `2026.3.12` or later can install the same package: diff --git a/packages/typescript/README_ZH.md b/packages/typescript/README_ZH.md index 38241c30..5f903b6a 100644 --- a/packages/typescript/README_ZH.md +++ b/packages/typescript/README_ZH.md @@ -8,7 +8,7 @@ reme start workspace_dir=/absolute/path/to/workspace ``` -默认服务地址为 `http://127.0.0.1:2333`。所有入口均支持 `REME_URL`,也支持组合使用 `REME_HOST` 和 `REME_PORT`;还可以通过 `REME_API_KEY` 配置可选的 Bearer Token。 +默认服务地址为 `http://127.0.0.1:2333`。所有入口均支持 `REME_URL`,也支持组合使用 `REME_HOST` 和 `REME_PORT`。ReMe HTTP 服务不使用 API Key 认证。 ## DeepSeek Harness @@ -18,7 +18,7 @@ reme start workspace_dir=/absolute/path/to/workspace dsh plugin --profile web add @agentscope-ai/reme ``` -安装后可在 **设置 → 插件 → 插件配置 → ReMe Memory** 中配置服务地址、记忆指引语言、自动记忆、每日记忆整理和超时时间,并查看服务健康状态。`apiKey` 属于部署级密钥,不会显示在设置页面中。 +安装后可在 **设置 → 插件 → 插件配置 → ReMe Memory** 中配置服务地址、记忆指引语言、自动记忆、每日记忆整理和超时时间,并查看服务健康状态。每日记忆整理和每日批次统一使用配置的 workspace 时区。 完整配置项和 `cordis.patch.yml` 示例请参阅[英文文档](./README.md#deepseek-harness)。 diff --git a/packages/typescript/openclaw.plugin.json b/packages/typescript/openclaw.plugin.json index 5b5add21..eb0d53e0 100644 --- a/packages/typescript/openclaw.plugin.json +++ b/packages/typescript/openclaw.plugin.json @@ -8,11 +8,6 @@ "label": "ReMe endpoint", "placeholder": "http://127.0.0.1:2333" }, - "apiKey": { - "label": "ReMe API key", - "sensitive": true, - "advanced": true - }, "autoRecall": { "label": "Auto recall" }, @@ -29,7 +24,6 @@ "additionalProperties": false, "properties": { "endpoint": { "type": "string" }, - "apiKey": { "type": "string" }, "requestTimeoutMs": { "type": "integer", "minimum": 1000, diff --git a/packages/typescript/src/core/client.ts b/packages/typescript/src/core/client.ts index 27bef6df..ff0bccbc 100644 --- a/packages/typescript/src/core/client.ts +++ b/packages/typescript/src/core/client.ts @@ -193,9 +193,6 @@ export class ReMeClient { method: "POST", headers: { "Content-Type": "application/json", - ...(config.apiKey - ? { Authorization: `Bearer ${config.apiKey}` } - : {}), }, body: JSON.stringify(payload), signal, diff --git a/packages/typescript/src/core/types.ts b/packages/typescript/src/core/types.ts index 00625332..15deb89a 100644 --- a/packages/typescript/src/core/types.ts +++ b/packages/typescript/src/core/types.ts @@ -1,7 +1,6 @@ /** Connection settings shared by every TypeScript host adapter. */ export interface ReMeClientConfig { endpoint: string; - apiKey: string; requestTimeoutMs: number; backgroundTimeoutMs: number; } diff --git a/packages/typescript/src/dsh/client/status-page.tsx b/packages/typescript/src/dsh/client/status-page.tsx index c89cf233..62958648 100644 --- a/packages/typescript/src/dsh/client/status-page.tsx +++ b/packages/typescript/src/dsh/client/status-page.tsx @@ -1171,7 +1171,6 @@ function isRuntimeSnapshot(value: unknown): value is ReMeRuntimeSnapshot { function diagnosticClient(settings: ReMeSettings): ReMeClient { return new ReMeClient({ endpoint: settings.endpoint, - apiKey: "", requestTimeoutMs: settings.requestTimeoutMs, backgroundTimeoutMs: settings.backgroundTimeoutMs, }); diff --git a/packages/typescript/src/dsh/config.ts b/packages/typescript/src/dsh/config.ts index e71fe9ff..c179a2a3 100644 --- a/packages/typescript/src/dsh/config.ts +++ b/packages/typescript/src/dsh/config.ts @@ -9,14 +9,13 @@ export const REME_SETTINGS_NAMESPACE = settingsNamespace("reme-memory"); export const Config = z.object({ endpoint: z.string().description("ReMe HTTP service URL"), - apiKey: z.string().role("secret").description("Optional ReMe bearer token"), 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 DSH process timezone"), + dreamCron: z.string().description("Daily cron in the workspace timezone"), dreamHint: z.string().default(""), dreamIntervalMs: z.natural().max(2147483647).default(0), rootAgentsOnly: z.boolean().default(true), @@ -40,7 +39,7 @@ export const SettingsConfig: z = z.object({ dreamCron: z .string() .required() - .description("Daily cron in the DSH process timezone"), + .description("Daily cron in the workspace timezone"), dreamHint: z.string().default(""), rootAgentsOnly: z.boolean().default(true), language: z.union(["en", "zh"]).default("en"), @@ -53,7 +52,6 @@ export const SettingsConfig: z = z.object({ const DEFAULT_CONFIG: Readonly = Object.freeze({ endpoint: "http://127.0.0.1:2333", - apiKey: "", requestTimeoutMs: 10000, backgroundTimeoutMs: 3600000, shutdownTimeoutMs: 5000, @@ -86,7 +84,6 @@ export function resolveConfig( ...DEFAULT_CONFIG, ...input, endpoint: input.endpoint || env.REME_URL || `http://${host}:${port}`, - apiKey: input.apiKey || env.REME_API_KEY || "", dreamCron: input.dreamCron || env.REME_DSH_DREAM_CRON || DEFAULT_CONFIG.dreamCron, }; @@ -130,17 +127,13 @@ 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); + nextDailyRun(config.dreamCron, config.timezone); return config; } /** Project the full plugin configuration into its user-editable settings section. */ export function settingsFrom(config: ReMeConfig): ReMeSettings { - const { - apiKey: _apiKey, - dreamIntervalMs: _dreamIntervalMs, - ...settings - } = config; + const { dreamIntervalMs: _dreamIntervalMs, ...settings } = config; return settings; } @@ -158,7 +151,7 @@ export function validateSettings(settings: ReMeSettings): void { if (!validTimezone(settings.timezone)) { throw new TypeError(`Invalid ReMe timezone: ${String(settings.timezone)}`); } - nextDailyRun(settings.dreamCron); + nextDailyRun(settings.dreamCron, settings.timezone); } function assertEndpoint(value: string): void { diff --git a/packages/typescript/src/dsh/runtime.ts b/packages/typescript/src/dsh/runtime.ts index 3de52abc..4033ff11 100644 --- a/packages/typescript/src/dsh/runtime.ts +++ b/packages/typescript/src/dsh/runtime.ts @@ -24,6 +24,7 @@ export class ReMeRuntime { readonly states = new Map(); private readonly configSource: () => ReMeConfig; private dreamTimer: ReturnType | null = null; + private dreamScheduleGeneration = 0; private dreamTask: Promise | null = null; private dreamController: AbortController | null = null; private started = false; @@ -168,7 +169,7 @@ export class ReMeRuntime { start(): void { this.started = true; if (!this.configSource().autoDreamEnabled || this.stopping) return; - this.scheduleDream(); + this.scheduleDream(this.dreamScheduleGeneration); } /** Apply a changed settings snapshot to pending batching and dream scheduling. */ @@ -179,21 +180,29 @@ export class ReMeRuntime { for (const state of this.states.values()) this.scheduleAutoMemory(state); } if (!this.started) return; + this.dreamScheduleGeneration += 1; if (this.dreamTimer) clearTimeout(this.dreamTimer); this.dreamTimer = null; this.nextDreamAt = undefined; - if (config.autoDreamEnabled) this.scheduleDream(); + if (config.autoDreamEnabled) + this.scheduleDream(this.dreamScheduleGeneration); } - private scheduleDream(): void { + private scheduleDream(generation: number): void { const config = this.configSource(); - if (this.stopping || !config.autoDreamEnabled) return; + if ( + generation !== this.dreamScheduleGeneration || + this.stopping || + !config.autoDreamEnabled + ) + return; let delay: number; try { delay = config.dreamIntervalMs > 0 ? config.dreamIntervalMs - : nextDailyRun(config.dreamCron).getTime() - Date.now(); + : nextDailyRun(config.dreamCron, config.timezone).getTime() - + Date.now(); } catch (error) { this.log("warn", "auto_dream_schedule_invalid", { error: error instanceof Error ? error.message : String(error), @@ -204,7 +213,7 @@ export class ReMeRuntime { this.dreamTimer = setTimeout(() => { this.dreamTimer = null; this.nextDreamAt = undefined; - void this.runDream().finally(() => this.scheduleDream()); + void this.runDream().finally(() => this.scheduleDream(generation)); }, delay); this.dreamTimer.unref?.(); } @@ -289,6 +298,7 @@ export class ReMeRuntime { async disposeAll(): Promise { this.stopping = true; this.started = false; + this.dreamScheduleGeneration += 1; if (this.dreamTimer) clearTimeout(this.dreamTimer); this.dreamTimer = null; this.nextDreamAt = undefined; diff --git a/packages/typescript/src/dsh/scheduler.ts b/packages/typescript/src/dsh/scheduler.ts index ca058158..e4aa2372 100644 --- a/packages/typescript/src/dsh/scheduler.ts +++ b/packages/typescript/src/dsh/scheduler.ts @@ -1,6 +1,10 @@ const DAILY_CRON = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/; -export function nextDailyRun(cron: string, now = new Date()): Date { +export function nextDailyRun( + cron: string, + timezone: string, + now = new Date(), +): Date { const match = DAILY_CRON.exec(String(cron || "").trim()); if (!match) { throw new Error( @@ -11,8 +15,32 @@ export function nextDailyRun(cron: string, now = new Date()): Date { const hour = Number(match[2]); if (minute > 59 || hour > 23) throw new Error("dreamCron contains an invalid hour or minute"); - const next = new Date(now.getTime()); - next.setHours(hour, minute, 0, 0); - if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1); - return next; + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "numeric", + hourCycle: "h23", + }); + const next = new Date(now.getTime() - 26 * 60 * 60 * 1000); + next.setUTCSeconds(0, 0); + const scheduledDays = new Set(); + for (let checked = 0; checked < 5 * 24 * 60; checked += 1) { + const parts = formatter.formatToParts(next); + const part = (type: Intl.DateTimeFormatPartTypes): string | undefined => + parts.find((candidate) => candidate.type === type)?.value; + const candidateHour = Number(part("hour")); + const candidateMinute = Number(part("minute")); + if (candidateHour === hour && candidateMinute === minute) { + const day = `${part("year")}-${part("month")}-${part("day")}`; + if (!scheduledDays.has(day)) { + scheduledDays.add(day); + if (next.getTime() > now.getTime()) return next; + } + } + next.setUTCMinutes(next.getUTCMinutes() + 1); + } + throw new Error("dreamCron has no occurrence in the scheduling window"); } diff --git a/packages/typescript/src/dsh/types.ts b/packages/typescript/src/dsh/types.ts index f15b47ac..ed5fdf0a 100644 --- a/packages/typescript/src/dsh/types.ts +++ b/packages/typescript/src/dsh/types.ts @@ -2,7 +2,6 @@ import type { ReMeClientConfig } from "../core/types.js"; export interface ReMeConfigInput { endpoint?: string; - apiKey?: string; requestTimeoutMs?: number; backgroundTimeoutMs?: number; shutdownTimeoutMs?: number; @@ -33,7 +32,7 @@ export interface ReMeConfig extends ReMeClientConfig { } /** ReMe integration fields owned by the DSH user-settings document. */ -export type ReMeSettings = Omit; +export type ReMeSettings = Omit; export interface SessionEvent { type: string; diff --git a/packages/typescript/src/openclaw/config.ts b/packages/typescript/src/openclaw/config.ts index a51226f8..ed05f6ba 100644 --- a/packages/typescript/src/openclaw/config.ts +++ b/packages/typescript/src/openclaw/config.ts @@ -10,7 +10,6 @@ export interface OpenClawReMeConfig extends ReMeClientConfig { const DEFAULT_CONFIG: Readonly = Object.freeze({ endpoint: "http://127.0.0.1:2333", - apiKey: "", requestTimeoutMs: 5000, backgroundTimeoutMs: 3600000, shutdownTimeoutMs: 5000, @@ -26,7 +25,6 @@ export const OPENCLAW_CONFIG_SCHEMA = { additionalProperties: false, properties: { endpoint: { type: "string" }, - apiKey: { type: "string" }, requestTimeoutMs: { type: "integer", minimum: 1000, maximum: 120000 }, backgroundTimeoutMs: { type: "integer", minimum: 1000, maximum: 3600000 }, shutdownTimeoutMs: { type: "integer", minimum: 100, maximum: 60000 }, @@ -60,7 +58,6 @@ export function resolveOpenClawConfig( } return { endpoint: normalizedEndpoint, - apiKey: stringValue(input.apiKey) || env.REME_API_KEY || "", requestTimeoutMs: integer( input.requestTimeoutMs, 1000, diff --git a/packages/typescript/src/openclaw/messages.ts b/packages/typescript/src/openclaw/messages.ts index 5858aa67..82738d92 100644 --- a/packages/typescript/src/openclaw/messages.ts +++ b/packages/typescript/src/openclaw/messages.ts @@ -42,9 +42,8 @@ function normalizeMessage( sessionId: string, index: number, ): ReMeMessage | null { - const text = messageText(value.content); - if (!text || text.includes('')) - return null; + const text = stripAutoRecallContext(messageText(value.content)); + if (!text) return null; const nativeId = typeof value.id === "string" && value.id ? value.id : `${index}\n${text}`; const createdAt = timestamp(value.created_at ?? value.timestamp); @@ -57,6 +56,15 @@ function normalizeMessage( }; } +function stripAutoRecallContext(value: string): string { + const opening = ''; + if (!value.startsWith(opening)) return value; + const closing = ""; + const end = value.indexOf(closing, opening.length); + if (end === -1) return value; + return value.slice(end + closing.length).trim(); +} + function messageText(content: unknown): string { if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) return ""; diff --git a/packages/typescript/tests/client.test.mjs b/packages/typescript/tests/client.test.mjs index 950819f8..0202e0d7 100644 --- a/packages/typescript/tests/client.test.mjs +++ b/packages/typescript/tests/client.test.mjs @@ -20,7 +20,6 @@ test("calls ReMe jobs with their native request and response envelopes", async ( endpoint: "http://127.0.0.1:2333", requestTimeoutMs: 1000, backgroundTimeoutMs: 1000, - apiKey: "", }); const result = await client.search("deployment", { limit: 5, minScore: 0 }); assert.equal(result.ok, true); @@ -49,7 +48,6 @@ test("combines caller cancellation with the request timeout", async () => { endpoint: "http://127.0.0.1:2333", requestTimeoutMs: 1000, backgroundTimeoutMs: 1000, - apiKey: "", }); const controller = new AbortController(); const request = client.search("deployment", { signal: controller.signal }); @@ -102,7 +100,6 @@ test("returns typed health, memory status, and redacted server configuration", a endpoint, requestTimeoutMs: 1000, backgroundTimeoutMs: 1000, - apiKey: "", })); const health = await client.healthCheck(); endpoint = "http://second.test"; @@ -149,7 +146,6 @@ test("lists and loads read-only ReMe workspace files", async () => { endpoint: "http://127.0.0.1:2333", requestTimeoutMs: 1000, backgroundTimeoutMs: 1000, - apiKey: "", }); const listing = await client.listFiles("daily", { limit: 1 }); const file = await client.loadFile("daily/2026-08-20/session.md"); diff --git a/packages/typescript/tests/config.test.mjs b/packages/typescript/tests/config.test.mjs index 1de5d1c5..0c169cbd 100644 --- a/packages/typescript/tests/config.test.mjs +++ b/packages/typescript/tests/config.test.mjs @@ -40,6 +40,10 @@ test("rejects unknown options and invalid IANA timezones", () => { () => resolveConfig({ timezone: "Mars/Olympus" }, {}), /Invalid ReMe timezone/, ); + assert.throws( + () => resolveConfig({ apiKey: "unsupported" }, {}), + /Unknown ReMe config option/, + ); }); test("normalizes bounded plugin configuration", () => { @@ -60,10 +64,9 @@ test("normalizes bounded plugin configuration", () => { assert.equal(config.rootAgentsOnly, false); }); -test("projects the editable DSH settings without deployment-only secrets", async () => { - const base = resolveConfig({ apiKey: "secret", dreamIntervalMs: 5000 }, {}); +test("projects editable DSH settings without test-only intervals", async () => { + const base = resolveConfig({ dreamIntervalMs: 5000 }, {}); const settings = settingsFrom(base); - assert.equal("apiKey" in settings, false); assert.equal("dreamIntervalMs" in settings, false); const validated = await SettingsConfig["~standard"].validate({ ...settings, @@ -71,7 +74,6 @@ test("projects the editable DSH settings without deployment-only secrets", async }); assert.equal(validated.issues, undefined); const merged = mergeSettings(base, validated.value); - assert.equal(merged.apiKey, "secret"); assert.equal(merged.searchLimit, 8); }); diff --git a/packages/typescript/tests/openclaw.test.mjs b/packages/typescript/tests/openclaw.test.mjs index a7fca20b..98710099 100644 --- a/packages/typescript/tests/openclaw.test.mjs +++ b/packages/typescript/tests/openclaw.test.mjs @@ -22,6 +22,10 @@ test("normalizes OpenClaw configuration and stable session ids", () => { () => resolveOpenClawConfig({ endpoint: "file:///tmp/reme" }, {}), /http\(s\)/, ); + assert.throws( + () => resolveOpenClawConfig({ apiKey: "unsupported" }, {}), + /Unknown ReMe config option/, + ); }); test("keeps the runtime schema aligned with the OpenClaw manifest", async () => { @@ -54,6 +58,27 @@ test("captures only the last OpenClaw user and assistant pair", () => { ); }); +test("removes recalled context while preserving the current OpenClaw prompt", () => { + const messages = captureLastTurn( + [ + { + role: "user", + content: + '\nremembered deployment\n\n\nremember blue', + }, + { role: "assistant", content: "noted" }, + ], + "session", + ); + assert.deepEqual( + messages.map((message) => [message.role, message.content[0].text]), + [ + ["user", "remember blue"], + ["assistant", "noted"], + ], + ); +}); + test("registers OpenClaw recall, capture, tool, and shutdown lifecycle", async () => { const originalFetch = globalThis.fetch; const calls = []; diff --git a/packages/typescript/tests/runtime.test.mjs b/packages/typescript/tests/runtime.test.mjs index c4e1f35f..3b035c14 100644 --- a/packages/typescript/tests/runtime.test.mjs +++ b/packages/typescript/tests/runtime.test.mjs @@ -302,6 +302,41 @@ test("applies changed batching and dream settings without replacing the runtime" await runtime.disposeAll(); }); +test("keeps one auto-dream schedule when reconfigured during a run", async () => { + let calls = 0; + let releaseFirst; + const firstRequest = new Promise((resolve) => { + releaseFirst = resolve; + }); + let config = { + ...CONFIG, + autoDreamEnabled: true, + dreamIntervalMs: 20, + shutdownTimeoutMs: 100, + }; + const runtime = new ReMeRuntime( + { + async autoDream() { + calls += 1; + if (calls === 1) await firstRequest; + return { ok: true }; + }, + }, + () => config, + silentLogger(), + ); + + runtime.start(); + await delay(25); + config = { ...config }; + runtime.reconfigure(); + releaseFirst(); + await delay(55); + + assert.ok(calls >= 2 && calls <= 3, `expected one schedule, got ${calls}`); + await runtime.disposeAll(); +}); + function completeTurn(runtime, session, turn, seq, time) { runtime.capture(session, { type: "turn/start", data: { turn } }); runtime.capture(session, { @@ -335,3 +370,7 @@ function completeTurn(runtime, session, turn, seq, time) { function silentLogger() { return { debug() {}, warn() {}, log() {} }; } + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/typescript/tests/scheduler.test.mjs b/packages/typescript/tests/scheduler.test.mjs index 88a67cd6..52d2714f 100644 --- a/packages/typescript/tests/scheduler.test.mjs +++ b/packages/typescript/tests/scheduler.test.mjs @@ -3,18 +3,62 @@ import test from "node:test"; import { nextDailyRun } from "../dist/dsh/scheduler.js"; test("computes today's or tomorrow's daily dream run", () => { - const before = new Date(2026, 7, 19, 22, 30, 0); - const today = nextDailyRun("0 23 * * *", before); - assert.equal(today.getDate(), 19); - assert.equal(today.getHours(), 23); + const before = new Date("2026-08-19T14:30:00Z"); + const today = nextDailyRun("0 23 * * *", "Asia/Shanghai", before); + assert.equal(today.toISOString(), "2026-08-19T15:00:00.000Z"); - const after = new Date(2026, 7, 19, 23, 30, 0); - const tomorrow = nextDailyRun("0 23 * * *", after); - assert.equal(tomorrow.getDate(), 20); - assert.equal(tomorrow.getHours(), 23); + const after = new Date("2026-08-19T15:30:00Z"); + const tomorrow = nextDailyRun("0 23 * * *", "Asia/Shanghai", after); + assert.equal(tomorrow.toISOString(), "2026-08-20T15:00:00.000Z"); +}); + +test("uses the configured IANA timezone instead of the process timezone", () => { + const now = new Date("2026-08-19T12:00:00Z"); + assert.equal( + nextDailyRun("0 23 * * *", "UTC", now).toISOString(), + "2026-08-19T23:00:00.000Z", + ); + assert.equal( + nextDailyRun("0 23 * * *", "America/Los_Angeles", now).toISOString(), + "2026-08-20T06:00:00.000Z", + ); +}); + +test("skips a nonexistent local time across daylight-saving changes", () => { + const beforeSpringForward = new Date("2026-03-08T09:00:00Z"); + assert.equal( + nextDailyRun( + "30 2 * * *", + "America/Los_Angeles", + beforeSpringForward, + ).toISOString(), + "2026-03-09T09:30:00.000Z", + ); +}); + +test("runs only once on a local date when daylight saving time repeats", () => { + const beforeFirstOccurrence = new Date("2026-11-01T07:00:00Z"); + assert.equal( + nextDailyRun( + "30 1 * * *", + "America/Los_Angeles", + beforeFirstOccurrence, + ).toISOString(), + "2026-11-01T08:30:00.000Z", + ); + + const afterFirstOccurrence = new Date("2026-11-01T08:45:00Z"); + assert.equal( + nextDailyRun( + "30 1 * * *", + "America/Los_Angeles", + afterFirstOccurrence, + ).toISOString(), + "2026-11-02T09:30:00.000Z", + ); }); test("rejects unsupported or invalid cron expressions", () => { - assert.throws(() => nextDailyRun("*/5 * * * *"), /daily form/); - assert.throws(() => nextDailyRun("99 23 * * *"), /invalid/); + assert.throws(() => nextDailyRun("*/5 * * * *", "UTC"), /daily form/); + assert.throws(() => nextDailyRun("99 23 * * *", "UTC"), /invalid/); });