openclaw-memory-alibaba-local 0.1.0

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 ADDED
@@ -0,0 +1,88 @@
1
+ # openclaw-memory-alibaba-local
2
+
3
+ OpenClaw 记忆插件:**本地 LanceDB** 向量存储。支持用户记忆三分类(`user_memory_fact` / `user_memory_preference` / `user_memory_decision`)、可选 **全文按条** 落库(`full_context_*`)、自进化记忆、LLM 或正则抽取、可选 LLM 去重/冲突处理、可选召回时间衰减。
4
+
5
+ - **自动召回**:`before_prompt_build`(不含 `full_context_*`,仅用户记忆 + 自进化)。
6
+ - **自动写入**:仅在 **`agent_end`**(本轮成功结束)时处理:按 **消息 role(source)** 维护游标,计算各 source 的 **delta** → 对 `full_context_*` **立即 embedding 并写入 LanceDB**(同一轮共用 `batchId`,管理端全文 Tab **按 batch 可折叠**)→ **`Promise.all` 并行**:仅 **user** 的 delta 走 **用户记忆** 抽取链路;**user + assistant** 的 delta 拼成上下文走 **自进化** 链路。游标文件:`dbPath` 下 `memory-alibaba-local-agent-end-cursors.json`(若存在旧版 `memory-alibaba-local-full-context-cursor.json` 会在首次加载时尝试改名为新文件并迁移为按 role 计数)。
7
+
8
+ **Embedding** 支持 **`local`**(默认,本机 `llama-embedding` 从 **stdin** 读入文本)与 **`remote`**(OpenAI 兼容 `/v1/embeddings`)。**LLM** 仍可用 DashScope 兼容接口。相似度与官方 **memory-lancedb** 一致:`score = 1 / (1 + L2_distance)`。
9
+
10
+ **长文本**:按**空行分段**;每段用 **约 `maxToken` 个 token(字数/4)** 为上限,超长段再切分。每段单独算向量并**单独占一行**(不向量化合并);召回时对查询各段分别搜向量,再按 **`category` + 正文** 合并取最高分。
11
+
12
+ ## 与官方 memory-lancedb 共用目录时的表名
13
+
14
+ 默认 **`dbPath`** 与官方插件相同:`~/.openclaw/memory/lancedb`。本插件使用独立表 **`openclaw_memories_alibaba_local`**(常量 `LANCEDB_TABLE_NAME`),不与官方的 `memories` 表混用。
15
+
16
+ ### 表字段与重建(`chunkIndex`)
17
+
18
+ 设计上每条向量行带有 **`seqInBatch`**(如 `agent_end` 同一 `batchId` 内的消息序)与 **`chunkIndex`**(同一条逻辑记忆因长文本切分产生的多段向量序号,从 0 递增)。**新建表**会自动带上这些列。
19
+
20
+ 若你曾在无 `chunkIndex` 的旧版本下建过表,管理端列表可能出现 *No field named "chunkIndex"*。测试或升级时可**删掉该 LanceDB 表后重启**(插件会按新 schema 建表),例如在本机 `dbPath` 目录下删除名为 `openclaw_memories_alibaba_local` 的表数据(具体文件布局以 LanceDB 版本为准),或临时改用新的 `dbPath`。**注意:会清空本插件表内全部记忆行。**
21
+
22
+ ## 最简配置(默认本机 embedding)
23
+
24
+ ```json
25
+ {
26
+ "plugins": {
27
+ "slots": { "memory": "openclaw-memory-alibaba-local" },
28
+ "entries": {
29
+ "openclaw-memory-alibaba-local": {
30
+ "enabled": true,
31
+ "config": {
32
+ "embedding": { "mode": "local" },
33
+ "llm": {
34
+ "apiKey": "${DASHSCOPE_API_KEY}",
35
+ "model": "qwen-plus",
36
+ "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ 可选 **`local`** 字段:`commandPrefix`(默认使用 `llama-embedding -m ~/.openclaw/embedding_model/embeddinggemma-300M-Q8_0.gguf -f /dev/stdin --embd-output-format json`)、`dimensions`(默认 **768**)、`maxToken`(默认 **2048**)。命令需从 **stdin** 读入待嵌入文本;推荐使用 **`--embd-output-format json`**(与 OpenAI 列表格式兼容)。
46
+
47
+ ## 远程 embedding(OpenAI 兼容)
48
+
49
+ 必须显式 **`"mode": "remote"`**,并填写 **`apiKey` / `model` / `baseUrl` / `dimensions` / `maxToken`**(不在此写默认值;配置错误通常在**第一次 embed** 时失败,启动阶段不校验远程连通性)。
50
+
51
+ ```json
52
+ "embedding": {
53
+ "mode": "remote",
54
+ "apiKey": "${DASHSCOPE_API_KEY}",
55
+ "model": "text-embedding-v3",
56
+ "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
57
+ "dimensions": 1024,
58
+ "maxToken": 2048
59
+ }
60
+ ```
61
+
62
+ 默认 **`memoryExtractionMethod` 为 `llm`**,因此需要可用的 **`llm.apiKey`** 与 **`llm.model`**(可在插件里写全,或见下节从主机配置补齐)。若改为 **`regex`**,可不配 `llm`(除非开启 **`memory_duplication_conflict_process`**)。
63
+
64
+ ## LLM 默认值(`openclaw.json`)
65
+
66
+ 当需要 LLM 时,若插件 **`config.llm`** 未写某字段,会按顺序从主机 OpenClaw 配置文件读取(路径:`OPENCLAW_CONFIG_PATH`,否则为 `OPENCLAW_STATE_DIR/openclaw.json`,再否则 `~/.openclaw/openclaw.json`):
67
+
68
+ | 插件字段 | 来源 |
69
+ |----------|------|
70
+ | `apiKey` | `models.providers.bailian.apiKey`(支持字符串或 `SecretRef`,如 `{ "source": "env", "provider": "default", "id": "DASHSCOPE_API_KEY" }`) |
71
+ | `baseUrl` | `models.providers.bailian.baseUrl`(缺省仍为 DashScope 兼容地址) |
72
+ | `model` | `agents.defaults.model`:可为字符串,或对象里的 **`primary`**(OpenClaw 常用 `bailian/qwen-plus` 形式) |
73
+
74
+ **模型名格式**:若 `primary` 为 `bailian/xxx` 或 `dashscope/xxx`,发给 Chat Completions 时会自动去掉 provider 前缀,只保留 **`xxx`**,与 DashScope 兼容接口一致。
75
+
76
+ 插件里显式写的 `llm` 字段始终优先于上述默认值。若既未在插件中提供完整 `apiKey`+`model`,主机配置也无法补齐,启动会报错。
77
+
78
+ ## 依赖
79
+
80
+ - Node 环境下需能加载 **`@lancedb/lancedb`** 原生绑定(部分平台与官方 memory-lancedb 相同限制)。
81
+
82
+ ## 源码与发布
83
+
84
+ 本包为可独立安装的 OpenClaw 插件;远程仓库地址以 **`package.json`** 中 **`repository`** 字段为准。
85
+
86
+ ## 许可证
87
+
88
+ MIT
package/bm25-recall.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Okapi BM25 over an in-memory corpus (supplement to vector recall).
3
+ * Tokenization: Unicode letters + numbers (works for Latin; CJK often single-char tokens).
4
+ */
5
+
6
+ export function tokenizeForBm25(text: string): string[] {
7
+ const m = text.toLowerCase().match(/[\p{L}\p{N}]+/gu);
8
+ return m ?? [];
9
+ }
10
+
11
+ export type Bm25DocInput = { id: string; text: string };
12
+
13
+ /** Returns docs sorted by BM25 score descending. */
14
+ export function scoreDocumentsBm25(query: string, docs: Bm25DocInput[]): Array<{ id: string; score: number }> {
15
+ if (docs.length === 0) {
16
+ return [];
17
+ }
18
+ const k1 = 1.2;
19
+ const b = 0.75;
20
+
21
+ type Prepared = { id: string; freqs: Map<string, number>; len: number };
22
+ const prepared: Prepared[] = docs.map((d) => {
23
+ const terms = tokenizeForBm25(d.text);
24
+ const freqs = new Map<string, number>();
25
+ for (const t of terms) {
26
+ freqs.set(t, (freqs.get(t) ?? 0) + 1);
27
+ }
28
+ return { id: d.id, freqs, len: terms.length };
29
+ });
30
+
31
+ const N = prepared.length;
32
+ const avgdl = prepared.reduce((s, p) => s + p.len, 0) / Math.max(1, N);
33
+
34
+ const df = new Map<string, number>();
35
+ for (const p of prepared) {
36
+ for (const term of p.freqs.keys()) {
37
+ df.set(term, (df.get(term) ?? 0) + 1);
38
+ }
39
+ }
40
+
41
+ const qTerms = tokenizeForBm25(query);
42
+ if (qTerms.length === 0) {
43
+ return prepared.map((p) => ({ id: p.id, score: 0 }));
44
+ }
45
+
46
+ const scores = new Map<string, number>();
47
+ for (const p of prepared) {
48
+ scores.set(p.id, 0);
49
+ }
50
+
51
+ for (const term of qTerms) {
52
+ const dfi = df.get(term) ?? 0;
53
+ if (dfi === 0) {
54
+ continue;
55
+ }
56
+ const idf = Math.log((N - dfi + 0.5) / (dfi + 0.5) + 1);
57
+ for (const p of prepared) {
58
+ const f = p.freqs.get(term) ?? 0;
59
+ if (f === 0) {
60
+ continue;
61
+ }
62
+ const denom = f + k1 * (1 - b + (b * p.len) / avgdl);
63
+ const add = (idf * (f * (k1 + 1))) / denom;
64
+ scores.set(p.id, (scores.get(p.id) ?? 0) + add);
65
+ }
66
+ }
67
+
68
+ return [...scores.entries()]
69
+ .map(([id, score]) => ({ id, score }))
70
+ .sort((a, b) => b.score - a.score);
71
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Persisted cursors for agent_end capture: per-message-role counts (not flat transcript index).
3
+ * File: memory-alibaba-local-agent-end-cursors.json next to LanceDB dir.
4
+ * Migrates legacy memory-alibaba-local-full-context-cursor.json (lastEndExclusive) on first use.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+
10
+ const CURSOR_FILENAME_V2 = "memory-alibaba-local-agent-end-cursors.json";
11
+ const LEGACY_CURSOR_FILENAME = "memory-alibaba-local-full-context-cursor.json";
12
+
13
+ /** v2: how many messages per role have been fully processed (including empty-body turns). */
14
+ export type AgentEndCursorEntryV2 = {
15
+ version: 2;
16
+ roleCounts: Record<string, number>;
17
+ lastMessagesLength: number;
18
+ };
19
+
20
+ export type LegacyCursorEntry = {
21
+ lastEndExclusive: number;
22
+ };
23
+
24
+ export type CursorFileEntry = AgentEndCursorEntryV2 | LegacyCursorEntry;
25
+
26
+ function cursorPathV2(lancedbDir: string): string {
27
+ return join(lancedbDir, CURSOR_FILENAME_V2);
28
+ }
29
+
30
+ function legacyCursorPath(lancedbDir: string): string {
31
+ return join(lancedbDir, LEGACY_CURSOR_FILENAME);
32
+ }
33
+
34
+ /**
35
+ * Infer agentId when ctx.agentId is missing. OpenClaw canonical keys are `agent:<agentId>:<rest>` (≥3 segments).
36
+ * Plugin fallback `session:<sessionId>` must not use the literal prefix `session` as agentId (would hide rows in UI when Agent defaults to `main`).
37
+ */
38
+ export function parseAgentIdFromSessionKey(sessionKey: string): string {
39
+ const parts = sessionKey.split(":").filter(Boolean);
40
+ if (parts.length >= 2 && parts[0] === "agent") {
41
+ return parts[1] || "main";
42
+ }
43
+ const head = (parts[0] ?? "").toLowerCase();
44
+ if (head === "session") {
45
+ return "main";
46
+ }
47
+ return parts[0] || "main";
48
+ }
49
+
50
+ export function getFullContextCursorKey(agentId: string, sessionKey: string): string {
51
+ return `${agentId}\n${sessionKey}`;
52
+ }
53
+
54
+ function isV2Entry(v: unknown): v is AgentEndCursorEntryV2 {
55
+ return (
56
+ !!v &&
57
+ typeof v === "object" &&
58
+ !Array.isArray(v) &&
59
+ (v as AgentEndCursorEntryV2).version === 2 &&
60
+ typeof (v as AgentEndCursorEntryV2).roleCounts === "object" &&
61
+ (v as AgentEndCursorEntryV2).roleCounts !== null &&
62
+ !Array.isArray((v as AgentEndCursorEntryV2).roleCounts)
63
+ );
64
+ }
65
+
66
+ function isLegacyEntry(v: unknown): v is LegacyCursorEntry {
67
+ return (
68
+ !!v &&
69
+ typeof v === "object" &&
70
+ !Array.isArray(v) &&
71
+ typeof (v as LegacyCursorEntry).lastEndExclusive === "number" &&
72
+ Number.isFinite((v as LegacyCursorEntry).lastEndExclusive)
73
+ );
74
+ }
75
+
76
+ /** Load all cursor entries (v2 and/or legacy shapes). Migrates legacy file into v2 path once if needed. */
77
+ export function loadAgentEndCursorMap(lancedbDir: string): Record<string, CursorFileEntry> {
78
+ const v2Path = cursorPathV2(lancedbDir);
79
+ const legPath = legacyCursorPath(lancedbDir);
80
+
81
+ if (!existsSync(v2Path) && existsSync(legPath)) {
82
+ try {
83
+ renameSync(legPath, v2Path);
84
+ } catch {
85
+ // if rename fails, read legacy below
86
+ }
87
+ }
88
+
89
+ const readPath = existsSync(v2Path) ? v2Path : existsSync(legPath) ? legPath : v2Path;
90
+ try {
91
+ const raw = readFileSync(readPath, "utf8");
92
+ const parsed = JSON.parse(raw) as unknown;
93
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
94
+ return {};
95
+ }
96
+ const out: Record<string, CursorFileEntry> = {};
97
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
98
+ if (isV2Entry(v)) {
99
+ out[k] = {
100
+ version: 2,
101
+ roleCounts: { ...v.roleCounts },
102
+ lastMessagesLength:
103
+ typeof v.lastMessagesLength === "number" && Number.isFinite(v.lastMessagesLength)
104
+ ? Math.floor(v.lastMessagesLength)
105
+ : 0,
106
+ };
107
+ } else if (isLegacyEntry(v)) {
108
+ out[k] = { lastEndExclusive: Math.max(0, Math.floor(v.lastEndExclusive)) };
109
+ }
110
+ }
111
+ return out;
112
+ } catch {
113
+ return {};
114
+ }
115
+ }
116
+
117
+ export function saveAgentEndCursorMap(
118
+ lancedbDir: string,
119
+ map: Record<string, CursorFileEntry>,
120
+ ): void {
121
+ const p = cursorPathV2(lancedbDir);
122
+ try {
123
+ mkdirSync(dirname(p), { recursive: true });
124
+ } catch {
125
+ // ignore
126
+ }
127
+ writeFileSync(p, JSON.stringify(map, null, 0), "utf8");
128
+ }
129
+
130
+ /** Role key stable for counting (aligns with full_context source roles). */
131
+ export function normalizeRoleForCursor(role: string): string {
132
+ const t = (role ?? "").trim();
133
+ if (!t) {
134
+ return "others";
135
+ }
136
+ if (t === "developer") {
137
+ return "system";
138
+ }
139
+ if (t === "toolResult" || t === "tool_result") {
140
+ return "tool_result";
141
+ }
142
+ return t;
143
+ }
144
+
145
+ export function countRolesInMessagesPrefix(messages: unknown[], endExclusive: number): Record<string, number> {
146
+ const counts: Record<string, number> = {};
147
+ const end = Math.max(0, Math.min(Math.floor(endExclusive), messages.length));
148
+ for (let i = 0; i < end; i++) {
149
+ const role = normalizeRoleForCursor(getMessageRoleRaw(messages[i]));
150
+ counts[role] = (counts[role] ?? 0) + 1;
151
+ }
152
+ return counts;
153
+ }
154
+
155
+ function getMessageRoleRaw(msg: unknown): string {
156
+ if (!msg || typeof msg !== "object") {
157
+ return "";
158
+ }
159
+ const r = (msg as Record<string, unknown>).role;
160
+ return typeof r === "string" ? r : "";
161
+ }
162
+
163
+ /**
164
+ * Resolve saved role counts for this session key; migrates legacy lastEndExclusive using current transcript.
165
+ */
166
+ export function resolveRoleCountsForSession(
167
+ entry: CursorFileEntry | undefined,
168
+ messages: unknown[],
169
+ log: { info: (m: string) => void },
170
+ ): { roleCounts: Record<string, number>; lastMessagesLength: number } {
171
+ if (isV2Entry(entry)) {
172
+ return {
173
+ roleCounts: { ...entry.roleCounts },
174
+ lastMessagesLength: entry.lastMessagesLength ?? 0,
175
+ };
176
+ }
177
+ if (isLegacyEntry(entry)) {
178
+ const end = Math.min(Math.max(0, entry.lastEndExclusive), messages.length);
179
+ log.info("openclaw-memory-alibaba-local: migrated legacy full-context cursor to per-role counts");
180
+ return {
181
+ roleCounts: countRolesInMessagesPrefix(messages, end),
182
+ lastMessagesLength: end,
183
+ };
184
+ }
185
+ return { roleCounts: {}, lastMessagesLength: 0 };
186
+ }
187
+
188
+ /** @deprecated use loadAgentEndCursorMap */
189
+ export function loadFullContextCursors(lancedbDir: string): Record<string, { lastEndExclusive: number }> {
190
+ const map = loadAgentEndCursorMap(lancedbDir);
191
+ const out: Record<string, { lastEndExclusive: number }> = {};
192
+ for (const [k, v] of Object.entries(map)) {
193
+ if (isLegacyEntry(v)) {
194
+ out[k] = { lastEndExclusive: v.lastEndExclusive };
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /** @deprecated no-op for v2; kept for API compatibility */
201
+ export function saveFullContextCursors(
202
+ _lancedbDir: string,
203
+ _cursors: Record<string, { lastEndExclusive: number }>,
204
+ ): void {
205
+ // v2 uses saveAgentEndCursorMap only
206
+ }
package/categories.ts ADDED
@@ -0,0 +1,106 @@
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). Legacy single category. */
24
+ export const FULL_CONTEXT_MEMORY = "full_context_memory" as const;
25
+
26
+ /** Full context by source (user / assistant / system / tool / tool_result / others) */
27
+ export const FULL_CONTEXT_USER = "full_context_user" as const;
28
+ export const FULL_CONTEXT_ASSISTANT = "full_context_assistant" as const;
29
+ export const FULL_CONTEXT_SYSTEM = "full_context_system" as const;
30
+ export const FULL_CONTEXT_TOOL = "full_context_tool" as const;
31
+ export const FULL_CONTEXT_TOOL_RESULT = "full_context_tool_result" as const;
32
+ export const FULL_CONTEXT_OTHERS = "full_context_others" as const;
33
+
34
+ export const FULL_CONTEXT_SOURCE_CATEGORIES = [
35
+ FULL_CONTEXT_USER,
36
+ FULL_CONTEXT_ASSISTANT,
37
+ FULL_CONTEXT_SYSTEM,
38
+ FULL_CONTEXT_TOOL,
39
+ FULL_CONTEXT_TOOL_RESULT,
40
+ FULL_CONTEXT_OTHERS,
41
+ ] as const;
42
+
43
+ export type FullContextSourceCategory = (typeof FULL_CONTEXT_SOURCE_CATEGORIES)[number];
44
+
45
+ export function isFullContextSourceCategory(cat: string): cat is FullContextSourceCategory {
46
+ return FULL_CONTEXT_SOURCE_CATEGORIES.includes(cat as FullContextSourceCategory);
47
+ }
48
+
49
+ /** Self-improving memory sub-categories */
50
+ export const SELF_IMPROVING_LEARNINGS = "self_improving_learnings" as const;
51
+ export const SELF_IMPROVING_ERRORS = "self_improving_errors" as const;
52
+ export const SELF_IMPROVING_FEATURE_REQUESTS = "self_improving_feature_requests" as const;
53
+
54
+ export const SELF_IMPROVING_CATEGORIES = [
55
+ SELF_IMPROVING_LEARNINGS,
56
+ SELF_IMPROVING_ERRORS,
57
+ SELF_IMPROVING_FEATURE_REQUESTS,
58
+ ] as const;
59
+
60
+ export type SelfImprovingCategory = (typeof SELF_IMPROVING_CATEGORIES)[number];
61
+
62
+ /** All allowed category values */
63
+ export type MemoryCategory =
64
+ | UserMemoryCategory
65
+ | typeof FULL_CONTEXT_MEMORY
66
+ | FullContextSourceCategory
67
+ | SelfImprovingCategory;
68
+
69
+ export const ALL_CATEGORIES: readonly MemoryCategory[] = [
70
+ ...USER_MEMORY_CATEGORIES,
71
+ FULL_CONTEXT_MEMORY,
72
+ ...FULL_CONTEXT_SOURCE_CATEGORIES,
73
+ ...SELF_IMPROVING_CATEGORIES,
74
+ ];
75
+
76
+ export function isUserMemoryCategory(cat: string): cat is UserMemoryCategory {
77
+ return USER_MEMORY_CATEGORIES.includes(cat as UserMemoryCategory);
78
+ }
79
+
80
+ export function isSelfImprovingCategory(cat: string): cat is SelfImprovingCategory {
81
+ return SELF_IMPROVING_CATEGORIES.includes(cat as SelfImprovingCategory);
82
+ }
83
+
84
+ /** Session id for rows inserted from the admin UI (manual add). */
85
+ export const MANUAL_INSERT_SESSION = "manual_insert" as const;
86
+
87
+ /** Chinese labels for admin UI and APIs (fixed mapping). */
88
+ export const MEMORY_CATEGORY_LABEL_ZH: Readonly<Record<MemoryCategory, string>> = {
89
+ [USER_MEMORY_FACT]: "用户事实",
90
+ [USER_MEMORY_PREFERENCE]: "用户偏好",
91
+ [USER_MEMORY_DECISION]: "用户决策",
92
+ [FULL_CONTEXT_MEMORY]: "全文记忆",
93
+ [FULL_CONTEXT_USER]: "全文 · 用户消息",
94
+ [FULL_CONTEXT_ASSISTANT]: "全文 · AI助手消息",
95
+ [FULL_CONTEXT_SYSTEM]: "全文 · 系统消息",
96
+ [FULL_CONTEXT_TOOL]: "全文 · 工具调用",
97
+ [FULL_CONTEXT_TOOL_RESULT]: "全文 · 工具结果",
98
+ [FULL_CONTEXT_OTHERS]: "全文 · 其他消息",
99
+ [SELF_IMPROVING_LEARNINGS]: "最佳实践",
100
+ [SELF_IMPROVING_ERRORS]: "错误经验",
101
+ [SELF_IMPROVING_FEATURE_REQUESTS]: "行为诉求",
102
+ };
103
+
104
+ export function categoryLabelZh(category: string): string {
105
+ return MEMORY_CATEGORY_LABEL_ZH[category as MemoryCategory] ?? category;
106
+ }