@dsh-overdrive/gateway 0.1.6 → 0.1.8

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/dist/memory.js ADDED
@@ -0,0 +1,100 @@
1
+ // 记忆系统(对标 OpenClaw 的 long-term memory)。
2
+ // 按 platform:userId 作用域存储用户显式记忆(/remember),支持搜索(/recall)与删除(/forget);
3
+ // 入站消息时自动检索相关记忆注入上下文,让 agent「记得你」。
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ /** 作用域键:platform:userId(记忆跟随用户,跨频道共享,与 OpenClaw 一致)。 */
8
+ export function memoryScope(adapterId, userId) {
9
+ return `${adapterId}:${userId}`;
10
+ }
11
+ /** 字符 2-gram:CJK 无空格分词,用共享 bigram 判断相关性(比子串包含更鲁棒)。 */
12
+ function bigrams(text) {
13
+ const clean = text.toLowerCase();
14
+ const out = new Set();
15
+ for (let i = 0; i < clean.length - 1; i++)
16
+ out.add(clean.slice(i, i + 2));
17
+ return out;
18
+ }
19
+ /** 纯函数:按查询文本检索记忆——与查询共享任意 2-gram 即相关;空查询返回全部。 */
20
+ export function searchMemories(entries, query) {
21
+ const queryBigrams = bigrams(query);
22
+ if (queryBigrams.size === 0)
23
+ return entries;
24
+ return entries.filter((entry) => {
25
+ const memoryBigrams = bigrams(entry.text);
26
+ for (const gram of queryBigrams) {
27
+ if (memoryBigrams.has(gram))
28
+ return true;
29
+ }
30
+ return false;
31
+ });
32
+ }
33
+ /** 纯函数:记忆列表 → 注入文本(拼在用户消息后)。 */
34
+ export function formatMemories(entries) {
35
+ if (entries.length === 0)
36
+ return '';
37
+ const lines = entries.map((e) => `- ${e.text}`).join('\n');
38
+ return `\n📌 相关记忆:\n${lines}`;
39
+ }
40
+ /** JSON 文件持久化的记忆存储;file 缺省时仅内存(测试/无盘环境用)。 */
41
+ export class MemoryStore {
42
+ data = new Map();
43
+ file;
44
+ constructor(file) {
45
+ this.file = file;
46
+ if (file && existsSync(file)) {
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
49
+ for (const [scope, entries] of Object.entries(parsed)) {
50
+ if (Array.isArray(entries))
51
+ this.data.set(scope, entries);
52
+ }
53
+ }
54
+ catch {
55
+ /* 损坏则从空开始 */
56
+ }
57
+ }
58
+ }
59
+ persist() {
60
+ if (!this.file)
61
+ return;
62
+ try {
63
+ mkdirSync(dirname(this.file), { recursive: true });
64
+ writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.data), null, 2), 'utf8');
65
+ }
66
+ catch {
67
+ /* 持久化失败不阻断 */
68
+ }
69
+ }
70
+ add(scope, text) {
71
+ const entry = { id: randomUUID().slice(0, 8), text: text.trim(), createdAt: new Date().toISOString() };
72
+ const list = this.data.get(scope) ?? [];
73
+ list.push(entry);
74
+ this.data.set(scope, list);
75
+ this.persist();
76
+ return entry;
77
+ }
78
+ list(scope) {
79
+ return this.data.get(scope) ?? [];
80
+ }
81
+ search(scope, query) {
82
+ return searchMemories(this.list(scope), query);
83
+ }
84
+ /** 删除某作用域下指定 id 的记忆;不存在返回 false。 */
85
+ remove(scope, id) {
86
+ const list = this.data.get(scope);
87
+ if (!list)
88
+ return false;
89
+ const next = list.filter((e) => e.id !== id);
90
+ if (next.length === list.length)
91
+ return false;
92
+ this.data.set(scope, next);
93
+ this.persist();
94
+ return true;
95
+ }
96
+ count(scope) {
97
+ return this.list(scope).length;
98
+ }
99
+ }
100
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "@dsh-overdrive/gateway",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc"
package/src/adapter.ts CHANGED
@@ -7,9 +7,18 @@ export interface NormalizedMessage {
7
7
 
8
8
  export interface OutboundButton { id: string; label: string; }
9
9
 
10
+ /** 出站媒体(/send 等):path 为本地文件路径。 */
11
+ export interface OutboundMedia {
12
+ kind: 'image' | 'file' | 'voice';
13
+ path: string;
14
+ caption?: string;
15
+ }
16
+
10
17
  export interface OutboundPayload {
11
18
  text: string;
12
19
  buttons?: OutboundButton[];
20
+ /** 可选:随消息发送的本地媒体文件;不支持媒体的适配器忽略并只发文本。 */
21
+ media?: OutboundMedia;
13
22
  }
14
23
 
15
24
  /** 按钮回执的点击者身份(用于白名单校验)。chatId 在个别平台回调中可能缺失,缺失时按未授权处理(fail-closed)。 */
@@ -102,6 +102,13 @@ export class DiscordAdapter implements Adapter {
102
102
  console.error(`[discord] 无法向 ${chatId} 发送(channel 不可用)`);
103
103
  return;
104
104
  }
105
+ if (payload.media) {
106
+ await (channel as { send: (o: unknown) => Promise<unknown> }).send({
107
+ content: payload.text,
108
+ files: [payload.media.path],
109
+ });
110
+ return;
111
+ }
105
112
  if (payload.buttons?.length) {
106
113
  const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
107
114
  payload.buttons.map((b) =>
@@ -102,6 +102,14 @@ export class SlackAdapter implements Adapter {
102
102
  }
103
103
 
104
104
  async send(chatId: string, payload: OutboundPayload): Promise<void> {
105
+ if (payload.media) {
106
+ await this.app.client.files.uploadV2({
107
+ channel_id: chatId,
108
+ file: payload.media.path,
109
+ filename: payload.media.caption ?? payload.media.path.split('/').pop(),
110
+ });
111
+ return;
112
+ }
105
113
  await this.app.client.chat.postMessage({
106
114
  channel: chatId,
107
115
  text: payload.text,
@@ -1,4 +1,4 @@
1
- import { Bot, InlineKeyboard } from 'grammy';
1
+ import { Bot, InlineKeyboard, InputFile } from 'grammy';
2
2
  import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
3
3
 
4
4
  // ── 纯函数 ────────────────────────────────────────────────────
@@ -118,6 +118,15 @@ export class TelegramAdapter implements Adapter {
118
118
  }
119
119
 
120
120
  async send(chatId: string, payload: OutboundPayload): Promise<void> {
121
+ if (payload.media) {
122
+ const file = new InputFile(payload.media.path);
123
+ if (payload.media.kind === 'image') {
124
+ await this.bot.api.sendPhoto(chatId, file, { caption: payload.media.caption ?? '' });
125
+ } else {
126
+ await this.bot.api.sendDocument(chatId, file, { caption: payload.media.caption ?? '' });
127
+ }
128
+ return;
129
+ }
121
130
  if (payload.buttons?.length) {
122
131
  const kb = new InlineKeyboard();
123
132
  for (const [label, id] of buttonRows(payload.buttons)) kb.text(label, id);
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import makeWASocket, {
2
3
  useMultiFileAuthState,
3
4
  DisconnectReason,
@@ -199,6 +200,20 @@ export class WhatsAppAdapter implements Adapter {
199
200
 
200
201
  async send(chatId: string, payload: OutboundPayload): Promise<void> {
201
202
  if (!this.sock) return;
203
+ if (payload.media) {
204
+ const { kind, path, caption } = payload.media;
205
+ const buf = readFileSync(path);
206
+ if (kind === 'image') {
207
+ await this.sock.sendMessage(chatId, { image: buf, caption: caption ?? '' } satisfies AnyMessageContent);
208
+ } else {
209
+ await this.sock.sendMessage(chatId, {
210
+ document: buf,
211
+ fileName: caption ?? path,
212
+ mimetype: 'application/octet-stream',
213
+ } satisfies AnyMessageContent);
214
+ }
215
+ return;
216
+ }
202
217
  if (payload.buttons?.length) {
203
218
  this.pendingButtons.set(chatId, payload.buttons);
204
219
  // 原生交互按钮优先:Baileys 6.x 的 AnyMessageContent 无 interactive 键,
package/src/commands.ts CHANGED
@@ -6,10 +6,19 @@ export type ParsedCommand =
6
6
  | { kind: 'task'; prompt: string }
7
7
  | { kind: 'cron'; schedule: string; prompt: string }
8
8
  | { kind: 'crons' }
9
- | { kind: 'cronrm'; taskId: string };
9
+ | { kind: 'cronrm'; taskId: string }
10
+ | { kind: 'remember'; text: string }
11
+ | { kind: 'recall'; query: string }
12
+ | { kind: 'forget'; memoryId: string }
13
+ | { kind: 'remind'; text: string; inMinutes: number | null; atTime: string | null }
14
+ | { kind: 'send'; path: string }
15
+ | { kind: 'status' };
10
16
 
11
17
  // cron 语法:/cron <分 时 日 月 周> <需求>(schedule 为 5 个空白分隔字段)
12
18
  const CRON_RE = /^\/cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/;
19
+ // /remind in N 分钟/小时/天 <text>(也支持 min/minutes/hour/hours/day/days);或 /remind at HH:MM <text>
20
+ const REMIND_IN_RE = /^\/remind\s+in\s+(\d+)\s+(\S+)\s+(.+)$/i;
21
+ const REMIND_AT_RE = /^\/remind\s+at\s+(\d{1,2}:\d{2})\s+(.+)$/i;
13
22
 
14
23
  export function parseCommand(text: string): ParsedCommand | null {
15
24
  const trimmed = text.trim();
@@ -18,12 +27,30 @@ export function parseCommand(text: string): ParsedCommand | null {
18
27
  if (trimmed === '/agents') return { kind: 'agents' };
19
28
  if (trimmed === '/help') return { kind: 'help' };
20
29
  if (trimmed === '/crons') return { kind: 'crons' };
30
+ if (trimmed === '/status') return { kind: 'status' };
21
31
  const task = trimmed.match(/^\/task\s+(.+)$/);
22
32
  if (task) return { kind: 'task', prompt: task[1] };
23
33
  const cron = trimmed.match(CRON_RE);
24
34
  if (cron) return { kind: 'cron', schedule: cron[1], prompt: cron[2] };
25
35
  const cronrm = trimmed.match(/^\/cronrm\s+(\S+)$/);
26
36
  if (cronrm) return { kind: 'cronrm', taskId: cronrm[1] };
37
+ const remember = trimmed.match(/^\/remember\s+(.+)$/);
38
+ if (remember) return { kind: 'remember', text: remember[1] };
39
+ const recall = trimmed.match(/^\/recall\s*(.*)$/);
40
+ if (recall) return { kind: 'recall', query: recall[1].trim() };
41
+ const forget = trimmed.match(/^\/forget\s+(\S+)$/);
42
+ if (forget) return { kind: 'forget', memoryId: forget[1] };
43
+ const remindIn = trimmed.match(REMIND_IN_RE);
44
+ if (remindIn) {
45
+ const unit = (remindIn[2] ?? '').toLowerCase();
46
+ const n = Number(remindIn[1]);
47
+ const minutes = /^(小|h|hour)/.test(unit) ? n * 60 : /^(天|d)/.test(unit) ? n * 1440 : n;
48
+ return { kind: 'remind', text: remindIn[3], inMinutes: minutes, atTime: null };
49
+ }
50
+ const remindAt = trimmed.match(REMIND_AT_RE);
51
+ if (remindAt) return { kind: 'remind', text: remindAt[2], inMinutes: null, atTime: remindAt[1] };
52
+ const send = trimmed.match(/^\/send\s+(.+)$/);
53
+ if (send) return { kind: 'send', path: send[1].trim() };
27
54
  return null;
28
55
  }
29
56
 
@@ -34,6 +61,12 @@ export const HELP_TEXT = [
34
61
  '/cron <分 时 日 月 周> <需求> — 定时任务',
35
62
  '/crons — 查看定时任务列表',
36
63
  '/cronrm <任务id> — 删除定时任务',
64
+ '/remind in 10 分钟 <提醒内容> — 一次性定时提醒(也支持 at HH:MM)',
65
+ '/remember <事实> — 记住关于我的事',
66
+ '/recall <关键词> — 回忆相关记忆',
67
+ '/forget <记忆id> — 删除一条记忆',
68
+ '/send <文件路径> — 把本地文件/图片发到当前聊天',
69
+ '/status — 查看运行状态',
37
70
  '/agents — 查看子任务状态',
38
71
  '/new — 重置会话',
39
72
  ].join('\n');
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename } from 'node:path';
1
3
  import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
2
4
  import type { Adapter, OutboundPayload } from './adapter.js';
3
5
  import { Allowlist, buildSessionKey } from './session.js';
@@ -7,6 +9,7 @@ import { parseCommand, HELP_TEXT, type ParsedCommand } from './commands.js';
7
9
  import { TrajectoryAggregator, formatTrajectorySummary } from './trajectory.js';
8
10
  import { createStatusServer } from './status.js';
9
11
  import { createTranscriber, type AsrTranscriber } from './asr.js';
12
+ import { MemoryStore, memoryScope, formatMemories } from './memory.js';
10
13
 
11
14
  /**
12
15
  * message.delta → 打字指示去重:同一 turn 内首个 delta 触发一次 typing,
@@ -69,9 +72,32 @@ export interface WireOptions {
69
72
  allowAll?: boolean;
70
73
  /** ASR 转写器;配置了 API key 时启用,语音消息转成文本再发给 agent。 */
71
74
  asr?: AsrTranscriber;
75
+ /** 记忆系统(OpenClaw 式长期记忆);未提供则记忆命令返回不可用。 */
76
+ memory?: MemoryStore;
72
77
  }
73
78
 
74
- /** 命令面分发:/trace /new /task /cron /agents /help(M4)。 */
79
+ /** 纯函数:一次性提醒的时间 cron 5 字段表达式(分钟精度)。 */
80
+ export function remindSchedule(minutes: number, atTime: string | null, now = new Date()): string {
81
+ const target = new Date(now);
82
+ if (atTime) {
83
+ const [h, m] = atTime.split(':').map(Number);
84
+ target.setHours(h, m, 0, 0);
85
+ if (target <= now) target.setDate(target.getDate() + 1); // 已过则明天
86
+ } else {
87
+ target.setMinutes(target.getMinutes() + minutes);
88
+ }
89
+ return `${target.getMinutes()} ${target.getHours()} ${target.getDate()} ${target.getMonth() + 1} *`;
90
+ }
91
+
92
+ /** 纯函数:本地文件路径 → 出站媒体类型(图片/语音/其他文件)。 */
93
+ export function mediaKindFromPath(path: string): 'image' | 'voice' | 'file' {
94
+ const ext = path.toLowerCase().split('.').pop() ?? '';
95
+ if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'].includes(ext)) return 'image';
96
+ if (['ogg', 'oga', 'opus', 'mp3', 'wav', 'webm', 'm4a', 'aac'].includes(ext)) return 'voice';
97
+ return 'file';
98
+ }
99
+
100
+ /** 命令面分发:/trace /new /task /cron /agents /help /remind /remember /recall /forget(M4 + v0.3)。 */
75
101
  async function handleCommand(
76
102
  adapter: Adapter,
77
103
  client: GatewayClient,
@@ -79,6 +105,7 @@ async function handleCommand(
79
105
  sessionId: string,
80
106
  chatId: string,
81
107
  aggregator: TrajectoryAggregator,
108
+ memory: MemoryStore | undefined,
82
109
  ): Promise<void> {
83
110
  switch (command.kind) {
84
111
  case 'trace': {
@@ -125,6 +152,66 @@ async function handleCommand(
125
152
  await adapter.send(chatId, { text: '(M4 简化)子任务状态由 agent 汇报,/task 派发' });
126
153
  return;
127
154
  }
155
+ case 'remember': {
156
+ if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
157
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
158
+ const entry = memory.add(scope, command.text);
159
+ await adapter.send(chatId, { text: `✅ 已记住(\`${entry.id}\`):${command.text}` });
160
+ return;
161
+ }
162
+ case 'recall': {
163
+ if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
164
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
165
+ const hits = memory.search(scope, command.query);
166
+ if (hits.length === 0) { await adapter.send(chatId, { text: '没有相关记忆。' }); return; }
167
+ await adapter.send(chatId, {
168
+ text: `🧠 ${hits.length} 条记忆:\n` + hits.map((e) => `- \`${e.id}\` ${e.text}`).join('\n'),
169
+ });
170
+ return;
171
+ }
172
+ case 'forget': {
173
+ if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
174
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
175
+ const ok = memory.remove(scope, command.memoryId);
176
+ await adapter.send(chatId, { text: ok ? `🗑️ 已删除记忆 \`${command.memoryId}\`` : `未找到记忆 \`${command.memoryId}\`` });
177
+ return;
178
+ }
179
+ case 'remind': {
180
+ const schedule = remindSchedule(command.inMinutes ?? 0, command.atTime);
181
+ await client.createTask({ sessionId, kind: 'cron', prompt: `⏰ 提醒:${command.text}`, schedule, once: true });
182
+ await adapter.send(chatId, {
183
+ text: `⏰ 已设置提醒「${command.text}」(${command.atTime ? `at ${command.atTime}` : `${command.inMinutes} 分钟后`},一次性)`,
184
+ });
185
+ return;
186
+ }
187
+ case 'send': {
188
+ // 媒体发送(/send <path>):读本地文件 → 类型判定 → 交给适配器;不支持的平台降级为文本
189
+ const path = command.path;
190
+ if (!existsSync(path)) {
191
+ await adapter.send(chatId, { text: `❌ 找不到文件:${path}` });
192
+ return;
193
+ }
194
+ await adapter.send(chatId, {
195
+ text: `📎 ${path}`,
196
+ media: { kind: mediaKindFromPath(path), path, caption: basename(path) },
197
+ });
198
+ return;
199
+ }
200
+ case 'status': {
201
+ const connected = adapter.status?.().connected ?? false;
202
+ const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
203
+ const memCount = memory ? memory.count(scope) : 0;
204
+ const crons = await client.listTasks();
205
+ await adapter.send(chatId, {
206
+ text: [
207
+ `📊 状态`,
208
+ `- 适配器 ${adapter.id}: ${connected ? '✅ 已连接' : '❌ 未连接'}`,
209
+ `- 你的记忆: ${memCount} 条`,
210
+ `- 定时任务: ${crons.tasks.length} 个`,
211
+ ].join('\n'),
212
+ });
213
+ return;
214
+ }
128
215
  case 'help': {
129
216
  await adapter.send(chatId, { text: HELP_TEXT });
130
217
  return;
@@ -156,10 +243,20 @@ export async function wireAdapter(
156
243
  const command = parseCommand(msg.text);
157
244
  if (command) {
158
245
  console.log(`[gateway][${adapter.id}] 命令: ${JSON.stringify(command)}`);
159
- await handleCommand(adapter, client, command, key, msg.chatId, aggregator);
246
+ await handleCommand(adapter, client, command, key, msg.chatId, aggregator, opts.memory);
160
247
  return;
161
248
  }
162
249
 
250
+ // OpenClaw 式记忆注入:入站消息前检索相关记忆,拼到文本后让 agent「记得你」
251
+ if (opts.memory) {
252
+ const scope = memoryScope(adapter.id, msg.userId);
253
+ const hits = opts.memory.search(scope, msg.text);
254
+ if (hits.length > 0) {
255
+ msg.text = `${msg.text}${formatMemories(hits)}`;
256
+ console.log(`[gateway][${adapter.id}] 注入 ${hits.length} 条相关记忆`);
257
+ }
258
+ }
259
+
163
260
  // ASR 语音转写:配置了 API key 时把语音消息转成文本;失败/未配置走原降级路径
164
261
  if (msg.media?.kind === 'voice' && opts.asr?.enabled) {
165
262
  const transcript = await opts.asr.transcribe(msg.media);
@@ -246,6 +343,8 @@ async function main(): Promise<void> {
246
343
  model: env.asrModel,
247
344
  });
248
345
  if (asr.enabled) console.log('[gateway] ASR 语音转写已启用');
346
+ const memory = new MemoryStore(process.env.MEMORY_FILE ?? 'data/memory.json');
347
+ console.log(`[gateway] 记忆系统已启用(文件: ${process.env.MEMORY_FILE ?? 'data/memory.json'})`);
249
348
 
250
349
  const client = new GatewayClient(dshBaseUrl, dshToken);
251
350
  await client.health(); // 确认 DSH 侧(或 mock)活着
@@ -253,7 +352,7 @@ async function main(): Promise<void> {
253
352
  const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
254
353
  for (const adapter of adapters) {
255
354
  await adapter.connect();
256
- await wireAdapter(adapter, client, { allowlist, allowAll, asr });
355
+ await wireAdapter(adapter, client, { allowlist, allowAll, asr, memory });
257
356
  console.log(`[gateway] ${adapter.id} 适配器已就绪`);
258
357
  }
259
358
 
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
+ }
@@ -21,4 +21,23 @@ 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
+ });
38
+ it('识别 /send 与 /status', () => {
39
+ expect(parseCommand('/send /tmp/report.png')).toEqual({ kind: 'send', path: '/tmp/report.png' });
40
+ expect(parseCommand('/status')).toEqual({ kind: 'status' });
41
+ expect(parseCommand('/send')).toBeNull(); // 缺路径
42
+ });
24
43
  });
@@ -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
+ });