openclaw-memory-alibaba-mysql_beta 0.1.5
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 +68 -0
- package/categories.ts +57 -0
- package/config.ts +290 -0
- package/db.ts +248 -0
- package/index.ts +924 -0
- package/openclaw.plugin.json +85 -0
- package/package.json +45 -0
- package/prompts.ts +116 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# OpenClaw Memory (Alibaba Cloud RDS MySQL)
|
|
2
|
+
|
|
3
|
+
OpenClaw 记忆插件,使用阿里云 RDS MySQL 向量存储。用户记忆细分为三类写入与召回。
|
|
4
|
+
|
|
5
|
+
## 记忆分类 (category)
|
|
6
|
+
|
|
7
|
+
| 类型 | category | 说明 |
|
|
8
|
+
|------|----------|------|
|
|
9
|
+
| 用户事实 | `user_memory_fact` | 用户陈述的事实、习惯、身份信息等 |
|
|
10
|
+
| 用户偏好 | `user_memory_preference` | 喜欢/不喜欢、偏好、意愿 |
|
|
11
|
+
| 用户决策 | `user_memory_decision` | 已做的决定、打算、计划 |
|
|
12
|
+
|
|
13
|
+
- **自动抓取 (autoCapture)**:从用户消息中按关键词推断类别并写入上述三者之一。
|
|
14
|
+
- **memory_store 工具**:可显式指定 `category` 为 `user_memory_fact` / `user_memory_preference` / `user_memory_decision`,默认 `user_memory_fact`。
|
|
15
|
+
- **召回**:`before_agent_start` 与 `memory_recall` 不区分子类型,统一按向量检索并注入上下文。
|
|
16
|
+
|
|
17
|
+
## 配置示例
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"plugins": {
|
|
22
|
+
"slots": { "memory": "openclaw-memory-alibaba-mysql_beta" },
|
|
23
|
+
"entries": {
|
|
24
|
+
"openclaw-memory-alibaba-mysql_beta": {
|
|
25
|
+
"enabled": true,
|
|
26
|
+
"config": {
|
|
27
|
+
"mysql": {
|
|
28
|
+
"host": "your-rds.aliyuncs.com",
|
|
29
|
+
"port": 3306,
|
|
30
|
+
"user": "openclaw",
|
|
31
|
+
"password": "${MYSQL_PASSWORD}",
|
|
32
|
+
"database": "openclaw",
|
|
33
|
+
"ssl": true
|
|
34
|
+
},
|
|
35
|
+
"embedding": {
|
|
36
|
+
"apiKey": "${DASHSCOPE_API_KEY}",
|
|
37
|
+
"model": "text-embedding-v3",
|
|
38
|
+
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
39
|
+
},
|
|
40
|
+
"autoRecall": true,
|
|
41
|
+
"autoCapture": false,
|
|
42
|
+
"captureMaxChars": 500,
|
|
43
|
+
"tableName": "openclaw_memories"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 环境变量
|
|
52
|
+
|
|
53
|
+
- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`:MySQL 连接(或写在 config 中,password 支持 `${VAR}`)。
|
|
54
|
+
- `DASHSCOPE_API_KEY`:DashScope 用于 embedding(若 apiKey 用 `${DASHSCOPE_API_KEY}`)。
|
|
55
|
+
|
|
56
|
+
## 测试
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export MYSQL_HOST=your-host MYSQL_PASSWORD=xxx DASHSCOPE_API_KEY=xxx
|
|
60
|
+
npx tsx test-agent-isolation.ts
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 文件说明
|
|
64
|
+
|
|
65
|
+
- `categories.ts`:常量 `user_memory_fact` / `user_memory_preference` / `user_memory_decision` 及类型。
|
|
66
|
+
- `config.ts`:MySQL、embedding、autoRecall、autoCapture、tableName 解析。
|
|
67
|
+
- `db.ts`:MemoryDB 建表、store、search、delete(向量 COSINE)。
|
|
68
|
+
- `index.ts`:插件注册、memory_recall / memory_store / memory_forget、before_agent_start / agent_end 钩子。
|
package/categories.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory category constants.
|
|
3
|
+
* user_memory is subdivided into fact / preference / decision for storage and recall.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** User memory sub-categories (user-side facts, preferences, decisions) */
|
|
7
|
+
export const USER_MEMORY_FACT = "user_memory_fact" as const;
|
|
8
|
+
export const USER_MEMORY_PREFERENCE = "user_memory_preference" as const;
|
|
9
|
+
export const USER_MEMORY_DECISION = "user_memory_decision" as const;
|
|
10
|
+
|
|
11
|
+
/** All user memory category values (for recall: treat as one logical "user_memory" set) */
|
|
12
|
+
export const USER_MEMORY_CATEGORIES = [
|
|
13
|
+
USER_MEMORY_FACT,
|
|
14
|
+
USER_MEMORY_PREFERENCE,
|
|
15
|
+
USER_MEMORY_DECISION,
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type UserMemoryCategory =
|
|
19
|
+
| typeof USER_MEMORY_FACT
|
|
20
|
+
| typeof USER_MEMORY_PREFERENCE
|
|
21
|
+
| typeof USER_MEMORY_DECISION;
|
|
22
|
+
|
|
23
|
+
/** Full context (conversation audit / per-message) */
|
|
24
|
+
export const FULL_CONTEXT_MEMORY = "full_context_memory" as const;
|
|
25
|
+
|
|
26
|
+
/** Self-improving memory sub-categories */
|
|
27
|
+
export const SELF_IMPROVING_LEARNINGS = "self_improving_learnings" as const;
|
|
28
|
+
export const SELF_IMPROVING_ERRORS = "self_improving_errors" as const;
|
|
29
|
+
export const SELF_IMPROVING_FEATURE_REQUESTS = "self_improving_feature_requests" as const;
|
|
30
|
+
|
|
31
|
+
export const SELF_IMPROVING_CATEGORIES = [
|
|
32
|
+
SELF_IMPROVING_LEARNINGS,
|
|
33
|
+
SELF_IMPROVING_ERRORS,
|
|
34
|
+
SELF_IMPROVING_FEATURE_REQUESTS,
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export type SelfImprovingCategory = (typeof SELF_IMPROVING_CATEGORIES)[number];
|
|
38
|
+
|
|
39
|
+
/** All allowed category values */
|
|
40
|
+
export type MemoryCategory =
|
|
41
|
+
| UserMemoryCategory
|
|
42
|
+
| typeof FULL_CONTEXT_MEMORY
|
|
43
|
+
| SelfImprovingCategory;
|
|
44
|
+
|
|
45
|
+
export const ALL_CATEGORIES: readonly MemoryCategory[] = [
|
|
46
|
+
...USER_MEMORY_CATEGORIES,
|
|
47
|
+
FULL_CONTEXT_MEMORY,
|
|
48
|
+
...SELF_IMPROVING_CATEGORIES,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export function isUserMemoryCategory(cat: string): cat is UserMemoryCategory {
|
|
52
|
+
return USER_MEMORY_CATEGORIES.includes(cat as UserMemoryCategory);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isSelfImprovingCategory(cat: string): cat is SelfImprovingCategory {
|
|
56
|
+
return SELF_IMPROVING_CATEGORIES.includes(cat as SelfImprovingCategory);
|
|
57
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { MemoryCategory } from "./categories.js";
|
|
2
|
+
import { ALL_CATEGORIES, USER_MEMORY_FACT } from "./categories.js";
|
|
3
|
+
|
|
4
|
+
export type MysqlConnectionConfig = {
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
user: string;
|
|
8
|
+
password: string;
|
|
9
|
+
database: string;
|
|
10
|
+
ssl: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type EmbeddingConfig = {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
model: string;
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
dimensions?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LLMConfig = {
|
|
21
|
+
apiKey: string;
|
|
22
|
+
model: string;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MemoryConfig = {
|
|
27
|
+
mysql: MysqlConnectionConfig;
|
|
28
|
+
embedding: EmbeddingConfig;
|
|
29
|
+
/** When true, use LLM to decide insert vs update among top-10 similar memories; requires llm config. Default false. */
|
|
30
|
+
memory_duplication_conflict_process: boolean;
|
|
31
|
+
/** Required when memory_duplication_conflict_process is true. */
|
|
32
|
+
llm?: LLMConfig;
|
|
33
|
+
/** Similarity threshold for user_memory_* (0–1). Default 0.95. */
|
|
34
|
+
similarityThresholdUserMemory: number;
|
|
35
|
+
/** Similarity threshold for self_improving_* (0–1). Default 0.92. */
|
|
36
|
+
similarityThresholdSelfImproving: number;
|
|
37
|
+
/** When false, full_context_memory is not written or recalled. Default false. */
|
|
38
|
+
enableFullContextMemory: boolean;
|
|
39
|
+
/** When false, self_improving_* is not written or recalled. Default false. */
|
|
40
|
+
enableSelfImprovingMemory: boolean;
|
|
41
|
+
/** How to extract user and self_improving memories in auto-capture: "regex" (default) or "llm". Case-insensitive; invalid values fall back to "regex". When "llm", llm config is required. */
|
|
42
|
+
memoryExtractionMethod: "regex" | "llm";
|
|
43
|
+
autoRecall: boolean;
|
|
44
|
+
autoCapture: boolean;
|
|
45
|
+
captureMaxChars: number;
|
|
46
|
+
/** When true, apply time decay to recall scores (older = lower effective score). Default false. */
|
|
47
|
+
enableMemoryDecay: boolean;
|
|
48
|
+
/** Half-life in days for exponential decay. Used when enableMemoryDecay and strategy is exponential. Default 30. */
|
|
49
|
+
memoryDecayHalfLifeDays: number;
|
|
50
|
+
/** Decay curve: "exponential" (0.5^(age/halfLife)), "linear" (1 - age/(2*halfLife)), "none". Default "exponential". */
|
|
51
|
+
memoryDecayStrategy: "exponential" | "linear" | "none";
|
|
52
|
+
tableName: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Re-export for tools and DB (user_memory_* + full_context + self_improving_*) */
|
|
56
|
+
export { ALL_CATEGORIES, USER_MEMORY_FACT };
|
|
57
|
+
export type { MemoryCategory };
|
|
58
|
+
|
|
59
|
+
const DEFAULT_MODEL = "text-embedding-v3";
|
|
60
|
+
const DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
61
|
+
const DEFAULT_TABLE_NAME = "openclaw_memories";
|
|
62
|
+
export const DEFAULT_CAPTURE_MAX_CHARS = 500;
|
|
63
|
+
|
|
64
|
+
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
|
65
|
+
"text-embedding-v3": 1024,
|
|
66
|
+
"text-embedding-v2": 1536,
|
|
67
|
+
"text-embedding-3-small": 1536,
|
|
68
|
+
"text-embedding-3-large": 3072,
|
|
69
|
+
"text-embedding-ada-002": 1536,
|
|
70
|
+
"embed-english-v3.0": 1024,
|
|
71
|
+
"embed-multilingual-v3.0": 1024,
|
|
72
|
+
"embed-english-light-v3.0": 384,
|
|
73
|
+
"embed-multilingual-light-v3.0": 384,
|
|
74
|
+
"jina-embeddings-v3": 1024,
|
|
75
|
+
"jina-embeddings-v2-base-en": 768,
|
|
76
|
+
"jina-embeddings-v2-base-zh": 768,
|
|
77
|
+
"bge-large-zh-v1.5": 1024,
|
|
78
|
+
"bge-large-en-v1.5": 1024,
|
|
79
|
+
"bge-m3": 1024,
|
|
80
|
+
"nomic-embed-text": 768,
|
|
81
|
+
"text-embedding-004": 768,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const FLEX_DIMS_MODELS = new Set([
|
|
85
|
+
"text-embedding-v3",
|
|
86
|
+
"text-embedding-3-small",
|
|
87
|
+
"text-embedding-3-large",
|
|
88
|
+
"jina-embeddings-v3",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
export function vectorDimsForModel(model: string, explicit?: number): number {
|
|
92
|
+
if (explicit && explicit > 0) return explicit;
|
|
93
|
+
return EMBEDDING_DIMENSIONS[model] ?? 1024;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function modelSupportsFlexDimensions(model: string): boolean {
|
|
97
|
+
return FLEX_DIMS_MODELS.has(model);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveEnvVars(value: string): string {
|
|
101
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
|
102
|
+
const envValue = process.env[envVar];
|
|
103
|
+
if (!envValue) {
|
|
104
|
+
throw new Error(`Environment variable ${envVar} is not set`);
|
|
105
|
+
}
|
|
106
|
+
return envValue;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
|
|
111
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
112
|
+
if (unknown.length > 0) {
|
|
113
|
+
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function requireString(obj: Record<string, unknown>, key: string, label: string): string {
|
|
118
|
+
const v = obj[key];
|
|
119
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
120
|
+
throw new Error(`${label}.${key} is required and must be a non-empty string`);
|
|
121
|
+
}
|
|
122
|
+
return v;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseMysqlConfig(raw: unknown): MysqlConnectionConfig {
|
|
126
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
127
|
+
throw new Error("mysql config is required");
|
|
128
|
+
}
|
|
129
|
+
const m = raw as Record<string, unknown>;
|
|
130
|
+
assertAllowedKeys(m, ["host", "port", "user", "password", "database", "ssl"], "mysql");
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
host: requireString(m, "host", "mysql"),
|
|
134
|
+
port: typeof m.port === "number" ? m.port : 3306,
|
|
135
|
+
user: requireString(m, "user", "mysql"),
|
|
136
|
+
password: resolveEnvVars(requireString(m, "password", "mysql")),
|
|
137
|
+
database: requireString(m, "database", "mysql"),
|
|
138
|
+
ssl: m.ssl === true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseEmbeddingConfig(raw: unknown): EmbeddingConfig {
|
|
143
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
144
|
+
throw new Error("embedding config is required");
|
|
145
|
+
}
|
|
146
|
+
const e = raw as Record<string, unknown>;
|
|
147
|
+
assertAllowedKeys(e, ["apiKey", "model", "baseUrl", "dimensions"], "embedding");
|
|
148
|
+
|
|
149
|
+
const model = typeof e.model === "string" ? e.model : DEFAULT_MODEL;
|
|
150
|
+
const explicitDims = typeof e.dimensions === "number" ? e.dimensions : undefined;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
apiKey: resolveEnvVars(requireString(e, "apiKey", "embedding")),
|
|
154
|
+
model,
|
|
155
|
+
baseUrl: typeof e.baseUrl === "string" ? resolveEnvVars(e.baseUrl) : DEFAULT_BASE_URL,
|
|
156
|
+
dimensions: explicitDims,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const DEFAULT_LLM_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
161
|
+
|
|
162
|
+
function parseLLMConfig(raw: unknown): LLMConfig {
|
|
163
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
164
|
+
throw new Error("llm config is required when memory_duplication_conflict_process is true");
|
|
165
|
+
}
|
|
166
|
+
const l = raw as Record<string, unknown>;
|
|
167
|
+
assertAllowedKeys(l, ["apiKey", "model", "baseUrl"], "llm");
|
|
168
|
+
return {
|
|
169
|
+
apiKey: resolveEnvVars(requireString(l, "apiKey", "llm")),
|
|
170
|
+
model: requireString(l, "model", "llm"),
|
|
171
|
+
baseUrl: typeof l.baseUrl === "string" ? resolveEnvVars(l.baseUrl) : DEFAULT_LLM_BASE_URL,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const memoryConfigSchema = {
|
|
176
|
+
parse(value: unknown): MemoryConfig {
|
|
177
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
178
|
+
throw new Error("memory-alibaba-mysql: plugin config is required");
|
|
179
|
+
}
|
|
180
|
+
const cfg = value as Record<string, unknown>;
|
|
181
|
+
|
|
182
|
+
// --- Allowed keys ---
|
|
183
|
+
assertAllowedKeys(
|
|
184
|
+
cfg,
|
|
185
|
+
[
|
|
186
|
+
"mysql",
|
|
187
|
+
"embedding",
|
|
188
|
+
"memory_duplication_conflict_process",
|
|
189
|
+
"llm",
|
|
190
|
+
"similarityThresholdUserMemory",
|
|
191
|
+
"similarityThresholdSelfImproving",
|
|
192
|
+
"enableFullContextMemory",
|
|
193
|
+
"enableSelfImprovingMemory",
|
|
194
|
+
"memoryExtractionMethod",
|
|
195
|
+
"autoRecall",
|
|
196
|
+
"autoCapture",
|
|
197
|
+
"captureMaxChars",
|
|
198
|
+
"enableMemoryDecay",
|
|
199
|
+
"memoryDecayHalfLifeDays",
|
|
200
|
+
"memoryDecayStrategy",
|
|
201
|
+
"tableName",
|
|
202
|
+
],
|
|
203
|
+
"memory config",
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// --- LLM requirement ---
|
|
207
|
+
const memory_duplication_conflict_process = cfg.memory_duplication_conflict_process === true;
|
|
208
|
+
const rawMethod =
|
|
209
|
+
typeof cfg.memoryExtractionMethod === "string"
|
|
210
|
+
? cfg.memoryExtractionMethod.trim().toLowerCase()
|
|
211
|
+
: "";
|
|
212
|
+
const memoryExtractionMethod: "regex" | "llm" =
|
|
213
|
+
rawMethod === "llm" ? "llm" : "regex";
|
|
214
|
+
const needsLlm = memory_duplication_conflict_process || memoryExtractionMethod === "llm";
|
|
215
|
+
if (needsLlm && (!cfg.llm || typeof cfg.llm !== "object")) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
"llm config is required when memory_duplication_conflict_process is true or memoryExtractionMethod is \"llm\"",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Thresholds and feature flags ---
|
|
222
|
+
const similarityThresholdUserMemory =
|
|
223
|
+
typeof cfg.similarityThresholdUserMemory === "number"
|
|
224
|
+
? cfg.similarityThresholdUserMemory
|
|
225
|
+
: 0.95;
|
|
226
|
+
const similarityThresholdSelfImproving =
|
|
227
|
+
typeof cfg.similarityThresholdSelfImproving === "number"
|
|
228
|
+
? cfg.similarityThresholdSelfImproving
|
|
229
|
+
: 0.92;
|
|
230
|
+
if (
|
|
231
|
+
similarityThresholdUserMemory < 0 ||
|
|
232
|
+
similarityThresholdUserMemory > 1 ||
|
|
233
|
+
similarityThresholdSelfImproving < 0 ||
|
|
234
|
+
similarityThresholdSelfImproving > 1
|
|
235
|
+
) {
|
|
236
|
+
throw new Error("similarityThresholdUserMemory and similarityThresholdSelfImproving must be between 0 and 1");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const enableFullContextMemory = cfg.enableFullContextMemory === true;
|
|
240
|
+
const enableSelfImprovingMemory = cfg.enableSelfImprovingMemory === true;
|
|
241
|
+
|
|
242
|
+
const captureMaxChars =
|
|
243
|
+
typeof cfg.captureMaxChars === "number" ? Math.floor(cfg.captureMaxChars) : undefined;
|
|
244
|
+
if (typeof captureMaxChars === "number" && (captureMaxChars < 100 || captureMaxChars > 10_000)) {
|
|
245
|
+
throw new Error("captureMaxChars must be between 100 and 10000");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Memory decay ---
|
|
249
|
+
const enableMemoryDecay = cfg.enableMemoryDecay === true;
|
|
250
|
+
const memoryDecayHalfLifeDays =
|
|
251
|
+
typeof cfg.memoryDecayHalfLifeDays === "number" && cfg.memoryDecayHalfLifeDays > 0
|
|
252
|
+
? cfg.memoryDecayHalfLifeDays
|
|
253
|
+
: 30;
|
|
254
|
+
const rawDecayStrategy =
|
|
255
|
+
typeof cfg.memoryDecayStrategy === "string" ? cfg.memoryDecayStrategy.trim().toLowerCase() : "";
|
|
256
|
+
const memoryDecayStrategy: "exponential" | "linear" | "none" =
|
|
257
|
+
rawDecayStrategy === "linear"
|
|
258
|
+
? "linear"
|
|
259
|
+
: rawDecayStrategy === "none"
|
|
260
|
+
? "none"
|
|
261
|
+
: "exponential";
|
|
262
|
+
|
|
263
|
+
// --- Table name and final object ---
|
|
264
|
+
const tableName = typeof cfg.tableName === "string" ? cfg.tableName : DEFAULT_TABLE_NAME;
|
|
265
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Invalid tableName "${tableName}": must contain only alphanumeric characters and underscores`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
mysql: parseMysqlConfig(cfg.mysql),
|
|
273
|
+
embedding: parseEmbeddingConfig(cfg.embedding),
|
|
274
|
+
memory_duplication_conflict_process,
|
|
275
|
+
llm: needsLlm ? parseLLMConfig(cfg.llm) : undefined,
|
|
276
|
+
similarityThresholdUserMemory,
|
|
277
|
+
similarityThresholdSelfImproving,
|
|
278
|
+
enableFullContextMemory,
|
|
279
|
+
enableSelfImprovingMemory,
|
|
280
|
+
memoryExtractionMethod,
|
|
281
|
+
autoRecall: cfg.autoRecall !== false,
|
|
282
|
+
autoCapture: cfg.autoCapture !== false,
|
|
283
|
+
captureMaxChars: captureMaxChars ?? DEFAULT_CAPTURE_MAX_CHARS,
|
|
284
|
+
enableMemoryDecay,
|
|
285
|
+
memoryDecayHalfLifeDays,
|
|
286
|
+
memoryDecayStrategy,
|
|
287
|
+
tableName,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
package/db.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import mysql from "mysql2/promise";
|
|
3
|
+
import type { Pool } from "mysql2/promise";
|
|
4
|
+
import type { MemoryCategory } from "./config.js";
|
|
5
|
+
import { USER_MEMORY_FACT } from "./categories.js";
|
|
6
|
+
import type { MysqlConnectionConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
export type MemoryEntry = {
|
|
9
|
+
id: string;
|
|
10
|
+
agentId: string;
|
|
11
|
+
text: string;
|
|
12
|
+
importance: number;
|
|
13
|
+
category: MemoryCategory;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
/** 0 = active, 1 = soft-deleted. Omitted when reading from old rows without column. */
|
|
16
|
+
isDeleted?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type MemorySearchResult = {
|
|
20
|
+
entry: MemoryEntry;
|
|
21
|
+
score: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
+
|
|
26
|
+
export class MemoryDB {
|
|
27
|
+
private pool: Pool | null = null;
|
|
28
|
+
private initPromise: Promise<void> | null = null;
|
|
29
|
+
private vectorIndexCreated = false;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly mysqlConfig: MysqlConnectionConfig,
|
|
33
|
+
private readonly tableName: string,
|
|
34
|
+
private readonly vectorDim: number,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
private async ensureInitialized(): Promise<void> {
|
|
38
|
+
if (this.pool) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (this.initPromise) {
|
|
42
|
+
return this.initPromise;
|
|
43
|
+
}
|
|
44
|
+
this.initPromise = this.doInitialize();
|
|
45
|
+
return this.initPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async doInitialize(): Promise<void> {
|
|
49
|
+
this.pool = mysql.createPool({
|
|
50
|
+
host: this.mysqlConfig.host,
|
|
51
|
+
port: this.mysqlConfig.port,
|
|
52
|
+
user: this.mysqlConfig.user,
|
|
53
|
+
password: this.mysqlConfig.password,
|
|
54
|
+
database: this.mysqlConfig.database,
|
|
55
|
+
ssl: this.mysqlConfig.ssl ? {} : undefined,
|
|
56
|
+
connectionLimit: 5,
|
|
57
|
+
waitForConnections: true,
|
|
58
|
+
queueLimit: 0,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const conn = await this.pool.getConnection();
|
|
62
|
+
try {
|
|
63
|
+
await conn.query("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED");
|
|
64
|
+
await conn.query(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS \`${this.tableName}\` (
|
|
66
|
+
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
|
67
|
+
agent_id VARCHAR(128) NOT NULL,
|
|
68
|
+
text TEXT NOT NULL,
|
|
69
|
+
embedding VECTOR(${this.vectorDim}) NOT NULL,
|
|
70
|
+
importance FLOAT DEFAULT 0,
|
|
71
|
+
category VARCHAR(64) DEFAULT '${USER_MEMORY_FACT}',
|
|
72
|
+
created_at BIGINT NOT NULL,
|
|
73
|
+
is_deleted TINYINT NOT NULL DEFAULT 0,
|
|
74
|
+
INDEX idx_agent_id (agent_id)
|
|
75
|
+
) ENGINE=InnoDB
|
|
76
|
+
`);
|
|
77
|
+
await this.ensureIsDeletedColumn(conn);
|
|
78
|
+
await this.tryCreateVectorIndex(conn);
|
|
79
|
+
} finally {
|
|
80
|
+
conn.release();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async ensureIsDeletedColumn(conn: mysql.PoolConnection): Promise<void> {
|
|
85
|
+
const [rows] = await conn.query(
|
|
86
|
+
`SELECT COLUMN_NAME FROM information_schema.COLUMNS
|
|
87
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'is_deleted'`,
|
|
88
|
+
[this.tableName],
|
|
89
|
+
);
|
|
90
|
+
if ((rows as Array<unknown>).length > 0) return;
|
|
91
|
+
await conn.query(
|
|
92
|
+
`ALTER TABLE \`${this.tableName}\` ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async tryCreateVectorIndex(conn: mysql.PoolConnection): Promise<void> {
|
|
97
|
+
if (this.vectorIndexCreated) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const [rows] = await conn.query(
|
|
102
|
+
`SELECT COUNT(1) AS cnt FROM information_schema.statistics
|
|
103
|
+
WHERE table_schema = DATABASE()
|
|
104
|
+
AND table_name = ?
|
|
105
|
+
AND index_name = 'idx_embedding'`,
|
|
106
|
+
[this.tableName],
|
|
107
|
+
);
|
|
108
|
+
const cnt = (rows as Array<{ cnt: number }>)[0]?.cnt ?? 0;
|
|
109
|
+
if (cnt > 0) {
|
|
110
|
+
this.vectorIndexCreated = true;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await conn.query(
|
|
114
|
+
`ALTER TABLE \`${this.tableName}\` ADD VECTOR INDEX idx_embedding (embedding) DISTANCE=COSINE`,
|
|
115
|
+
);
|
|
116
|
+
this.vectorIndexCreated = true;
|
|
117
|
+
} catch {
|
|
118
|
+
// HNSW index creation may fail on empty tables; will retry after first insert.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async store(
|
|
123
|
+
agentId: string,
|
|
124
|
+
entry: { text: string; vector: number[]; importance: number; category: MemoryCategory },
|
|
125
|
+
): Promise<MemoryEntry> {
|
|
126
|
+
await this.ensureInitialized();
|
|
127
|
+
|
|
128
|
+
const id = randomUUID();
|
|
129
|
+
const createdAt = Date.now();
|
|
130
|
+
const vectorStr = JSON.stringify(entry.vector);
|
|
131
|
+
|
|
132
|
+
await this.pool!.query(
|
|
133
|
+
`INSERT INTO \`${this.tableName}\` (id, agent_id, text, embedding, importance, category, created_at, is_deleted)
|
|
134
|
+
VALUES (?, ?, ?, VEC_FROMTEXT(?), ?, ?, ?, 0)`,
|
|
135
|
+
[id, agentId, entry.text, vectorStr, entry.importance, entry.category, createdAt],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (!this.vectorIndexCreated) {
|
|
139
|
+
const conn = await this.pool!.getConnection();
|
|
140
|
+
try {
|
|
141
|
+
await this.tryCreateVectorIndex(conn);
|
|
142
|
+
} finally {
|
|
143
|
+
conn.release();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
agentId,
|
|
150
|
+
text: entry.text,
|
|
151
|
+
importance: entry.importance,
|
|
152
|
+
category: entry.category,
|
|
153
|
+
createdAt,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Search memories by vector similarity.
|
|
159
|
+
* @param categories - When non-empty, only return rows with category IN (categories). Omit for no category filter.
|
|
160
|
+
*/
|
|
161
|
+
async search(
|
|
162
|
+
agentId: string,
|
|
163
|
+
vector: number[],
|
|
164
|
+
limit = 5,
|
|
165
|
+
minScore = 0.5,
|
|
166
|
+
categories?: MemoryCategory[],
|
|
167
|
+
): Promise<MemorySearchResult[]> {
|
|
168
|
+
await this.ensureInitialized();
|
|
169
|
+
|
|
170
|
+
const vectorStr = JSON.stringify(vector);
|
|
171
|
+
const hasCategoryFilter = Array.isArray(categories) && categories.length > 0;
|
|
172
|
+
const placeholders = hasCategoryFilter ? categories!.map(() => "?").join(", ") : "";
|
|
173
|
+
const whereClause = hasCategoryFilter
|
|
174
|
+
? `WHERE agent_id = ? AND (is_deleted = 0 OR is_deleted IS NULL) AND category IN (${placeholders})`
|
|
175
|
+
: `WHERE agent_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)`;
|
|
176
|
+
const args: unknown[] = hasCategoryFilter
|
|
177
|
+
? [vectorStr, agentId, ...categories!, limit]
|
|
178
|
+
: [vectorStr, agentId, limit];
|
|
179
|
+
|
|
180
|
+
const [rows] = await this.pool!.query(
|
|
181
|
+
`SELECT id, text, importance, category, created_at, is_deleted,
|
|
182
|
+
VEC_DISTANCE_COSINE(embedding, VEC_FROMTEXT(?)) AS distance
|
|
183
|
+
FROM \`${this.tableName}\`
|
|
184
|
+
${whereClause}
|
|
185
|
+
ORDER BY distance ASC
|
|
186
|
+
LIMIT ?`,
|
|
187
|
+
args,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const results: MemorySearchResult[] = [];
|
|
191
|
+
for (const row of rows as Array<Record<string, unknown>>) {
|
|
192
|
+
const distance = Number(row.distance) || 0;
|
|
193
|
+
const score = 1 - distance;
|
|
194
|
+
if (score < minScore) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const isDeleted = row.is_deleted;
|
|
198
|
+
results.push({
|
|
199
|
+
entry: {
|
|
200
|
+
id: row.id as string,
|
|
201
|
+
agentId,
|
|
202
|
+
text: row.text as string,
|
|
203
|
+
importance: Number(row.importance) || 0,
|
|
204
|
+
category: (row.category as MemoryCategory) || USER_MEMORY_FACT,
|
|
205
|
+
createdAt: Number(row.created_at) || 0,
|
|
206
|
+
isDeleted: isDeleted !== undefined && isDeleted !== null ? Number(isDeleted) : undefined,
|
|
207
|
+
},
|
|
208
|
+
score,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Soft-delete: set is_deleted = 1. Returns true if a row was updated. */
|
|
216
|
+
async softDelete(agentId: string, id: string): Promise<boolean> {
|
|
217
|
+
if (!UUID_RE.test(id)) {
|
|
218
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
219
|
+
}
|
|
220
|
+
await this.ensureInitialized();
|
|
221
|
+
const [result] = await this.pool!.query(
|
|
222
|
+
`UPDATE \`${this.tableName}\` SET is_deleted = 1 WHERE id = ? AND agent_id = ?`,
|
|
223
|
+
[id, agentId],
|
|
224
|
+
);
|
|
225
|
+
return ((result as mysql.ResultSetHeader).affectedRows ?? 0) > 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async delete(agentId: string, id: string): Promise<boolean> {
|
|
229
|
+
if (!UUID_RE.test(id)) {
|
|
230
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
231
|
+
}
|
|
232
|
+
await this.ensureInitialized();
|
|
233
|
+
|
|
234
|
+
const [result] = await this.pool!.query(
|
|
235
|
+
`DELETE FROM \`${this.tableName}\` WHERE id = ? AND agent_id = ?`,
|
|
236
|
+
[id, agentId],
|
|
237
|
+
);
|
|
238
|
+
return ((result as mysql.ResultSetHeader).affectedRows ?? 0) > 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async close(): Promise<void> {
|
|
242
|
+
if (this.pool) {
|
|
243
|
+
this.pool.end();
|
|
244
|
+
this.pool = null;
|
|
245
|
+
this.initPromise = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|