@offerpilot/axiomruntime 0.0.1
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/README.md +185 -0
- package/dist/cli/commands/add.js +29 -0
- package/dist/cli/commands/context.js +29 -0
- package/dist/cli/commands/doctor.js +62 -0
- package/dist/cli/commands/edit.js +85 -0
- package/dist/cli/commands/help.js +16 -0
- package/dist/cli/commands/list.js +25 -0
- package/dist/cli/commands/log.js +63 -0
- package/dist/cli/commands/memory.js +241 -0
- package/dist/cli/commands/report.js +35 -0
- package/dist/cli/commands/session.js +74 -0
- package/dist/cli/commands/setup.js +86 -0
- package/dist/cli/commands/status.js +50 -0
- package/dist/cli/commands/telegram.js +701 -0
- package/dist/cli/commands/use.js +108 -0
- package/dist/cli/commands/version.js +12 -0
- package/dist/cli/index.js +276 -0
- package/dist/cli/output/table.js +22 -0
- package/dist/cli/prompts/prompt.js +37 -0
- package/dist/cli/registry.js +16 -0
- package/dist/core/config/cache-store.js +193 -0
- package/dist/core/config/json-store.js +114 -0
- package/dist/core/config/paths.js +85 -0
- package/dist/core/config/providers-store.js +89 -0
- package/dist/core/config/schema.js +60 -0
- package/dist/core/config/session-store.js +30 -0
- package/dist/core/config/usage-store.js +18 -0
- package/dist/core/context/context-service.js +186 -0
- package/dist/core/integrations/integration-state.js +105 -0
- package/dist/core/logs/log-service.js +56 -0
- package/dist/core/memory/embedding-check.js +121 -0
- package/dist/core/memory/memory-config.js +122 -0
- package/dist/core/models/model-discovery.js +430 -0
- package/dist/core/models/model-filter.js +13 -0
- package/dist/core/providers/provider-service.js +212 -0
- package/dist/core/reports/report-service.js +166 -0
- package/dist/core/runner/command-resolver.js +60 -0
- package/dist/core/runner/engine-registry.js +93 -0
- package/dist/core/runner/fallback.js +114 -0
- package/dist/core/runner/openai-usage-http.js +82 -0
- package/dist/core/runner/openai-usage-proxy.js +1 -0
- package/dist/core/runner/openai-usage-recording.js +172 -0
- package/dist/core/runner/openai-usage-responses.js +469 -0
- package/dist/core/runner/openai-usage-server.js +319 -0
- package/dist/core/runner/openai-usage-types.js +1 -0
- package/dist/core/runner/tool-runner.js +138 -0
- package/dist/core/sessions/session-service.js +47 -0
- package/dist/core/status/doctor-service.js +391 -0
- package/dist/core/status/status-service.js +60 -0
- package/dist/core/types.js +1 -0
- package/dist/core/usage/pricing.js +113 -0
- package/dist/core/usage/usage-service.js +30 -0
- package/dist/core/utils/is-record.js +3 -0
- package/dist/server/index.js +28 -0
- package/dist/server/runtime-server.js +430 -0
- package/dist/telegram/bot-registry.js +80 -0
- package/dist/telegram/bot.js +128 -0
- package/dist/telegram/config.js +235 -0
- package/dist/telegram/engine/claude-engine.js +240 -0
- package/dist/telegram/engine/codex-engine.js +437 -0
- package/dist/telegram/engine/engine-utils.js +67 -0
- package/dist/telegram/engine/process-utils.js +132 -0
- package/dist/telegram/engine/registry.js +31 -0
- package/dist/telegram/engine/types.js +1 -0
- package/dist/telegram/handler-registry.js +28 -0
- package/dist/telegram/handlers/callback.js +311 -0
- package/dist/telegram/handlers/command.js +272 -0
- package/dist/telegram/handlers/document.js +108 -0
- package/dist/telegram/handlers/memory.js +305 -0
- package/dist/telegram/handlers/message.js +701 -0
- package/dist/telegram/handlers/provider.js +332 -0
- package/dist/telegram/handlers/setup.js +527 -0
- package/dist/telegram/handlers/usage.js +124 -0
- package/dist/telegram/index.js +93 -0
- package/dist/telegram/interaction/approval.js +108 -0
- package/dist/telegram/interaction/command-menu.js +253 -0
- package/dist/telegram/interaction/formatter.js +487 -0
- package/dist/telegram/interaction/keyboards.js +145 -0
- package/dist/telegram/interaction/progress-reporter.js +160 -0
- package/dist/telegram/interaction/prompt-middleware.js +168 -0
- package/dist/telegram/interaction/result-store.js +41 -0
- package/dist/telegram/interaction/token-budget.js +21 -0
- package/dist/telegram/interaction/tool-name.js +41 -0
- package/dist/telegram/lifecycle-registry.js +47 -0
- package/dist/telegram/log.js +46 -0
- package/dist/telegram/memory/memory-inject.js +52 -0
- package/dist/telegram/memory/memory-service.js +413 -0
- package/dist/telegram/memory/memory-store.js +216 -0
- package/dist/telegram/memory/types.js +1 -0
- package/dist/telegram/network-retry.js +22 -0
- package/dist/telegram/network.js +53 -0
- package/dist/telegram/session/manager.js +229 -0
- package/dist/telegram/session/store.js +363 -0
- package/dist/telegram/session/types.js +1 -0
- package/dist/telegram/supervisor.js +57 -0
- package/dist/telegram/templates/messages.js +1 -0
- package/docs/README.md +98 -0
- package/docs/USAGE.html +853 -0
- package/package.json +57 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getMemoryBackupDir } from "../../core/config/paths.js";
|
|
4
|
+
import { escapeHtml, truncateText } from "../interaction/formatter.js";
|
|
5
|
+
import { MemoryStore } from "./memory-store.js";
|
|
6
|
+
const DEFAULT_INJECTION_CHAR_BUDGET = 3000;
|
|
7
|
+
const DEFAULT_SHORT_TERM_TTL_MS = 72 * 60 * 60 * 1000;
|
|
8
|
+
const PROFILE_TITLE = "用户画像";
|
|
9
|
+
const PERSONA_TITLE = "Bot 人设";
|
|
10
|
+
const RAW_DESCRIPTION_TITLE = "用户原始自我描述";
|
|
11
|
+
export class MemoryService {
|
|
12
|
+
botId;
|
|
13
|
+
store;
|
|
14
|
+
ownsStore;
|
|
15
|
+
injectionCharBudget;
|
|
16
|
+
shortTermTtlMs;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.botId = options.botId || "default";
|
|
19
|
+
this.store = options.store ?? new MemoryStore();
|
|
20
|
+
this.ownsStore = !options.store;
|
|
21
|
+
this.injectionCharBudget = options.injectionCharBudget ?? DEFAULT_INJECTION_CHAR_BUDGET;
|
|
22
|
+
this.shortTermTtlMs = options.shortTermTtlMs ?? DEFAULT_SHORT_TERM_TTL_MS;
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
if (this.ownsStore)
|
|
26
|
+
this.store.close();
|
|
27
|
+
}
|
|
28
|
+
getStorePath() {
|
|
29
|
+
return this.store.getPath();
|
|
30
|
+
}
|
|
31
|
+
save(chatId, input) {
|
|
32
|
+
const normalized = this.normalizeSaveInput(chatId, input);
|
|
33
|
+
return this.store.save(normalized);
|
|
34
|
+
}
|
|
35
|
+
remember(chatId, input) {
|
|
36
|
+
const category = input.category ?? classifyCategory(input.content);
|
|
37
|
+
if (category === "credential") {
|
|
38
|
+
throw new Error("加密记忆尚未启用,凭证类内容不会明文保存。");
|
|
39
|
+
}
|
|
40
|
+
const scope = input.scope ?? defaultScopeForCategory(category);
|
|
41
|
+
const duration = input.duration ?? "permanent";
|
|
42
|
+
const expiresAt = duration === "short_term"
|
|
43
|
+
? Date.now() + (input.ttlMs ?? this.shortTermTtlMs)
|
|
44
|
+
: null;
|
|
45
|
+
return this.save(chatId, {
|
|
46
|
+
scope,
|
|
47
|
+
category,
|
|
48
|
+
title: input.title ?? inferTitle(input.content, category),
|
|
49
|
+
content: input.content,
|
|
50
|
+
tags: input.tags ?? inferTags(input.content, category),
|
|
51
|
+
duration,
|
|
52
|
+
expiresAt,
|
|
53
|
+
priority: category === "profile" || category === "persona" ? 1 : 0
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
saveProfile(chatId, content) {
|
|
57
|
+
return this.store.upsertSingleton(this.normalizeSaveInput(chatId, {
|
|
58
|
+
scope: "global",
|
|
59
|
+
category: "profile",
|
|
60
|
+
title: PROFILE_TITLE,
|
|
61
|
+
content,
|
|
62
|
+
tags: ["profile"],
|
|
63
|
+
priority: 1,
|
|
64
|
+
duration: "permanent"
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
savePersona(chatId, content) {
|
|
68
|
+
return this.store.upsertSingleton(this.normalizeSaveInput(chatId, {
|
|
69
|
+
scope: "bot",
|
|
70
|
+
category: "persona",
|
|
71
|
+
title: PERSONA_TITLE,
|
|
72
|
+
content,
|
|
73
|
+
tags: ["persona", this.botId],
|
|
74
|
+
priority: 1,
|
|
75
|
+
duration: "permanent"
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
saveRawDescription(chatId, content) {
|
|
79
|
+
return this.store.upsertSingleton(this.normalizeSaveInput(chatId, {
|
|
80
|
+
scope: "bot",
|
|
81
|
+
category: "fact",
|
|
82
|
+
title: RAW_DESCRIPTION_TITLE,
|
|
83
|
+
content,
|
|
84
|
+
tags: ["profile", "raw"],
|
|
85
|
+
priority: 0,
|
|
86
|
+
duration: "permanent"
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
getProfile(chatId) {
|
|
90
|
+
return this.store.getProfile(normalizeChatId(chatId));
|
|
91
|
+
}
|
|
92
|
+
getPersona(chatId) {
|
|
93
|
+
return this.store.getPersona(normalizeChatId(chatId), this.botId);
|
|
94
|
+
}
|
|
95
|
+
listVisible(chatId, query, limit = 50) {
|
|
96
|
+
return this.store.listVisible({
|
|
97
|
+
chatId: normalizeChatId(chatId),
|
|
98
|
+
botId: this.botId,
|
|
99
|
+
query,
|
|
100
|
+
limit
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
retrieveForPrompt(chatId, prompt) {
|
|
104
|
+
const visible = this.store.listVisible({
|
|
105
|
+
chatId: normalizeChatId(chatId),
|
|
106
|
+
botId: this.botId,
|
|
107
|
+
limit: 200
|
|
108
|
+
});
|
|
109
|
+
if (!visible.length)
|
|
110
|
+
return [];
|
|
111
|
+
const required = visible.filter((entry) => entry.priority > 0);
|
|
112
|
+
const keywords = extractKeywords(prompt);
|
|
113
|
+
const scored = visible
|
|
114
|
+
.filter((entry) => entry.priority <= 0)
|
|
115
|
+
.map((entry) => ({ entry, score: scoreMemory(entry, keywords) }))
|
|
116
|
+
.filter((item) => item.score > 0)
|
|
117
|
+
.sort((a, b) => b.score - a.score || b.entry.updatedAt - a.entry.updatedAt)
|
|
118
|
+
.slice(0, 8)
|
|
119
|
+
.map((item) => item.entry);
|
|
120
|
+
return trimEntriesToBudget(dedupeEntries([
|
|
121
|
+
...sortRequiredEntries(required),
|
|
122
|
+
...scored
|
|
123
|
+
]), this.injectionCharBudget);
|
|
124
|
+
}
|
|
125
|
+
delete(chatId, selector) {
|
|
126
|
+
const value = selector.trim();
|
|
127
|
+
if (!value)
|
|
128
|
+
return 0;
|
|
129
|
+
if (/^\d+$/.test(value)) {
|
|
130
|
+
return this.store.deleteVisibleById(normalizeChatId(chatId), this.botId, Number(value));
|
|
131
|
+
}
|
|
132
|
+
if (value === "profile") {
|
|
133
|
+
const profile = this.getProfile(chatId);
|
|
134
|
+
return profile ? this.store.deleteVisibleById(normalizeChatId(chatId), this.botId, profile.id) : 0;
|
|
135
|
+
}
|
|
136
|
+
if (value === "persona") {
|
|
137
|
+
const persona = this.getPersona(chatId);
|
|
138
|
+
return persona ? this.store.deleteVisibleById(normalizeChatId(chatId), this.botId, persona.id) : 0;
|
|
139
|
+
}
|
|
140
|
+
return this.store.deleteVisibleByKeyword(normalizeChatId(chatId), this.botId, value);
|
|
141
|
+
}
|
|
142
|
+
promote(chatId, id) {
|
|
143
|
+
return this.store.promote(normalizeChatId(chatId), this.botId, id);
|
|
144
|
+
}
|
|
145
|
+
clearScope(chatId, scope) {
|
|
146
|
+
return this.store.clearScope(normalizeChatId(chatId), this.botId, scope);
|
|
147
|
+
}
|
|
148
|
+
cleanupExpired(now = Date.now()) {
|
|
149
|
+
return this.store.deleteExpired(now);
|
|
150
|
+
}
|
|
151
|
+
async backup(retain = 7) {
|
|
152
|
+
const dbPath = this.store.getPath();
|
|
153
|
+
try {
|
|
154
|
+
await fs.access(dbPath);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const backupDir = getMemoryBackupDir();
|
|
160
|
+
await fs.mkdir(backupDir, { recursive: true });
|
|
161
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
162
|
+
const backupPath = path.join(backupDir, `memory-${stamp}.sqlite`);
|
|
163
|
+
try {
|
|
164
|
+
await fs.copyFile(dbPath, backupPath);
|
|
165
|
+
await pruneBackups(backupDir, retain);
|
|
166
|
+
return backupPath;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
formatList(chatId, query) {
|
|
173
|
+
const entries = this.listVisible(chatId, query, 30);
|
|
174
|
+
if (!entries.length)
|
|
175
|
+
return "🤖 还没有记忆。";
|
|
176
|
+
return [
|
|
177
|
+
"🤖 当前记忆",
|
|
178
|
+
"",
|
|
179
|
+
...entries.map(formatMemorySummary)
|
|
180
|
+
].join("\n\n");
|
|
181
|
+
}
|
|
182
|
+
normalizeSaveInput(chatId, input) {
|
|
183
|
+
if (!input.content.trim()) {
|
|
184
|
+
throw new Error("记忆内容不能为空。");
|
|
185
|
+
}
|
|
186
|
+
const category = input.category ?? "fact";
|
|
187
|
+
if (category === "credential" && !input.encrypted) {
|
|
188
|
+
throw new Error("加密记忆尚未启用,凭证类内容不会明文保存。");
|
|
189
|
+
}
|
|
190
|
+
const scope = normalizeScope(input.scope ?? defaultScopeForCategory(category), category);
|
|
191
|
+
const duration = input.duration ?? "permanent";
|
|
192
|
+
const expiresAt = duration === "short_term"
|
|
193
|
+
? input.expiresAt ?? Date.now() + this.shortTermTtlMs
|
|
194
|
+
: null;
|
|
195
|
+
return {
|
|
196
|
+
chatId: normalizeChatId(chatId),
|
|
197
|
+
scope,
|
|
198
|
+
botId: scope === "bot" ? this.botId : null,
|
|
199
|
+
category,
|
|
200
|
+
title: (input.title ?? inferTitle(input.content, category)).trim(),
|
|
201
|
+
content: input.content.trim(),
|
|
202
|
+
tags: normalizeTags(input.tags ?? []),
|
|
203
|
+
priority: input.priority ?? 0,
|
|
204
|
+
duration,
|
|
205
|
+
encrypted: input.encrypted ?? false,
|
|
206
|
+
expiresAt
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function getBotIdFromToken(token) {
|
|
211
|
+
const value = String(token ?? "").trim();
|
|
212
|
+
if (!value)
|
|
213
|
+
return "default";
|
|
214
|
+
return value.split(":", 1)[0] || "default";
|
|
215
|
+
}
|
|
216
|
+
export function buildFullProfilePrompt(rawDescription) {
|
|
217
|
+
return [
|
|
218
|
+
"根据以下用户的自我描述,生成两份内容,用分隔符 '=====PERSONA=====' 分开。",
|
|
219
|
+
"",
|
|
220
|
+
"<user_description>",
|
|
221
|
+
rawDescription,
|
|
222
|
+
"</user_description>",
|
|
223
|
+
"",
|
|
224
|
+
"第一部分【用户画像】(跨所有助手共享,只描述用户本人):",
|
|
225
|
+
"- 用户身份、技术背景、通用偏好、通用工作方式",
|
|
226
|
+
"- 不要包含任何特定助手的角色定位",
|
|
227
|
+
"- 500 字符以内",
|
|
228
|
+
"",
|
|
229
|
+
"建议结构:",
|
|
230
|
+
"## 用户背景",
|
|
231
|
+
"## 通用偏好",
|
|
232
|
+
"",
|
|
233
|
+
"第二部分【Bot 人设】(仅本助手使用):",
|
|
234
|
+
"- 本助手的职责域、专业风格、风险边界",
|
|
235
|
+
"- 400 字符以内",
|
|
236
|
+
"",
|
|
237
|
+
"建议结构:",
|
|
238
|
+
"## 角色与职责",
|
|
239
|
+
"## 工作风格",
|
|
240
|
+
"## 风险边界",
|
|
241
|
+
"",
|
|
242
|
+
"通用要求:输出纯文本;中文输入就使用中文;只输出内容本身,不要多余说明。"
|
|
243
|
+
].join("\n");
|
|
244
|
+
}
|
|
245
|
+
export function buildPersonaPrompt(rawDescription, existingProfile) {
|
|
246
|
+
return [
|
|
247
|
+
"已知用户画像如下(勿修改、勿重复其中内容):",
|
|
248
|
+
"<existing_profile>",
|
|
249
|
+
existingProfile,
|
|
250
|
+
"</existing_profile>",
|
|
251
|
+
"",
|
|
252
|
+
"用户希望本助手承担的职责描述:",
|
|
253
|
+
"<bot_description>",
|
|
254
|
+
rawDescription,
|
|
255
|
+
"</bot_description>",
|
|
256
|
+
"",
|
|
257
|
+
"生成本助手的 Bot 人设,要求:",
|
|
258
|
+
"- 只描述本助手的职责域、专业风格、风险边界",
|
|
259
|
+
"- 400 字符以内;中文输入就使用中文",
|
|
260
|
+
"",
|
|
261
|
+
"建议结构:",
|
|
262
|
+
"## 角色与职责",
|
|
263
|
+
"## 工作风格",
|
|
264
|
+
"## 风险边界",
|
|
265
|
+
"",
|
|
266
|
+
"只输出人设内容,不要多余说明。"
|
|
267
|
+
].join("\n");
|
|
268
|
+
}
|
|
269
|
+
export function splitProfileAndPersona(value) {
|
|
270
|
+
const [profile, ...rest] = value.split("=====PERSONA=====");
|
|
271
|
+
return {
|
|
272
|
+
profile: profile?.trim() || value.trim(),
|
|
273
|
+
persona: rest.join("=====PERSONA=====").trim()
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function normalizeChatId(chatId) {
|
|
277
|
+
return String(chatId);
|
|
278
|
+
}
|
|
279
|
+
function normalizeScope(scope, category) {
|
|
280
|
+
if (category === "profile")
|
|
281
|
+
return "global";
|
|
282
|
+
if (category === "persona" || category === "credential")
|
|
283
|
+
return "bot";
|
|
284
|
+
return scope;
|
|
285
|
+
}
|
|
286
|
+
function defaultScopeForCategory(category) {
|
|
287
|
+
if (category === "profile")
|
|
288
|
+
return "global";
|
|
289
|
+
if (category === "persona" || category === "credential" || category === "note" || category === "skill")
|
|
290
|
+
return "bot";
|
|
291
|
+
return "global";
|
|
292
|
+
}
|
|
293
|
+
function classifyCategory(content) {
|
|
294
|
+
const text = content.toLowerCase();
|
|
295
|
+
if (/密码|账号|token|api[_ -]?key|secret|credential|password/.test(text))
|
|
296
|
+
return "credential";
|
|
297
|
+
if (/偏好|喜欢|习惯|prefer|preference/.test(text))
|
|
298
|
+
return "preference";
|
|
299
|
+
if (/明天|今天|本周|临时|提醒|todo|计划/.test(text))
|
|
300
|
+
return "note";
|
|
301
|
+
return "fact";
|
|
302
|
+
}
|
|
303
|
+
function inferTitle(content, category) {
|
|
304
|
+
const firstLine = content.trim().split(/\r?\n/, 1)[0] ?? "";
|
|
305
|
+
const title = firstLine.replace(/^#+\s*/, "").trim();
|
|
306
|
+
if (title)
|
|
307
|
+
return truncateText(title, 40);
|
|
308
|
+
if (category === "preference")
|
|
309
|
+
return "偏好";
|
|
310
|
+
if (category === "note")
|
|
311
|
+
return "临时笔记";
|
|
312
|
+
return "记忆";
|
|
313
|
+
}
|
|
314
|
+
function inferTags(content, category) {
|
|
315
|
+
const tags = new Set([category]);
|
|
316
|
+
for (const match of content.matchAll(/#([\p{L}\p{N}_-]+)/gu)) {
|
|
317
|
+
tags.add(match[1]);
|
|
318
|
+
}
|
|
319
|
+
return [...tags].slice(0, 8);
|
|
320
|
+
}
|
|
321
|
+
function normalizeTags(tags) {
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
const result = [];
|
|
324
|
+
for (const tag of tags) {
|
|
325
|
+
const value = String(tag).trim();
|
|
326
|
+
if (!value || seen.has(value))
|
|
327
|
+
continue;
|
|
328
|
+
seen.add(value);
|
|
329
|
+
result.push(value);
|
|
330
|
+
if (result.length >= 12)
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
function extractKeywords(prompt) {
|
|
336
|
+
const words = prompt
|
|
337
|
+
.toLowerCase()
|
|
338
|
+
.replace(/[^\p{L}\p{N}_-]+/gu, " ")
|
|
339
|
+
.split(/\s+/)
|
|
340
|
+
.filter((word) => word.length >= 2);
|
|
341
|
+
return [...new Set(words)].slice(0, 20);
|
|
342
|
+
}
|
|
343
|
+
function scoreMemory(entry, keywords) {
|
|
344
|
+
if (!keywords.length)
|
|
345
|
+
return 0;
|
|
346
|
+
const haystack = `${entry.title}\n${entry.content}\n${entry.tags.join(" ")}`.toLowerCase();
|
|
347
|
+
let score = 0;
|
|
348
|
+
for (const keyword of keywords) {
|
|
349
|
+
if (haystack.includes(keyword))
|
|
350
|
+
score += entry.title.toLowerCase().includes(keyword) ? 3 : 1;
|
|
351
|
+
}
|
|
352
|
+
if (entry.category === "preference")
|
|
353
|
+
score += 1;
|
|
354
|
+
if (entry.category === "note")
|
|
355
|
+
score += 0.5;
|
|
356
|
+
return score;
|
|
357
|
+
}
|
|
358
|
+
function sortRequiredEntries(entries) {
|
|
359
|
+
const order = {
|
|
360
|
+
persona: 0,
|
|
361
|
+
profile: 1,
|
|
362
|
+
preference: 2,
|
|
363
|
+
fact: 3,
|
|
364
|
+
note: 4,
|
|
365
|
+
skill: 5,
|
|
366
|
+
credential: 6
|
|
367
|
+
};
|
|
368
|
+
return [...entries].sort((a, b) => order[a.category] - order[b.category] || b.updatedAt - a.updatedAt);
|
|
369
|
+
}
|
|
370
|
+
function dedupeEntries(entries) {
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
const result = [];
|
|
373
|
+
for (const entry of entries) {
|
|
374
|
+
if (seen.has(entry.id))
|
|
375
|
+
continue;
|
|
376
|
+
seen.add(entry.id);
|
|
377
|
+
result.push(entry);
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
function trimEntriesToBudget(entries, budget) {
|
|
382
|
+
const required = entries.filter((entry) => entry.priority > 0);
|
|
383
|
+
const optional = entries.filter((entry) => entry.priority <= 0);
|
|
384
|
+
const result = [...required];
|
|
385
|
+
let used = required.reduce((sum, entry) => sum + entry.content.length + entry.title.length, 0);
|
|
386
|
+
for (const entry of optional) {
|
|
387
|
+
const cost = entry.content.length + entry.title.length;
|
|
388
|
+
if (used + cost > budget && result.length)
|
|
389
|
+
continue;
|
|
390
|
+
result.push(entry);
|
|
391
|
+
used += cost;
|
|
392
|
+
if (used >= budget)
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
function formatMemorySummary(entry) {
|
|
398
|
+
const scope = entry.scope === "global" ? "global" : "bot";
|
|
399
|
+
const expiry = entry.expiresAt ? `\nExpires:<code>${escapeHtml(new Date(entry.expiresAt).toISOString())}</code>` : "";
|
|
400
|
+
return [
|
|
401
|
+
`<b>#${entry.id} ${escapeHtml(entry.title)}</b>`,
|
|
402
|
+
`Scope:<code>${scope}</code> · Category:<code>${entry.category}</code>`,
|
|
403
|
+
`Content:${escapeHtml(truncateText(entry.encrypted ? "(encrypted)" : entry.content, 220))}${expiry}`
|
|
404
|
+
].join("\n");
|
|
405
|
+
}
|
|
406
|
+
async function pruneBackups(backupDir, retain) {
|
|
407
|
+
const entries = await fs.readdir(backupDir).catch(() => []);
|
|
408
|
+
const files = entries
|
|
409
|
+
.filter((name) => /^memory-\d{4}-\d{2}-\d{2}\.sqlite$/.test(name))
|
|
410
|
+
.sort()
|
|
411
|
+
.reverse();
|
|
412
|
+
await Promise.all(files.slice(retain).map((name) => fs.rm(path.join(backupDir, name), { force: true })));
|
|
413
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { getMemoryDbPath } from "../../core/config/paths.js";
|
|
5
|
+
export class MemoryStore {
|
|
6
|
+
dbPath;
|
|
7
|
+
db;
|
|
8
|
+
constructor(dbPath = getMemoryDbPath()) {
|
|
9
|
+
this.dbPath = dbPath;
|
|
10
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
11
|
+
this.db = new Database(dbPath);
|
|
12
|
+
this.db.pragma("journal_mode = WAL");
|
|
13
|
+
this.db.pragma("busy_timeout = 5000");
|
|
14
|
+
this.migrate();
|
|
15
|
+
}
|
|
16
|
+
close() {
|
|
17
|
+
this.db.close();
|
|
18
|
+
}
|
|
19
|
+
getPath() {
|
|
20
|
+
return this.dbPath;
|
|
21
|
+
}
|
|
22
|
+
save(input) {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const result = this.db.prepare(`insert into memories (
|
|
25
|
+
chatId, scope, botId, category, title, content, tags, priority, duration,
|
|
26
|
+
encrypted, expiresAt, createdAt, updatedAt
|
|
27
|
+
) values (
|
|
28
|
+
@chatId, @scope, @botId, @category, @title, @content, @tagsJson, @priority, @duration,
|
|
29
|
+
@encryptedValue, @expiresAt, @now, @now
|
|
30
|
+
)`).run({
|
|
31
|
+
...input,
|
|
32
|
+
tagsJson: JSON.stringify(input.tags),
|
|
33
|
+
encryptedValue: input.encrypted ? 1 : 0,
|
|
34
|
+
now
|
|
35
|
+
});
|
|
36
|
+
return this.getById(Number(result.lastInsertRowid));
|
|
37
|
+
}
|
|
38
|
+
upsertSingleton(input) {
|
|
39
|
+
const current = this.getSingleton(input.chatId, input.scope, input.botId, input.category, input.title);
|
|
40
|
+
if (current) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
this.db.prepare(`update memories set
|
|
43
|
+
content = @content,
|
|
44
|
+
tags = @tagsJson,
|
|
45
|
+
priority = @priority,
|
|
46
|
+
duration = @duration,
|
|
47
|
+
encrypted = @encryptedValue,
|
|
48
|
+
expiresAt = @expiresAt,
|
|
49
|
+
updatedAt = @now
|
|
50
|
+
where id = @id`).run({
|
|
51
|
+
id: current.id,
|
|
52
|
+
content: input.content,
|
|
53
|
+
tagsJson: JSON.stringify(input.tags),
|
|
54
|
+
priority: input.priority,
|
|
55
|
+
duration: input.duration,
|
|
56
|
+
encryptedValue: input.encrypted ? 1 : 0,
|
|
57
|
+
expiresAt: input.expiresAt,
|
|
58
|
+
now
|
|
59
|
+
});
|
|
60
|
+
return this.getById(current.id);
|
|
61
|
+
}
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
this.db.prepare(`insert into memories (
|
|
64
|
+
chatId, scope, botId, category, title, content, tags, priority, duration,
|
|
65
|
+
encrypted, expiresAt, createdAt, updatedAt
|
|
66
|
+
) values (
|
|
67
|
+
@chatId, @scope, @botId, @category, @title, @content, @tagsJson, @priority, @duration,
|
|
68
|
+
@encryptedValue, @expiresAt, @now, @now
|
|
69
|
+
)`).run({
|
|
70
|
+
...input,
|
|
71
|
+
tagsJson: JSON.stringify(input.tags),
|
|
72
|
+
encryptedValue: input.encrypted ? 1 : 0,
|
|
73
|
+
now
|
|
74
|
+
});
|
|
75
|
+
return this.getSingleton(input.chatId, input.scope, input.botId, input.category, input.title);
|
|
76
|
+
}
|
|
77
|
+
getById(id) {
|
|
78
|
+
const row = this.db.prepare("select * from memories where id = ?").get(id);
|
|
79
|
+
return row ? rowToEntry(row) : null;
|
|
80
|
+
}
|
|
81
|
+
getSingleton(chatId, scope, botId, category, title) {
|
|
82
|
+
const row = this.db.prepare(`select * from memories
|
|
83
|
+
where chatId = ? and scope = ? and botId is ? and category = ? and title = ?
|
|
84
|
+
limit 1`).get(chatId, scope, botId, category, title);
|
|
85
|
+
return row ? rowToEntry(row) : null;
|
|
86
|
+
}
|
|
87
|
+
getProfile(chatId) {
|
|
88
|
+
const row = this.db.prepare("select * from memories where chatId = ? and scope = 'global' and category = 'profile' order by updatedAt desc limit 1").get(chatId);
|
|
89
|
+
return row ? rowToEntry(row) : null;
|
|
90
|
+
}
|
|
91
|
+
getPersona(chatId, botId) {
|
|
92
|
+
const row = this.db.prepare("select * from memories where chatId = ? and scope = 'bot' and botId = ? and category = 'persona' order by updatedAt desc limit 1").get(chatId, botId);
|
|
93
|
+
return row ? rowToEntry(row) : null;
|
|
94
|
+
}
|
|
95
|
+
listVisible(input) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const params = {
|
|
98
|
+
chatId: input.chatId,
|
|
99
|
+
botId: input.botId,
|
|
100
|
+
now,
|
|
101
|
+
limit: input.limit ?? 100
|
|
102
|
+
};
|
|
103
|
+
const query = input.query?.trim().toLowerCase();
|
|
104
|
+
const searchClause = query
|
|
105
|
+
? "and (lower(title) like @query or lower(content) like @query or lower(tags) like @query)"
|
|
106
|
+
: "";
|
|
107
|
+
if (query)
|
|
108
|
+
params.query = `%${query}%`;
|
|
109
|
+
const rows = this.db.prepare(`select * from memories
|
|
110
|
+
where ${visibilityClause()} and (expiresAt is null or expiresAt > @now)
|
|
111
|
+
${searchClause}
|
|
112
|
+
order by priority desc, updatedAt desc, id desc
|
|
113
|
+
limit @limit`).all(params);
|
|
114
|
+
return rows.map(rowToEntry);
|
|
115
|
+
}
|
|
116
|
+
deleteVisibleById(chatId, botId, id) {
|
|
117
|
+
const result = this.db.prepare(`delete from memories
|
|
118
|
+
where id = @id and ${visibilityClause()}`).run({ chatId, botId, id });
|
|
119
|
+
return result.changes;
|
|
120
|
+
}
|
|
121
|
+
deleteVisibleByKeyword(chatId, botId, keyword) {
|
|
122
|
+
if (keyword.trim().length < 2)
|
|
123
|
+
return 0;
|
|
124
|
+
const query = `%${keyword.trim().toLowerCase()}%`;
|
|
125
|
+
const result = this.db.prepare(`delete from memories
|
|
126
|
+
where ${visibilityClause()}
|
|
127
|
+
and (lower(title) like @query or lower(content) like @query or lower(tags) like @query)`).run({ chatId, botId, query });
|
|
128
|
+
return result.changes;
|
|
129
|
+
}
|
|
130
|
+
clearScope(chatId, botId, scope) {
|
|
131
|
+
const result = scope === "global"
|
|
132
|
+
? this.db.prepare("delete from memories where chatId = ? and scope = 'global'").run(chatId)
|
|
133
|
+
: this.db.prepare("delete from memories where chatId = ? and scope = 'bot' and botId = ?").run(chatId, botId);
|
|
134
|
+
return result.changes;
|
|
135
|
+
}
|
|
136
|
+
promote(chatId, botId, id) {
|
|
137
|
+
const entry = this.getById(id);
|
|
138
|
+
if (!entry || entry.chatId !== chatId || entry.scope !== "bot" || entry.botId !== botId)
|
|
139
|
+
return null;
|
|
140
|
+
if (entry.category === "persona") {
|
|
141
|
+
throw new Error("Bot 人设不能升级为 global。");
|
|
142
|
+
}
|
|
143
|
+
this.db.prepare("update memories set scope = 'global', botId = null, updatedAt = ? where id = ?").run(Date.now(), id);
|
|
144
|
+
return this.getById(id);
|
|
145
|
+
}
|
|
146
|
+
deleteExpired(now = Date.now()) {
|
|
147
|
+
const result = this.db.prepare("delete from memories where expiresAt is not null and expiresAt <= ?").run(now);
|
|
148
|
+
return result.changes;
|
|
149
|
+
}
|
|
150
|
+
migrate() {
|
|
151
|
+
this.db.exec(`
|
|
152
|
+
create table if not exists memories (
|
|
153
|
+
id integer primary key autoincrement,
|
|
154
|
+
chatId text not null,
|
|
155
|
+
scope text not null default 'bot' check (scope in ('global', 'bot')),
|
|
156
|
+
botId text,
|
|
157
|
+
category text not null check (category in ('profile', 'persona', 'credential', 'fact', 'preference', 'note', 'skill')),
|
|
158
|
+
title text not null,
|
|
159
|
+
content text not null,
|
|
160
|
+
tags text not null default '[]',
|
|
161
|
+
priority integer not null default 0,
|
|
162
|
+
duration text not null default 'permanent' check (duration in ('permanent', 'short_term')),
|
|
163
|
+
encrypted integer not null default 0,
|
|
164
|
+
expiresAt integer,
|
|
165
|
+
createdAt integer not null,
|
|
166
|
+
updatedAt integer not null,
|
|
167
|
+
check (scope = 'global' or botId is not null),
|
|
168
|
+
unique (chatId, scope, botId, category, title)
|
|
169
|
+
);
|
|
170
|
+
create index if not exists idx_memories_owner on memories (chatId, scope, botId);
|
|
171
|
+
create index if not exists idx_memories_expiry on memories (expiresAt) where expiresAt is not null;
|
|
172
|
+
|
|
173
|
+
create table if not exists memory_keys (
|
|
174
|
+
chatId text primary key,
|
|
175
|
+
salt text not null,
|
|
176
|
+
verifier text not null,
|
|
177
|
+
failCount integer not null default 0,
|
|
178
|
+
lockedUntil integer,
|
|
179
|
+
createdAt integer not null,
|
|
180
|
+
updatedAt integer not null
|
|
181
|
+
);
|
|
182
|
+
`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function visibilityClause() {
|
|
186
|
+
return "chatId = @chatId and (scope = 'global' or (scope = 'bot' and botId = @botId))";
|
|
187
|
+
}
|
|
188
|
+
function rowToEntry(row) {
|
|
189
|
+
return {
|
|
190
|
+
id: row.id,
|
|
191
|
+
chatId: row.chatId,
|
|
192
|
+
scope: row.scope,
|
|
193
|
+
botId: row.botId,
|
|
194
|
+
category: row.category,
|
|
195
|
+
title: row.title,
|
|
196
|
+
content: row.content,
|
|
197
|
+
tags: parseTags(row.tags),
|
|
198
|
+
priority: row.priority,
|
|
199
|
+
duration: row.duration,
|
|
200
|
+
encrypted: row.encrypted === 1,
|
|
201
|
+
expiresAt: row.expiresAt,
|
|
202
|
+
createdAt: row.createdAt,
|
|
203
|
+
updatedAt: row.updatedAt
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function parseTags(value) {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(value);
|
|
209
|
+
return Array.isArray(parsed)
|
|
210
|
+
? parsed.filter((item) => typeof item === "string")
|
|
211
|
+
: [];
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const DEFAULT_RETRY_DELAYS_MS = [250, 750];
|
|
2
|
+
export async function withTelegramNetworkRetry(operation, options = {}) {
|
|
3
|
+
const delays = options.delaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
4
|
+
for (let attempt = 0;; attempt += 1) {
|
|
5
|
+
try {
|
|
6
|
+
return await operation();
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (attempt >= delays.length || !isRetryableTelegramNetworkError(error)) {
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
await delay(delays[attempt]);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function isRetryableTelegramNetworkError(error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
return /Network request|fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up|\b5\d\d\b/i.test(message);
|
|
19
|
+
}
|
|
20
|
+
function delay(ms) {
|
|
21
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
22
|
+
}
|