@dsh-overdrive/gateway 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { parseCommand, HELP_TEXT, type ParsedCommand } from './commands.js';
7
7
  import { TrajectoryAggregator, formatTrajectorySummary } from './trajectory.js';
8
8
  import { createStatusServer } from './status.js';
9
9
  import { createTranscriber, type AsrTranscriber } from './asr.js';
10
+ import { MemoryStore, memoryScope, formatMemories } from './memory.js';
10
11
 
11
12
  /**
12
13
  * message.delta → 打字指示去重:同一 turn 内首个 delta 触发一次 typing,
@@ -69,9 +70,24 @@ export interface WireOptions {
69
70
  allowAll?: boolean;
70
71
  /** ASR 转写器;配置了 API key 时启用,语音消息转成文本再发给 agent。 */
71
72
  asr?: AsrTranscriber;
73
+ /** 记忆系统(OpenClaw 式长期记忆);未提供则记忆命令返回不可用。 */
74
+ memory?: MemoryStore;
72
75
  }
73
76
 
74
- /** 命令面分发:/trace /new /task /cron /agents /help(M4)。 */
77
+ /** 纯函数:一次性提醒的时间 cron 5 字段表达式(分钟精度)。 */
78
+ export function remindSchedule(minutes: number, atTime: string | null, now = new Date()): string {
79
+ const target = new Date(now);
80
+ if (atTime) {
81
+ const [h, m] = atTime.split(':').map(Number);
82
+ target.setHours(h, m, 0, 0);
83
+ if (target <= now) target.setDate(target.getDate() + 1); // 已过则明天
84
+ } else {
85
+ target.setMinutes(target.getMinutes() + minutes);
86
+ }
87
+ return `${target.getMinutes()} ${target.getHours()} ${target.getDate()} ${target.getMonth() + 1} *`;
88
+ }
89
+
90
+ /** 命令面分发:/trace /new /task /cron /agents /help /remind /remember /recall /forget(M4 + v0.3)。 */
75
91
  async function handleCommand(
76
92
  adapter: Adapter,
77
93
  client: GatewayClient,
@@ -79,6 +95,7 @@ async function handleCommand(
79
95
  sessionId: string,
80
96
  chatId: string,
81
97
  aggregator: TrajectoryAggregator,
98
+ memory: MemoryStore | undefined,
82
99
  ): Promise<void> {
83
100
  switch (command.kind) {
84
101
  case 'trace': {
@@ -104,7 +121,12 @@ async function handleCommand(
104
121
  case 'crons': {
105
122
  const res = await client.listTasks();
106
123
  const text = res.tasks.length
107
- ? res.tasks.map((task) => `- \`${task.id}\` ${task.schedule} — ${task.prompt}`).join('\n')
124
+ ? res.tasks.map((task) => {
125
+ const next = task.nextRunAt
126
+ ? new Date(task.nextRunAt).toLocaleString('zh-CN', { hour12: false })
127
+ : '(无下次触发)';
128
+ return `- \`${task.id}\` ${task.schedule} — ${task.prompt}(下次 ${next})`;
129
+ }).join('\n')
108
130
  : '暂无定时任务。';
109
131
  await adapter.send(chatId, { text: `⏰ 定时任务(${res.tasks.length}):\n${text}` });
110
132
  return;
@@ -120,6 +142,38 @@ async function handleCommand(
120
142
  await adapter.send(chatId, { text: '(M4 简化)子任务状态由 agent 汇报,/task 派发' });
121
143
  return;
122
144
  }
145
+ case 'remember': {
146
+ if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
147
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
148
+ const entry = memory.add(scope, command.text);
149
+ await adapter.send(chatId, { text: `✅ 已记住(\`${entry.id}\`):${command.text}` });
150
+ return;
151
+ }
152
+ case 'recall': {
153
+ if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
154
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
155
+ const hits = memory.search(scope, command.query);
156
+ if (hits.length === 0) { await adapter.send(chatId, { text: '没有相关记忆。' }); return; }
157
+ await adapter.send(chatId, {
158
+ text: `🧠 ${hits.length} 条记忆:\n` + hits.map((e) => `- \`${e.id}\` ${e.text}`).join('\n'),
159
+ });
160
+ return;
161
+ }
162
+ case 'forget': {
163
+ if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
164
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
165
+ const ok = memory.remove(scope, command.memoryId);
166
+ await adapter.send(chatId, { text: ok ? `🗑️ 已删除记忆 \`${command.memoryId}\`` : `未找到记忆 \`${command.memoryId}\`` });
167
+ return;
168
+ }
169
+ case 'remind': {
170
+ const schedule = remindSchedule(command.inMinutes ?? 0, command.atTime);
171
+ await client.createTask({ sessionId, kind: 'cron', prompt: `⏰ 提醒:${command.text}`, schedule, once: true });
172
+ await adapter.send(chatId, {
173
+ text: `⏰ 已设置提醒「${command.text}」(${command.atTime ? `at ${command.atTime}` : `${command.inMinutes} 分钟后`},一次性)`,
174
+ });
175
+ return;
176
+ }
123
177
  case 'help': {
124
178
  await adapter.send(chatId, { text: HELP_TEXT });
125
179
  return;
@@ -151,10 +205,20 @@ export async function wireAdapter(
151
205
  const command = parseCommand(msg.text);
152
206
  if (command) {
153
207
  console.log(`[gateway][${adapter.id}] 命令: ${JSON.stringify(command)}`);
154
- await handleCommand(adapter, client, command, key, msg.chatId, aggregator);
208
+ await handleCommand(adapter, client, command, key, msg.chatId, aggregator, opts.memory);
155
209
  return;
156
210
  }
157
211
 
212
+ // OpenClaw 式记忆注入:入站消息前检索相关记忆,拼到文本后让 agent「记得你」
213
+ if (opts.memory) {
214
+ const scope = memoryScope(adapter.id, msg.userId);
215
+ const hits = opts.memory.search(scope, msg.text);
216
+ if (hits.length > 0) {
217
+ msg.text = `${msg.text}${formatMemories(hits)}`;
218
+ console.log(`[gateway][${adapter.id}] 注入 ${hits.length} 条相关记忆`);
219
+ }
220
+ }
221
+
158
222
  // ASR 语音转写:配置了 API key 时把语音消息转成文本;失败/未配置走原降级路径
159
223
  if (msg.media?.kind === 'voice' && opts.asr?.enabled) {
160
224
  const transcript = await opts.asr.transcribe(msg.media);
@@ -229,7 +293,7 @@ export async function wireAdapter(
229
293
 
230
294
  async function main(): Promise<void> {
231
295
  const dshBaseUrl = process.env.DSH_BASE_URL ?? 'http://127.0.0.1:3191';
232
- const dshToken = process.env.DSH_TOKEN ?? 'dev-token';
296
+ const dshToken = process.env.DSH_OVERDRIVE_TOKEN ?? process.env.DSH_TOKEN ?? 'dev-token';
233
297
  const allowlist = (process.env.ALLOWLIST ?? '')
234
298
  .split(',').map((s) => s.trim()).filter(Boolean);
235
299
  const allowAll = process.env.ALLOW_ALL === '1';
@@ -241,6 +305,8 @@ async function main(): Promise<void> {
241
305
  model: env.asrModel,
242
306
  });
243
307
  if (asr.enabled) console.log('[gateway] ASR 语音转写已启用');
308
+ const memory = new MemoryStore(process.env.MEMORY_FILE ?? 'data/memory.json');
309
+ console.log(`[gateway] 记忆系统已启用(文件: ${process.env.MEMORY_FILE ?? 'data/memory.json'})`);
244
310
 
245
311
  const client = new GatewayClient(dshBaseUrl, dshToken);
246
312
  await client.health(); // 确认 DSH 侧(或 mock)活着
@@ -248,7 +314,7 @@ async function main(): Promise<void> {
248
314
  const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
249
315
  for (const adapter of adapters) {
250
316
  await adapter.connect();
251
- await wireAdapter(adapter, client, { allowlist, allowAll, asr });
317
+ await wireAdapter(adapter, client, { allowlist, allowAll, asr, memory });
252
318
  console.log(`[gateway] ${adapter.id} 适配器已就绪`);
253
319
  }
254
320
 
package/src/memory.ts ADDED
@@ -0,0 +1,108 @@
1
+ // 记忆系统(对标 OpenClaw 的 long-term memory)。
2
+ // 按 platform:userId 作用域存储用户显式记忆(/remember),支持搜索(/recall)与删除(/forget);
3
+ // 入站消息时自动检索相关记忆注入上下文,让 agent「记得你」。
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+
9
+ export interface MemoryEntry {
10
+ id: string;
11
+ text: string;
12
+ createdAt: string;
13
+ }
14
+
15
+ /** 作用域键:platform:userId(记忆跟随用户,跨频道共享,与 OpenClaw 一致)。 */
16
+ export function memoryScope(adapterId: string, userId: string): string {
17
+ return `${adapterId}:${userId}`;
18
+ }
19
+
20
+ /** 字符 2-gram:CJK 无空格分词,用共享 bigram 判断相关性(比子串包含更鲁棒)。 */
21
+ function bigrams(text: string): Set<string> {
22
+ const clean = text.toLowerCase();
23
+ const out = new Set<string>();
24
+ for (let i = 0; i < clean.length - 1; i++) out.add(clean.slice(i, i + 2));
25
+ return out;
26
+ }
27
+
28
+ /** 纯函数:按查询文本检索记忆——与查询共享任意 2-gram 即相关;空查询返回全部。 */
29
+ export function searchMemories(entries: MemoryEntry[], query: string): MemoryEntry[] {
30
+ const queryBigrams = bigrams(query);
31
+ if (queryBigrams.size === 0) return entries;
32
+ return entries.filter((entry) => {
33
+ const memoryBigrams = bigrams(entry.text);
34
+ for (const gram of queryBigrams) {
35
+ if (memoryBigrams.has(gram)) return true;
36
+ }
37
+ return false;
38
+ });
39
+ }
40
+
41
+ /** 纯函数:记忆列表 → 注入文本(拼在用户消息后)。 */
42
+ export function formatMemories(entries: MemoryEntry[]): string {
43
+ if (entries.length === 0) return '';
44
+ const lines = entries.map((e) => `- ${e.text}`).join('\n');
45
+ return `\n📌 相关记忆:\n${lines}`;
46
+ }
47
+
48
+ /** JSON 文件持久化的记忆存储;file 缺省时仅内存(测试/无盘环境用)。 */
49
+ export class MemoryStore {
50
+ private readonly data = new Map<string, MemoryEntry[]>();
51
+ private readonly file?: string;
52
+
53
+ constructor(file?: string) {
54
+ this.file = file;
55
+ if (file && existsSync(file)) {
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Record<string, MemoryEntry[]>;
58
+ for (const [scope, entries] of Object.entries(parsed)) {
59
+ if (Array.isArray(entries)) this.data.set(scope, entries);
60
+ }
61
+ } catch {
62
+ /* 损坏则从空开始 */
63
+ }
64
+ }
65
+ }
66
+
67
+ private persist(): void {
68
+ if (!this.file) return;
69
+ try {
70
+ mkdirSync(dirname(this.file), { recursive: true });
71
+ writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.data), null, 2), 'utf8');
72
+ } catch {
73
+ /* 持久化失败不阻断 */
74
+ }
75
+ }
76
+
77
+ add(scope: string, text: string): MemoryEntry {
78
+ const entry: MemoryEntry = { id: randomUUID().slice(0, 8), text: text.trim(), createdAt: new Date().toISOString() };
79
+ const list = this.data.get(scope) ?? [];
80
+ list.push(entry);
81
+ this.data.set(scope, list);
82
+ this.persist();
83
+ return entry;
84
+ }
85
+
86
+ list(scope: string): MemoryEntry[] {
87
+ return this.data.get(scope) ?? [];
88
+ }
89
+
90
+ search(scope: string, query: string): MemoryEntry[] {
91
+ return searchMemories(this.list(scope), query);
92
+ }
93
+
94
+ /** 删除某作用域下指定 id 的记忆;不存在返回 false。 */
95
+ remove(scope: string, id: string): boolean {
96
+ const list = this.data.get(scope);
97
+ if (!list) return false;
98
+ const next = list.filter((e) => e.id !== id);
99
+ if (next.length === list.length) return false;
100
+ this.data.set(scope, next);
101
+ this.persist();
102
+ return true;
103
+ }
104
+
105
+ count(scope: string): number {
106
+ return this.list(scope).length;
107
+ }
108
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildGetUpdatesBody, buildNumberedReplyText, buildSendMessageBody, chunkText, extractWeChatText, parseWeChatUpdate,
4
+ } from '../src/adapters/wechat.js';
5
+
6
+ describe('extractWeChatText', () => {
7
+ it('提取 text_item 文本', () => {
8
+ expect(extractWeChatText({ from_user_id: 'u1', text_item: { text: 'hello' } })).toBe('hello');
9
+ });
10
+ it('提取 item_list 中 type===1 的文本', () => {
11
+ expect(extractWeChatText({ item_list: [{ type: 2, text_item: { text: 'img' } }, { type: 1, text_item: { text: 'text' } }] })).toBe('text');
12
+ });
13
+ it('无文本返回 null', () => {
14
+ expect(extractWeChatText({ from_user_id: 'u1' })).toBeNull();
15
+ });
16
+ });
17
+
18
+ describe('parseWeChatUpdate', () => {
19
+ it('文本消息 → NormalizedMessage(chatId=userId=from_user_id)', () => {
20
+ expect(parseWeChatUpdate({ from_user_id: 'wx-1', context_token: 'tok', text_item: { text: 'hi' } }))
21
+ .toEqual({ chatId: 'wx-1', userId: 'wx-1', text: 'hi' });
22
+ });
23
+ it('非文本或缺发送者返回 null', () => {
24
+ expect(parseWeChatUpdate({ from_user_id: 'wx-1', text_item: { text: '' } })).toBeNull();
25
+ expect(parseWeChatUpdate({ text_item: { text: 'x' } })).toBeNull();
26
+ });
27
+ });
28
+
29
+ describe('buildGetUpdatesBody', () => {
30
+ it('带同步游标与 longpolling,且必须带 base_info.channel_version', () => {
31
+ const body = buildGetUpdatesBody('buf-1');
32
+ expect(body.get_updates_buf).toBe('buf-1');
33
+ expect(body.longpolling_timeout).toBe(35000);
34
+ expect(body.base_info).toEqual({ channel_version: '1.0.2' });
35
+ });
36
+ });
37
+
38
+ describe('buildSendMessageBody', () => {
39
+ it('msg 包裹 + text_item,带回话 context_token', () => {
40
+ const body = buildSendMessageBody('wx-1', '回复', 'tok-1', 'client-1');
41
+ expect(body.base_info).toEqual({ channel_version: '1.0.2' });
42
+ expect(body.msg).toMatchObject({
43
+ from_user_id: '',
44
+ to_user_id: 'wx-1',
45
+ client_id: 'client-1',
46
+ message_type: 2,
47
+ message_state: 2,
48
+ context_token: 'tok-1',
49
+ item_list: [{ type: 1, text_item: { text: '回复' } }],
50
+ });
51
+ });
52
+ });
53
+
54
+ describe('chunkText', () => {
55
+ it('长文本按上限分段', () => {
56
+ const chunks = chunkText('a'.repeat(1700), 800);
57
+ expect(chunks).toHaveLength(3);
58
+ expect(chunks[0].length).toBe(800);
59
+ expect(chunks[2].length).toBe(100);
60
+ });
61
+ it('短文本单段', () => {
62
+ expect(chunkText('hi', 800)).toEqual(['hi']);
63
+ });
64
+ });
65
+
66
+ describe('buildNumberedReplyText', () => {
67
+ it('生成 1/2 选项文本', () => {
68
+ const text = buildNumberedReplyText('需要批准', [
69
+ { id: 'approve:r1', label: '✅ 同意' },
70
+ { id: 'reject:r1', label: '🚫 拒绝' },
71
+ ]);
72
+ expect(text).toContain('1) ✅ 同意');
73
+ expect(text).toContain('回复数字选择');
74
+ });
75
+ it('无按钮时原样返回', () => {
76
+ expect(buildNumberedReplyText('hi', [])).toBe('hi');
77
+ });
78
+ });
@@ -21,4 +21,18 @@ describe('parseCommand', () => {
21
21
  expect(parseCommand('/task')).toBeNull(); // 缺参数
22
22
  expect(parseCommand('/cronrm')).toBeNull(); // 缺任务 id
23
23
  });
24
+ it('识别 /remember /recall /forget', () => {
25
+ expect(parseCommand('/remember 用户喜欢美式咖啡')).toEqual({ kind: 'remember', text: '用户喜欢美式咖啡' });
26
+ expect(parseCommand('/recall 咖啡')).toEqual({ kind: 'recall', query: '咖啡' });
27
+ expect(parseCommand('/recall')).toEqual({ kind: 'recall', query: '' });
28
+ expect(parseCommand('/forget abc123')).toEqual({ kind: 'forget', memoryId: 'abc123' });
29
+ expect(parseCommand('/remember')).toBeNull(); // 缺内容
30
+ });
31
+ it('识别 /remind(相对时间与定点时间)', () => {
32
+ expect(parseCommand('/remind in 10 分钟 喝水')).toEqual({ kind: 'remind', text: '喝水', inMinutes: 10, atTime: null });
33
+ expect(parseCommand('/remind in 2 小时 开会')).toEqual({ kind: 'remind', text: '开会', inMinutes: 120, atTime: null });
34
+ expect(parseCommand('/remind in 30 minutes 散步')).toEqual({ kind: 'remind', text: '散步', inMinutes: 30, atTime: null });
35
+ expect(parseCommand('/remind in 1 day 汇报')).toEqual({ kind: 'remind', text: '汇报', inMinutes: 1440, atTime: null });
36
+ expect(parseCommand('/remind at 14:30 开会')).toEqual({ kind: 'remind', text: '开会', inMinutes: null, atTime: '14:30' });
37
+ });
24
38
  });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MemoryStore, formatMemories, memoryScope, searchMemories } from '../src/memory.js';
3
+
4
+ describe('memoryScope', () => {
5
+ it('按 platform:userId 作用域(记忆跟随用户跨频道)', () => {
6
+ expect(memoryScope('telegram', 'u1')).toBe('telegram:u1');
7
+ });
8
+ });
9
+
10
+ describe('searchMemories', () => {
11
+ const entries = [
12
+ { id: '1', text: '用户喜欢喝美式咖啡', createdAt: 'x' },
13
+ { id: '2', text: '用户住在杭州', createdAt: 'x' },
14
+ { id: '3', text: '项目用 TypeScript', createdAt: 'x' },
15
+ ];
16
+ it('任一关键词命中即返回', () => {
17
+ expect(searchMemories(entries, '咖啡').map((e) => e.id)).toEqual(['1']);
18
+ expect(searchMemories(entries, '杭州 咖啡').map((e) => e.id).sort()).toEqual(['1', '2']);
19
+ });
20
+ it('无匹配返回空', () => {
21
+ expect(searchMemories(entries, '滑雪')).toEqual([]);
22
+ });
23
+ it('空查询返回全部', () => {
24
+ expect(searchMemories(entries, '')).toHaveLength(3);
25
+ });
26
+ it('大小写不敏感', () => {
27
+ expect(searchMemories(entries, 'typescript').map((e) => e.id)).toEqual(['3']);
28
+ });
29
+ });
30
+
31
+ describe('formatMemories', () => {
32
+ it('空列表返回空串', () => {
33
+ expect(formatMemories([])).toBe('');
34
+ });
35
+ it('渲染注入块', () => {
36
+ const text = formatMemories([{ id: '1', text: '用户住在杭州', createdAt: 'x' }]);
37
+ expect(text).toContain('📌 相关记忆');
38
+ expect(text).toContain('用户住在杭州');
39
+ });
40
+ });
41
+
42
+ describe('MemoryStore(内存模式)', () => {
43
+ it('add / list / search / remove 全流程', () => {
44
+ const store = new MemoryStore(); // 无文件 = 纯内存
45
+ const entry = store.add('telegram:u1', '用户喜欢喝美式咖啡');
46
+ expect(store.count('telegram:u1')).toBe(1);
47
+ expect(store.list('telegram:u1')[0].text).toBe('用户喜欢喝美式咖啡');
48
+ expect(store.search('telegram:u1', '咖啡')).toHaveLength(1);
49
+ expect(store.remove('telegram:u1', entry.id)).toBe(true);
50
+ expect(store.count('telegram:u1')).toBe(0);
51
+ expect(store.remove('telegram:u1', entry.id)).toBe(false);
52
+ });
53
+ it('不同作用域隔离', () => {
54
+ const store = new MemoryStore();
55
+ store.add('telegram:u1', 'A 的记忆');
56
+ store.add('whatsapp:u1', 'B 的记忆');
57
+ expect(store.list('telegram:u1')).toHaveLength(1);
58
+ expect(store.list('whatsapp:u1')).toHaveLength(1);
59
+ });
60
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { wireAdapter } from '../src/index.js';
2
+ import { remindSchedule, wireAdapter } from '../src/index.js';
3
3
  import { GatewayClient } from '@dsh-overdrive/sdk';
4
+ import { MemoryStore } from '../src/memory.js';
4
5
  import type { Adapter, NormalizedMessage, OutboundPayload, ReplySender } from '../src/adapter.js';
5
6
 
6
7
  /** 可编程 FakeAdapter:验证 wiring 逻辑(白名单/会话键/错误兜底)。 */
@@ -104,4 +105,51 @@ describe('wireAdapter(多适配器装配核心)', () => {
104
105
  await adapter.emit({ chatId: 'C1', userId: 'U1', text: 'hi' });
105
106
  expect(adapter.sent[0].payload.text).toContain('❌');
106
107
  });
108
+
109
+ it('相关记忆自动注入到发给 agent 的文本(OpenClaw 式)', async () => {
110
+ const adapter = new FakeAdapter('telegram');
111
+ const { client, messages } = fakeClient();
112
+ const memory = new MemoryStore();
113
+ memory.add('telegram:222', '用户喜欢美式咖啡');
114
+ await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
115
+
116
+ await adapter.emit({ chatId: '111', userId: '222', text: '帮我点一杯咖啡' });
117
+ expect(messages[0].text).toContain('帮我点一杯咖啡');
118
+ expect(messages[0].text).toContain('📌 相关记忆');
119
+ expect(messages[0].text).toContain('用户喜欢美式咖啡');
120
+ });
121
+
122
+ it('无相关记忆时不注入', async () => {
123
+ const adapter = new FakeAdapter('telegram');
124
+ const { client, messages } = fakeClient();
125
+ const memory = new MemoryStore();
126
+ memory.add('telegram:222', '用户喜欢美式咖啡');
127
+ await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
128
+
129
+ await adapter.emit({ chatId: '111', userId: '222', text: '今天天气如何' });
130
+ expect(messages[0].text).toBe('今天天气如何');
131
+ });
132
+
133
+ it('/remember 命令写入记忆并回执', async () => {
134
+ const adapter = new FakeAdapter('telegram');
135
+ const { client } = fakeClient();
136
+ const memory = new MemoryStore();
137
+ await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
138
+
139
+ await adapter.emit({ chatId: '111', userId: '222', text: '/remember 用户住在杭州' });
140
+ expect(memory.count('telegram:222')).toBe(1);
141
+ expect(adapter.sent[0].payload.text).toContain('已记住');
142
+ });
143
+ });
144
+
145
+ describe('remindSchedule', () => {
146
+ it('相对分钟 → cron 5 字段', () => {
147
+ const now = new Date('2026-08-20T10:05:00');
148
+ expect(remindSchedule(10, null, now)).toBe('15 10 20 8 *');
149
+ });
150
+ it('定点时间 → cron;已过则推到明天', () => {
151
+ const now = new Date('2026-08-20T10:05:00');
152
+ expect(remindSchedule(0, '14:30', now)).toBe('30 14 20 8 *');
153
+ expect(remindSchedule(0, '09:00', now)).toBe('0 9 21 8 *');
154
+ });
107
155
  });