@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/adapter.d.ts +8 -0
- package/dist/adapters/discord.js +7 -0
- package/dist/adapters/discord.js.map +1 -1
- package/dist/adapters/slack.js +8 -0
- package/dist/adapters/slack.js.map +1 -1
- package/dist/adapters/telegram.js +11 -1
- package/dist/adapters/telegram.js.map +1 -1
- package/dist/adapters/whatsapp.js +16 -0
- package/dist/adapters/whatsapp.js.map +1 -1
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +33 -0
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +113 -4
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +24 -0
- package/dist/memory.js +100 -0
- package/dist/memory.js.map +1 -0
- package/package.json +2 -2
- package/src/adapter.ts +9 -0
- package/src/adapters/discord.ts +7 -0
- package/src/adapters/slack.ts +8 -0
- package/src/adapters/telegram.ts +10 -1
- package/src/adapters/whatsapp.ts +15 -0
- package/src/commands.ts +34 -1
- package/src/index.ts +102 -3
- package/src/memory.ts +108 -0
- package/test/commands.test.ts +19 -0
- package/test/memory.test.ts +60 -0
- package/test/multi.test.ts +111 -1
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":"AAAA,wCAAwC;AACxC,sEAAsE;AACtE,mCAAmC;AAEnC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAQzC,wDAAwD;AACxD,MAAM,UAAU,WAAW,CAAC,SAAiB,EAAE,MAAc;IAC3D,OAAO,GAAG,SAAS,IAAI,MAAM,EAAE,CAAC;AAClC,CAAC;AAED,sDAAsD;AACtD,SAAS,OAAO,CAAC,IAAY;IAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IACjC,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,EAAE;QAAE,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC1E,OAAO,GAAG,CAAC;AACb,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,cAAc,CAAC,OAAsB,EAAE,KAAa;IAClE,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,YAAY,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC;IAC5C,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE;QAC9B,MAAM,aAAa,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC1C,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;YAChC,IAAI,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAC;QAC3C,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC,CAAC,CAAC;AACL,CAAC;AAED,gCAAgC;AAChC,MAAM,UAAU,cAAc,CAAC,OAAsB;IACnD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACpC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC3D,OAAO,eAAe,KAAK,EAAE,CAAC;AAChC,CAAC;AAED,6CAA6C;AAC7C,MAAM,OAAO,WAAW;IACL,IAAI,GAAG,IAAI,GAAG,EAAyB,CAAC;IACxC,IAAI,CAAU;IAE/B,YAAY,IAAa;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,IAAI,IAAI,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAkC,CAAC;gBACvF,KAAK,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;oBACtD,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;wBAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;gBAC5D,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,aAAa;YACf,CAAC;QACH,CAAC;IACH,CAAC;IAEO,OAAO;QACb,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,OAAO;QACvB,IAAI,CAAC;YACH,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YACnD,aAAa,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;QAC3F,CAAC;QAAC,MAAM,CAAC;YACP,cAAc;QAChB,CAAC;IACH,CAAC;IAED,GAAG,CAAC,KAAa,EAAE,IAAY;QAC7B,MAAM,KAAK,GAAgB,EAAE,EAAE,EAAE,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC;QACpH,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACxC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACjB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC,KAAa;QAChB,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IACpC,CAAC;IAED,MAAM,CAAC,KAAa,EAAE,KAAa;QACjC,OAAO,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC;IACjD,CAAC;IAED,oCAAoC;IACpC,MAAM,CAAC,KAAa,EAAE,EAAU;QAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAClC,IAAI,CAAC,IAAI;YAAE,OAAO,KAAK,CAAC;QACxB,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;QAC7C,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QAC9C,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;IAED,KAAK,CAAC,KAAa;QACjB,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC;IACjC,CAAC;CACF"}
|
package/package.json
CHANGED
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)。 */
|
package/src/adapters/discord.ts
CHANGED
|
@@ -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) =>
|
package/src/adapters/slack.ts
CHANGED
|
@@ -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,
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -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);
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|
package/test/commands.test.ts
CHANGED
|
@@ -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
|
+
});
|